changeset 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents d10748475025
children 040095a5dc7f
files i18n/fr/LC_MESSAGES/libervia_backend.mo i18n/fr/LC_MESSAGES/libervia_backend.po i18n/fr/LC_MESSAGES/sat.mo i18n/fr/LC_MESSAGES/sat.po libervia/backend/VERSION libervia/backend/__init__.py libervia/backend/bridge/__init__.py libervia/backend/bridge/bridge_constructor/__init__.py libervia/backend/bridge/bridge_constructor/base_constructor.py libervia/backend/bridge/bridge_constructor/bridge_constructor.py libervia/backend/bridge/bridge_constructor/bridge_template.ini libervia/backend/bridge/bridge_constructor/constants.py libervia/backend/bridge/bridge_constructor/constructors/__init__.py libervia/backend/bridge/bridge_constructor/constructors/dbus-xml/__init__.py libervia/backend/bridge/bridge_constructor/constructors/dbus-xml/constructor.py libervia/backend/bridge/bridge_constructor/constructors/dbus-xml/dbus_xml_template.xml libervia/backend/bridge/bridge_constructor/constructors/dbus/__init__.py libervia/backend/bridge/bridge_constructor/constructors/dbus/constructor.py libervia/backend/bridge/bridge_constructor/constructors/dbus/dbus_core_template.py libervia/backend/bridge/bridge_constructor/constructors/dbus/dbus_frontend_template.py libervia/backend/bridge/bridge_constructor/constructors/embedded/__init__.py libervia/backend/bridge/bridge_constructor/constructors/embedded/constructor.py libervia/backend/bridge/bridge_constructor/constructors/embedded/embedded_frontend_template.py libervia/backend/bridge/bridge_constructor/constructors/embedded/embedded_template.py libervia/backend/bridge/bridge_constructor/constructors/mediawiki/__init__.py libervia/backend/bridge/bridge_constructor/constructors/mediawiki/constructor.py libervia/backend/bridge/bridge_constructor/constructors/mediawiki/mediawiki_template.tpl libervia/backend/bridge/bridge_constructor/constructors/pb/__init__.py libervia/backend/bridge/bridge_constructor/constructors/pb/constructor.py libervia/backend/bridge/bridge_constructor/constructors/pb/pb_core_template.py libervia/backend/bridge/bridge_constructor/constructors/pb/pb_frontend_template.py libervia/backend/bridge/dbus_bridge.py libervia/backend/bridge/pb.py libervia/backend/core/__init__.py libervia/backend/core/constants.py libervia/backend/core/core_types.py libervia/backend/core/exceptions.py libervia/backend/core/i18n.py libervia/backend/core/launcher.py libervia/backend/core/log.py libervia/backend/core/log_config.py libervia/backend/core/patches.py libervia/backend/core/sat_main.py libervia/backend/core/xmpp.py libervia/backend/memory/__init__.py libervia/backend/memory/cache.py libervia/backend/memory/crypto.py libervia/backend/memory/disco.py libervia/backend/memory/encryption.py libervia/backend/memory/memory.py libervia/backend/memory/migration/README libervia/backend/memory/migration/__init__.py libervia/backend/memory/migration/alembic.ini libervia/backend/memory/migration/env.py libervia/backend/memory/migration/script.py.mako libervia/backend/memory/migration/versions/129ac51807e4_create_virtual_table_for_full_text_.py libervia/backend/memory/migration/versions/4b002773cf92_add_origin_id_column_to_history_and_.py libervia/backend/memory/migration/versions/602caf848068_drop_message_types_table_fix_nullable.py libervia/backend/memory/migration/versions/79e5f3313fa4_create_table_for_pubsub_subscriptions.py libervia/backend/memory/migration/versions/8974efc51d22_create_tables_for_pubsub_caching.py libervia/backend/memory/migration/versions/__init__.py libervia/backend/memory/params.py libervia/backend/memory/persistent.py libervia/backend/memory/sqla.py libervia/backend/memory/sqla_config.py libervia/backend/memory/sqla_mapping.py libervia/backend/plugins/__init__.py libervia/backend/plugins/plugin_adhoc_dbus.py libervia/backend/plugins/plugin_app_manager_docker/__init__.py libervia/backend/plugins/plugin_app_manager_docker/sat_app_weblate.yaml libervia/backend/plugins/plugin_blog_import.py libervia/backend/plugins/plugin_blog_import_dokuwiki.py libervia/backend/plugins/plugin_blog_import_dotclear.py libervia/backend/plugins/plugin_comp_ap_gateway/__init__.py libervia/backend/plugins/plugin_comp_ap_gateway/ad_hoc.py libervia/backend/plugins/plugin_comp_ap_gateway/constants.py libervia/backend/plugins/plugin_comp_ap_gateway/events.py libervia/backend/plugins/plugin_comp_ap_gateway/http_server.py libervia/backend/plugins/plugin_comp_ap_gateway/pubsub_service.py libervia/backend/plugins/plugin_comp_ap_gateway/regex.py libervia/backend/plugins/plugin_comp_file_sharing.py libervia/backend/plugins/plugin_comp_file_sharing_management.py libervia/backend/plugins/plugin_dbg_manhole.py libervia/backend/plugins/plugin_exp_command_export.py libervia/backend/plugins/plugin_exp_invitation.py libervia/backend/plugins/plugin_exp_invitation_file.py libervia/backend/plugins/plugin_exp_invitation_pubsub.py libervia/backend/plugins/plugin_exp_jingle_stream.py libervia/backend/plugins/plugin_exp_lang_detect.py libervia/backend/plugins/plugin_exp_list_of_interest.py libervia/backend/plugins/plugin_exp_parrot.py libervia/backend/plugins/plugin_exp_pubsub_admin.py libervia/backend/plugins/plugin_exp_pubsub_hook.py libervia/backend/plugins/plugin_import.py libervia/backend/plugins/plugin_merge_req_mercurial.py libervia/backend/plugins/plugin_misc_account.py libervia/backend/plugins/plugin_misc_android.py libervia/backend/plugins/plugin_misc_app_manager.py libervia/backend/plugins/plugin_misc_attach.py libervia/backend/plugins/plugin_misc_debug.py libervia/backend/plugins/plugin_misc_download.py libervia/backend/plugins/plugin_misc_email_invitation.py libervia/backend/plugins/plugin_misc_extra_pep.py libervia/backend/plugins/plugin_misc_file.py libervia/backend/plugins/plugin_misc_forums.py libervia/backend/plugins/plugin_misc_groupblog.py libervia/backend/plugins/plugin_misc_identity.py libervia/backend/plugins/plugin_misc_ip.py libervia/backend/plugins/plugin_misc_lists.py libervia/backend/plugins/plugin_misc_merge_requests.py libervia/backend/plugins/plugin_misc_nat_port.py libervia/backend/plugins/plugin_misc_quiz.py libervia/backend/plugins/plugin_misc_radiocol.py libervia/backend/plugins/plugin_misc_register_account.py libervia/backend/plugins/plugin_misc_room_game.py libervia/backend/plugins/plugin_misc_static_blog.py libervia/backend/plugins/plugin_misc_tarot.py libervia/backend/plugins/plugin_misc_text_commands.py libervia/backend/plugins/plugin_misc_text_syntaxes.py libervia/backend/plugins/plugin_misc_upload.py libervia/backend/plugins/plugin_misc_uri_finder.py libervia/backend/plugins/plugin_misc_watched.py libervia/backend/plugins/plugin_misc_welcome.py libervia/backend/plugins/plugin_misc_xmllog.py libervia/backend/plugins/plugin_pubsub_cache.py libervia/backend/plugins/plugin_sec_aesgcm.py libervia/backend/plugins/plugin_sec_otr.py libervia/backend/plugins/plugin_sec_oxps.py libervia/backend/plugins/plugin_sec_pte.py libervia/backend/plugins/plugin_sec_pubsub_signing.py libervia/backend/plugins/plugin_syntax_wiki_dotclear.py libervia/backend/plugins/plugin_tickets_import.py libervia/backend/plugins/plugin_tickets_import_bugzilla.py libervia/backend/plugins/plugin_tmp_directory_subscription.py libervia/backend/plugins/plugin_xep_0020.py libervia/backend/plugins/plugin_xep_0033.py libervia/backend/plugins/plugin_xep_0045.py libervia/backend/plugins/plugin_xep_0047.py libervia/backend/plugins/plugin_xep_0048.py libervia/backend/plugins/plugin_xep_0049.py libervia/backend/plugins/plugin_xep_0050.py libervia/backend/plugins/plugin_xep_0054.py libervia/backend/plugins/plugin_xep_0055.py libervia/backend/plugins/plugin_xep_0059.py libervia/backend/plugins/plugin_xep_0060.py libervia/backend/plugins/plugin_xep_0065.py libervia/backend/plugins/plugin_xep_0070.py libervia/backend/plugins/plugin_xep_0071.py libervia/backend/plugins/plugin_xep_0077.py libervia/backend/plugins/plugin_xep_0080.py libervia/backend/plugins/plugin_xep_0082.py libervia/backend/plugins/plugin_xep_0084.py libervia/backend/plugins/plugin_xep_0085.py libervia/backend/plugins/plugin_xep_0092.py libervia/backend/plugins/plugin_xep_0095.py libervia/backend/plugins/plugin_xep_0096.py libervia/backend/plugins/plugin_xep_0100.py libervia/backend/plugins/plugin_xep_0103.py libervia/backend/plugins/plugin_xep_0106.py libervia/backend/plugins/plugin_xep_0115.py libervia/backend/plugins/plugin_xep_0163.py libervia/backend/plugins/plugin_xep_0166/__init__.py libervia/backend/plugins/plugin_xep_0166/models.py libervia/backend/plugins/plugin_xep_0167/__init__.py libervia/backend/plugins/plugin_xep_0167/constants.py libervia/backend/plugins/plugin_xep_0167/mapping.py libervia/backend/plugins/plugin_xep_0176.py libervia/backend/plugins/plugin_xep_0184.py libervia/backend/plugins/plugin_xep_0191.py libervia/backend/plugins/plugin_xep_0198.py libervia/backend/plugins/plugin_xep_0199.py libervia/backend/plugins/plugin_xep_0203.py libervia/backend/plugins/plugin_xep_0215.py libervia/backend/plugins/plugin_xep_0231.py libervia/backend/plugins/plugin_xep_0234.py libervia/backend/plugins/plugin_xep_0249.py libervia/backend/plugins/plugin_xep_0260.py libervia/backend/plugins/plugin_xep_0261.py libervia/backend/plugins/plugin_xep_0264.py libervia/backend/plugins/plugin_xep_0277.py libervia/backend/plugins/plugin_xep_0280.py libervia/backend/plugins/plugin_xep_0292.py libervia/backend/plugins/plugin_xep_0293.py libervia/backend/plugins/plugin_xep_0294.py libervia/backend/plugins/plugin_xep_0297.py libervia/backend/plugins/plugin_xep_0300.py libervia/backend/plugins/plugin_xep_0313.py libervia/backend/plugins/plugin_xep_0320.py libervia/backend/plugins/plugin_xep_0329.py libervia/backend/plugins/plugin_xep_0334.py libervia/backend/plugins/plugin_xep_0338.py libervia/backend/plugins/plugin_xep_0339.py libervia/backend/plugins/plugin_xep_0346.py libervia/backend/plugins/plugin_xep_0352.py libervia/backend/plugins/plugin_xep_0353.py libervia/backend/plugins/plugin_xep_0359.py libervia/backend/plugins/plugin_xep_0363.py libervia/backend/plugins/plugin_xep_0372.py libervia/backend/plugins/plugin_xep_0373.py libervia/backend/plugins/plugin_xep_0374.py libervia/backend/plugins/plugin_xep_0376.py libervia/backend/plugins/plugin_xep_0380.py libervia/backend/plugins/plugin_xep_0384.py libervia/backend/plugins/plugin_xep_0391.py libervia/backend/plugins/plugin_xep_0420.py libervia/backend/plugins/plugin_xep_0422.py libervia/backend/plugins/plugin_xep_0424.py libervia/backend/plugins/plugin_xep_0428.py libervia/backend/plugins/plugin_xep_0444.py libervia/backend/plugins/plugin_xep_0446.py libervia/backend/plugins/plugin_xep_0447.py libervia/backend/plugins/plugin_xep_0448.py libervia/backend/plugins/plugin_xep_0465.py libervia/backend/plugins/plugin_xep_0470.py libervia/backend/plugins/plugin_xep_0471.py libervia/backend/stdui/__init__.py libervia/backend/stdui/ui_contact_list.py libervia/backend/stdui/ui_profile_manager.py libervia/backend/test/__init__.py libervia/backend/test/constants.py libervia/backend/test/helpers.py libervia/backend/test/helpers_plugins.py libervia/backend/test/test_core_xmpp.py libervia/backend/test/test_helpers_plugins.py libervia/backend/test/test_memory.py libervia/backend/test/test_memory_crypto.py libervia/backend/test/test_plugin_misc_groupblog.py libervia/backend/test/test_plugin_misc_radiocol.py libervia/backend/test/test_plugin_misc_room_game.py libervia/backend/test/test_plugin_misc_text_syntaxes.py libervia/backend/test/test_plugin_xep_0033.py libervia/backend/test/test_plugin_xep_0085.py libervia/backend/test/test_plugin_xep_0203.py libervia/backend/test/test_plugin_xep_0277.py libervia/backend/test/test_plugin_xep_0297.py libervia/backend/test/test_plugin_xep_0313.py libervia/backend/test/test_plugin_xep_0334.py libervia/backend/tools/__init__.py libervia/backend/tools/async_trigger.py libervia/backend/tools/common/__init__.py libervia/backend/tools/common/ansi.py libervia/backend/tools/common/async_process.py libervia/backend/tools/common/async_utils.py libervia/backend/tools/common/data_format.py libervia/backend/tools/common/data_objects.py libervia/backend/tools/common/date_utils.py libervia/backend/tools/common/dynamic_import.py libervia/backend/tools/common/email.py libervia/backend/tools/common/files_utils.py libervia/backend/tools/common/regex.py libervia/backend/tools/common/template.py libervia/backend/tools/common/template_xmlui.py libervia/backend/tools/common/tls.py libervia/backend/tools/common/uri.py libervia/backend/tools/common/utils.py libervia/backend/tools/config.py libervia/backend/tools/image.py libervia/backend/tools/sat_defer.py libervia/backend/tools/stream.py libervia/backend/tools/trigger.py libervia/backend/tools/utils.py libervia/backend/tools/video.py libervia/backend/tools/web.py libervia/backend/tools/xml_tools.py libervia/backend/tools/xmpp_datetime.py sat/VERSION sat/__init__.py sat/bridge/__init__.py sat/bridge/bridge_constructor/__init__.py sat/bridge/bridge_constructor/base_constructor.py sat/bridge/bridge_constructor/bridge_constructor.py sat/bridge/bridge_constructor/bridge_template.ini sat/bridge/bridge_constructor/constants.py sat/bridge/bridge_constructor/constructors/__init__.py sat/bridge/bridge_constructor/constructors/dbus-xml/__init__.py sat/bridge/bridge_constructor/constructors/dbus-xml/constructor.py sat/bridge/bridge_constructor/constructors/dbus-xml/dbus_xml_template.xml sat/bridge/bridge_constructor/constructors/dbus/__init__.py sat/bridge/bridge_constructor/constructors/dbus/constructor.py sat/bridge/bridge_constructor/constructors/dbus/dbus_core_template.py sat/bridge/bridge_constructor/constructors/dbus/dbus_frontend_template.py sat/bridge/bridge_constructor/constructors/embedded/__init__.py sat/bridge/bridge_constructor/constructors/embedded/constructor.py sat/bridge/bridge_constructor/constructors/embedded/embedded_frontend_template.py sat/bridge/bridge_constructor/constructors/embedded/embedded_template.py sat/bridge/bridge_constructor/constructors/mediawiki/__init__.py sat/bridge/bridge_constructor/constructors/mediawiki/constructor.py sat/bridge/bridge_constructor/constructors/mediawiki/mediawiki_template.tpl sat/bridge/bridge_constructor/constructors/pb/__init__.py sat/bridge/bridge_constructor/constructors/pb/constructor.py sat/bridge/bridge_constructor/constructors/pb/pb_core_template.py sat/bridge/bridge_constructor/constructors/pb/pb_frontend_template.py sat/bridge/dbus_bridge.py sat/bridge/pb.py sat/core/__init__.py sat/core/constants.py sat/core/core_types.py sat/core/exceptions.py sat/core/i18n.py sat/core/launcher.py sat/core/log.py sat/core/log_config.py sat/core/patches.py sat/core/sat_main.py sat/core/xmpp.py sat/memory/__init__.py sat/memory/cache.py sat/memory/crypto.py sat/memory/disco.py sat/memory/encryption.py sat/memory/memory.py sat/memory/migration/README sat/memory/migration/__init__.py sat/memory/migration/alembic.ini sat/memory/migration/env.py sat/memory/migration/script.py.mako sat/memory/migration/versions/129ac51807e4_create_virtual_table_for_full_text_.py sat/memory/migration/versions/4b002773cf92_add_origin_id_column_to_history_and_.py sat/memory/migration/versions/602caf848068_drop_message_types_table_fix_nullable.py sat/memory/migration/versions/79e5f3313fa4_create_table_for_pubsub_subscriptions.py sat/memory/migration/versions/8974efc51d22_create_tables_for_pubsub_caching.py sat/memory/migration/versions/__init__.py sat/memory/params.py sat/memory/persistent.py sat/memory/sqla.py sat/memory/sqla_config.py sat/memory/sqla_mapping.py sat/plugins/__init__.py sat/plugins/plugin_adhoc_dbus.py sat/plugins/plugin_app_manager_docker/__init__.py sat/plugins/plugin_app_manager_docker/sat_app_weblate.yaml sat/plugins/plugin_blog_import.py sat/plugins/plugin_blog_import_dokuwiki.py sat/plugins/plugin_blog_import_dotclear.py sat/plugins/plugin_comp_ap_gateway/__init__.py sat/plugins/plugin_comp_ap_gateway/ad_hoc.py sat/plugins/plugin_comp_ap_gateway/constants.py sat/plugins/plugin_comp_ap_gateway/events.py sat/plugins/plugin_comp_ap_gateway/http_server.py sat/plugins/plugin_comp_ap_gateway/pubsub_service.py sat/plugins/plugin_comp_ap_gateway/regex.py sat/plugins/plugin_comp_file_sharing.py sat/plugins/plugin_comp_file_sharing_management.py sat/plugins/plugin_dbg_manhole.py sat/plugins/plugin_exp_command_export.py sat/plugins/plugin_exp_invitation.py sat/plugins/plugin_exp_invitation_file.py sat/plugins/plugin_exp_invitation_pubsub.py sat/plugins/plugin_exp_jingle_stream.py sat/plugins/plugin_exp_lang_detect.py sat/plugins/plugin_exp_list_of_interest.py sat/plugins/plugin_exp_parrot.py sat/plugins/plugin_exp_pubsub_admin.py sat/plugins/plugin_exp_pubsub_hook.py sat/plugins/plugin_import.py sat/plugins/plugin_merge_req_mercurial.py sat/plugins/plugin_misc_account.py sat/plugins/plugin_misc_android.py sat/plugins/plugin_misc_app_manager.py sat/plugins/plugin_misc_attach.py sat/plugins/plugin_misc_debug.py sat/plugins/plugin_misc_download.py sat/plugins/plugin_misc_email_invitation.py sat/plugins/plugin_misc_extra_pep.py sat/plugins/plugin_misc_file.py sat/plugins/plugin_misc_forums.py sat/plugins/plugin_misc_groupblog.py sat/plugins/plugin_misc_identity.py sat/plugins/plugin_misc_ip.py sat/plugins/plugin_misc_lists.py sat/plugins/plugin_misc_merge_requests.py sat/plugins/plugin_misc_nat_port.py sat/plugins/plugin_misc_quiz.py sat/plugins/plugin_misc_radiocol.py sat/plugins/plugin_misc_register_account.py sat/plugins/plugin_misc_room_game.py sat/plugins/plugin_misc_static_blog.py sat/plugins/plugin_misc_tarot.py sat/plugins/plugin_misc_text_commands.py sat/plugins/plugin_misc_text_syntaxes.py sat/plugins/plugin_misc_upload.py sat/plugins/plugin_misc_uri_finder.py sat/plugins/plugin_misc_watched.py sat/plugins/plugin_misc_welcome.py sat/plugins/plugin_misc_xmllog.py sat/plugins/plugin_pubsub_cache.py sat/plugins/plugin_sec_aesgcm.py sat/plugins/plugin_sec_otr.py sat/plugins/plugin_sec_oxps.py sat/plugins/plugin_sec_pte.py sat/plugins/plugin_sec_pubsub_signing.py sat/plugins/plugin_syntax_wiki_dotclear.py sat/plugins/plugin_tickets_import.py sat/plugins/plugin_tickets_import_bugzilla.py sat/plugins/plugin_tmp_directory_subscription.py sat/plugins/plugin_xep_0020.py sat/plugins/plugin_xep_0033.py sat/plugins/plugin_xep_0045.py sat/plugins/plugin_xep_0047.py sat/plugins/plugin_xep_0048.py sat/plugins/plugin_xep_0049.py sat/plugins/plugin_xep_0050.py sat/plugins/plugin_xep_0054.py sat/plugins/plugin_xep_0055.py sat/plugins/plugin_xep_0059.py sat/plugins/plugin_xep_0060.py sat/plugins/plugin_xep_0065.py sat/plugins/plugin_xep_0070.py sat/plugins/plugin_xep_0071.py sat/plugins/plugin_xep_0077.py sat/plugins/plugin_xep_0080.py sat/plugins/plugin_xep_0082.py sat/plugins/plugin_xep_0084.py sat/plugins/plugin_xep_0085.py sat/plugins/plugin_xep_0092.py sat/plugins/plugin_xep_0095.py sat/plugins/plugin_xep_0096.py sat/plugins/plugin_xep_0100.py sat/plugins/plugin_xep_0103.py sat/plugins/plugin_xep_0106.py sat/plugins/plugin_xep_0115.py sat/plugins/plugin_xep_0163.py sat/plugins/plugin_xep_0166/__init__.py sat/plugins/plugin_xep_0166/models.py sat/plugins/plugin_xep_0167/__init__.py sat/plugins/plugin_xep_0167/constants.py sat/plugins/plugin_xep_0167/mapping.py sat/plugins/plugin_xep_0176.py sat/plugins/plugin_xep_0184.py sat/plugins/plugin_xep_0191.py sat/plugins/plugin_xep_0198.py sat/plugins/plugin_xep_0199.py sat/plugins/plugin_xep_0203.py sat/plugins/plugin_xep_0215.py sat/plugins/plugin_xep_0231.py sat/plugins/plugin_xep_0234.py sat/plugins/plugin_xep_0249.py sat/plugins/plugin_xep_0260.py sat/plugins/plugin_xep_0261.py sat/plugins/plugin_xep_0264.py sat/plugins/plugin_xep_0277.py sat/plugins/plugin_xep_0280.py sat/plugins/plugin_xep_0292.py sat/plugins/plugin_xep_0293.py sat/plugins/plugin_xep_0294.py sat/plugins/plugin_xep_0297.py sat/plugins/plugin_xep_0300.py sat/plugins/plugin_xep_0313.py sat/plugins/plugin_xep_0320.py sat/plugins/plugin_xep_0329.py sat/plugins/plugin_xep_0334.py sat/plugins/plugin_xep_0338.py sat/plugins/plugin_xep_0339.py sat/plugins/plugin_xep_0346.py sat/plugins/plugin_xep_0352.py sat/plugins/plugin_xep_0353.py sat/plugins/plugin_xep_0359.py sat/plugins/plugin_xep_0363.py sat/plugins/plugin_xep_0372.py sat/plugins/plugin_xep_0373.py sat/plugins/plugin_xep_0374.py sat/plugins/plugin_xep_0376.py sat/plugins/plugin_xep_0380.py sat/plugins/plugin_xep_0384.py sat/plugins/plugin_xep_0391.py sat/plugins/plugin_xep_0420.py sat/plugins/plugin_xep_0422.py sat/plugins/plugin_xep_0424.py sat/plugins/plugin_xep_0428.py sat/plugins/plugin_xep_0444.py sat/plugins/plugin_xep_0446.py sat/plugins/plugin_xep_0447.py sat/plugins/plugin_xep_0448.py sat/plugins/plugin_xep_0465.py sat/plugins/plugin_xep_0470.py sat/plugins/plugin_xep_0471.py sat/stdui/__init__.py sat/stdui/ui_contact_list.py sat/stdui/ui_profile_manager.py sat/test/__init__.py sat/test/constants.py sat/test/helpers.py sat/test/helpers_plugins.py sat/test/test_core_xmpp.py sat/test/test_helpers_plugins.py sat/test/test_memory.py sat/test/test_memory_crypto.py sat/test/test_plugin_misc_groupblog.py sat/test/test_plugin_misc_radiocol.py sat/test/test_plugin_misc_room_game.py sat/test/test_plugin_misc_text_syntaxes.py sat/test/test_plugin_xep_0033.py sat/test/test_plugin_xep_0085.py sat/test/test_plugin_xep_0203.py sat/test/test_plugin_xep_0277.py sat/test/test_plugin_xep_0297.py sat/test/test_plugin_xep_0313.py sat/test/test_plugin_xep_0334.py sat/tools/__init__.py sat/tools/async_trigger.py sat/tools/common/__init__.py sat/tools/common/ansi.py sat/tools/common/async_process.py sat/tools/common/async_utils.py sat/tools/common/data_format.py sat/tools/common/data_objects.py sat/tools/common/date_utils.py sat/tools/common/dynamic_import.py sat/tools/common/email.py sat/tools/common/files_utils.py sat/tools/common/regex.py sat/tools/common/template.py sat/tools/common/template_xmlui.py sat/tools/common/tls.py sat/tools/common/uri.py sat/tools/common/utils.py sat/tools/config.py sat/tools/image.py sat/tools/sat_defer.py sat/tools/stream.py sat/tools/trigger.py sat/tools/utils.py sat/tools/video.py sat/tools/web.py sat/tools/xml_tools.py sat/tools/xmpp_datetime.py sat_frontends/bridge/dbus_bridge.py sat_frontends/bridge/pb.py sat_frontends/jp/arg_tools.py sat_frontends/jp/base.py sat_frontends/jp/cmd_account.py sat_frontends/jp/cmd_adhoc.py sat_frontends/jp/cmd_application.py sat_frontends/jp/cmd_avatar.py sat_frontends/jp/cmd_blocking.py sat_frontends/jp/cmd_blog.py sat_frontends/jp/cmd_bookmarks.py sat_frontends/jp/cmd_debug.py sat_frontends/jp/cmd_encryption.py sat_frontends/jp/cmd_event.py sat_frontends/jp/cmd_file.py sat_frontends/jp/cmd_forums.py sat_frontends/jp/cmd_identity.py sat_frontends/jp/cmd_info.py sat_frontends/jp/cmd_input.py sat_frontends/jp/cmd_invitation.py sat_frontends/jp/cmd_list.py sat_frontends/jp/cmd_merge_request.py sat_frontends/jp/cmd_message.py sat_frontends/jp/cmd_param.py sat_frontends/jp/cmd_ping.py sat_frontends/jp/cmd_pipe.py sat_frontends/jp/cmd_profile.py sat_frontends/jp/cmd_pubsub.py sat_frontends/jp/cmd_roster.py sat_frontends/jp/cmd_shell.py sat_frontends/jp/cmd_uri.py sat_frontends/jp/common.py sat_frontends/jp/constants.py sat_frontends/jp/loops.py sat_frontends/jp/output_std.py sat_frontends/jp/output_template.py sat_frontends/jp/output_xml.py sat_frontends/jp/output_xmlui.py sat_frontends/jp/xml_tools.py sat_frontends/jp/xmlui_manager.py sat_frontends/primitivus/base.py sat_frontends/primitivus/chat.py sat_frontends/primitivus/contact_list.py sat_frontends/primitivus/game_tarot.py sat_frontends/primitivus/profile_manager.py sat_frontends/primitivus/progress.py sat_frontends/primitivus/status.py sat_frontends/primitivus/widget.py sat_frontends/primitivus/xmlui.py sat_frontends/quick_frontend/constants.py sat_frontends/quick_frontend/quick_app.py sat_frontends/quick_frontend/quick_blog.py sat_frontends/quick_frontend/quick_chat.py sat_frontends/quick_frontend/quick_contact_list.py sat_frontends/quick_frontend/quick_contact_management.py sat_frontends/quick_frontend/quick_game_tarot.py sat_frontends/quick_frontend/quick_games.py sat_frontends/quick_frontend/quick_menus.py sat_frontends/quick_frontend/quick_profile_manager.py sat_frontends/quick_frontend/quick_utils.py sat_frontends/quick_frontend/quick_widgets.py sat_frontends/tools/css_color.py sat_frontends/tools/xmlui.py setup.py tests/e2e/libervia-cli/conftest.py tests/e2e/libervia-cli/test_libervia-cli.py tests/e2e/run_e2e.py tests/unit/conftest.py tests/unit/test_ap-gateway.py tests/unit/test_plugin_xep_0082.py tests/unit/test_plugin_xep_0167.py tests/unit/test_plugin_xep_0176.py tests/unit/test_plugin_xep_0215.py tests/unit/test_plugin_xep_0293.py tests/unit/test_plugin_xep_0294.py tests/unit/test_plugin_xep_0320.py tests/unit/test_plugin_xep_0338.py tests/unit/test_plugin_xep_0339.py tests/unit/test_plugin_xep_0373.py tests/unit/test_plugin_xep_0420.py tests/unit/test_pubsub-cache.py twisted/plugins/sat_plugin.py
diffstat 574 files changed, 100353 insertions(+), 100353 deletions(-) [+]
line wrap: on
line diff
Binary file i18n/fr/LC_MESSAGES/libervia_backend.mo has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/i18n/fr/LC_MESSAGES/libervia_backend.po	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,9225 @@
+# French translations for Libervia.
+# Copyright (C) 2021 ORGANIZATION
+# This file is distributed under the same license as the Libervia project.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2021.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version:  0.0.2\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2021-06-15 10:11+0200\n"
+"PO-Revision-Date: 2010-03-05 19:24+1100\n"
+"Last-Translator: Goffi <goffi@goffi.org>\n"
+"Language: fr\n"
+"Language-Team: French <goffi@goffi.org>\n"
+"Plural-Forms: nplurals=2; plural=(n > 1)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.9.0\n"
+
+#: sat/bridge/bridge_constructor/constructors/dbus/dbus_core_template.py:273
+#: sat/bridge/bridge_constructor/constructors/dbus/dbus_frontend_template.py:85
+#: sat/bridge/bridge_constructor/generated/dbus_bridge.py:85
+#: sat/bridge/dbus_bridge.py:747 sat_frontends/bridge/dbus_bridge.py:85
+msgid ""
+"D-Bus is not launched, please see README to see instructions on how to "
+"launch it"
+msgstr ""
+
+#: sat/bridge/bridge_constructor/constructors/dbus/dbus_frontend_template.py:99
+#: sat/bridge/bridge_constructor/generated/dbus_bridge.py:99
+#: sat_frontends/bridge/dbus_bridge.py:99
+#, fuzzy
+msgid "Unknown interface"
+msgstr "Type d'action inconnu"
+
+#: sat/core/sat_main.py:212
+#, fuzzy
+msgid "Memory initialised"
+msgstr "Le flux XML est initialisé"
+
+#: sat/core/sat_main.py:219
+msgid "Could not initialize backend: {reason}"
+msgstr ""
+
+#: sat/core/sat_main.py:227
+msgid "Backend is ready"
+msgstr ""
+
+#: sat/core/sat_main.py:238
+msgid "Following profiles will be connected automatically: {profiles}"
+msgstr ""
+
+#: sat/core/sat_main.py:251
+#, fuzzy
+msgid "Can't autoconnect profile {profile}: {reason}"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat/core/sat_main.py:321
+msgid ""
+"Can't import plugin [{path}]:\n"
+"{error}"
+msgstr ""
+
+#: sat/core/sat_main.py:340
+msgid "{type} type must be used with {mode} mode, ignoring plugin"
+msgstr ""
+
+#: sat/core/sat_main.py:349
+msgid ""
+"Name conflict for import name [{import_name}], can't import plugin "
+"[{name}]"
+msgstr ""
+
+#: sat/core/sat_main.py:385
+msgid "Recommended plugin not found: {}"
+msgstr ""
+
+#: sat/core/sat_main.py:406
+msgid "Can't import plugin {name}: {error}"
+msgstr ""
+
+#: sat/core/sat_main.py:478
+#, fuzzy
+msgid "already connected !"
+msgstr "Vous  n'êtes pas connecté !"
+
+#: sat/core/sat_main.py:495
+msgid "not connected !"
+msgstr "Vous  n'êtes pas connecté !"
+
+#: sat/core/sat_main.py:591
+msgid "Trying to remove reference to a client not referenced"
+msgstr ""
+
+#: sat/core/sat_main.py:604
+msgid "running app"
+msgstr "Lancement de l'application"
+
+#: sat/core/sat_main.py:608
+msgid "stopping app"
+msgstr "Arrêt de l'application"
+
+#: sat/core/sat_main.py:646
+msgid "profile_key must not be empty"
+msgstr ""
+
+#: sat/core/sat_main.py:666
+msgid "Unexpected error: {failure_}"
+msgstr ""
+
+#: sat/core/sat_main.py:921
+msgid "asking connection status for a non-existant profile"
+msgstr "demande de l'état de connexion pour un profile qui n'existe pas"
+
+#: sat/core/sat_main.py:1020
+#, fuzzy, python-format
+msgid "subsciption request [%(subs_type)s] for %(jid)s"
+msgstr "demande d'inscription [%(type)s] pour %(jid)s"
+
+#: sat/core/sat_main.py:1162
+msgid "Can't find features for service {service_jid}, ignoring"
+msgstr ""
+
+#: sat/core/sat_main.py:1221
+msgid "Can't retrieve {full_jid} infos, ignoring"
+msgstr ""
+
+#: sat/core/sat_main.py:1292
+msgid "Trying to remove an unknow progress callback"
+msgstr "Tentative d'effacement d'une callback de progression inconnue."
+
+#: sat/core/sat_main.py:1382
+#, fuzzy
+msgid "id already registered"
+msgstr "Vous êtes maintenant désinscrit"
+
+#: sat/core/sat_main.py:1424
+#, fuzzy
+msgid "trying to launch action with a non-existant profile"
+msgstr "Tentative d'ajout d'un contact à un profile inexistant"
+
+#: sat/core/sat_main.py:1520
+#, fuzzy
+msgid "A menu with the same path and type already exists"
+msgstr "Ce nom de profile existe déjà"
+
+#: sat/core/sat_main.py:1619
+#, fuzzy
+msgid "help_string"
+msgstr "enregistrement"
+
+#: sat/core/xmpp.py:196
+#, fuzzy
+msgid "Can't parse port value, using default value"
+msgstr "Pas de modèle de paramètres, utilisation du modèle par défaut"
+
+#: sat/core/xmpp.py:223
+msgid "We'll use the stable resource {resource}"
+msgstr ""
+
+#: sat/core/xmpp.py:255
+msgid "setting plugins parents"
+msgstr "Configuration des parents des extensions"
+
+#: sat/core/xmpp.py:275
+#, fuzzy
+msgid "Plugins initialisation error"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/core/xmpp.py:297
+msgid "Error while disconnecting: {}"
+msgstr ""
+
+#: sat/core/xmpp.py:301
+#, fuzzy
+msgid "{profile} identified"
+msgstr "Aucun profile sélectionné"
+
+#: sat/core/xmpp.py:309
+msgid "XML stream is initialized"
+msgstr "Le flux XML est initialisé"
+
+#: sat/core/xmpp.py:317
+#, fuzzy, python-format
+msgid "********** [{profile}] CONNECTED **********"
+msgstr "********** [%s] CONNECTÉ **********"
+
+#: sat/core/xmpp.py:343
+#, python-format
+msgid "ERROR: XMPP connection failed for profile '%(profile)s': %(reason)sprofile"
+msgstr ""
+
+#: sat/core/xmpp.py:398
+msgid "stopping connection because of network disabled"
+msgstr ""
+
+#: sat/core/xmpp.py:421
+msgid "network is available, trying to connect"
+msgstr ""
+
+#: sat/core/xmpp.py:445
+#, fuzzy, python-format
+msgid "********** [{profile}] DISCONNECTED **********"
+msgstr "********** [%s] CONNECTÉ **********"
+
+#: sat/core/xmpp.py:464
+msgid ""
+"Your server certificate is not valid (its identity can't be checked).\n"
+"\n"
+"This should never happen and may indicate that somebody is trying to spy "
+"on you.\n"
+"Please contact your server administrator."
+msgstr ""
+
+#: sat/core/xmpp.py:515
+#, fuzzy
+msgid "Disconnecting..."
+msgstr "Déconnexion..."
+
+#: sat/core/xmpp.py:688
+#, fuzzy, python-format
+msgid "Sending message (type {type}, to {to})"
+msgstr "Envoi du message jabber à %s"
+
+#: sat/core/xmpp.py:696
+msgid ""
+"Triggers, storage and echo have been inhibited by the 'send_only' "
+"parameter"
+msgstr ""
+
+#: sat/core/xmpp.py:762
+#, fuzzy, python-format
+msgid "No message found"
+msgstr "message reçu de: %s"
+
+#: sat/core/xmpp.py:814
+msgid "invalid data used for host: {data}"
+msgstr ""
+
+#: sat/core/xmpp.py:839
+msgid ""
+"Certificate validation is deactivated, this is unsecure and somebody may "
+"be spying on you. If you have no good reason to disable certificate "
+"validation, please activate \"Check certificate\" in your settings in "
+"\"Connection\" tab."
+msgstr ""
+
+#: sat/core/xmpp.py:843
+msgid "Security notice"
+msgstr ""
+
+#: sat/core/xmpp.py:978
+msgid "The requested entry point ({entry_point}) is not available"
+msgstr ""
+
+#: sat/core/xmpp.py:1016
+msgid ""
+"Plugin {current_name} is needed for {entry_name}, but it doesn't handle "
+"component mode"
+msgstr ""
+
+#: sat/core/xmpp.py:1024
+msgid "invalid plugin mode"
+msgstr ""
+
+#: sat/core/xmpp.py:1128
+msgid "parseMessage used with a non <message/> stanza, ignoring: {xml}"
+msgstr ""
+
+#: sat/core/xmpp.py:1140
+msgid "received <message> with a wrong namespace: {xml}"
+msgstr ""
+
+#: sat/core/xmpp.py:1226
+#, fuzzy, python-format
+msgid "got message from: {from_}"
+msgstr "message reçu de: %s"
+
+#: sat/core/xmpp.py:1341
+msgid "There's no subscription between you and [{}]!"
+msgstr ""
+
+#: sat/core/xmpp.py:1346
+msgid "You are not subscribed to [{}]!"
+msgstr ""
+
+#: sat/core/xmpp.py:1348
+msgid "[{}] is not subscribed to you!"
+msgstr ""
+
+#: sat/core/xmpp.py:1384
+msgid "our server support roster versioning, we use it"
+msgstr ""
+
+#: sat/core/xmpp.py:1390
+msgid "no roster in cache, we start fresh"
+msgstr ""
+
+#: sat/core/xmpp.py:1394
+msgid "We have roster v{version} in cache"
+msgstr ""
+
+#: sat/core/xmpp.py:1405
+msgid "our server doesn't support roster versioning"
+msgstr ""
+
+#: sat/core/xmpp.py:1462
+msgid "adding {entity} to roster"
+msgstr ""
+
+#: sat/core/xmpp.py:1486
+#, fuzzy, python-format
+msgid "removing {entity} from roster"
+msgstr "supppression du contact %s"
+
+#: sat/core/xmpp.py:1640
+#, python-format
+msgid "presence update for [%(entity)s] (unavailable, statuses=%(statuses)s)"
+msgstr ""
+"Mise à jour de l'information de présence pour [%(entity)s] (unavailable, "
+"statuses=%(statuses)s)"
+
+#: sat/core/xmpp.py:1724
+#, fuzzy
+msgid "sending automatic \"from\" subscription request"
+msgstr "envoi automatique de la demande d'inscription \"to\""
+
+#: sat/core/xmpp.py:1732
+#, python-format
+msgid "subscription approved for [%s]"
+msgstr "inscription approuvée pour [%s]"
+
+#: sat/core/xmpp.py:1736
+#, fuzzy, python-format
+msgid "unsubscription confirmed for [%s]"
+msgstr "demande de désinscription pour [%s]"
+
+#: sat/core/xmpp.py:1741
+#, fuzzy, python-format
+msgid "subscription request from [%s]"
+msgstr "inscription approuvée pour [%s]"
+
+#: sat/core/xmpp.py:1747
+#, fuzzy
+msgid "sending automatic subscription acceptance"
+msgstr "envoi automatique de la demande d'inscription \"to\""
+
+#: sat/core/xmpp.py:1759
+#, python-format
+msgid "unsubscription asked for [%s]"
+msgstr "demande de désinscription pour [%s]"
+
+#: sat/core/xmpp.py:1763
+#, fuzzy
+msgid "automatic contact deletion"
+msgstr "Sélection du contrat"
+
+#: sat/memory/cache.py:69
+msgid "Can't read metadata file at {path}"
+msgstr ""
+
+#: sat/memory/cache.py:80
+msgid "Invalid cache metadata at {path}"
+msgstr ""
+
+#: sat/memory/cache.py:87
+msgid "cache {cache_file!r} references an inexisting file: {filepath!r}"
+msgstr ""
+
+#: sat/memory/cache.py:102
+msgid "following file is missing while purging cache: {path}"
+msgstr ""
+
+#: sat/memory/cache.py:200
+msgid "missing filename for cache {uid!r}"
+msgstr ""
+
+#: sat/memory/cache.py:207
+msgid "missing file referenced in cache {uid!r}: {filename}"
+msgstr ""
+
+#: sat/memory/disco.py:95
+msgid ""
+"no feature/identity found in disco element (hash: {cap_hash}), ignoring: "
+"{xml}"
+msgstr ""
+
+#: sat/memory/disco.py:274
+#, python-format
+msgid "Error while requesting [%(jid)s]: %(error)s"
+msgstr ""
+
+#: sat/memory/disco.py:338
+msgid "received an item without jid"
+msgstr ""
+
+#: sat/memory/disco.py:410
+msgid "Capability hash generated: [{cap_hash}]"
+msgstr ""
+
+#: sat/memory/disco.py:459
+msgid "invalid item (no jid)"
+msgstr ""
+
+#: sat/memory/encryption.py:71
+msgid "Could not restart {namespace!r} encryption with {entity}: {err}"
+msgstr ""
+
+#: sat/memory/encryption.py:74
+msgid "encryption sessions restored"
+msgstr ""
+
+#: sat/memory/encryption.py:116
+msgid "Encryption plugin registered: {name}"
+msgstr ""
+
+#: sat/memory/encryption.py:127
+msgid "Can't find requested encryption plugin: {namespace}"
+msgstr ""
+
+#: sat/memory/encryption.py:148
+msgid "Can't find a plugin with the name \"{name}\"."
+msgstr ""
+
+#: sat/memory/encryption.py:213
+msgid "No encryption plugin is registered, an encryption session can't be started"
+msgstr ""
+
+#: sat/memory/encryption.py:226
+msgid "Session with {bare_jid} is already encrypted with {name}. Nothing to do."
+msgstr ""
+
+#: sat/memory/encryption.py:237
+msgid ""
+"Session with {bare_jid} is already encrypted with {name}. Please stop "
+"encryption session before changing algorithm."
+msgstr ""
+
+#: sat/memory/encryption.py:249
+msgid "No resource found for {destinee}, can't encrypt with {name}"
+msgstr ""
+
+#: sat/memory/encryption.py:251
+msgid "No resource specified to encrypt with {name}, using {destinee}."
+msgstr ""
+
+#: sat/memory/encryption.py:257
+msgid "{name} encryption must be used with bare jids."
+msgstr ""
+
+#: sat/memory/encryption.py:261
+msgid "Encryption session has been set for {entity_jid} with {encryption_name}"
+msgstr ""
+
+#: sat/memory/encryption.py:268
+msgid ""
+"Encryption session started: your messages with {destinee} are now end to "
+"end encrypted using {name} algorithm."
+msgstr ""
+
+#: sat/memory/encryption.py:273
+msgid "Message are encrypted only for {nb_devices} device(s): {devices_list}."
+msgstr ""
+
+#: sat/memory/encryption.py:291
+msgid "There is no encryption session with this entity."
+msgstr ""
+
+#: sat/memory/encryption.py:295
+msgid ""
+"The encryption session is not run with the expected plugin: encrypted "
+"with {current_name} and was expecting {expected_name}"
+msgstr ""
+
+#: sat/memory/encryption.py:304
+msgid ""
+"There is a session for the whole entity (i.e. all devices of the entity),"
+" not a directed one. Please use bare jid if you want to stop the whole "
+"encryption with this entity."
+msgstr ""
+
+#: sat/memory/encryption.py:312
+msgid "There is no directed session with this entity."
+msgstr ""
+
+#: sat/memory/encryption.py:327
+msgid "encryption session stopped with entity {entity}"
+msgstr ""
+
+#: sat/memory/encryption.py:335
+msgid ""
+"Encryption session finished: your messages with {destinee} are NOT end to"
+" end encrypted anymore.\n"
+"Your server administrators or {destinee} server administrators will be "
+"able to read them."
+msgstr ""
+
+#: sat/memory/encryption.py:389 sat/memory/encryption.py:397
+#: sat/memory/encryption.py:404
+#, fuzzy
+msgid "Encryption"
+msgstr "Connexion..."
+
+#: sat/memory/encryption.py:389
+msgid "unencrypted (plain text)"
+msgstr ""
+
+#: sat/memory/encryption.py:392
+msgid "End encrypted session"
+msgstr ""
+
+#: sat/memory/encryption.py:400
+msgid "Start {name} session"
+msgstr ""
+
+#: sat/memory/encryption.py:404
+msgid "⛨ {name} trust"
+msgstr ""
+
+#: sat/memory/encryption.py:407
+msgid "Manage {name} trust"
+msgstr ""
+
+#: sat/memory/encryption.py:470
+msgid "Starting e2e session with {peer_jid} as we receive encrypted messages"
+msgstr ""
+
+#: sat/memory/memory.py:230
+msgid "Memory manager init"
+msgstr "Initialisation du gestionnaire de mémoire"
+
+#: sat/memory/memory.py:249
+#, fuzzy
+msgid "Loading default params template"
+msgstr "Impossible de charger le modèle des paramètres !"
+
+#: sat/memory/memory.py:281
+#, python-format
+msgid "Parameters loaded from file: %s"
+msgstr ""
+
+#: sat/memory/memory.py:284
+#, fuzzy, python-format
+msgid "Can't load parameters from file: %s"
+msgstr "Impossible de charger le modèle des paramètres !"
+
+#: sat/memory/memory.py:299
+#, fuzzy, python-format
+msgid "Parameters saved to file: %s"
+msgstr "Échec de la désinscription: %s"
+
+#: sat/memory/memory.py:302
+#, fuzzy, python-format
+msgid "Can't save parameters to file: %s"
+msgstr "Impossible de charger le modèle des paramètres !"
+
+#: sat/memory/memory.py:404
+msgid "Authentication failure of profile {profile}"
+msgstr ""
+
+#: sat/memory/memory.py:431
+#, fuzzy, python-format
+msgid "[%s] Profile session purge"
+msgstr "Ce profile n'est pas utilisé"
+
+#: sat/memory/memory.py:437
+#, python-format
+msgid "Trying to purge roster status cache for a profile not in memory: [%s]"
+msgstr ""
+
+#: sat/memory/memory.py:451
+msgid "requesting no profiles at all"
+msgstr ""
+
+#: sat/memory/memory.py:508
+msgid "Can't find component {component} entry point"
+msgstr ""
+
+#: sat/memory/memory.py:996
+msgid "Need a bare jid to delete all resources"
+msgstr ""
+
+#: sat/memory/memory.py:1028
+#, python-format
+msgid "Trying to encrypt a value for %s while the personal key is undefined!"
+msgstr ""
+
+#: sat/memory/memory.py:1048
+#, python-format
+msgid "Trying to decrypt a value for %s while the personal key is undefined!"
+msgstr ""
+
+#: sat/memory/memory.py:1069
+#, python-format
+msgid "Personal data (%(ns)s, %(key)s) has been successfuly encrypted"
+msgstr ""
+
+#: sat/memory/memory.py:1097
+msgid "Asking waiting subscriptions for a non-existant profile"
+msgstr "Demande des inscriptions en attente pour un profile inexistant"
+
+#: sat/memory/memory.py:1218
+msgid "invalid permission"
+msgstr ""
+
+#: sat/memory/memory.py:1249
+#, fuzzy
+msgid "unknown access type: {type}"
+msgstr "Type d'action inconnu"
+
+#: sat/memory/memory.py:1284
+msgid "You can't use path and parent at the same time"
+msgstr ""
+
+#: sat/memory/memory.py:1288
+msgid "\"..\" or \".\" can't be used in path"
+msgstr ""
+
+#: sat/memory/memory.py:1307
+msgid "Several directories found, this should not happen"
+msgstr ""
+
+#: sat/memory/memory.py:1766
+msgid "Can't delete directory, it is not empty"
+msgstr ""
+
+#: sat/memory/memory.py:1778
+msgid "deleting file {name} with hash {file_hash}"
+msgstr ""
+
+#: sat/memory/memory.py:1787
+msgid "no reference left to {file_path}, deleting"
+msgstr ""
+
+#: sat/memory/params.py:85 sat_frontends/primitivus/base.py:533
+msgid "General"
+msgstr "Général"
+
+#: sat/memory/params.py:86
+#, fuzzy
+msgid "Connection"
+msgstr "Connexion..."
+
+#: sat/memory/params.py:88
+msgid "Chat history limit"
+msgstr ""
+
+#: sat/memory/params.py:90
+msgid "Show offline contacts"
+msgstr ""
+
+#: sat/memory/params.py:92
+msgid "Show empty groups"
+msgstr ""
+
+#: sat/memory/params.py:95
+#, fuzzy
+msgid "Connect on backend startup"
+msgstr "Connexion au démarrage des frontends"
+
+#: sat/memory/params.py:96
+#, fuzzy
+msgid "Connect on frontend startup"
+msgstr "Connexion au démarrage des frontends"
+
+#: sat/memory/params.py:97
+#, fuzzy
+msgid "Disconnect on frontend closure"
+msgstr "Déconnexion à la fermeture des frontends"
+
+#: sat/memory/params.py:98
+msgid "Check certificate (don't uncheck if unsure)"
+msgstr ""
+
+#: sat/memory/params.py:163
+#, fuzzy, python-format
+msgid "Trying to purge cache of a profile not in memory: [%s]"
+msgstr "Tentative d'appel d'un profile inconnue"
+
+#: sat/memory/params.py:188
+#, fuzzy
+msgid "The profile name already exists"
+msgstr "Ce nom de profile existe déjà"
+
+#: sat/memory/params.py:203
+#, fuzzy
+msgid "Trying to delete an unknown profile"
+msgstr "Tentative d'accès à un profile inconnu"
+
+#: sat/memory/params.py:209
+#, fuzzy
+msgid "Trying to delete a connected profile"
+msgstr "Tentative de suppression d'un contact pour un profile inexistant"
+
+#: sat/memory/params.py:228
+msgid "No default profile, returning first one"
+msgstr "Pas de profile par défaut, envoi du premier"
+
+#: sat/memory/params.py:234
+#, fuzzy
+msgid "No profile exist yet"
+msgstr "Aucun profile sélectionné"
+
+#: sat/memory/params.py:244
+#, fuzzy, python-format
+msgid "Trying to access an unknown profile (%s)"
+msgstr "Tentative d'accès à un profile inconnu"
+
+#: sat/memory/params.py:338
+msgid "Trying to register frontends parameters with no specified app: aborted"
+msgstr ""
+
+#: sat/memory/params.py:347
+#, python-format
+msgid "Trying to register twice frontends parameters for %(app)s: abortedapp"
+msgstr ""
+
+#: sat/memory/params.py:363
+#, python-format
+msgid "Can't determine default value for [%(category)s/%(name)s]: %(reason)s"
+msgstr ""
+"Impossible de déterminer la valeur par défaut pour "
+"[%(category)s/%(name)s]: %(reason)s"
+
+#: sat/memory/params.py:385 sat/memory/params.py:563 sat/memory/params.py:624
+#, python-format
+msgid "Requested param [%(name)s] in category [%(category)s] doesn't exist !"
+msgstr ""
+"Le paramètre demandé  [%(name)s] dans la catégorie [%(category)s] "
+"n'existe pas !"
+
+#: sat/memory/params.py:440
+#, python-format
+msgid ""
+"Unset parameter (%(cat)s, %(param)s) of type list will use the default "
+"option '%(value)s'"
+msgstr ""
+
+#: sat/memory/params.py:448
+#, python-format
+msgid "Parameter (%(cat)s, %(param)s) of type list has no default option!"
+msgstr ""
+
+#: sat/memory/params.py:455
+#, python-format
+msgid ""
+"Parameter (%(cat)s, %(param)s) of type list has more than one default "
+"option!"
+msgstr ""
+
+#: sat/memory/params.py:585
+msgid "Requesting a param for an non-existant profile"
+msgstr "Demande d'un paramètre pour un profile inconnu"
+
+#: sat/memory/params.py:589
+#, fuzzy
+msgid "Requesting synchronous param for not connected profile"
+msgstr "Demande d'un paramètre pour un profile inconnu"
+
+#: sat/memory/params.py:633
+#, python-format
+msgid ""
+"Trying to get parameter '%(param)s' in category '%(cat)s' without "
+"authorization!!!param"
+msgstr ""
+
+#: sat/memory/params.py:649
+#, fuzzy
+msgid "Requesting a param for a non-existant profile"
+msgstr "Demande d'un paramètre pour un profile inconnu"
+
+#: sat/memory/params.py:962
+#, fuzzy
+msgid "Trying to set parameter for an unknown profile"
+msgstr "Tentative d'accès à un profile inconnu"
+
+#: sat/memory/params.py:968
+#, python-format
+msgid "Requesting an unknown parameter (%(category)s/%(name)s)"
+msgstr "Demande d'un paramètre inconnu: (%(category)s/%(name)s)"
+
+#: sat/memory/params.py:974
+msgid ""
+"{profile!r} is trying to set parameter {name!r} in category {category!r} "
+"without authorization!!!"
+msgstr ""
+
+#: sat/memory/params.py:992
+msgid ""
+"Trying to set parameter {name} in category {category} withan non-integer "
+"value"
+msgstr ""
+
+#: sat/memory/params.py:1011
+#, fuzzy, python-format
+msgid "Setting parameter (%(category)s, %(name)s) = %(value)s"
+msgstr "Demande d'un paramètre inconnu: (%(category)s/%(name)s)"
+
+#: sat/memory/params.py:1043
+msgid "Trying to encrypt a password while the personal key is undefined!"
+msgstr ""
+
+#: sat/memory/persistent.py:45
+msgid "PersistentDict can't be used before memory initialisation"
+msgstr ""
+
+#: sat/memory/persistent.py:175
+msgid "Calling load on LazyPersistentBinaryDict while it's not needed"
+msgstr ""
+
+#: sat/memory/sqlite.py:163
+msgid ""
+"too many db tries, we abandon! Error message: {msg}\n"
+"query was {query}"
+msgstr ""
+
+#: sat/memory/sqlite.py:166
+msgid "exception while running query, retrying ({try_}): {msg}"
+msgstr ""
+
+#: sat/memory/sqlite.py:188
+msgid ""
+"too many interaction tries, we abandon! Error message: {msg}\n"
+"interaction method was: {interaction}\n"
+"interaction arguments were: {args}"
+msgstr ""
+
+#: sat/memory/sqlite.py:191
+msgid "exception while running interaction, retrying ({try_}): {msg}"
+msgstr ""
+
+#: sat/memory/sqlite.py:210
+msgid "Connecting database"
+msgstr ""
+
+#: sat/memory/sqlite.py:223
+#, fuzzy
+msgid "The database is new, creating the tables"
+msgstr "Ce nom de profile existe déjà"
+
+#: sat/memory/sqlite.py:337
+#, fuzzy, python-format
+msgid "Can't delete profile [%s]"
+msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
+
+#: sat/memory/sqlite.py:354
+#, fuzzy, python-format
+msgid "Profile [%s] deleted"
+msgstr "Aucun profile sélectionné"
+
+#: sat/memory/sqlite.py:370
+#, fuzzy
+msgid "loading general parameters from database"
+msgstr "Impossible de charger les paramètres généraux !"
+
+#: sat/memory/sqlite.py:385
+#, fuzzy
+msgid "loading individual parameters from database"
+msgstr "Impossible de charger les paramètres individuels !"
+
+#: sat/memory/sqlite.py:426
+#, fuzzy, python-format
+msgid "Can't set general parameter (%(category)s/%(name)s) in databasecategory"
+msgstr "Demande d'un paramètre inconnu: (%(category)s/%(name)s)"
+
+#: sat/memory/sqlite.py:439
+#, fuzzy, python-format
+msgid ""
+"Can't set individual parameter (%(category)s/%(name)s) for [%(profile)s] "
+"in databasecategory"
+msgstr ""
+"Impossible de déterminer la valeur par défaut pour "
+"[%(category)s/%(name)s]: %(reason)s"
+
+#: sat/memory/sqlite.py:459
+msgid "Can't save following {key} in history (uid: {uid}, lang:{lang}): {value}"
+msgstr ""
+
+#: sat/memory/sqlite.py:473
+msgid ""
+"Can't save following thread in history (uid: {uid}): thread: {thread}), "
+"parent:{parent}"
+msgstr ""
+
+#: sat/memory/sqlite.py:498
+msgid ""
+"Can't save following message in history: from [{from_jid}] to [{to_jid}] "
+"(uid: {uid})"
+msgstr ""
+
+#: sat/memory/sqlite.py:701
+msgid ""
+"Can't {operation} data in database for namespace "
+"{namespace}{and_key}{for_profile}: {msg}"
+msgstr ""
+
+#: sat/memory/sqlite.py:752
+msgid ""
+"getting {type}{binary} private values from database for namespace "
+"{namespace}{keys}"
+msgstr ""
+
+#: sat/memory/sqlite.py:986
+msgid "Can't save file metadata for [{profile}]: {reason}"
+msgstr ""
+
+#: sat/memory/sqlite.py:1025
+msgid "table not updated, probably due to race condition, trying again ({tries})"
+msgstr ""
+
+#: sat/memory/sqlite.py:1027
+msgid "Can't update file table"
+msgstr ""
+
+#: sat/memory/sqlite.py:1132
+msgid ""
+"Your local schema is up-to-date, but database versions mismatch, fixing "
+"it..."
+msgstr ""
+
+#: sat/memory/sqlite.py:1142
+msgid ""
+"There is a schema mismatch, but as we are on a dev version, database will"
+" be updated"
+msgstr ""
+
+#: sat/memory/sqlite.py:1146
+msgid ""
+"schema version is up-to-date, but local schema differ from expected "
+"current schema"
+msgstr ""
+
+#: sat/memory/sqlite.py:1149
+#, python-format
+msgid ""
+"Here are the commands that should fix the situation, use at your own risk"
+" (do a backup before modifying database), you can go to SàT's MUC room at"
+" sat@chat.jabberfr.org for help\n"
+"### SQL###\n"
+"%s\n"
+"### END SQL ###\n"
+msgstr ""
+
+#: sat/memory/sqlite.py:1153
+msgid ""
+"You database version is higher than the one used in this SàT version, are"
+" you using several version at the same time? We can't run SàT with this "
+"database."
+msgstr ""
+
+#: sat/memory/sqlite.py:1161
+msgid ""
+"Database content needs a specific processing, local database will be "
+"updated"
+msgstr ""
+
+#: sat/memory/sqlite.py:1163
+msgid "Database schema has changed, local database will be updated"
+msgstr ""
+
+#: sat/plugins/plugin_adhoc_dbus.py:91
+#, fuzzy
+msgid "Add D-Bus management to Ad-Hoc commands"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_adhoc_dbus.py:98
+#, fuzzy
+msgid "plugin Ad-Hoc D-Bus initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_adhoc_dbus.py:127
+msgid "Media Players"
+msgstr ""
+
+#: sat/plugins/plugin_adhoc_dbus.py:255
+#, fuzzy
+msgid "Command selection"
+msgstr "Sélection du contrat"
+
+#: sat/plugins/plugin_adhoc_dbus.py:298
+#, fuzzy
+msgid "Updated"
+msgstr "mise à jour de %s"
+
+#: sat/plugins/plugin_adhoc_dbus.py:302
+msgid "Command sent"
+msgstr ""
+
+#: sat/plugins/plugin_adhoc_dbus.py:367
+msgid "Can't retrieve remote controllers on {device_jid}: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_adhoc_dbus.py:405
+#, fuzzy
+msgid "No media player found."
+msgstr "Aucune donnée trouvée"
+
+#: sat/plugins/plugin_adhoc_dbus.py:409 sat/plugins/plugin_adhoc_dbus.py:451
+#, fuzzy
+msgid "Media Player Selection"
+msgstr "Sélection du contrat"
+
+#: sat/plugins/plugin_adhoc_dbus.py:414
+msgid "Ignoring MPRIS bus without suffix"
+msgstr ""
+
+#: sat/plugins/plugin_adhoc_dbus.py:428
+msgid "missing media_player value"
+msgstr ""
+
+#: sat/plugins/plugin_adhoc_dbus.py:431
+msgid ""
+"Media player ad-hoc command trying to use non MPRIS bus. Hack attempt? "
+"Refused bus: {bus_name}"
+msgstr ""
+
+#: sat/plugins/plugin_adhoc_dbus.py:434
+msgid "Invalid player name."
+msgstr ""
+
+#: sat/plugins/plugin_adhoc_dbus.py:440
+msgid "Can't get D-Bus proxy: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_adhoc_dbus.py:441
+msgid "Media player is not available anymore"
+msgstr ""
+
+#: sat/plugins/plugin_adhoc_dbus.py:460
+msgid "Can't retrieve attribute {name}: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_blog_import.py:45
+msgid ""
+"Blog import management:\n"
+"This plugin manage the different blog importers which can register to it,"
+" and handle generic importing tasks."
+msgstr ""
+
+#: sat/plugins/plugin_blog_import.py:64
+#, fuzzy
+msgid "plugin Blog Import initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_blog_import_dokuwiki.py:58
+msgid "Blog importer for Dokuwiki blog engine."
+msgstr ""
+
+#: sat/plugins/plugin_blog_import_dokuwiki.py:61
+msgid "import posts from Dokuwiki blog engine"
+msgstr ""
+
+#: sat/plugins/plugin_blog_import_dokuwiki.py:63
+msgid ""
+"This importer handle Dokuwiki blog engine.\n"
+"\n"
+"To use it, you need an admin access to a running Dokuwiki website\n"
+"(local or on the Internet). The importer retrieves the data using\n"
+"the XMLRPC Dokuwiki API.\n"
+"\n"
+"You can specify a namespace (that could be a namespace directory\n"
+"or a single post) or leave it empty to use the root namespace \"/\"\n"
+"and import all the posts.\n"
+"\n"
+"You can specify a new media repository to modify the internal\n"
+"media links and make them point to the URL of your choice, but\n"
+"note that the upload is not done automatically: a temporary\n"
+"directory will be created on your local drive and you will\n"
+"need to upload it yourself to your repository via SSH or FTP.\n"
+"\n"
+"Following options are recognized:\n"
+"\n"
+"location: DokuWiki site URL\n"
+"user: DokuWiki admin user\n"
+"passwd: DokuWiki admin password\n"
+"namespace: DokuWiki namespace to import (default: root namespace \"/\")\n"
+"media_repo: URL to the new remote media repository (default: none)\n"
+"limit: maximal number of posts to import (default: 100)\n"
+"\n"
+"Example of usage (with jp frontend):\n"
+"\n"
+"jp import dokuwiki -p dave --pwd xxxxxx --connect\n"
+"    http://127.0.1.1 -o user souliane -o passwd qwertz\n"
+"    -o namespace public:2015:10\n"
+"    -o media_repo http://media.diekulturvermittlung.at\n"
+"\n"
+"This retrieves the 100 last blog posts from http://127.0.1.1 that\n"
+"are inside the namespace \"public:2015:10\" using the Dokuwiki user\n"
+"\"souliane\", and it imports them to sat profile dave's microblog node.\n"
+"Internal Dokuwiki media that were hosted on http://127.0.1.1 are now\n"
+"pointing to http://media.diekulturvermittlung.at.\n"
+msgstr ""
+
+#: sat/plugins/plugin_blog_import_dokuwiki.py:351
+#, fuzzy
+msgid "plugin Dokuwiki Import initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_blog_import_dokuwiki.py:383
+msgid ""
+"DokuWiki media files will be *downloaded* to {temp_dir} - to finish the "
+"import you have to upload them *manually* to {media_repo}"
+msgstr ""
+
+#: sat/plugins/plugin_blog_import_dokuwiki.py:389
+msgid ""
+"DokuWiki media files will be *uploaded* to the XMPP server. Hyperlinks to"
+" these media may not been updated though."
+msgstr ""
+
+#: sat/plugins/plugin_blog_import_dokuwiki.py:393
+msgid ""
+"DokuWiki media files will *stay* on {location} - some of them may be "
+"protected by DokuWiki ACL and will not be accessible."
+msgstr ""
+
+#: sat/plugins/plugin_blog_import_dotclear.py:42
+msgid "Blog importer for Dotclear blog engine."
+msgstr ""
+
+#: sat/plugins/plugin_blog_import_dotclear.py:45
+msgid "import posts from Dotclear blog engine"
+msgstr ""
+
+#: sat/plugins/plugin_blog_import_dotclear.py:47
+msgid ""
+"This importer handle Dotclear blog engine.\n"
+"\n"
+"To use it, you'll need to export your blog to a flat file.\n"
+"You must go in your admin interface and select Plugins/Maintenance then "
+"Backup.\n"
+"Export only one blog if you have many, i.e. select \"Download database of"
+" current blog\"\n"
+"Depending on your configuration, your may need to use Import/Export "
+"plugin and export as a flat file.\n"
+"\n"
+"location: you must use the absolute path to your backup for the location "
+"parameter\n"
+msgstr ""
+
+#: sat/plugins/plugin_blog_import_dotclear.py:266
+#, fuzzy
+msgid "plugin Dotclear Import initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_comp_file_sharing.py:69
+msgid "Component hosting and sharing files"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing.py:79
+msgid ""
+"You are over quota, your maximum allowed size is {quota} and you are "
+"already using {used_space}, you can't upload {file_size} more."
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing.py:350
+#, fuzzy
+msgid "File Sharing initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_comp_file_sharing.py:431
+#: sat/plugins/plugin_comp_file_sharing_management.py:422
+msgid "Can't create thumbnail: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing.py:454
+msgid "Reusing already generated hash"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing.py:485
+msgid "Can't get thumbnail for {final_path}: {e}"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing.py:574
+msgid "{peer_jid} is trying to access an unauthorized file: {name}"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing.py:582
+msgid "no matching file found ({file_data})"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:43
+msgid ""
+"Experimental handling of file management for file sharing. This plugins "
+"allows to change permissions of stored files/directories or remove them."
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:72
+#, fuzzy
+msgid "File Sharing Management plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:185
+#, fuzzy
+msgid "file not found"
+msgstr "Aucun profile sélectionné"
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:187
+#: sat/plugins/plugin_comp_file_sharing_management.py:192
+#: sat/plugins/plugin_comp_file_sharing_management.py:474
+msgid "forbidden"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:191
+msgid "Only owner can manage files"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:258
+msgid "Please select permissions for this directory"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:260
+msgid "Please select permissions for this file"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:305
+msgid "Can't use read_allowed values: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:332
+msgid "management session done"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:358
+msgid ""
+"Are you sure to delete directory {name} and all files and directories "
+"under it?"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:362
+#, fuzzy, python-format
+msgid "Are you sure to delete file {name}?"
+msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:387
+#, fuzzy, python-format
+msgid "file deleted"
+msgstr "Aucun profile sélectionné"
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:465
+msgid "thumbnails generated"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:481
+msgid "You are currently using {size_used} on {size_quota}"
+msgstr ""
+
+#: sat/plugins/plugin_comp_file_sharing_management.py:484
+msgid "unlimited quota"
+msgstr ""
+
+#: sat/plugins/plugin_dbg_manhole.py:39
+msgid "Debug plugin to have a telnet server"
+msgstr ""
+
+#: sat/plugins/plugin_dbg_manhole.py:53
+msgid ""
+"/!\\ Manhole debug server activated, be sure to not use it in production,"
+" this is dangerous /!\\"
+msgstr ""
+
+#: sat/plugins/plugin_dbg_manhole.py:55
+msgid "You can connect to manhole server using telnet on port {port}"
+msgstr ""
+
+#: sat/plugins/plugin_exp_command_export.py:39
+#, fuzzy
+msgid "Implementation of command export"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_exp_command_export.py:92
+#, fuzzy
+msgid "Plugin command export initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_exp_events.py:50
+msgid "Experimental implementation of XMPP events management"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:60
+#, fuzzy
+msgid "Event plugin initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_exp_events.py:177
+msgid "no src found for image"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:187
+#, fuzzy
+msgid "no {uri_type} element found!"
+msgstr "Aucun profile sélectionné"
+
+#: sat/plugins/plugin_exp_events.py:189
+msgid "incomplete {uri_type} element"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:191
+msgid "bad {uri_type} element"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:231
+#, fuzzy
+msgid "No event element has been found"
+msgstr "Aucune donnée trouvée"
+
+#: sat/plugins/plugin_exp_events.py:233
+msgid "No event with this id has been found"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:290
+msgid "event_id must be set"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:333
+msgid "The given URI is not valid: {uri}"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:354
+#: sat/plugins/plugin_exp_list_of_interest.py:100
+#, fuzzy
+msgid "requested node already exists"
+msgstr "Ce nom de profile existe déjà"
+
+#: sat/plugins/plugin_exp_events.py:373
+msgid "missing node"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:426
+msgid "No event found in item {item_id}, ignoring"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:519
+msgid "no data found for {item_id} (service: {service}, node: {node})"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:542 sat/plugins/plugin_exp_events.py:623
+msgid "\"XEP-0277\" (blog) plugin is needed for this feature"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:548
+msgid "got event data"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:579
+msgid "affiliation set on blog and comments nodes"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:619
+msgid "\"Invitations\" plugin is needed for this feature"
+msgstr ""
+
+#: sat/plugins/plugin_exp_events.py:632
+#, fuzzy
+msgid "invitation created"
+msgstr "Connexion..."
+
+#: sat/plugins/plugin_exp_invitation.py:44
+#, fuzzy
+msgid "Experimental handling of invitations"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_exp_invitation.py:57
+#, fuzzy
+msgid "Invitation plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_exp_invitation.py:251
+msgid "Can't get item linked with invitation: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_exp_invitation.py:256
+msgid "Invitation was linking to a non existing item"
+msgstr ""
+
+#: sat/plugins/plugin_exp_invitation.py:262
+msgid "Can't retrieve namespace of invitation: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_exp_invitation.py:281
+msgid "Bad invitation, ignoring"
+msgstr ""
+
+#: sat/plugins/plugin_exp_invitation.py:321
+msgid "No handler for namespace \"{namespace}\", invitation ignored"
+msgstr ""
+
+#: sat/plugins/plugin_exp_invitation_file.py:39
+msgid "Experimental handling of invitations for file sharing"
+msgstr ""
+
+#: sat/plugins/plugin_exp_invitation_file.py:46
+#, fuzzy
+msgid "File Sharing Invitation plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_exp_invitation_file.py:85
+#: sat/plugins/plugin_exp_invitation_file.py:92
+msgid "file sharing"
+msgstr ""
+
+#: sat/plugins/plugin_exp_invitation_file.py:87
+msgid "photo album"
+msgstr ""
+
+#: sat/plugins/plugin_exp_invitation_file.py:93
+msgid ""
+"{profile} has received an invitation for a files repository "
+"({type_human}) with namespace {sharing_ns!r} at path [{path}]"
+msgstr ""
+
+#: sat/plugins/plugin_exp_invitation_pubsub.py:42
+msgid "Invitations for pubsub based features"
+msgstr ""
+
+#: sat/plugins/plugin_exp_invitation_pubsub.py:49
+#, fuzzy
+msgid "Pubsub Invitation plugin initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_exp_jingle_stream.py:52
+msgid "Jingle Stream plugin"
+msgstr ""
+
+#: sat/plugins/plugin_exp_jingle_stream.py:55
+#, fuzzy, python-format
+msgid "{peer} wants to send you a stream, do you accept ?"
+msgstr ""
+"Le contact %(jid)s veut vous envoyer le fichier %(filename)s\n"
+"Êtes vous d'accord ?"
+
+#: sat/plugins/plugin_exp_jingle_stream.py:56
+#, fuzzy
+msgid "Stream Request"
+msgstr "Gestion des paramètres"
+
+#: sat/plugins/plugin_exp_jingle_stream.py:123
+msgid "stream can't be used with multiple consumers"
+msgstr ""
+
+#: sat/plugins/plugin_exp_jingle_stream.py:170
+#, fuzzy
+msgid "No client connected, can't send data"
+msgstr "Connexion du client SOCKS 5 démarrée"
+
+#: sat/plugins/plugin_exp_jingle_stream.py:180
+#, fuzzy
+msgid "Plugin Stream initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_exp_jingle_stream.py:270
+msgid "given port is invalid"
+msgstr ""
+
+#: sat/plugins/plugin_exp_lang_detect.py:45
+msgid "Detect and set message language when unknown"
+msgstr ""
+
+#: sat/plugins/plugin_exp_lang_detect.py:48
+#: sat/plugins/plugin_misc_watched.py:43 sat/plugins/plugin_xep_0249.py:73
+msgid "Misc"
+msgstr "Divers"
+
+#: sat/plugins/plugin_exp_lang_detect.py:50
+msgid "language detection"
+msgstr ""
+
+#: sat/plugins/plugin_exp_lang_detect.py:66
+#, fuzzy
+msgid "Language detection plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_exp_list_of_interest.py:46
+msgid "Experimental handling of interesting XMPP locations"
+msgstr ""
+
+#: sat/plugins/plugin_exp_list_of_interest.py:56
+#, fuzzy
+msgid "List of Interest plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_exp_list_of_interest.py:287
+msgid "Missing interest element: {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_exp_parrot.py:40
+msgid "Implementation of parrot mode (repeat messages between 2 entities)"
+msgstr ""
+
+#: sat/plugins/plugin_exp_parrot.py:56
+#, fuzzy
+msgid "Plugin Parrot initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_exp_parrot.py:63 sat/plugins/plugin_xep_0045.py:150
+#: sat/plugins/plugin_xep_0048.py:102 sat/plugins/plugin_xep_0092.py:61
+#: sat/plugins/plugin_xep_0199.py:56 sat/plugins/plugin_xep_0249.py:95
+#: sat/plugins/plugin_xep_0384.py:476
+msgid "Text commands not available"
+msgstr ""
+
+#: sat/plugins/plugin_exp_pubsub_admin.py:40
+msgid ""
+"\\Implementation of Pubsub Administrator\n"
+"This allows a pubsub administrator to overwrite completly items, "
+"including publisher.\n"
+"Specially useful when importing a node."
+msgstr ""
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:40
+msgid "Experimental plugin to launch on action on Pubsub notifications"
+msgstr ""
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:56
+#, fuzzy
+msgid "PubSub Hook initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:93
+msgid "node manager already set for {node}"
+msgstr ""
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:101
+msgid "node manager installed on {node}"
+msgstr ""
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:107
+#, fuzzy
+msgid "trying to remove a {node} without hook"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:112
+msgid "hook removed"
+msgstr ""
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:114
+msgid "node still needed for an other hook"
+msgstr ""
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:119
+msgid "{hook_type} is not handled"
+msgstr ""
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:123
+#: sat/plugins/plugin_exp_pubsub_hook.py:167
+msgid "{hook_type} hook type not implemented yet"
+msgstr ""
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:139
+msgid "{persistent} hook installed on {node} for {profile}"
+msgstr ""
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:140
+msgid "persistent"
+msgstr ""
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:140
+msgid "temporary"
+msgstr ""
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:173
+msgid "Can't load Pubsub hook at node {node}, it will be removed: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_exp_pubsub_hook.py:185
+msgid "Error while running Pubsub hook for node {node}: {msg}"
+msgstr ""
+
+#: sat/plugins/plugin_import.py:41
+msgid "Generic import plugin, base for specialized importers"
+msgstr ""
+
+#: sat/plugins/plugin_import.py:49
+#, fuzzy
+msgid "plugin Import initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_import.py:67
+msgid "initializing {name} import handler"
+msgstr ""
+
+#: sat/plugins/plugin_import.py:158
+msgid "invalid json option: {option}"
+msgstr ""
+
+#: sat/plugins/plugin_import.py:296
+msgid "uploading subitems"
+msgstr ""
+
+#: sat/plugins/plugin_import.py:327
+#, fuzzy
+msgid "An {handler_name} importer with the name {name} already exist"
+msgstr "Ce nom de profile existe déjà"
+
+#: sat/plugins/plugin_merge_req_mercurial.py:37
+msgid "Merge request handler for Mercurial"
+msgstr ""
+
+#: sat/plugins/plugin_merge_req_mercurial.py:40
+msgid "handle Mercurial repository"
+msgstr ""
+
+#: sat/plugins/plugin_merge_req_mercurial.py:71
+msgid "Mercurial merge request handler initialization"
+msgstr ""
+
+#: sat/plugins/plugin_merge_req_mercurial.py:75
+msgid "Mercurial executable (hg) not found, can't use Mercurial handler"
+msgstr ""
+
+#: sat/plugins/plugin_merge_req_mercurial.py:116
+msgid "invalid changeset signature"
+msgstr ""
+
+#: sat/plugins/plugin_merge_req_mercurial.py:136
+msgid "unexpected time data: {data}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:50
+msgid "Libervia account creation"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:75
+msgid ""
+"Welcome to Libervia, the web interface of Salut à Toi.\n"
+"\n"
+"Your account on {domain} has been successfully created.\n"
+"This is a demonstration version to show you the current status of the "
+"project.\n"
+"It is still under development, please keep it in mind!\n"
+"\n"
+"Here is your connection information:\n"
+"\n"
+"Login on {domain}: {profile}\n"
+"Jabber ID (JID): {jid}\n"
+"Your password has been chosen by yourself during registration.\n"
+"\n"
+"In the beginning, you have nobody to talk to. To find some contacts, you "
+"may use the users' directory:\n"
+"    - make yourself visible in \"Service / Directory subscription\".\n"
+"    - search for people with \"Contacts\" / Search directory\".\n"
+"\n"
+"Any feedback welcome. Thank you!\n"
+"\n"
+"Salut à Toi association\n"
+"https://www.salut-a-toi.org\n"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:109
+#, fuzzy
+msgid "Plugin Account initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_account.py:294
+msgid "Failed to send account creation confirmation to {email}: {msg}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:313
+msgid "New Libervia account created"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:339
+msgid "Your Libervia account has been created"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:363
+msgid "xmpp_domain needs to be set in sat.conf. Using \"{default}\" meanwhile"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:379
+msgid "Manage your account"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:385
+#, fuzzy
+msgid "Change your password"
+msgstr "Sauvegarde du nouveau mot de passe"
+
+#: sat/plugins/plugin_misc_account.py:387
+msgid "Current profile password"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:389
+#, fuzzy
+msgid "New password"
+msgstr "Sauvegarde du nouveau mot de passe"
+
+#: sat/plugins/plugin_misc_account.py:391
+#, fuzzy
+msgid "New password (again)"
+msgstr "Sauvegarde du nouveau mot de passe"
+
+#: sat/plugins/plugin_misc_account.py:431 sat/stdui/ui_profile_manager.py:73
+#, fuzzy
+msgid "The provided profile password doesn't match."
+msgstr "Le fichier [%s] n'existe pas !"
+
+#: sat/plugins/plugin_misc_account.py:432
+msgid "Attempt failure"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:477
+msgid "The values entered for the new password are not equal."
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:495
+#, fuzzy
+msgid "Change your password?"
+msgstr "Sauvegarde du nouveau mot de passe"
+
+#: sat/plugins/plugin_misc_account.py:500
+msgid ""
+"Note for advanced users: this will actually change both your SàT profile "
+"password AND your XMPP account password."
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:504
+msgid "Continue with changing the password?"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:528
+#: sat/plugins/plugin_misc_register_account.py:133
+#, fuzzy
+msgid "Confirmation"
+msgstr "Connexion..."
+
+#: sat/plugins/plugin_misc_account.py:529
+msgid "Your password has been changed."
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:533
+#: sat/plugins/plugin_misc_account.py:606
+#: sat/plugins/plugin_misc_account.py:716 sat_frontends/primitivus/base.py:790
+#: sat_frontends/primitivus/base.py:831
+#: sat_frontends/quick_frontend/quick_profile_manager.py:133
+msgid "Error"
+msgstr "Erreur"
+
+#: sat/plugins/plugin_misc_account.py:535
+#, python-format
+msgid "Your password could not be changed: %s"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:548
+#, fuzzy
+msgid "Delete your account?"
+msgstr "Enregistrement d'un nouveau compte"
+
+#: sat/plugins/plugin_misc_account.py:551
+msgid ""
+"If you confirm this dialog, you will be disconnected and then your XMPP "
+"account AND your SàT profile will both be DELETED."
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:555
+msgid "contact list, messages history, blog posts and commentsGROUPBLOG"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:558
+msgid "contact list and messages history"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:561
+#, python-format
+msgid ""
+"All your data stored on %(server)s, including your %(target)s will be "
+"erased."
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:567
+#: sat/plugins/plugin_misc_account.py:642
+#: sat/plugins/plugin_misc_account.py:658
+#: sat/plugins/plugin_misc_account.py:674
+msgid ""
+"There is no other confirmation dialog, this is the very last one! Are you"
+" sure?"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:608
+#, python-format
+msgid "Your XMPP account could not be deleted: %s"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:628
+msgid "Delete all your (micro-)blog posts and comments?"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:632
+msgid ""
+"If you confirm this dialog, all the (micro-)blog data you submitted will "
+"be erased."
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:637
+msgid "These are the public and private posts and comments you sent to any group."
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:649
+msgid "Delete all your (micro-)blog posts?"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:653
+msgid ""
+"If you confirm this dialog, all the public and private posts you sent to "
+"any group will be erased."
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:665
+msgid "Delete all your (micro-)blog comments?"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:669
+msgid ""
+"If you confirm this dialog, all the public and private comments you made "
+"on other people's posts will be erased."
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:689
+msgid "blog posts and comments"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:694
+msgid "blog posts"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:699
+msgid "comments"
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:705
+#, fuzzy
+msgid "Deletion confirmation"
+msgstr "désinscription confirmée pour [%s]"
+
+#: sat/plugins/plugin_misc_account.py:707
+#, python-format
+msgid "Your %(target)s have been deleted."
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:709
+msgid ""
+"Known issue of the demo version: you need to refresh the page to make the"
+" deleted posts actually disappear."
+msgstr ""
+
+#: sat/plugins/plugin_misc_account.py:718
+#, python-format
+msgid "Your %(target)s could not be deleted: %(message)s"
+msgstr ""
+
+#: sat/plugins/plugin_misc_android.py:50
+msgid "Manage Android platform specificities, like pause or notifications"
+msgstr ""
+
+#: sat/plugins/plugin_misc_android.py:92
+msgid "sound on notifications"
+msgstr ""
+
+#: sat/plugins/plugin_misc_android.py:94
+#, fuzzy
+msgid "Normal"
+msgstr "Général"
+
+#: sat/plugins/plugin_misc_android.py:95 sat/plugins/plugin_misc_android.py:103
+msgid "Never"
+msgstr ""
+
+#: sat/plugins/plugin_misc_android.py:99
+msgid "Vibrate on notifications"
+msgstr ""
+
+#: sat/plugins/plugin_misc_android.py:101
+#, fuzzy
+msgid "Always"
+msgstr "Chercher les transports"
+
+#: sat/plugins/plugin_misc_android.py:102
+msgid "In vibrate mode"
+msgstr ""
+
+#: sat/plugins/plugin_misc_android.py:243
+#, fuzzy
+msgid "plugin Android initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_android.py:362
+#, fuzzy
+msgid "new message from {contact}"
+msgstr "Attend qu'un fichier soit envoyé par un contact"
+
+#: sat/plugins/plugin_misc_app_manager.py:64
+msgid ""
+"Applications Manager\n"
+"\n"
+"Manage external applications using packagers, OS "
+"virtualization/containers or other\n"
+"software management tools.\n"
+msgstr ""
+
+#: sat/plugins/plugin_misc_app_manager.py:80
+#, fuzzy
+msgid "plugin Applications Manager initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_app_manager.py:166
+msgid ""
+"No value found for \"public_url\", using \"example.org\" for now, please "
+"set the proper value in libervia.conf"
+msgstr ""
+
+#: sat/plugins/plugin_misc_app_manager.py:170
+msgid ""
+"invalid value for \"public_url\" ({value}), it musts not start with "
+"schema (\"http\"), ignoring it and using \"example.org\" instead"
+msgstr ""
+
+#: sat/plugins/plugin_misc_attach.py:43
+msgid "Attachments handler"
+msgstr ""
+
+#: sat/plugins/plugin_misc_attach.py:53
+#, fuzzy
+msgid "plugin Attach initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_attach.py:109
+msgid "Can't resize attachment of unknown type: {attachment}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_attach.py:125
+msgid "Attachment {path!r} has been resized at {new_path!r}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_attach.py:129
+msgid "Can't resize attachment of type {main_type!r}: {attachment}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_attach.py:143
+msgid "No plugin can handle attachment with {destinee}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_attach.py:210
+msgid "certificate check disabled for upload, this is dangerous!"
+msgstr ""
+
+#: sat/plugins/plugin_misc_debug.py:35
+msgid "Set of method to make development and debugging easier"
+msgstr ""
+
+#: sat/plugins/plugin_misc_debug.py:41
+#, fuzzy
+msgid "Plugin Debug initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_download.py:43
+msgid "File download management"
+msgstr ""
+
+#: sat/plugins/plugin_misc_download.py:50
+#, fuzzy
+msgid "plugin Download initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_download.py:95
+msgid "Can't download file: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_download.py:99 sat_frontends/jp/cmd_file.py:498
+#, fuzzy
+msgid "Can't download file"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat/plugins/plugin_misc_download.py:176
+msgid "certificate check disabled for download, this is dangerous!"
+msgstr ""
+
+#: sat/plugins/plugin_misc_download.py:187
+msgid "Can't download URI {uri}: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:44
+msgid "invitation of people without XMPP account"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:59
+msgid "You have been invited by {host_name} to {app_name}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:60
+msgid ""
+"Hello {name}!\n"
+"\n"
+"You have received an invitation from {host_name} to participate to "
+"\"{app_name}\".\n"
+"To join, you just have to click on the following URL:\n"
+"{url}\n"
+"\n"
+"Please note that this URL should not be shared with anybody!\n"
+"If you want more details on {app_name}, you can check {app_url}.\n"
+"\n"
+"Welcome!\n"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:76
+#, fuzzy
+msgid "plugin Invitations initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_email_invitation.py:105
+msgid "You can't use following key(s) in extra, they are reserved: {}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:198
+msgid "You can't use following key(s) in both args and extra: {}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:207
+msgid "You need to provide a main email address before using emails_extra"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:213
+msgid "You need to provide url_template if you use default message body"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:216
+#, fuzzy
+msgid "creating an invitation"
+msgstr "Connexion..."
+
+#: sat/plugins/plugin_misc_email_invitation.py:237
+msgid "You need to specify xmpp_domain in sat.conf"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:251
+msgid "Can't create XMPP account"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:254
+msgid "requested jid already exists, trying with {}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:265
+msgid "account {jid_} created"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:317
+msgid "somebody"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:345
+msgid "Not all arguments have been consumed: {}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_email_invitation.py:443
+msgid "Skipping reserved key {key}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_extra_pep.py:38
+msgid "Display messages from extra PEP services"
+msgstr ""
+
+#: sat/plugins/plugin_misc_extra_pep.py:69
+#, fuzzy
+msgid "Plugin Extra PEP initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_file.py:45
+msgid ""
+"File Tansfer Management:\n"
+"This plugin manage the various ways of sending a file, and choose the "
+"best one."
+msgstr ""
+
+#: sat/plugins/plugin_misc_file.py:52
+msgid "Please select a file to send to {peer}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_file.py:53
+msgid "File sending"
+msgstr ""
+
+#: sat/plugins/plugin_misc_file.py:54
+msgid ""
+"{peer} wants to send the file \"{name}\" to you:\n"
+"{desc}\n"
+"\n"
+"The file has a size of {size_human}\n"
+"\n"
+"Do you accept ?"
+msgstr ""
+
+#: sat/plugins/plugin_misc_file.py:58
+#, fuzzy
+msgid "Confirm file transfer"
+msgstr "Transfert de fichier"
+
+#: sat/plugins/plugin_misc_file.py:59
+msgid "File {} already exists, are you sure you want to overwrite ?"
+msgstr ""
+
+#: sat/plugins/plugin_misc_file.py:60
+#, fuzzy
+msgid "File exists"
+msgstr "Aucun profile sélectionné"
+
+#: sat/plugins/plugin_misc_file.py:70
+#, fuzzy
+msgid "plugin File initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_file.py:82
+#, fuzzy
+msgid "Action"
+msgstr "Connexion..."
+
+#: sat/plugins/plugin_misc_file.py:82
+#, fuzzy
+msgid "send file"
+msgstr "Envoi un fichier"
+
+#: sat/plugins/plugin_misc_file.py:85
+#, fuzzy
+msgid "Send a file"
+msgstr "Envoi un fichier"
+
+#: sat/plugins/plugin_misc_file.py:121
+msgid "{name} method will be used to send the file"
+msgstr ""
+
+#: sat/plugins/plugin_misc_file.py:132
+msgid "Can't send {filepath} to {peer_jid} with {method_name}: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_file.py:166 sat/plugins/plugin_xep_0100.py:101
+msgid "Invalid JID"
+msgstr ""
+
+#: sat/plugins/plugin_misc_forums.py:36
+#, fuzzy
+msgid "forums management"
+msgstr "Initialisation du gestionnaire de mémoire"
+
+#: sat/plugins/plugin_misc_forums.py:43
+msgid "forums management plugin"
+msgstr ""
+
+#: sat/plugins/plugin_misc_forums.py:54
+#, fuzzy
+msgid "forums plugin initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_forums.py:97
+msgid "forums arguments must be a list of forums"
+msgstr ""
+
+#: sat/plugins/plugin_misc_forums.py:109
+msgid "A forum item must be a dictionary"
+msgstr ""
+
+#: sat/plugins/plugin_misc_forums.py:114
+msgid "following forum name is not unique: {name}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_forums.py:116
+msgid "creating missing forum node"
+msgstr ""
+
+#: sat/plugins/plugin_misc_forums.py:130
+msgid "Unknown forum attribute: {key}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_forums.py:136
+msgid "forum need a title or a name"
+msgstr ""
+
+#: sat/plugins/plugin_misc_forums.py:138
+msgid "forum need uri or sub-forums"
+msgstr ""
+
+#: sat/plugins/plugin_misc_forums.py:154
+msgid "missing <forums> element"
+msgstr ""
+
+#: sat/plugins/plugin_misc_forums.py:160
+msgid "Unexpected element: {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_forums.py:168
+msgid "Following attributes are unknown: {unknown}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_forums.py:176
+msgid "invalid forum, ignoring: {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_forums.py:180
+msgid "unkown forums sub element: {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_groupblog.py:53
+#, fuzzy
+msgid "Implementation of microblogging fine permissions"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_misc_groupblog.py:61
+#, fuzzy
+msgid "Group blog plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_misc_groupblog.py:80
+msgid "Server is not able to manage item-access pubsub, we can't use group blog"
+msgstr ""
+
+#: sat/plugins/plugin_misc_groupblog.py:86
+msgid "Server can manage group blogs"
+msgstr ""
+
+#: sat/plugins/plugin_misc_identity.py:49
+msgid "Identity manager"
+msgstr ""
+
+#: sat/plugins/plugin_misc_identity.py:58
+#, fuzzy
+msgid "Plugin Identity initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_identity.py:293
+#: sat/plugins/plugin_misc_identity.py:365
+msgid "No callback registered for {metadata_name}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_identity.py:316
+msgid "Error while trying to get {metadata_name} with {callback}: {e}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_identity.py:376
+msgid "Error while trying to set {metadata_name} with {callback}: {e}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_identity.py:691
+msgid "Can't set metadata {metadata_name!r}: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_ip.py:57
+msgid "This plugin help to discover our external IP address."
+msgstr ""
+
+#: sat/plugins/plugin_misc_ip.py:64
+msgid "Allow external get IP"
+msgstr ""
+
+#: sat/plugins/plugin_misc_ip.py:67
+msgid "Confirm external site request"
+msgstr ""
+
+#: sat/plugins/plugin_misc_ip.py:68
+msgid ""
+"To facilitate data transfer, we need to contact a website.\n"
+"A request will be done on {page}\n"
+"That means that administrators of {domain} can know that you use "
+"\"{app_name}\" and your IP Address.\n"
+"\n"
+"IP address is an identifier to locate you on Internet (similar to a phone"
+" number).\n"
+"\n"
+"Do you agree to do this request ?\n"
+msgstr ""
+
+#: sat/plugins/plugin_misc_ip.py:100
+#, fuzzy
+msgid "plugin IP discovery initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_lists.py:39
+msgid "Pubsub Lists"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:47
+msgid "Pubsub lists management plugin"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:52
+msgid "TODO List"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:63 sat/plugins/plugin_misc_lists.py:113
+#: sat/plugins/plugin_misc_lists.py:156
+msgid "status"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:67
+msgid "to do"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:71
+#, fuzzy
+msgid "in progress"
+msgstr "Progression: "
+
+#: sat/plugins/plugin_misc_lists.py:75
+#, fuzzy
+msgid "done"
+msgstr "En ligne"
+
+#: sat/plugins/plugin_misc_lists.py:83 sat/plugins/plugin_misc_lists.py:180
+msgid "priority"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:87 sat/plugins/plugin_misc_lists.py:184
+msgid "major"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:91 sat/plugins/plugin_misc_lists.py:188
+#, fuzzy
+msgid "normal"
+msgstr "Général"
+
+#: sat/plugins/plugin_misc_lists.py:95 sat/plugins/plugin_misc_lists.py:192
+msgid "minor"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:106
+msgid "Grocery List"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:109 sat_frontends/jp/cmd_info.py:69
+#: sat_frontends/jp/cmd_info.py:111
+#, fuzzy
+msgid "name"
+msgstr "Jeu"
+
+#: sat/plugins/plugin_misc_lists.py:110
+msgid "quantity"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:117
+msgid "to buy"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:121
+#, fuzzy
+msgid "bought"
+msgstr "À propos"
+
+#: sat/plugins/plugin_misc_lists.py:130
+msgid "Tickets"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:140 sat_frontends/jp/cmd_info.py:69
+msgid "type"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:144
+msgid "bug"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:148
+#, fuzzy
+msgid "feature request"
+msgstr "Gestion des paramètres"
+
+#: sat/plugins/plugin_misc_lists.py:160
+#, fuzzy
+msgid "queued"
+msgstr "refusé"
+
+#: sat/plugins/plugin_misc_lists.py:164
+msgid "started"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:168
+msgid "review"
+msgstr ""
+
+#: sat/plugins/plugin_misc_lists.py:172
+#, fuzzy
+msgid "closed"
+msgstr "fermeture"
+
+#: sat/plugins/plugin_misc_lists.py:208
+#, fuzzy
+msgid "Pubsub lists plugin initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_merge_requests.py:35
+msgid "Merge requests management"
+msgstr ""
+
+#: sat/plugins/plugin_misc_merge_requests.py:42
+msgid "Merge requests management plugin"
+msgstr ""
+
+#: sat/plugins/plugin_misc_merge_requests.py:69
+#, fuzzy
+msgid "Merge requests plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_misc_merge_requests.py:121
+#, fuzzy
+msgid "a handler with name {name} already exists!"
+msgstr "Ce nom de profile existe déjà"
+
+#: sat/plugins/plugin_misc_merge_requests.py:134
+msgid ""
+"merge requests of type {type} are already handled by {old_handler}, "
+"ignoring {new_handler}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_merge_requests.py:241
+msgid "repository must be specified"
+msgstr ""
+
+#: sat/plugins/plugin_misc_merge_requests.py:244
+msgid "{field} is set by backend, you must not set it in frontend"
+msgstr ""
+
+#: sat/plugins/plugin_misc_merge_requests.py:253
+msgid "{name} handler will be used"
+msgstr ""
+
+#: sat/plugins/plugin_misc_merge_requests.py:256
+msgid "repository {path} can't be handled by any installed handler"
+msgstr ""
+
+#: sat/plugins/plugin_misc_merge_requests.py:259
+msgid "no handler for this repository has been found"
+msgstr ""
+
+#: sat/plugins/plugin_misc_merge_requests.py:265
+msgid "No handler of this name found"
+msgstr ""
+
+#: sat/plugins/plugin_misc_merge_requests.py:269
+msgid "export data is empty, do you have any change to send?"
+msgstr ""
+
+#: sat/plugins/plugin_misc_merge_requests.py:312
+msgid "No handler can handle data type \"{type}\""
+msgstr ""
+
+#: sat/plugins/plugin_misc_merge_requests.py:348
+msgid "No handler found to import {data_type}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_merge_requests.py:350
+msgid "Importing patch [{item_id}] using {name} handler"
+msgstr ""
+
+#: sat/plugins/plugin_misc_nat_port.py:45
+msgid "Automatic NAT port mapping using UPnP"
+msgstr ""
+
+#: sat/plugins/plugin_misc_nat_port.py:62
+#, fuzzy
+msgid "plugin NAT Port initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_nat_port.py:177
+msgid "addportmapping error: {msg}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_nat_port.py:215
+msgid "error while trying to map ports: {msg}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_quiz.py:42
+#, fuzzy
+msgid "Implementation of Quiz game"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_misc_quiz.py:55
+#, fuzzy
+msgid "Plugin Quiz initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_quiz.py:345
+msgid ""
+"Bienvenue dans cette partie rapide de quizz, le premier à atteindre le "
+"score de 9 remporte le jeu\n"
+"\n"
+"Attention, tu es prêt ?"
+msgstr ""
+
+#: sat/plugins/plugin_misc_quiz.py:380 sat/plugins/plugin_misc_tarot.py:664
+#, python-format
+msgid "Player %(player)s is ready to start [status: %(status)s]"
+msgstr "Le joueur %(player)s est prêt à commencer [statut: %(status)s]"
+
+#: sat/plugins/plugin_misc_quiz.py:456 sat/plugins/plugin_misc_radiocol.py:353
+#, fuzzy, python-format
+msgid "Unmanaged game element: %s"
+msgstr "élément de jeu de carte inconnu: %s"
+
+#: sat/plugins/plugin_misc_radiocol.py:57
+#, fuzzy
+msgid "Implementation of radio collective"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_misc_radiocol.py:76
+#, fuzzy
+msgid "Radio collective initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_radiocol.py:180
+msgid ""
+"The uploaded file has been rejected, only Ogg Vorbis and MP3 songs are "
+"accepted."
+msgstr ""
+
+#: sat/plugins/plugin_misc_radiocol.py:210
+msgid "No more participants in the radiocol: cleaning data"
+msgstr ""
+
+#: sat/plugins/plugin_misc_radiocol.py:249
+msgid "INTERNAL ERROR: can't find full path of the song to delete"
+msgstr ""
+
+#: sat/plugins/plugin_misc_radiocol.py:258
+#, python-format
+msgid "INTERNAL ERROR: can't find %s on the file system"
+msgstr ""
+
+#: sat/plugins/plugin_misc_register_account.py:41
+#, fuzzy
+msgid "Register XMPP account"
+msgstr "Enregistrement d'un nouveau compte"
+
+#: sat/plugins/plugin_misc_register_account.py:49
+#, fuzzy
+msgid "Plugin Register Account initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_register_account.py:76
+msgid "Missing values"
+msgstr ""
+
+#: sat/plugins/plugin_misc_register_account.py:78
+#, fuzzy
+msgid "No user JID or password given: can't register new account."
+msgstr ""
+"L'utilisateur, le mot de passe ou le serveur n'ont pas été spécifiés, "
+"impossible d'inscrire un nouveau compte."
+
+#: sat/plugins/plugin_misc_register_account.py:87
+msgid "Register new account"
+msgstr "Enregistrement d'un nouveau compte"
+
+#: sat/plugins/plugin_misc_register_account.py:92
+msgid "Do you want to register a new XMPP account {jid}?"
+msgstr ""
+
+#: sat/plugins/plugin_misc_register_account.py:134
+#, fuzzy
+msgid "Registration successful."
+msgstr "Inscription réussie"
+
+#: sat/plugins/plugin_misc_register_account.py:138
+msgid "Failure"
+msgstr ""
+
+#: sat/plugins/plugin_misc_register_account.py:139
+#, fuzzy, python-format
+msgid "Registration failed: %s"
+msgstr "Échec de l'inscription: %s"
+
+#: sat/plugins/plugin_misc_register_account.py:143
+#, fuzzy
+msgid "Username already exists, please choose an other one."
+msgstr "Ce nom d'utilisateur existe déjà, veuillez en choisir un autre"
+
+#: sat/plugins/plugin_misc_room_game.py:49
+msgid "Base class for MUC games"
+msgstr ""
+
+#: sat/plugins/plugin_misc_room_game.py:221
+#, python-format
+msgid "%(user)s not allowed to join the game %(game)s in %(room)s"
+msgstr ""
+
+#: sat/plugins/plugin_misc_room_game.py:380
+#, python-format
+msgid "%(user)s not allowed to invite for the game %(game)s in %(room)s"
+msgstr ""
+
+#: sat/plugins/plugin_misc_room_game.py:433
+#, python-format
+msgid "Still waiting for %(users)s before starting the game %(game)s in %(room)s"
+msgstr ""
+
+#: sat/plugins/plugin_misc_room_game.py:472
+#, python-format
+msgid "Preparing room for %s game"
+msgstr ""
+
+#: sat/plugins/plugin_misc_room_game.py:475
+#, fuzzy
+msgid "Unknown profile"
+msgstr "Afficher profile"
+
+#: sat/plugins/plugin_misc_room_game.py:583
+#, fuzzy, python-format
+msgid "%(game)s game already created in room %(room)s"
+msgstr "%(profile)s est déjà dans le salon %(room_jid)s"
+
+#: sat/plugins/plugin_misc_room_game.py:589
+#, python-format
+msgid "%(game)s game in room %(room)s can only be created by %(user)s"
+msgstr ""
+
+#: sat/plugins/plugin_misc_room_game.py:610
+#, fuzzy, python-format
+msgid "Creating %(game)s game in room %(room)s"
+msgstr "Construction du jeu de Tarot"
+
+#: sat/plugins/plugin_misc_room_game.py:615
+#: sat/plugins/plugin_misc_room_game.py:646
+#: sat/plugins/plugin_misc_tarot.py:581
+#, python-format
+msgid "profile %s is unknown"
+msgstr "le profil %s est inconnu"
+
+#: sat/plugins/plugin_misc_room_game.py:661
+#, python-format
+msgid "new round for %s game"
+msgstr ""
+
+#: sat/plugins/plugin_misc_static_blog.py:44
+msgid "Plugin for static blogs"
+msgstr ""
+
+#: sat/plugins/plugin_misc_static_blog.py:66
+#, fuzzy
+msgid "Page title"
+msgstr "Petite"
+
+#: sat/plugins/plugin_misc_static_blog.py:68
+msgid "Banner URL"
+msgstr ""
+
+#: sat/plugins/plugin_misc_static_blog.py:70
+msgid "Background image URL"
+msgstr ""
+
+#: sat/plugins/plugin_misc_static_blog.py:72
+msgid "Keywords"
+msgstr ""
+
+#: sat/plugins/plugin_misc_static_blog.py:74
+msgid "Description"
+msgstr ""
+
+#: sat/plugins/plugin_misc_static_blog.py:97 sat/plugins/plugin_sec_otr.py:508
+#: sat/plugins/plugin_sec_otr.py:542 sat/plugins/plugin_sec_otr.py:568
+#: sat/plugins/plugin_sec_otr.py:592
+msgid "jid key is not present !"
+msgstr ""
+
+#: sat/plugins/plugin_misc_static_blog.py:102
+msgid "Not available"
+msgstr ""
+
+#: sat/plugins/plugin_misc_static_blog.py:104
+msgid "Retrieving a blog from an external domain is not implemented yet."
+msgstr ""
+
+#: sat/plugins/plugin_misc_tarot.py:47
+#, fuzzy
+msgid "Implementation of Tarot card game"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_misc_tarot.py:60
+#, fuzzy
+msgid "Plugin Tarot initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_tarot.py:78
+msgid "Passe"
+msgstr "Passe"
+
+#: sat/plugins/plugin_misc_tarot.py:79
+msgid "Petite"
+msgstr "Petite"
+
+#: sat/plugins/plugin_misc_tarot.py:80
+msgid "Garde"
+msgstr "Garde"
+
+#: sat/plugins/plugin_misc_tarot.py:81
+msgid "Garde Sans"
+msgstr "Garde Sans"
+
+#: sat/plugins/plugin_misc_tarot.py:82
+msgid "Garde Contre"
+msgstr "Garde Contre"
+
+#: sat/plugins/plugin_misc_tarot.py:171
+msgid "contrat selection"
+msgstr "Sélection du contrat"
+
+#: sat/plugins/plugin_misc_tarot.py:189
+msgid "scores"
+msgstr "points"
+
+#: sat/plugins/plugin_misc_tarot.py:273 sat/plugins/plugin_misc_tarot.py:313
+#, python-format
+msgid ""
+"Player %(excuse_owner)s give %(card_waited)s to %(player_waiting)s for "
+"Excuse compensation"
+msgstr ""
+"Le joueur %(excuse_owner)s donne %(card_waited)s à %(player_waiting)s en "
+"compensation pour l'Excuse"
+
+#: sat/plugins/plugin_misc_tarot.py:327
+#, python-format
+msgid ""
+"%(excuse_owner)s keep the Excuse but has not card to give, %(winner)s is "
+"waiting for one"
+msgstr ""
+"%(excuse_owner)s garde l'Excuse mais n'a aucune carte à donner, "
+"%(winner)s en attend une"
+
+#: sat/plugins/plugin_misc_tarot.py:338
+#: sat_frontends/primitivus/game_tarot.py:309
+msgid "Draw game"
+msgstr ""
+
+#: sat/plugins/plugin_misc_tarot.py:341 sat/plugins/plugin_misc_tarot.py:436
+#, python-format
+msgid ""
+"\n"
+"--\n"
+"%(player)s:\n"
+"score for this game ==> %(score_game)i\n"
+"total score ==> %(total_score)i"
+msgstr ""
+"\n"
+"--\n"
+"%(player)s:\n"
+"points pour cette partie ==> %(score_game)i\n"
+"point au total ==> %(total_score)i"
+
+#: sat/plugins/plugin_misc_tarot.py:397
+#, fuzzy
+msgid "INTERNAL ERROR: contrat not managed (mispelled ?)"
+msgstr "ERREUR INTERNE: contrat inconnu (mal orthographié ?)"
+
+#: sat/plugins/plugin_misc_tarot.py:422
+#, fuzzy, python-format
+msgid ""
+"The attacker (%(attaquant)s) makes %(points)i and needs to make "
+"%(point_limit)i (%(nb_bouts)s oulder%(plural)s%(separator)s%(bouts)s): "
+"(s)he %(victory)s"
+msgstr ""
+"L'attaquant (%(attaquant)s) fait %(points)i et joue pour %(point_limit)i "
+"(%(nb_bouts)s bout%(plural)s%(separator)s%(bouts)s): il %(victory)s"
+
+#: sat/plugins/plugin_misc_tarot.py:507
+msgid "Internal error: unmanaged game stage"
+msgstr "ERREUR INTERNE: état de jeu inconnu"
+
+#: sat/plugins/plugin_misc_tarot.py:530 sat/plugins/plugin_misc_tarot.py:562
+msgid "session id doesn't exist, session has probably expired"
+msgstr ""
+
+#: sat/plugins/plugin_misc_tarot.py:540
+#, python-format
+msgid "contrat [%(contrat)s] choosed by %(profile)s"
+msgstr "contrat [%(contrat)s] choisi par %(profile)s"
+
+#: sat/plugins/plugin_misc_tarot.py:584
+#, fuzzy, python-format
+msgid "Cards played by %(profile)s: [%(cards)s]"
+msgstr "Cartes jouées par %(profile)s: [%(cards)s]"
+
+#: sat/plugins/plugin_misc_tarot.py:709
+msgid "Everybody is passing, round ended"
+msgstr ""
+
+#: sat/plugins/plugin_misc_tarot.py:723
+#, python-format
+msgid "%(player)s win the bid with %(contrat)s"
+msgstr "%(player)s remporte l'enchère avec %(contrat)s"
+
+#: sat/plugins/plugin_misc_tarot.py:751
+#, fuzzy
+msgid "tarot: chien received"
+msgstr "tarot: chien reçu"
+
+#: sat/plugins/plugin_misc_tarot.py:828
+#, python-format
+msgid "The winner of this trick is %s"
+msgstr "le vainqueur de cette main est %s"
+
+#: sat/plugins/plugin_misc_tarot.py:896
+#, fuzzy, python-format
+msgid "Unmanaged error type: %s"
+msgstr "type d'erreur inconnu: %s"
+
+#: sat/plugins/plugin_misc_tarot.py:898
+#, python-format
+msgid "Unmanaged card game element: %s"
+msgstr "élément de jeu de carte inconnu: %s"
+
+#: sat/plugins/plugin_misc_text_commands.py:40
+msgid "IRC like text commands"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:60
+msgid ""
+"Type '/help' to get a list of the available commands. If you didn't want "
+"to use a command, please start your message with '//' to escape the "
+"slash."
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:66
+#, fuzzy
+msgid "Text commands initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_misc_text_commands.py:162
+#, python-format
+msgid "Skipping not callable [%s] attribute"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:166
+msgid "Skipping cmd_ method"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:173
+msgid "Conflict for command [{old_name}], renaming it to [{new_name}]"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:180
+#, python-format
+msgid "Registered text command [%s]"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:244
+#, fuzzy, python-format
+msgid "Invalid command /%s. "
+msgstr "Mauvais nom de profile"
+
+#: sat/plugins/plugin_misc_text_commands.py:277
+#, fuzzy, python-format
+msgid "Unknown command /%s. "
+msgstr "Type d'action inconnu"
+
+#: sat/plugins/plugin_misc_text_commands.py:286
+msgid "group discussions"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:288
+msgid "one to one discussions"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:290
+msgid "/{command} command only applies in {context}."
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:374
+msgid "Invalid jid, can't whois"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:380
+#, python-format
+msgid "whois for %(jid)s"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:436
+msgid "Invalid command name [{}]\n"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:457
+#, python-format
+msgid ""
+"Text commands available:\n"
+"%s"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:462
+msgid ""
+"/{name}: {short_help}\n"
+"{syntax}{args_help}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_commands.py:465
+msgid " syntax: {}\n"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_syntaxes.py:43 sat/test/constants.py:56
+#, fuzzy
+msgid "Composition"
+msgstr "Connexion..."
+
+#: sat/plugins/plugin_misc_text_syntaxes.py:142
+msgid "Management of various text syntaxes (XHTML-IM, Markdown, etc)"
+msgstr ""
+
+#: sat/plugins/plugin_misc_text_syntaxes.py:184
+#, fuzzy
+msgid "Text syntaxes plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_misc_upload.py:41
+msgid "File upload management"
+msgstr ""
+
+#: sat/plugins/plugin_misc_upload.py:45
+#, fuzzy
+msgid "Please select a file to upload"
+msgstr "Veuillez entrer le nom du nouveau profile"
+
+#: sat/plugins/plugin_misc_upload.py:46
+msgid "File upload"
+msgstr ""
+
+#: sat/plugins/plugin_misc_upload.py:53
+#, fuzzy
+msgid "plugin Upload initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_upload.py:92
+msgid "Can't upload file: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_misc_upload.py:96 sat_frontends/jp/cmd_file.py:586
+msgid "Can't upload file"
+msgstr ""
+
+#: sat/plugins/plugin_misc_uri_finder.py:32
+msgid "URI finder"
+msgstr ""
+
+#: sat/plugins/plugin_misc_uri_finder.py:39
+msgid ""
+"    Plugin to find URIs in well know location.\n"
+"    This allows to retrieve settings to work with a project (e.g. pubsub "
+"node used for merge-requests).\n"
+"    "
+msgstr ""
+
+#: sat/plugins/plugin_misc_uri_finder.py:52
+#, fuzzy
+msgid "URI finder plugin initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_uri_finder.py:87
+msgid "Ignoring already found uri for key \"{key}\""
+msgstr ""
+
+#: sat/plugins/plugin_misc_watched.py:37
+msgid "Watch for entities presence, and send notification accordingly"
+msgstr ""
+
+#: sat/plugins/plugin_misc_watched.py:45
+#, fuzzy, python-format
+msgid "Watched entity {entity} is connected"
+msgstr "Vous êtes déjà connecté !"
+
+#: sat/plugins/plugin_misc_watched.py:62
+#, fuzzy
+msgid "Watched initialisation"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_misc_welcome.py:34
+msgid "Plugin which manage welcome message and things to to on first connection."
+msgstr ""
+
+#: sat/plugins/plugin_misc_welcome.py:42
+msgid "Display welcome message"
+msgstr ""
+
+#: sat/plugins/plugin_misc_welcome.py:43
+msgid "Welcome to Libervia/Salut à Toi"
+msgstr ""
+
+#: sat/plugins/plugin_misc_welcome.py:46
+msgid ""
+"Welcome to a free (as in freedom) network!\n"
+"\n"
+"If you have any trouble, or you want to help us for the bug hunting, you "
+"can contact us in real time chat by using the “Help / Official chat room”"
+"  menu.\n"
+"\n"
+"To use Libervia, you'll need to add contacts, either people you know, or "
+"people you discover by using the “Contacts / Search directory” menu.\n"
+"\n"
+"We hope that you'll enjoy using this project.\n"
+"\n"
+"The Libervia/Salut à Toi Team\n"
+msgstr ""
+
+#: sat/plugins/plugin_misc_welcome.py:75
+#, fuzzy
+msgid "plugin Welcome initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_xmllog.py:36
+msgid "Send raw XML logs to bridge"
+msgstr ""
+
+#: sat/plugins/plugin_misc_xmllog.py:51
+#, fuzzy
+msgid "Activate XML log"
+msgstr "Lancement du flux"
+
+#: sat/plugins/plugin_misc_xmllog.py:55
+#, fuzzy
+msgid "Plugin XML Log initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_misc_xmllog.py:69
+msgid "XML log activated"
+msgstr ""
+
+#: sat/plugins/plugin_misc_xmllog.py:81
+#, fuzzy
+msgid "INTERNAL ERROR: Unmanaged XML type"
+msgstr "ERREUR INTERNE: contrat inconnu (mal orthographié ?)"
+
+#: sat/plugins/plugin_sec_aesgcm.py:48
+msgid ""
+"    Implementation of AES-GCM scheme, a way to encrypt files (not "
+"official XMPP standard).\n"
+"    See https://xmpp.org/extensions/inbox/omemo-media-sharing.html for "
+"details\n"
+"    "
+msgstr ""
+
+#: sat/plugins/plugin_sec_aesgcm.py:63
+#, fuzzy
+msgid "AESGCM plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_sec_otr.py:50
+#, fuzzy
+msgid "Implementation of OTR"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_sec_otr.py:55
+msgid "OTR"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:56
+msgid ""
+"To authenticate your correspondent, you need to give your below "
+"fingerprint *BY AN EXTERNAL CANAL* (i.e. not in this chat), and check "
+"that the one he gives you is the same as below. If there is a mismatch, "
+"there can be a spy between you!"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:61
+msgid ""
+"You private key is used to encrypt messages for your correspondent, "
+"nobody except you must know it, if you are in doubt, you should drop it!"
+"\n"
+"\n"
+"Are you sure you want to drop your private key?"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:67
+msgid "Some of advanced features are disabled !"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:169
+#, python-format
+msgid "/!\\ conversation with %(other_jid)s is now UNENCRYPTED"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:182
+#, fuzzy
+msgid "trusted"
+msgstr "refusé"
+
+#: sat/plugins/plugin_sec_otr.py:182
+#, fuzzy
+msgid "untrusted"
+msgstr "refusé"
+
+#: sat/plugins/plugin_sec_otr.py:185
+msgid "{trusted} OTR conversation with {other_jid} REFRESHED"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:189
+msgid ""
+"{trusted} encrypted OTR conversation started with {other_jid}\n"
+"{extra_info}"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:201
+msgid "OTR conversation with {other_jid} is FINISHED"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:209
+msgid "Unknown OTR state"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:249
+msgid "Save is called but privkey is None !"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:308
+#, fuzzy
+msgid "OTR plugin initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_sec_otr.py:418
+msgid "You have no private key yet, start an OTR conversation to have one"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:424
+msgid "No private key"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:436
+msgid ""
+"Your fingerprint is:\n"
+"{fingerprint}\n"
+"\n"
+"Start an OTR conversation to have your correspondent one."
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:442 sat/plugins/plugin_xep_0384.py:687
+msgid "Fingerprint"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:453
+msgid "Your correspondent {correspondent} is now TRUSTED"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:459
+msgid "Your correspondent {correspondent} is now UNTRUSTED"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:477
+msgid "Authentication ({entity_jid})"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:483
+msgid ""
+"Your own fingerprint is:\n"
+"{fingerprint}"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:486
+msgid ""
+"Your correspondent fingerprint should be:\n"
+"{fingerprint}"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:492
+msgid "Is your correspondent fingerprint the same as here ?"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:494
+msgid "yes"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:494
+msgid "no"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:520
+msgid ""
+"Can't start an OTR session, there is already an encrypted session with "
+"{name}"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:598
+msgid "You don't have a private key yet !"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:611
+msgid "Your private key has been dropped"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:620
+msgid "Confirm private key drop"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:649
+msgid "WARNING: received unencrypted data in a supposedly encrypted context"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:656
+msgid "WARNING: received OTR encrypted data in an unencrypted context"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:662
+msgid "WARNING: received OTR error message: {msg}"
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:668
+#, fuzzy, python-format
+msgid "Error while trying de decrypt OTR message: {msg}"
+msgstr "Erreur en tentant de rejoindre le salon"
+
+#: sat/plugins/plugin_sec_otr.py:780
+msgid ""
+"Your message was not sent because your correspondent closed the encrypted"
+" conversation on his/her side. Either close your own side, or refresh the"
+" session."
+msgstr ""
+
+#: sat/plugins/plugin_sec_otr.py:785
+msgid "Message discarded because closed encryption channel"
+msgstr ""
+
+#: sat/plugins/plugin_syntax_wiki_dotclear.py:40
+#, fuzzy
+msgid "Implementation of Dotclear wiki syntax"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_syntax_wiki_dotclear.py:664
+#, fuzzy
+msgid "Dotclear wiki syntax plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_tickets_import.py:38
+msgid ""
+"Tickets import management:\n"
+"This plugin manage the different tickets importers which can register to "
+"it, and handle generic importing tasks."
+msgstr ""
+
+#: sat/plugins/plugin_tickets_import.py:57
+#, fuzzy
+msgid "plugin Tickets Import initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_tickets_import.py:111
+msgid "comments_uri key will be generated and must not be used by importer"
+msgstr ""
+
+#: sat/plugins/plugin_tickets_import.py:115
+msgid "{key} must be a list"
+msgstr ""
+
+#: sat/plugins/plugin_tickets_import.py:174
+msgid "mapping option must be a dictionary"
+msgstr ""
+
+#: sat/plugins/plugin_tickets_import.py:179
+msgid "keys and values of mapping must be sources and destinations ticket fields"
+msgstr ""
+
+#: sat/plugins/plugin_tickets_import_bugzilla.py:41
+msgid "Tickets importer for Bugzilla"
+msgstr ""
+
+#: sat/plugins/plugin_tickets_import_bugzilla.py:44
+msgid "import tickets from Bugzilla xml export file"
+msgstr ""
+
+#: sat/plugins/plugin_tickets_import_bugzilla.py:46
+msgid ""
+"This importer handle Bugzilla xml export file.\n"
+"\n"
+"To use it, you'll need to export tickets using XML.\n"
+"Tickets will be uploaded with the same ID as for Bugzilla, any existing "
+"ticket with this ID will be replaced.\n"
+"\n"
+"location: you must use the absolute path to your .xml file\n"
+msgstr ""
+
+#: sat/plugins/plugin_tickets_import_bugzilla.py:128
+#, fuzzy
+msgid "Bugilla Import plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_tmp_directory_subscription.py:37
+#, fuzzy
+msgid "Implementation of directory subscription"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_tmp_directory_subscription.py:47
+#, fuzzy
+msgid "Directory subscription plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_tmp_directory_subscription.py:50
+#: sat/plugins/plugin_xep_0050.py:315 sat/plugins/plugin_xep_0100.py:84
+msgid "Service"
+msgstr ""
+
+#: sat/plugins/plugin_tmp_directory_subscription.py:50
+msgid "Directory subscription"
+msgstr ""
+
+#: sat/plugins/plugin_tmp_directory_subscription.py:53
+msgid "User directory subscription"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0020.py:46
+#, fuzzy
+msgid "Implementation of Feature Negotiation"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0020.py:52
+#, fuzzy
+msgid "Plugin XEP_0020 initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0020.py:104
+msgid "More than one value choosed for {}, keeping the first one"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0033.py:66
+#, fuzzy
+msgid "Implementation of Extended Stanza Addressing"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0033.py:76
+#, fuzzy
+msgid "Extended Stanza Addressing plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0033.py:97
+msgid "XEP-0033 is being used but the server doesn't support it!"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0033.py:103
+#, fuzzy
+msgid " or "
+msgstr "Formulaire"
+
+#: sat/plugins/plugin_xep_0033.py:105
+#, python-format
+msgid ""
+"Stanzas using XEP-0033 should be addressed to %(expected)s, not "
+"%(current)s!"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0033.py:111
+msgid "TODO: addressing has been fixed by the backend... fix it in the frontend!"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:56
+#, fuzzy
+msgid "Implementation of Multi-User Chat"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0045.py:89
+#, fuzzy
+msgid "Plugin XEP_0045 initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0045.py:145
+msgid "MUC"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:145
+#, fuzzy
+msgid "configure"
+msgstr " Configurer l'application"
+
+#: sat/plugins/plugin_xep_0045.py:146
+#, fuzzy
+msgid "Configure Multi-User Chat room"
+msgstr "Implémentation de l'initialisation de flux pour le transfert de fichier "
+
+#: sat/plugins/plugin_xep_0045.py:194
+msgid ""
+"Received non delayed message in a room before its initialisation: "
+"state={state}, msg={msg}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:216 sat/plugins/plugin_xep_0045.py:224
+#: sat/plugins/plugin_xep_0045.py:880
+msgid "This room has not been joined"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:283
+msgid "Room joining cancelled by user"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:288
+msgid "Rooms in {}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:303
+msgid "room locked !"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:306
+#, fuzzy
+msgid "Error while configuring the room: {failure_}"
+msgstr "Erreur en tentant de rejoindre le salon"
+
+#: sat/plugins/plugin_xep_0045.py:322
+msgid "Room {} is restricted"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:323
+msgid "This room is restricted, please enter the password"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:332
+#, fuzzy, python-format
+msgid "Error while joining the room {room}{suffix}"
+msgstr "Erreur en tentant de rejoindre le salon"
+
+#: sat/plugins/plugin_xep_0045.py:334
+msgid "Group chat error"
+msgstr "Erreur de salon de discussion"
+
+#: sat/plugins/plugin_xep_0045.py:401
+msgid "room_jid key is not present !"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:406
+msgid "No configuration available for this room"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:440 sat/plugins/plugin_xep_0045.py:442
+msgid "Session ID doesn't exist, session has probably expired."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:441
+#, fuzzy
+msgid "Room configuration failed"
+msgstr "confirmation de type Oui/Non demandée"
+
+#: sat/plugins/plugin_xep_0045.py:447
+#, fuzzy
+msgid "Room configuration succeed"
+msgstr "confirmation de type Oui/Non demandée"
+
+#: sat/plugins/plugin_xep_0045.py:448
+msgid "The new settings have been saved."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:509
+msgid "No MUC service found on main server"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:538
+msgid ""
+"Invalid room identifier: {room_id}'. Please give a room short or full "
+"identifier like 'room' or 'room@{muc_service}'."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:558
+#, fuzzy, python-format
+msgid "{profile} is already in room {room_jid}"
+msgstr "%(profile)s est déjà dans le salon %(room_jid)s"
+
+#: sat/plugins/plugin_xep_0045.py:561
+#, fuzzy, python-format
+msgid "[{profile}] is joining room {room} with nick {nick}"
+msgstr "[%(profile)s] rejoint %(room)s avec %(nick)s"
+
+#: sat/plugins/plugin_xep_0045.py:729
+msgid "You must provide a member's nick to kick."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:738
+msgid "You have kicked {}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:740 sat/plugins/plugin_xep_0045.py:776
+msgid " for the following reason: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:763
+msgid "You must provide a valid JID to ban, like in '/ban contact@example.net'"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:774
+msgid "You have banned {}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:802
+msgid ""
+"You must provide a valid JID to affiliate, like in '/affiliate "
+"contact@example.net member'"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:808
+#, python-format
+msgid "You must provide a valid affiliation: %s"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:815
+msgid "New affiliation for {entity}: {affiliation}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:862
+msgid "No known default MUC service {unparsed}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:867
+#, fuzzy, python-format
+msgid "{} is not a valid JID!"
+msgstr "%s n'est pas un JID valide !"
+
+#: sat/plugins/plugin_xep_0045.py:885
+#, fuzzy, python-format
+msgid "Nickname: %s"
+msgstr "fichier enregistré dans %s"
+
+#: sat/plugins/plugin_xep_0045.py:887
+#, python-format
+msgid "Entity: %s"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:889
+#, python-format
+msgid "Affiliation: %s"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:891
+#, fuzzy, python-format
+msgid "Role: %s"
+msgstr "Profile:"
+
+#: sat/plugins/plugin_xep_0045.py:893
+#, fuzzy, python-format
+msgid "Status: %s"
+msgstr "Sélection du contrat"
+
+#: sat/plugins/plugin_xep_0045.py:895
+#, python-format
+msgid "Show: %s"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:960
+msgid ""
+"room {room} is not in expected state: room is in state {current_state} "
+"while we were expecting {expected_state}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:1093
+msgid "No message received while offline in {room_jid}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:1097
+msgid "We have received {num_mess} message(s) in {room_jid} while offline."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:1141
+msgid "missing nick in presence: {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:1217
+#, fuzzy, python-format
+msgid "user {nick} has joined room {room_id}"
+msgstr "L'utilisateur %(nick)s a rejoint le salon (%(room_id)s)"
+
+#: sat/plugins/plugin_xep_0045.py:1234
+msgid "=> {} has joined the room"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:1253
+#, fuzzy, python-format
+msgid "Room ({room}) left ({profile})"
+msgstr "contrat [%(contrat)s] choisi par %(profile)s"
+
+#: sat/plugins/plugin_xep_0045.py:1267
+#, fuzzy, python-format
+msgid "user {nick} left room {room_id}"
+msgstr "L'utilisateur %(nick)s a quitté le salon (%(room_id)s)"
+
+#: sat/plugins/plugin_xep_0045.py:1279
+msgid "<= {} has left the room"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:1342
+msgid "received history in unexpected state in room {room} (state: {state})"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:1350
+msgid "storing the unexpected message anyway, to avoid loss"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0045.py:1437
+#, fuzzy, python-format
+msgid "New subject for room ({room_id}): {subject}"
+msgstr "Nouveau sujet pour le salon (%(room_id)s): %(subject)s"
+
+#: sat/plugins/plugin_xep_0047.py:62
+#, fuzzy
+msgid "Implementation of In-Band Bytestreams"
+msgstr "Implémentation du « SOCKS5 Bytestreams » (flux d'octets SOCKS5)"
+
+#: sat/plugins/plugin_xep_0047.py:71
+#, fuzzy
+msgid "In-Band Bytestreams plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0047.py:162
+msgid "IBB stream opening"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0047.py:171
+#, python-format
+msgid "Ignoring unexpected IBB transfer: %s"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0047.py:176
+msgid "sended jid inconsistency (man in the middle attack attempt ?)"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0047.py:206
+msgid "IBB stream closing"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0047.py:228
+#, fuzzy
+msgid "Received data for an unknown session id"
+msgstr "Confirmation inconnue reçue"
+
+#: sat/plugins/plugin_xep_0047.py:236
+msgid ""
+"sended jid inconsistency (man in the middle attack attempt ?)\n"
+"initial={initial}\n"
+"given={given}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0047.py:246
+msgid "Sequence error"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0047.py:261
+msgid "Invalid base64 data"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0048.py:45
+#, fuzzy
+msgid "Implementation of bookmarks"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0048.py:58
+#, fuzzy
+msgid "Bookmarks plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0048.py:63 sat_frontends/primitivus/base.py:540
+msgid "Groups"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0048.py:63
+msgid "Bookmarks"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0048.py:66
+msgid "Use and manage bookmarks"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0048.py:147
+msgid "Private XML storage not available"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0048.py:260
+#, fuzzy
+msgid "No room jid selected"
+msgstr "Aucun profile sélectionné"
+
+#: sat/plugins/plugin_xep_0048.py:280
+msgid "Bookmarks manager"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0048.py:310 sat_frontends/jp/cmd_bookmarks.py:126
+msgid "add a bookmark"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0048.py:312
+#, fuzzy
+msgid "Name"
+msgstr "Jeu"
+
+#: sat/plugins/plugin_xep_0048.py:314 sat_frontends/jp/cmd_profile.py:175
+msgid "jid"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0048.py:316
+msgid "Nickname"
+msgstr "Surnon"
+
+#: sat/plugins/plugin_xep_0048.py:318
+msgid "Autojoin"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0048.py:321 sat_frontends/primitivus/xmlui.py:470
+msgid "Save"
+msgstr "Sauvegarder"
+
+#: sat/plugins/plugin_xep_0048.py:367
+msgid "Bookmarks will be local only"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0048.py:368
+#, python-format
+msgid "Type selected for \"auto\" storage: %s"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0048.py:500
+msgid "Bad arguments"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0048.py:509
+#, python-format
+msgid "All [%s] bookmarks are being removed"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0048.py:520
+msgid "Bookmark added"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0049.py:37
+#, fuzzy
+msgid "Implementation of private XML storage"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0049.py:45
+#, fuzzy
+msgid "Plugin XEP-0049 initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0050.py:51
+#: sat_frontends/quick_frontend/constants.py:29
+msgid "Online"
+msgstr "En ligne"
+
+#: sat/plugins/plugin_xep_0050.py:52
+msgid "Away"
+msgstr "Absent"
+
+#: sat/plugins/plugin_xep_0050.py:53
+#: sat_frontends/quick_frontend/constants.py:30
+msgid "Free for chat"
+msgstr "Libre pour discuter"
+
+#: sat/plugins/plugin_xep_0050.py:54
+#: sat_frontends/quick_frontend/constants.py:32
+msgid "Do not disturb"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0050.py:55
+msgid "Left"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0050.py:56 sat_frontends/primitivus/base.py:535
+#, fuzzy
+msgid "Disconnect"
+msgstr "Déconnexion..."
+
+#: sat/plugins/plugin_xep_0050.py:67
+#, fuzzy
+msgid "Implementation of Ad-Hoc Commands"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0050.py:126
+#, fuzzy, python-format
+msgid "The groups [{group}] is unknown for profile [{profile}])"
+msgstr "Tentative d'accès à un profile inconnu"
+
+#: sat/plugins/plugin_xep_0050.py:284
+#, fuzzy
+msgid "plugin XEP-0050 initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0050.py:315
+#, fuzzy
+msgid "Commands"
+msgstr "Mauvais nom de profile"
+
+#: sat/plugins/plugin_xep_0050.py:318
+msgid "Execute ad-hoc commands"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0050.py:329
+msgid "Status"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0050.py:364
+msgid "Missing command element"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0050.py:379
+#, fuzzy
+msgid "Please select a command"
+msgstr "Veuillez entrer le nom du nouveau profile"
+
+#: sat/plugins/plugin_xep_0050.py:397
+#, fuzzy, python-format
+msgid "Invalid note type [%s], using info"
+msgstr "Type d'action inconnu"
+
+#: sat/plugins/plugin_xep_0050.py:408
+msgid "WARNING"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0050.py:409
+#, fuzzy
+msgid "ERROR"
+msgstr "Erreur"
+
+#: sat/plugins/plugin_xep_0050.py:457
+msgid "No known payload found in ad-hoc command result, aborting"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0050.py:464
+#, fuzzy
+msgid "No payload found"
+msgstr "Aucune donnée trouvée"
+
+#: sat/plugins/plugin_xep_0050.py:574
+#, fuzzy
+msgid "Please enter target jid"
+msgstr "Veuillez entrer le JID de votre nouveau contact"
+
+#: sat/plugins/plugin_xep_0050.py:588
+#, fuzzy
+msgid "status selection"
+msgstr "Sélection du contrat"
+
+#: sat/plugins/plugin_xep_0050.py:618
+msgid "Status updated"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0054.py:64
+msgid "Implementation of vcard-temp"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0054.py:84
+msgid "Plugin XEP_0054 initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0054.py:99
+msgid "No avatar in cache for {profile}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0054.py:137
+msgid "Decoding binary"
+msgstr "Décodage des données"
+
+#: sat/plugins/plugin_xep_0054.py:242
+msgid "vCard element not found for {entity_jid}: {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0054.py:287
+msgid "Can't get vCard for {entity_jid}: {e}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0054.py:291
+msgid "VCard found"
+msgstr "VCard trouvée"
+
+#: sat/plugins/plugin_xep_0055.py:53
+#, fuzzy
+msgid "Implementation of Jabber Search"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0055.py:70
+#, fuzzy
+msgid "Jabber search plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0055.py:100 sat/stdui/ui_contact_list.py:39
+#: sat/stdui/ui_contact_list.py:45 sat/stdui/ui_contact_list.py:51
+#: sat_frontends/primitivus/base.py:539
+#: sat_frontends/primitivus/contact_list.py:50
+#, fuzzy
+msgid "Contacts"
+msgstr "&Contacts"
+
+#: sat/plugins/plugin_xep_0055.py:100
+msgid "Search directory"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:103
+msgid "Search user directory"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:143
+#, fuzzy, python-format
+msgid "Search users"
+msgstr "Remplacement de l'utilisateur %s"
+
+#: sat/plugins/plugin_xep_0055.py:174
+msgid "Search for"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:181
+msgid "Simple search"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:191 sat/plugins/plugin_xep_0055.py:305
+msgid "Search"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:226
+msgid "Advanced search"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:246
+msgid "Search on"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:248
+msgid "Other service"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:256
+msgid "Refresh fields"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:260
+msgid "Displaying the search form for"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:341
+msgid "Search results"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:346
+msgid "The search gave no result"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:386 sat/plugins/plugin_xep_0055.py:493
+msgid "No query element found"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:391 sat/plugins/plugin_xep_0055.py:498
+msgid "No data form found"
+msgstr "Aucune donnée trouvée"
+
+#: sat/plugins/plugin_xep_0055.py:403
+#, fuzzy, python-format
+msgid "Fields request failure: %s"
+msgstr "Échec de l'inscription: %s"
+
+#: sat/plugins/plugin_xep_0055.py:478
+msgid "The search could not be performed"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0055.py:510
+#, fuzzy, python-format
+msgid "Search request failure: %s"
+msgstr "Échec de la désinscription: %s"
+
+#: sat/plugins/plugin_xep_0059.py:42
+#, fuzzy
+msgid "Implementation of Result Set Management"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0059.py:52
+#, fuzzy
+msgid "Result Set Management plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0059.py:65
+msgid "rsm_max can't be negative"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0060.py:56
+#, fuzzy
+msgid "Implementation of PubSub Protocol"
+msgstr "Implémentation du protocole de transports"
+
+#: sat/plugins/plugin_xep_0060.py:95
+#, fuzzy
+msgid "PubSub plugin initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0060.py:323
+msgid "Can't retrieve pubsub_service from conf, we'll use first one that we find"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0060.py:487
+msgid "Can't parse items: {msg}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0060.py:556
+msgid "Invalid item: {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0060.py:572
+msgid ""
+"Can't use publish-options ({options}) on node {node}, re-publishing "
+"without them: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0060.py:905 sat/plugins/plugin_xep_0060.py:948
+msgid "Invalid result: missing <affiliations> element: {}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0060.py:916 sat/plugins/plugin_xep_0060.py:961
+msgid "Invalid result: bad <affiliation> element: {}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0060.py:1284
+msgid "Invalid result: missing <subscriptions> element: {}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0060.py:1289
+msgid "Invalid result: {}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0060.py:1299
+msgid "Invalid result: bad <subscription> element: {}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0065.py:90
+msgid "Implementation of SOCKS5 Bytestreams"
+msgstr "Implémentation du « SOCKS5 Bytestreams » (flux d'octets SOCKS5)"
+
+#: sat/plugins/plugin_xep_0065.py:528
+msgid "File transfer completed, closing connection"
+msgstr "Transfert de fichier terminé, fermeture de la connexion"
+
+#: sat/plugins/plugin_xep_0065.py:695
+#, python-format
+msgid "Socks 5 client connection lost (reason: %s)"
+msgstr "Connexion du client SOCKS5 perdue (raison: %s)"
+
+#: sat/plugins/plugin_xep_0065.py:723
+msgid "Plugin XEP_0065 initialization"
+msgstr "Initialisation du plugin XEP_0065"
+
+#: sat/plugins/plugin_xep_0065.py:781
+#, fuzzy, python-format
+msgid "Socks5 Stream server launched on port {}"
+msgstr "Lancement du serveur de flux Socks5 sur le port %d"
+
+#: sat/plugins/plugin_xep_0070.py:56
+#, fuzzy
+msgid "Implementation of HTTP Requests via XMPP"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0070.py:66
+#, fuzzy
+msgid "Plugin XEP_0070 initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0070.py:79
+msgid "XEP-0070 Verifying HTTP Requests via XMPP (iq)"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0070.py:88
+msgid "XEP-0070 Verifying HTTP Requests via XMPP (message)"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0070.py:98
+#, fuzzy
+msgid "Auth confirmation"
+msgstr "Connexion..."
+
+#: sat/plugins/plugin_xep_0070.py:99
+msgid ""
+"{auth_url} needs to validate your identity, do you agree?\n"
+"Validation code : {auth_id}\n"
+"\n"
+"Please check that this code is the same as on {auth_url}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0070.py:117
+msgid "XEP-0070 reply iq"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0070.py:122
+msgid "XEP-0070 reply message"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0070.py:127
+msgid "XEP-0070 reply error"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0071.py:55
+#, fuzzy
+msgid "Implementation of XHTML-IM"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0071.py:94
+#, fuzzy
+msgid "XHTML-IM plugin initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0071.py:223
+msgid "Can't have XHTML and rich content at the same time"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0077.py:41
+msgid "Implementation of in-band registration"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0077.py:54
+#, fuzzy
+msgid "Registration asked for {jid}"
+msgstr "Éched de l'insciption (%s)"
+
+#: sat/plugins/plugin_xep_0077.py:79
+msgid "Stream started with {server}, now registering"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0077.py:85
+#, fuzzy, python-format
+msgid "Registration answer: {}"
+msgstr "réponse à la demande d'inscription: %s"
+
+#: sat/plugins/plugin_xep_0077.py:89
+#, fuzzy, python-format
+msgid "Registration failure: {}"
+msgstr "Échec de l'inscription: %s"
+
+#: sat/plugins/plugin_xep_0077.py:116
+msgid "Plugin XEP_0077 initialization"
+msgstr "Initialisation du plugin XEP_0077"
+
+#: sat/plugins/plugin_xep_0077.py:176
+#, fuzzy
+msgid "Can't find data form"
+msgstr "Impossible de trouver la VCard de %s"
+
+#: sat/plugins/plugin_xep_0077.py:178
+msgid "This gateway can't be managed by SàT, sorry :("
+msgstr "Ce transport ne peut être gérée par SàT, désolé :("
+
+#: sat/plugins/plugin_xep_0077.py:202 sat/plugins/plugin_xep_0077.py:212
+#, python-format
+msgid "Registration failure: %s"
+msgstr "Échec de l'inscription: %s"
+
+#: sat/plugins/plugin_xep_0077.py:206
+#, python-format
+msgid "registration answer: %s"
+msgstr "réponse à la demande d'inscription: %s"
+
+#: sat/plugins/plugin_xep_0077.py:215
+msgid "Username already exists, please choose an other one"
+msgstr "Ce nom d'utilisateur existe déjà, veuillez en choisir un autre"
+
+#: sat/plugins/plugin_xep_0077.py:229
+#, fuzzy, python-format
+msgid "Asking registration for {}"
+msgstr "Demande d'enregistrement pour [%s]"
+
+#: sat/plugins/plugin_xep_0085.py:55
+#, fuzzy
+msgid "Implementation of Chat State Notifications Protocol"
+msgstr "Implémentation du protocole de transports"
+
+#: sat/plugins/plugin_xep_0085.py:97
+msgid "Enable chat state notifications"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0085.py:101
+#, fuzzy
+msgid "Chat State Notifications plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0092.py:42
+#, fuzzy
+msgid "Implementation of Software Version"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0092.py:48
+#, fuzzy
+msgid "Plugin XEP_0092 initialization"
+msgstr "Initialisation du plugin XEP_0096"
+
+#: sat/plugins/plugin_xep_0092.py:119
+#, fuzzy, python-format
+msgid "Client name: %s"
+msgstr "fichier enregistré dans %s"
+
+#: sat/plugins/plugin_xep_0092.py:121
+#, python-format
+msgid "Client version: %s"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0092.py:123
+#, fuzzy, python-format
+msgid "Operating system: %s"
+msgstr "réponse à la demande d'inscription: %s"
+
+#: sat/plugins/plugin_xep_0092.py:128
+msgid "Software version not available"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0092.py:130
+msgid "Client software version request timeout"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0095.py:41
+#, fuzzy
+msgid "Implementation of Stream Initiation"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0095.py:54
+#, fuzzy
+msgid "Plugin XEP_0095 initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0095.py:84
+#, fuzzy
+msgid "XEP-0095 Stream initiation"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0095.py:127
+msgid "sending stream initiation accept answer"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0095.py:168
+#, python-format
+msgid "Stream Session ID: %s"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0096.py:48
+msgid "Implementation of SI File Transfer"
+msgstr "Implémentation de l'initialisation de flux pour le transfert de fichier "
+
+#: sat/plugins/plugin_xep_0096.py:55
+#, fuzzy
+msgid "Stream Initiation"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0096.py:58
+msgid "Plugin XEP_0096 initialization"
+msgstr "Initialisation du plugin XEP_0096"
+
+#: sat/plugins/plugin_xep_0096.py:129
+msgid "XEP-0096 file transfer requested"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0096.py:377
+#, fuzzy, python-format
+msgid "The contact {} has refused your file"
+msgstr "Le contact %s a refusé votre inscription"
+
+#: sat/plugins/plugin_xep_0096.py:378
+#, fuzzy
+msgid "File refused"
+msgstr "refusé"
+
+#: sat/plugins/plugin_xep_0096.py:381
+msgid "Error during file transfer"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0096.py:382
+msgid ""
+"Something went wrong during the file transfer session initialisation: "
+"{reason}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0096.py:385
+#, fuzzy
+msgid "File transfer error"
+msgstr "Transfert de fichier"
+
+#: sat/plugins/plugin_xep_0096.py:394
+#, fuzzy, python-format
+msgid "transfer {sid} successfuly finished [{profile}]"
+msgstr "Transfert [%s] refusé"
+
+#: sat/plugins/plugin_xep_0096.py:402
+msgid "transfer {sid} failed [{profile}]: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:37
+msgid "Implementation of Gateways protocol"
+msgstr "Implémentation du protocole de transports"
+
+#: sat/plugins/plugin_xep_0100.py:40
+#, fuzzy
+msgid ""
+"Be careful ! Gateways allow you to use an external IM (legacy IM), so you"
+" can see your contact as XMPP contacts.\n"
+"But when you do this, all your messages go throught the external legacy "
+"IM server, it is a huge privacy issue (i.e.: all your messages throught "
+"the gateway can be monitored, recorded, analysed by the external server, "
+"most of time a private company)."
+msgstr ""
+"Soyez prudent ! Les transports vous permettent d'utiliser une messagerie "
+"externe, de façon à pouvoir afficher vos contacts comme des contacts "
+"jabber.\n"
+"Mais si vous faites cela, tous vos messages passeront par les serveurs de"
+" la messagerie externe, c'est un gros problème pour votre vie privée "
+"(comprenez: tous vos messages à travers le transport pourront être "
+"affichés, enregistrés, analysés par ces serveurs externes, la plupart du "
+"temps une entreprise privée)."
+
+#: sat/plugins/plugin_xep_0100.py:48
+msgid "Internet Relay Chat"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:49
+msgid "XMPP"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:50
+msgid "Tencent QQ"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:51
+msgid "SIP/SIMPLE"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:52
+msgid "ICQ"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:53
+msgid "Yahoo! Messenger"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:54
+msgid "Gadu-Gadu"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:55
+msgid "AOL Instant Messenger"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:56
+msgid "Windows Live Messenger"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:62
+msgid "Gateways plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0100.py:84
+#, fuzzy
+msgid "Gateways"
+msgstr "Chercher les transports"
+
+#: sat/plugins/plugin_xep_0100.py:87
+#, fuzzy
+msgid "Find gateways"
+msgstr "Chercher les transports"
+
+#: sat/plugins/plugin_xep_0100.py:108
+#, fuzzy, python-format
+msgid "Gateways manager (%s)"
+msgstr "Gestionnaire de transport"
+
+#: sat/plugins/plugin_xep_0100.py:121
+#, python-format
+msgid "Failed (%s)"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:134
+#, fuzzy
+msgid "Use external XMPP server"
+msgstr "Utiliser un autre serveur XMPP:"
+
+#: sat/plugins/plugin_xep_0100.py:136
+msgid "Go !"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:143
+#, fuzzy
+msgid "No gateway index selected"
+msgstr "Aucun profile sélectionné"
+
+#: sat/plugins/plugin_xep_0100.py:158
+#, python-format
+msgid ""
+"INTERNAL ERROR: identity category should always be \"gateway\" in "
+"_getTypeString, got \"%s\""
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:166
+msgid "Unknown IM"
+msgstr "Messagerie inconnue"
+
+#: sat/plugins/plugin_xep_0100.py:170
+msgid "Registration successful, doing the rest"
+msgstr "Inscription réussie, lancement du reste de la procédure"
+
+#: sat/plugins/plugin_xep_0100.py:195
+msgid "Timeout"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:211
+#, fuzzy, python-format
+msgid "Found gateway [%(jid)s]: %(identity_name)s"
+msgstr "Transport trouvé (%(jid)s): %(identity)s"
+
+#: sat/plugins/plugin_xep_0100.py:222
+#, python-format
+msgid "Skipping [%(jid)s] which is not a gateway"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0100.py:231
+msgid "No gateway found"
+msgstr "Aucun transport trouvé"
+
+#: sat/plugins/plugin_xep_0100.py:236
+#, python-format
+msgid "item found: %s"
+msgstr "object trouvé: %s"
+
+#: sat/plugins/plugin_xep_0100.py:260
+#, fuzzy, python-format
+msgid "find gateways (target = %(target)s, profile = %(profile)s)"
+msgstr "transports trouvée (cible = %s)"
+
+#: sat/plugins/plugin_xep_0106.py:38
+msgid "(Un)escape JID to use disallowed chars in local parts"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0115.py:50
+#, fuzzy
+msgid "Implementation of entity capabilities"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0115.py:58
+#, fuzzy
+msgid "Plugin XEP_0115 initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0115.py:73
+msgid "Caps optimisation enabled"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0115.py:76
+msgid "Caps optimisation not available"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0115.py:154
+#, python-format
+msgid "Received invalid capabilities tag: %s"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0115.py:170
+msgid ""
+"Unknown hash method for entity capabilities: [{hash_method}] (entity: "
+"{entity_jid}, node: {node})"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0115.py:183
+msgid ""
+"Computed hash differ from given hash:\n"
+"given: [{given}]\n"
+"computed: [{computed}]\n"
+"(entity: {entity_jid}, node: {node})"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0115.py:205
+msgid "Couldn't retrieve disco info for {jid}: {error}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0163.py:42
+#, fuzzy
+msgid "Implementation of Personal Eventing Protocol"
+msgstr "Implémentation du protocole de transports"
+
+#: sat/plugins/plugin_xep_0163.py:48
+#, fuzzy
+msgid "PEP plugin initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0163.py:131
+#, fuzzy, python-format
+msgid "Trying to send personal event with an unknown profile key [%s]"
+msgstr "Tentative d'appel d'un profile inconnue"
+
+#: sat/plugins/plugin_xep_0163.py:136
+#, fuzzy
+msgid "Trying to send personal event for an unknown type"
+msgstr "Tentative d'assigner un paramètre à un profile inconnu"
+
+#: sat/plugins/plugin_xep_0163.py:142
+#, fuzzy
+msgid "No item found"
+msgstr "Aucun transport trouvé"
+
+#: sat/plugins/plugin_xep_0163.py:149
+msgid "Can't find mood element in mood event"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0163.py:153
+#, fuzzy
+msgid "No mood found"
+msgstr "Aucune donnée trouvée"
+
+#: sat/plugins/plugin_xep_0166.py:50
+msgid "{entity} want to start a jingle session with you, do you accept ?"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0166.py:60
+#, fuzzy
+msgid "Implementation of Jingle"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0166.py:98
+#, fuzzy
+msgid "plugin Jingle initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0166.py:156
+#, fuzzy, python-format
+msgid "Error while terminating session: {msg}"
+msgstr "Erreur en tentant de rejoindre le salon"
+
+#: sat/plugins/plugin_xep_0166.py:395
+msgid "You can't do a jingle session with yourself"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0166.py:511
+msgid "Confirm Jingle session"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0184.py:71
+#, fuzzy
+msgid "Implementation of Message Delivery Receipts"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0184.py:96
+msgid "Enable message delivery receipts"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0184.py:100
+#, fuzzy
+msgid "Plugin XEP_0184 (message delivery receipts) initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0184.py:136
+msgid "[XEP-0184] Request acknowledgment for message id {}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0184.py:180
+msgid "[XEP-0184] Receive acknowledgment for message id {}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0184.py:190
+msgid "[XEP-0184] Delete waiting acknowledgment for message id {}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:46
+#, fuzzy
+msgid "Implementation of Stream Management"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0198.py:134
+#, fuzzy
+msgid "Plugin Stream Management initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0198.py:144
+msgid "Invalid ack_timeout value, please check your configuration"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:147
+msgid "Ack timeout disabled"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:149
+msgid "Ack timeout set to {timeout}s"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:171
+msgid ""
+"Your server doesn't support stream management ({namespace}), this is used"
+" to improve connection problems detection (like network outages). Please "
+"ask your server administrator to enable this feature."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:302
+msgid ""
+"Connection failed using location given by server (host: {host}, port: "
+"{port}), switching to normal host and port (host: {normal_host}, port: "
+"{normal_port})"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:317
+msgid "Incorrect <enabled/> element received, no \"id\" attribute"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:319
+msgid ""
+"You're server doesn't support session resuming with stream management, "
+"please contact your server administrator to enable it"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:341
+msgid "Invalid location received: {location}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:359
+msgid "Invalid \"max\" attribute"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:361
+msgid "Using default session max value ({max_s} s)."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:363
+msgid "Stream Management enabled"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:367
+msgid "Stream Management enabled, with a resumption time of {res_m:.2f} min"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:382
+msgid ""
+"Stream session resumed (disconnected for {d_time} s, {count} stanza(s) "
+"resent)"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:395
+msgid "Can't use stream management"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:399
+msgid "{msg}: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:408
+msgid "stream resumption not possible, restarting full session"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:495
+msgid "Server returned invalid ack element, disabling stream management: {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:501
+msgid "Server acked more stanzas than we have sent, disabling stream management."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0198.py:511
+msgid "Ack was not received in time, aborting connection"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0199.py:39
+#, fuzzy
+msgid "Implementation of XMPP Ping"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0199.py:49
+#, fuzzy
+msgid "XMPP Ping plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0199.py:109
+msgid "ping error ({err_msg}). Response time: {time} s"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0199.py:123
+msgid "Invalid jid: \"{entity_jid}\""
+msgstr ""
+
+#: sat/plugins/plugin_xep_0199.py:134
+msgid "XMPP PING received from {from_jid} [{profile}]"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0203.py:45
+#, fuzzy
+msgid "Implementation of Delayed Delivery"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0203.py:51
+#, fuzzy
+msgid "Delayed Delivery plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0231.py:48
+msgid "Implementation of bits of binary (used for small images/files)"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0231.py:59
+#, fuzzy
+msgid "plugin Bits of Binary initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0234.py:54
+#, fuzzy
+msgid "Implementation of Jingle File Transfer"
+msgstr "Implémentation de l'initialisation de flux pour le transfert de fichier "
+
+#: sat/plugins/plugin_xep_0234.py:67
+#, fuzzy
+msgid "file transfer"
+msgstr "Transfert de fichier"
+
+#: sat/plugins/plugin_xep_0234.py:70
+#, fuzzy
+msgid "plugin Jingle File Transfer initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0234.py:380
+msgid "hash_algo must be set if file_hash is set"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0234.py:385
+msgid "file_hash must be set if hash_algo is set"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0234.py:419
+msgid "only the following keys are allowed in extra: {keys}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0234.py:454
+msgid "you need to provide at least name or file hash"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0234.py:524
+#, fuzzy
+msgid "File continue is not implemented yet"
+msgstr "getGame n'est pas implémenté dans ce frontend"
+
+#: sat/plugins/plugin_xep_0249.py:55
+#, fuzzy
+msgid "Implementation of Direct MUC Invitations"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0249.py:75
+msgid "Auto-join MUC on invitation"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0249.py:86
+#, fuzzy
+msgid "Plugin XEP_0249 initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0249.py:140
+#, python-format
+msgid "Invitation accepted for room %(room)s [%(profile)s]"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0249.py:155
+msgid "invalid invitation received: {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0249.py:159
+#, python-format
+msgid "Invitation received for room %(room)s [%(profile)s]"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0249.py:170
+msgid "Invitation silently discarded because user is already in the room."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0249.py:181
+#, python-format
+msgid ""
+"An invitation from %(user)s to join the room %(room)s has been declined "
+"according to your personal settings."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0249.py:185 sat/plugins/plugin_xep_0249.py:192
+#, fuzzy
+msgid "MUC invitation"
+msgstr "Connexion..."
+
+#: sat/plugins/plugin_xep_0249.py:188
+#, python-format
+msgid ""
+"You have been invited by %(user)s to join the room %(room)s. Do you "
+"accept?"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0249.py:215
+msgid "You must provide a valid JID to invite, like in '/invite contact@{host}'"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0260.py:51
+#, fuzzy
+msgid "Implementation of Jingle SOCKS5 Bytestreams"
+msgstr "Implémentation du « SOCKS5 Bytestreams » (flux d'octets SOCKS5)"
+
+#: sat/plugins/plugin_xep_0260.py:64
+msgid "plugin Jingle SOCKS5 Bytestreams"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0261.py:47
+#, fuzzy
+msgid "Implementation of Jingle In-Band Bytestreams"
+msgstr "Implémentation du « SOCKS5 Bytestreams » (flux d'octets SOCKS5)"
+
+#: sat/plugins/plugin_xep_0261.py:55
+#, fuzzy
+msgid "plugin Jingle In-Band Bytestreams"
+msgstr "Implémentation du « SOCKS5 Bytestreams » (flux d'octets SOCKS5)"
+
+#: sat/plugins/plugin_xep_0264.py:67
+msgid "Thumbnails handling"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0264.py:79
+#, fuzzy
+msgid "Plugin XEP_0264 initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0277.py:71
+#, fuzzy
+msgid "Implementation of microblogging Protocol"
+msgstr "Implémentation du protocole de transports"
+
+#: sat/plugins/plugin_xep_0277.py:83
+#, fuzzy
+msgid "Microblogging plugin initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0277.py:286
+msgid "Content of type XHTML must declare its namespace!"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0277.py:557
+msgid "Can't have xhtml and rich content at the same time"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0277.py:1041
+#, python-format
+msgid "Microblog node has now access %s"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0277.py:1045
+msgid "Can't set microblog access"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0280.py:39
+#, fuzzy, python-format
+msgid "Message carbons"
+msgstr "message reçu de: %s"
+
+#: sat/plugins/plugin_xep_0280.py:50
+#, fuzzy
+msgid "Implementation of Message Carbons"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0280.py:75
+#, fuzzy
+msgid "Plugin XEP_0280 initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0280.py:102
+msgid "Not activating message carbons as requested in params"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0280.py:107
+msgid "server doesn't handle message carbons"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0280.py:109
+msgid "message carbons available, enabling it"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0280.py:117
+#, fuzzy
+msgid "message carbons activated"
+msgstr ""
+"Barre de progression désactivée\n"
+"--\n"
+
+#: sat/plugins/plugin_xep_0297.py:44
+#, fuzzy
+msgid "Implementation of Stanza Forwarding"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0297.py:52
+#, fuzzy
+msgid "Stanza Forwarding plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0300.py:45
+msgid "Management of cryptographic hashes"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0300.py:66
+#, fuzzy
+msgid "plugin Hashes initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0313.py:51
+#, fuzzy
+msgid "Implementation of Message Archive Management"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0313.py:64
+#, fuzzy
+msgid "Message Archive Management plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0313.py:92
+msgid "It seems that we have no MAM history yet"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0313.py:126
+msgid "missing \"to\" attribute in forwarded message"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0313.py:137
+msgid "missing \"from\" attribute in forwarded message"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0313.py:140
+msgid ""
+"was expecting a message sent by our jid, but this one if from {from_jid},"
+" ignoring\n"
+"{xml}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0313.py:158
+msgid "We have received no message while offline"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0313.py:160
+msgid "We have received {num_mess} message(s) while offline."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0329.py:50
+#, fuzzy
+msgid "Implementation of File Information Sharing"
+msgstr "Implémentation de l'initialisation de flux pour le transfert de fichier "
+
+#: sat/plugins/plugin_xep_0329.py:86
+msgid "path change chars found in name [{name}], hack attempt?"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0329.py:107
+msgid "path can only be set on path nodes"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0329.py:141
+msgid "a node can't have several parents"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0329.py:210
+msgid ""
+"parent dir (\"..\") found in path, hack attempt? path is {path} "
+"[{profile}]"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0329.py:271
+#, fuzzy
+msgid "File Information Sharing initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0329.py:394
+msgid "invalid path: {path}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0329.py:428
+msgid "{peer_jid} requested a file (s)he can't access [{profile}]"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0329.py:461
+#, fuzzy, python-format
+msgid "error while retrieving files: {msg}"
+msgstr "Erreur en tentant de rejoindre le salon"
+
+#: sat/plugins/plugin_xep_0329.py:513
+msgid "ignoring invalid unicode name ({name}): {msg}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0329.py:534
+msgid "unexpected type: {type}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0329.py:573
+#, fuzzy, python-format
+msgid "unknown node type: {type}"
+msgstr "Type d'action inconnu"
+
+#: sat/plugins/plugin_xep_0329.py:711
+msgid "unexpected element, ignoring: {elt}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0329.py:1184
+#, fuzzy, python-format
+msgid "This path doesn't exist!"
+msgstr "Le fichier [%s] n'existe pas !"
+
+#: sat/plugins/plugin_xep_0329.py:1186
+msgid "A path need to be specified"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0329.py:1188
+msgid "access must be a dict"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0329.py:1200
+#, fuzzy
+msgid "Can't find a proper name"
+msgstr "Impossible de trouver la VCard de %s"
+
+#: sat/plugins/plugin_xep_0329.py:1211
+msgid ""
+"A directory with this name is already shared, renamed to {new_name} "
+"[{profile}]"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0334.py:43
+#, fuzzy
+msgid "Implementation of Message Processing Hints"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0334.py:45
+msgid ""
+"             Frontends can use HINT_* constants in mess_data['extra'] in "
+"a serialized 'hints' dict.\n"
+"             Internal plugins can use directly addHint([HINT_* "
+"constant]).\n"
+"             Will set mess_data['extra']['history'] to 'skipped' when no "
+"store is requested and message is not saved in history."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0334.py:65
+#, fuzzy
+msgid "Message Processing Hints plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0346.py:54
+msgid "Handle Pubsub data schemas"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0346.py:60
+#, fuzzy
+msgid "PubSub Schema initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0346.py:208
+msgid "unspecified schema, we need to request it"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0346.py:212
+msgid ""
+"no schema specified, and this node has no schema either, we can't "
+"construct the data form"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0346.py:233
+msgid "Invalid Schema: {msg}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0346.py:246
+msgid "nodeIndentifier needs to be set"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0346.py:310
+msgid "empty node is not allowed"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0346.py:354
+msgid "default_node must be set if nodeIdentifier is not set"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0346.py:457
+#, fuzzy
+msgid "field {name} doesn't exist, ignoring it"
+msgstr "Le fichier [%s] n'existe pas !"
+
+#: sat/plugins/plugin_xep_0346.py:551
+msgid "Can't parse date field: {msg}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0346.py:652
+msgid "Can't get previous item, update ignored: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0346.py:661
+msgid "Can't parse previous item, update ignored: data form not found"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0346.py:719
+msgid "default_node must be set if node is not set"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0346.py:728
+msgid "if extra[\"update\"] is set, item_id must be set too"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0352.py:35
+msgid ""
+"Notify server when frontend is not actively used, to limit traffic and "
+"save bandwidth and battery life"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0352.py:45
+#, fuzzy
+msgid "Client State Indication plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0352.py:63
+msgid "Client State Indication is available on this server"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0352.py:67
+msgid ""
+"Client State Indication is not available on this server, some bandwidth "
+"optimisations can't be used."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0353.py:46
+#, fuzzy
+msgid "Implementation of Jingle Message Initiation"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0353.py:53
+#, fuzzy
+msgid "plugin {name} initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0353.py:122
+msgid "Message initiation with {peer_jid} timed out"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0353.py:166
+msgid ""
+"Somebody not in your contact list ({peer_jid}) wants to do a "
+"\"{human_name}\" session with you, this would leak your presence and "
+"possibly you IP (internet localisation), do you accept?"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0353.py:171
+#, fuzzy
+msgid "Invitation from an unknown contact"
+msgstr "Tentative d'assigner un paramètre à un profile inconnu"
+
+#: sat/plugins/plugin_xep_0353.py:211
+msgid "no pending session found with id {session_id}, did it timed out?"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0359.py:40
+#, fuzzy
+msgid "Implementation of Unique and Stable Stanza IDs"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0359.py:49
+#, fuzzy
+msgid "Unique and Stable Stanza IDs plugin initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/plugins/plugin_xep_0363.py:51
+#, fuzzy
+msgid "Implementation of HTTP File Upload"
+msgstr "Implémentation de l'initialisation de flux pour le transfert de fichier "
+
+#: sat/plugins/plugin_xep_0363.py:83
+#, fuzzy
+msgid "plugin HTTP File Upload initialization"
+msgstr "Initialisation du plugin XEP_0054"
+
+#: sat/plugins/plugin_xep_0363.py:200
+msgid "Can't get upload slot: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0363.py:265
+msgid "upload failed: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0363.py:349
+msgid "Invalid header element: {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0363.py:355
+msgid "Ignoring unauthorised header \"{name}\": {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0363.py:400
+msgid "no service can handle HTTP Upload request: {elt}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0380.py:35
+#, fuzzy
+msgid "Implementation of Explicit Message Encryption"
+msgstr "Implémentation de l'enregistrement en ligne"
+
+#: sat/plugins/plugin_xep_0380.py:94
+msgid ""
+"Message from {sender} is encrypted with {algorithm} and we can't decrypt "
+"it."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0380.py:96
+msgid ""
+"User {sender} sent you an encrypted message (encrypted with {algorithm}),"
+" and we can't decrypt it."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:62
+#, fuzzy
+msgid "Implementation of OMEMO"
+msgstr "Implementation de vcard-temp"
+
+#: sat/plugins/plugin_xep_0384.py:440
+msgid "Security"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:442
+msgid "OMEMO default trust policy"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:443
+msgid "Manual trust (more secure)"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:445
+msgid "Blind Trust Before Verification (more user friendly)"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:449
+msgid "OMEMO plugin initialization (omemo module v{version})"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:453
+msgid ""
+"Your version of omemo module is too old: {v[0]}.{v[1]}.{v[2]} is minimum "
+"required, please update."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:488
+msgid "You need to have OMEMO encryption activated to reset the session"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:503
+msgid "OMEMO session has been reset"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:551
+msgid "device {device} from {peer_jid} is not an auto-trusted device anymore"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:612
+msgid "Can't find bundle for device {device_id} of user {bare_jid}, ignoring"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:642
+#, fuzzy
+msgid "OMEMO trust management"
+msgstr "Initialisation du gestionnaire de mémoire"
+
+#: sat/plugins/plugin_xep_0384.py:645
+msgid ""
+"This is OMEMO trusting system. You'll see below the devices of your "
+"contacts, and a checkbox to trust them or not. A trusted device can read "
+"your messages in plain text, so be sure to only validate devices that you"
+" are sure are belonging to your contact. It's better to do this when you "
+"are next to your contact and her/his device, so you can check the "
+"\"fingerprint\" (the number next to the device) yourself. Do *not* "
+"validate a device if the fingerprint is wrong!"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:655
+msgid "This device ID"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:657
+msgid "This device fingerprint"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:669
+msgid "Automatically trust new devices?"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:683
+#, fuzzy
+msgid "Contact"
+msgstr "&Contacts"
+
+#: sat/plugins/plugin_xep_0384.py:685
+msgid "Device ID"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:691
+msgid "Trust this device?"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:696
+msgid "(automatically trusted)"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:724
+msgid "We have no identity for this device yet, let's generate one"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:749
+msgid "Saving public bundle for this device ({device_id})"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:770
+msgid "OMEMO devices list is stored in more that one items, this is not expected"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:776
+msgid "no list element found in OMEMO devices list"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:782
+msgid "device element is missing \"id\" attribute: {elt}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:785
+msgid "invalid device id: {device_id}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:804
+msgid "there is no node to handle OMEMO devices"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:826
+msgid "Can't set devices: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:850
+msgid "Bundle missing for device {device_id}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:855
+msgid "Can't get bundle for device {device_id}: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:859
+msgid ""
+"no item found in node {node}, can't get public bundle for device "
+"{device_id}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:864
+msgid "more than one item found in {node}, this is not expected"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:878
+msgid "invalid bundle for device {device_id}, ignoring"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:902
+msgid "error while decoding key for device {device_id}: {msg}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:916
+msgid "updating bundle for {device_id}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:949
+msgid "Can't set bundle: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:969
+msgid "Our own device is missing from devices list, fixing it"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:988
+msgid ""
+"Not all destination devices are trusted, unknown devices will be blind "
+"trusted due to the OMEMO Blind Trust Before Verification policy. If you "
+"want a more secure workflow, please activate \"manual\" OMEMO policy in "
+"settings' \"Security\" tab.\n"
+"Following fingerprint have been automatically trusted:\n"
+"{devices}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1010
+msgid ""
+"Not all destination devices are trusted, we can't encrypt message in such"
+" a situation. Please indicate if you trust those devices or not in the "
+"trust manager before we can send this message"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1053
+msgid "discarding untrusted device {device_id} with key {device_key} for {entity}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1095
+msgid ""
+"Can't retrieve bundle for device(s) {devices} of entity {peer}, the "
+"message will not be readable on this/those device(s)"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1100
+msgid ""
+"You're destinee {peer} has missing encryption data on some of his/her "
+"device(s) (bundle on device {devices}), the message won't  be readable on"
+" this/those device."
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1151
+msgid "Too many iterations in encryption loop"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1180
+msgid "Can't encrypt message for {entities}: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1270
+msgid "Invalid OMEMO encrypted stanza, ignoring: {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1276
+msgid "Invalid OMEMO encrypted stanza, missing sender device ID, ignoring: {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1284
+msgid ""
+"This OMEMO encrypted stanza has not been encrypted for our device "
+"(device_id: {device_id}, fingerprint: {fingerprint}): {xml}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1290
+msgid ""
+"An OMEMO message from {sender} has not been encrypted for our device, we "
+"can't decrypt it"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1297
+msgid "Invalid recipient ID: {msg}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1330
+msgid ""
+"Can't decrypt message: {reason}\n"
+"{xml}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1332
+msgid "An OMEMO message from {sender} can't be decrypted: {reason}"
+msgstr ""
+
+#: sat/plugins/plugin_xep_0384.py:1364
+msgid ""
+"Our message with UID {uid} has not been received in time, it has probably"
+" been lost. The message was: {msg!r}"
+msgstr ""
+
+#: sat/plugins/plugin_app_manager_docker/__init__.py:38
+msgid "Applications Manager for Docker"
+msgstr ""
+
+#: sat/plugins/plugin_app_manager_docker/__init__.py:48
+#, fuzzy
+msgid "Docker App Manager initialization"
+msgstr "Initialisation de l'extension pour les transports"
+
+#: sat/stdui/ui_contact_list.py:39 sat/stdui/ui_contact_list.py:42
+#: sat/stdui/ui_contact_list.py:190 sat/stdui/ui_contact_list.py:276
+#, fuzzy
+msgid "Add contact"
+msgstr "&Ajouter un contact"
+
+#: sat/stdui/ui_contact_list.py:45 sat/stdui/ui_contact_list.py:48
+#: sat/stdui/ui_contact_list.py:209
+#, fuzzy
+msgid "Update contact"
+msgstr "&Ajouter un contact"
+
+#: sat/stdui/ui_contact_list.py:51 sat/stdui/ui_contact_list.py:54
+#, fuzzy
+msgid "Remove contact"
+msgstr "Supp&rimer un contact"
+
+#: sat/stdui/ui_contact_list.py:157
+msgid "Select in which groups your contact is:"
+msgstr ""
+
+#: sat/stdui/ui_contact_list.py:172
+msgid "Add group"
+msgstr ""
+
+#: sat/stdui/ui_contact_list.py:174
+msgid "Add"
+msgstr ""
+
+#: sat/stdui/ui_contact_list.py:191
+#, fuzzy, python-format
+msgid "New contact identifier (JID):"
+msgstr "nouveau contact: %s"
+
+#: sat/stdui/ui_contact_list.py:203
+msgid "Nothing to update"
+msgstr ""
+
+#: sat/stdui/ui_contact_list.py:204 sat/stdui/ui_contact_list.py:223
+msgid "Your contact list is empty."
+msgstr ""
+
+#: sat/stdui/ui_contact_list.py:210
+msgid "Which contact do you want to update?"
+msgstr ""
+
+#: sat/stdui/ui_contact_list.py:222
+msgid "Nothing to delete"
+msgstr ""
+
+#: sat/stdui/ui_contact_list.py:228
+#, fuzzy, python-format
+msgid "Who do you want to remove from your contacts?"
+msgstr "Êtes vous sûr de vouloir supprimer %s de votre liste de contacts ?"
+
+#: sat/stdui/ui_contact_list.py:251
+#, fuzzy
+msgid "Delete contact"
+msgstr "&Ajouter un contact"
+
+#: sat/stdui/ui_contact_list.py:253
+#, fuzzy, python-format
+msgid "Are you sure you want to remove %s from your contact list?"
+msgstr "Êtes vous sûr de vouloir supprimer %s de votre liste de contacts ?"
+
+#: sat/stdui/ui_contact_list.py:277
+#, python-format
+msgid "Please enter a valid JID (like \"contact@%s\"):"
+msgstr ""
+
+#: sat/stdui/ui_profile_manager.py:62
+msgid "Profile password for {}"
+msgstr ""
+
+#: sat/stdui/ui_profile_manager.py:72 sat/stdui/ui_profile_manager.py:119
+#, fuzzy
+msgid "Connection error"
+msgstr "Connexion..."
+
+#: sat/stdui/ui_profile_manager.py:76
+#: sat_frontends/quick_frontend/quick_profile_manager.py:171
+#, fuzzy
+msgid "Internal error"
+msgstr "Transfert de fichier"
+
+#: sat/stdui/ui_profile_manager.py:77
+msgid "Internal error: {}"
+msgstr ""
+
+#: sat/stdui/ui_profile_manager.py:121
+#, python-format
+msgid "Can't connect to %s. Please check your connection details."
+msgstr ""
+
+#: sat/stdui/ui_profile_manager.py:127
+#, python-format
+msgid "XMPP password for %(profile)s%(counter)s"
+msgstr ""
+
+#: sat/stdui/ui_profile_manager.py:135
+#, python-format
+msgid ""
+"Can't connect to %s. Please check your connection details or try with "
+"another password."
+msgstr ""
+
+#: sat/test/constants.py:57
+msgid "Enable unibox"
+msgstr ""
+
+#: sat/test/constants.py:58
+msgid "'Wysiwyg' edition"
+msgstr ""
+
+#: sat/test/test_plugin_misc_room_game.py:43
+msgid "Dummy plugin to test room game"
+msgstr ""
+
+#: sat/tools/config.py:53
+#, fuzzy, python-format
+msgid "Testing file %s"
+msgstr "Échec de l'inscription: %s"
+
+#: sat/tools/config.py:72
+msgid "Config auto-update: {option} set to {value} in the file {config_file}."
+msgstr ""
+
+#: sat/tools/config.py:86
+msgid "Can't read main config: {msg}"
+msgstr ""
+
+#: sat/tools/config.py:91
+msgid "Configuration was read from: {filenames}"
+msgstr ""
+
+#: sat/tools/config.py:95
+#, fuzzy, python-format
+msgid "No configuration file found, using default settings"
+msgstr "Disposition inconnue, utilisation de celle par defaut"
+
+#: sat/tools/image.py:35
+msgid "SVG support not available, please install cairosvg: {e}"
+msgstr ""
+
+#: sat/tools/trigger.py:66
+#, python-format
+msgid "There is already a bound priority [%s]"
+msgstr ""
+
+#: sat/tools/trigger.py:69
+#, python-format
+msgid "There is already a trigger with the same priority [%s]"
+msgstr ""
+
+#: sat/tools/video.py:38
+msgid "ffmpeg executable not found, video thumbnails won't be available"
+msgstr ""
+
+#: sat/tools/video.py:56
+msgid "ffmpeg executable is not available, can't generate video thumbnail"
+msgstr ""
+
+#: sat/tools/xml_tools.py:86
+msgid "Fixed field has neither value nor label, ignoring it"
+msgstr ""
+
+#: sat/tools/xml_tools.py:485
+#, fuzzy
+msgid "INTERNAL ERROR: parameters xml not valid"
+msgstr "ERREUR INTERNE: les paramètres doivent avoir un nom"
+
+#: sat/tools/xml_tools.py:495
+msgid "INTERNAL ERROR: params categories must have a name"
+msgstr "ERREUR INTERNE: les catégories des paramètres doivent avoir un nom"
+
+#: sat/tools/xml_tools.py:505
+msgid "INTERNAL ERROR: params must have a name"
+msgstr "ERREUR INTERNE: les paramètres doivent avoir un nom"
+
+#: sat/tools/xml_tools.py:557
+msgid "The 'options' tag is not allowed in parameter of type 'list'!"
+msgstr ""
+
+#: sat/tools/xml_tools.py:655
+msgid "TabElement must be a child of TabsContainer"
+msgstr ""
+
+#: sat/tools/xml_tools.py:760
+msgid "Can't set row index if auto_index is True"
+msgstr ""
+
+#: sat/tools/xml_tools.py:893
+msgid "either items or columns need do be filled"
+msgstr ""
+
+#: sat/tools/xml_tools.py:907
+msgid "Headers lenght doesn't correspond to columns"
+msgstr ""
+
+#: sat/tools/xml_tools.py:954
+msgid "Incorrect number of items in list"
+msgstr ""
+
+#: sat/tools/xml_tools.py:978
+#, fuzzy
+msgid "A widget with the name \"{name}\" already exists."
+msgstr "Ce nom de profile existe déjà"
+
+#: sat/tools/xml_tools.py:1171
+msgid "Value must be an integer"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1186
+msgid "Value must be 0, 1, false or true"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1249
+msgid ""
+"\"multi\" flag and \"selected\" option are not compatible with "
+"\"noselect\" flag"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1258
+msgid "empty \"options\" list"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1277 sat/tools/xml_tools.py:1311
+msgid "invalid styles"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1335
+msgid "DialogElement must be a direct child of TopElement"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1350
+msgid "MessageElement must be a direct child of DialogElement"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1365
+msgid "ButtonsElement must be a direct child of DialogElement"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1379
+msgid "FileElement must be a direct child of DialogElement"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1458
+#, fuzzy, python-format
+msgid "Unknown panel type [%s]"
+msgstr "Type d'action inconnu"
+
+#: sat/tools/xml_tools.py:1460
+msgid "form XMLUI need a submit_id"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1462
+msgid "container argument must be a string"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1465
+msgid "dialog_opt can only be used with dialog panels"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1492
+msgid "createWidget can't be used with dialogs"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1590
+msgid "Submit ID must be filled for this kind of dialog"
+msgstr ""
+
+#: sat/tools/xml_tools.py:1618
+#, fuzzy, python-format
+msgid "Unknown container type [%s]"
+msgstr "Type d'action inconnu"
+
+#: sat/tools/xml_tools.py:1648
+#, fuzzy, python-format
+msgid "Invalid type [{type_}]"
+msgstr "Type d'action inconnu"
+
+#: sat/tools/common/async_process.py:86
+msgid ""
+"Can't complete {name} command (error code: {code}):\n"
+"stderr:\n"
+"{stderr}\n"
+"{stdout}\n"
+msgstr ""
+
+#: sat/tools/common/date_utils.py:76
+msgid "You can't use a direction (+ or -) and \"ago\" at the same time"
+msgstr ""
+
+#: sat/tools/common/template.py:149
+msgid "{site} can't be used as site name, it's reserved."
+msgstr ""
+
+#: sat/tools/common/template.py:157
+msgid "{theme} contain forbidden char. Following chars are forbidden: {reserved}"
+msgstr ""
+
+#: sat/tools/common/template.py:212
+msgid "Unregistered site requested: {site_to_check}"
+msgstr ""
+
+#: sat/tools/common/template.py:241
+msgid ""
+"Absolute template used while unsecure is disabled, hack attempt? "
+"Template: {template}"
+msgstr ""
+
+#: sat/tools/common/template.py:314
+msgid "Invalid attribute, please use one of \"defer\", \"async\" or \"\""
+msgstr ""
+
+#: sat/tools/common/template.py:332
+msgid "Can't find {libary} javascript library"
+msgstr ""
+
+#: sat/tools/common/template.py:389
+msgid ""
+"Can't add \"{name}\" site, it contains forbidden characters. Forbidden "
+"characters are {forbidden}."
+msgstr ""
+
+#: sat/tools/common/template.py:395
+msgid "Can't add \"{name}\" site, it should map to an absolute path"
+msgstr ""
+
+#: sat/tools/common/template.py:416
+msgid "Can't load theme settings at {path}: {e}"
+msgstr ""
+
+#: sat/tools/common/template.py:523
+msgid "Can't find template translation at {path}"
+msgstr ""
+
+#: sat/tools/common/template.py:526
+msgid "{site}Invalid locale name: {msg}"
+msgstr ""
+
+#: sat/tools/common/template.py:529
+msgid "{site}loaded {lang} templates translations"
+msgstr ""
+
+#: sat/tools/common/template.py:560
+msgid "invalid locale value: {msg}"
+msgstr ""
+
+#: sat/tools/common/template.py:569
+msgid "Can't find locale {locale}"
+msgstr ""
+
+#: sat/tools/common/template.py:574
+msgid "Switched to {lang}"
+msgstr ""
+
+#: sat/tools/common/template.py:774 sat_frontends/jp/cmd_event.py:134
+msgid "Can't parse date: {msg}"
+msgstr ""
+
+#: sat/tools/common/template.py:801
+#, fuzzy
+msgid "ignoring field \"{name}\": it doesn't exists"
+msgstr "Le fichier [%s] n'existe pas !"
+
+#: sat_frontends/jp/arg_tools.py:88
+msgid "ignoring {name}={value}, not corresponding to any argument (in USE)"
+msgstr ""
+
+#: sat_frontends/jp/arg_tools.py:95
+msgid "arg {name}={value} (in USE)"
+msgstr ""
+
+#: sat_frontends/jp/base.py:64
+#, fuzzy
+msgid ""
+"ProgressBar not available, please download it at "
+"http://pypi.python.org/pypi/progressbar\n"
+"Progress bar deactivated\n"
+"--\n"
+msgstr ""
+"ProgressBar n'est pas disponible, veuillez le télécharger à "
+"http://pypi.python.org/pypi/progressbar"
+
+#: sat_frontends/jp/base.py:155
+msgid ""
+"Invalid value set for \"background\" ({background}), please check your "
+"settings in libervia.conf"
+msgstr ""
+
+#: sat_frontends/jp/base.py:178
+msgid "Available commands"
+msgstr ""
+
+#: sat_frontends/jp/base.py:287
+#, python-format
+msgid "Use PROFILE profile key (default: %(default)s)"
+msgstr ""
+
+#: sat_frontends/jp/base.py:290
+msgid "Password used to connect profile, if necessary"
+msgstr ""
+
+#: sat_frontends/jp/base.py:297
+msgid "Connect the profile before doing anything else"
+msgstr ""
+
+#: sat_frontends/jp/base.py:307
+msgid "Start a profile session without connecting"
+msgstr ""
+
+#: sat_frontends/jp/base.py:313
+msgid "Show progress bar"
+msgstr "Affiche la barre de progression"
+
+#: sat_frontends/jp/base.py:318
+msgid "Add a verbosity level (can be used multiple times)"
+msgstr ""
+
+#: sat_frontends/jp/base.py:323
+msgid "be quiet (only output machine readable data)"
+msgstr ""
+
+#: sat_frontends/jp/base.py:326
+msgid "draft handling"
+msgstr ""
+
+#: sat_frontends/jp/base.py:328
+msgid "load current draft"
+msgstr ""
+
+#: sat_frontends/jp/base.py:330
+msgid "path to a draft file to retrieve"
+msgstr ""
+
+#: sat_frontends/jp/base.py:346
+msgid "Pubsub URL (xmpp or http)"
+msgstr ""
+
+#: sat_frontends/jp/base.py:348
+#, fuzzy
+msgid "JID of the PubSub service"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/base.py:350
+msgid "PEP service"
+msgstr ""
+
+#: sat_frontends/jp/base.py:352 sat_frontends/jp/base.py:360
+#: sat_frontends/jp/base.py:368
+msgid " (DEFAULT: {default})"
+msgstr ""
+
+#: sat_frontends/jp/base.py:356
+#, fuzzy
+msgid "node to request"
+msgstr "Demande de suppression de contact"
+
+#: sat_frontends/jp/base.py:358
+msgid "standard node"
+msgstr ""
+
+#: sat_frontends/jp/base.py:366
+msgid "last item"
+msgstr ""
+
+#: sat_frontends/jp/base.py:372
+msgid "retrieve last item"
+msgstr ""
+
+#: sat_frontends/jp/base.py:378
+msgid "items to retrieve (DEFAULT: all)"
+msgstr ""
+
+#: sat_frontends/jp/base.py:385
+msgid "maximum number of items to get ({no_limit} to get all items)"
+msgstr ""
+
+#: sat_frontends/jp/base.py:391
+msgid "maximum number of items to get per page (DEFAULT: 10)"
+msgstr ""
+
+#: sat_frontends/jp/base.py:398 sat_frontends/jp/cmd_message.py:217
+msgid "find page after this item"
+msgstr ""
+
+#: sat_frontends/jp/base.py:401 sat_frontends/jp/cmd_message.py:220
+msgid "find page before this item"
+msgstr ""
+
+#: sat_frontends/jp/base.py:404 sat_frontends/jp/cmd_message.py:223
+msgid "index of the page to retrieve"
+msgstr ""
+
+#: sat_frontends/jp/base.py:411
+#, fuzzy
+msgid "MAM filters to use"
+msgstr "Veuillez choisir le fichier à envoyer"
+
+#: sat_frontends/jp/base.py:424
+msgid "how items should be ordered"
+msgstr ""
+
+#: sat_frontends/jp/base.py:454
+msgid "there is already a default output for {type}, ignoring new one"
+msgstr ""
+
+#: sat_frontends/jp/base.py:475
+msgid "The following output options are invalid: {invalid_options}"
+msgstr ""
+
+#: sat_frontends/jp/base.py:499
+msgid "Can't import {module_path} plugin, ignoring it: {e}"
+msgstr ""
+
+#: sat_frontends/jp/base.py:505
+msgid "Missing module for plugin {name}: {missing}"
+msgstr ""
+
+#: sat_frontends/jp/base.py:520
+msgid "Invalid plugin module [{type}] {module}"
+msgstr ""
+
+#: sat_frontends/jp/base.py:552
+msgid "Can't parse HTML page : {msg}"
+msgstr ""
+
+#: sat_frontends/jp/base.py:558
+msgid ""
+"Could not find alternate \"xmpp:\" URI, can't find associated XMPP PubSub"
+" node/item"
+msgstr ""
+
+#: sat_frontends/jp/base.py:576
+msgid "invalid XMPP URL: {url}"
+msgstr ""
+
+#: sat_frontends/jp/base.py:596
+msgid "item specified in URL but not needed in command, ignoring it"
+msgstr ""
+
+#: sat_frontends/jp/base.py:612
+msgid "XMPP URL is not a pubsub one: {url}"
+msgstr ""
+
+#: sat_frontends/jp/base.py:618
+msgid "argument -s/--service is required"
+msgstr ""
+
+#: sat_frontends/jp/base.py:620
+msgid "argument -n/--node is required"
+msgstr ""
+
+#: sat_frontends/jp/base.py:622
+msgid "argument -i/--item is required"
+msgstr ""
+
+#: sat_frontends/jp/base.py:629
+msgid "--item and --item-last can't be used at the same time"
+msgstr ""
+
+#: sat_frontends/jp/base.py:659 sat_frontends/quick_frontend/quick_app.py:370
+msgid "Can't connect to SàT backend, are you sure it's launched ?"
+msgstr "Impossible de se connecter au démon SàT, êtes vous sûr qu'il est lancé ?"
+
+#: sat_frontends/jp/base.py:662 sat_frontends/quick_frontend/quick_app.py:373
+#, fuzzy
+msgid "Can't init bridge"
+msgstr "Construction du jeu de Tarot"
+
+#: sat_frontends/jp/base.py:666
+msgid "Error while initialising bridge: {e}"
+msgstr ""
+
+#: sat_frontends/jp/base.py:714
+msgid "action cancelled by user"
+msgstr ""
+
+#: sat_frontends/jp/base.py:785
+#, python-format
+msgid "%s is not a valid JID !"
+msgstr "%s n'est pas un JID valide !"
+
+#: sat_frontends/jp/base.py:837
+#, fuzzy
+msgid "invalid password"
+msgstr "Sauvegarde du nouveau mot de passe"
+
+#: sat_frontends/jp/base.py:839
+#, fuzzy
+msgid "please enter profile password:"
+msgstr "Veuillez entrer le nom du nouveau profile"
+
+#: sat_frontends/jp/base.py:859
+#, fuzzy, python-format
+msgid "The profile [{profile}] doesn't exist"
+msgstr "Le fichier [%s] n'existe pas !"
+
+#: sat_frontends/jp/base.py:881
+#, fuzzy, python-format
+msgid ""
+"Session for [{profile}] is not started, please start it before using jp, "
+"or use either --start-session or --connect option"
+msgstr "SAT n'est pas connecté, veuillez le connecter avant d'utiliser jp"
+
+#: sat_frontends/jp/base.py:901
+#, fuzzy, python-format
+msgid ""
+"Profile [{profile}] is not connected, please connect it before using jp, "
+"or use --connect option"
+msgstr "SAT n'est pas connecté, veuillez le connecter avant d'utiliser jp"
+
+#: sat_frontends/jp/base.py:1002
+msgid "select output format (default: {})"
+msgstr ""
+
+#: sat_frontends/jp/base.py:1005
+msgid "output specific option"
+msgstr ""
+
+#: sat_frontends/jp/base.py:1111
+msgid "file size is not known, we can't show a progress bar"
+msgstr ""
+
+#: sat_frontends/jp/base.py:1126 sat_frontends/jp/cmd_list.py:304
+msgid "Progress: "
+msgstr "Progression: "
+
+#: sat_frontends/jp/base.py:1156
+#, fuzzy
+msgid "Operation started"
+msgstr "inscription demandée pour"
+
+#: sat_frontends/jp/base.py:1172
+#, fuzzy, python-format
+msgid "Operation successfully finished"
+msgstr "Transfert [%s] refusé"
+
+#: sat_frontends/jp/base.py:1179
+msgid "Error while doing operation: {e}"
+msgstr ""
+
+#: sat_frontends/jp/base.py:1189
+msgid "trying to use output when use_output has not been set"
+msgstr ""
+
+#: sat_frontends/jp/cmd_account.py:42
+msgid "create a XMPP account"
+msgstr ""
+
+#: sat_frontends/jp/cmd_account.py:47
+msgid "jid to create"
+msgstr ""
+
+#: sat_frontends/jp/cmd_account.py:50
+msgid "password of the account"
+msgstr ""
+
+#: sat_frontends/jp/cmd_account.py:55
+msgid "create a profile to use this account (default: don't create profile)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_account.py:63
+msgid "email (usage depends of XMPP server)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_account.py:69
+msgid "server host (IP address or domain, default: use localhost)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_account.py:76
+msgid "server port (default: {port})"
+msgstr ""
+
+#: sat_frontends/jp/cmd_account.py:107
+msgid "XMPP account created"
+msgstr ""
+
+#: sat_frontends/jp/cmd_account.py:113
+#, fuzzy
+msgid "creating profile"
+msgstr "Veuillez entrer le nom du nouveau profile"
+
+#: sat_frontends/jp/cmd_account.py:129
+msgid "Can't create profile {profile} to associate with jid {jid}: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_account.py:142
+#, fuzzy
+msgid "profile created"
+msgstr "Aucun profile sélectionné"
+
+#: sat_frontends/jp/cmd_account.py:183
+msgid "change password for XMPP account"
+msgstr ""
+
+#: sat_frontends/jp/cmd_account.py:188
+#, fuzzy
+msgid "new XMPP password"
+msgstr "Sauvegarde du nouveau mot de passe"
+
+#: sat_frontends/jp/cmd_account.py:207
+msgid "delete a XMPP account"
+msgstr ""
+
+#: sat_frontends/jp/cmd_account.py:215
+msgid "delete account without confirmation"
+msgstr ""
+
+#: sat_frontends/jp/cmd_account.py:236
+msgid "Account deletion cancelled"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:34
+#, fuzzy
+msgid "remote control a software"
+msgstr "Supp&rimer un contact"
+
+#: sat_frontends/jp/cmd_adhoc.py:38
+msgid "software name"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:44
+msgid "jids allowed to use the command"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:51
+msgid "groups allowed to use the command"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:57
+msgid "groups that are *NOT* allowed to use the command"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:63
+msgid "jids that are *NOT* allowed to use the command"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:66
+#, fuzzy
+msgid "loop on the commands"
+msgstr "Mauvais nom de profile"
+
+#: sat_frontends/jp/cmd_adhoc.py:93
+#, fuzzy, python-format
+msgid "No bus name found"
+msgstr "Fonctionnalité trouvée: %s"
+
+#: sat_frontends/jp/cmd_adhoc.py:96
+#, fuzzy, python-format
+msgid "Bus name found: [%s]"
+msgstr "Fonctionnalité trouvée: %s"
+
+#: sat_frontends/jp/cmd_adhoc.py:100
+msgid "Command found: (path:{path}, iface: {iface}) [{command}]"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:112
+msgid "run an Ad-Hoc command"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:120 sat_frontends/jp/cmd_message.py:200
+msgid "jid of the service (default: profile's server"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:128
+msgid "submit form/page"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:137
+msgid "field value"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:143
+msgid "node of the command (default: list commands)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:171
+msgid "list Ad-Hoc commands of a service"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:179
+msgid "jid of the service (default: profile's server)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_adhoc.py:202
+msgid "Ad-hoc commands"
+msgstr ""
+
+#: sat_frontends/jp/cmd_application.py:33
+msgid "list available applications"
+msgstr ""
+
+#: sat_frontends/jp/cmd_application.py:45
+msgid "show applications with this status"
+msgstr ""
+
+#: sat_frontends/jp/cmd_application.py:72
+#, fuzzy
+msgid "start an application"
+msgstr "Sélection du contrat"
+
+#: sat_frontends/jp/cmd_application.py:78
+msgid "name of the application to start"
+msgstr ""
+
+#: sat_frontends/jp/cmd_application.py:98
+msgid "stop a running application"
+msgstr ""
+
+#: sat_frontends/jp/cmd_application.py:106
+msgid "name of the application to stop"
+msgstr ""
+
+#: sat_frontends/jp/cmd_application.py:111
+msgid "identifier of the instance to stop"
+msgstr ""
+
+#: sat_frontends/jp/cmd_application.py:142
+msgid "show data exposed by a running application"
+msgstr ""
+
+#: sat_frontends/jp/cmd_application.py:150
+msgid "name of the application to check"
+msgstr ""
+
+#: sat_frontends/jp/cmd_application.py:155
+msgid "identifier of the instance to check"
+msgstr ""
+
+#: sat_frontends/jp/cmd_application.py:189
+#, fuzzy
+msgid "manage applications"
+msgstr "Tab inconnu"
+
+#: sat_frontends/jp/cmd_avatar.py:38
+msgid "retrieve avatar of an entity"
+msgstr ""
+
+#: sat_frontends/jp/cmd_avatar.py:43 sat_frontends/jp/cmd_identity.py:42
+msgid "do no use cached values"
+msgstr ""
+
+#: sat_frontends/jp/cmd_avatar.py:46
+msgid "show avatar"
+msgstr ""
+
+#: sat_frontends/jp/cmd_avatar.py:48 sat_frontends/jp/cmd_info.py:111
+#, fuzzy
+msgid "entity"
+msgstr "Petite"
+
+#: sat_frontends/jp/cmd_avatar.py:87
+#, fuzzy
+msgid "No avatar found."
+msgstr "Aucune donnée trouvée"
+
+#: sat_frontends/jp/cmd_avatar.py:103
+msgid "set avatar of the profile or an entity"
+msgstr ""
+
+#: sat_frontends/jp/cmd_avatar.py:108
+msgid "entity whose avatar must be changed"
+msgstr ""
+
+#: sat_frontends/jp/cmd_avatar.py:110
+msgid "path to the image to upload"
+msgstr ""
+
+#: sat_frontends/jp/cmd_avatar.py:116
+#, fuzzy, python-format
+msgid "file {path} doesn't exist!"
+msgstr "Le fichier [%s] n'existe pas !"
+
+#: sat_frontends/jp/cmd_avatar.py:125
+msgid "avatar has been set"
+msgstr ""
+
+#: sat_frontends/jp/cmd_avatar.py:134
+msgid "avatar uploading/retrieving"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:129
+msgid "unknown syntax requested ({syntax})"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:138
+#, fuzzy
+msgid "title of the item"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_blog.py:143
+msgid "tag (category) of your item"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:148
+msgid "language of the item (ISO 639 code)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:158
+msgid ""
+"enable comments (default: comments not enabled except if they already "
+"exist)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:168
+msgid "disable comments (will remove comments node if it exist)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:174
+msgid "syntax to use (default: get profile's default syntax)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:211
+msgid "publish a new blog item or update an existing one"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:257
+msgid "get blog item(s)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:267
+msgid "microblog data key(s) to display (default: depend of verbosity)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:462
+msgid "edit an existing or new blog post"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:473
+msgid "launch a blog preview in parallel"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:478
+msgid "add \"publish: False\" to metadata"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:628
+msgid "You need lxml to edit pretty XHTML"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:662
+msgid "rename an blog item"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:666 sat_frontends/jp/cmd_pubsub.py:996
+msgid "new item id to use"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:690
+msgid "preview a blog content"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:700
+msgid "use inotify to handle preview"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:706
+#, fuzzy
+msgid "path to the content file"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_blog.py:810
+#, fuzzy, python-format
+msgid "File \"{file}\" doesn't exist!"
+msgstr "Le fichier [%s] n'existe pas !"
+
+#: sat_frontends/jp/cmd_blog.py:898
+msgid "import an external blog"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:905 sat_frontends/jp/cmd_list.py:207
+msgid "importer name, nothing to display importers list"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:907
+msgid "original blog host"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:911
+msgid "do *NOT* upload images (default: do upload images)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:915
+msgid "do not upload images from this host (default: upload all images)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:920
+msgid "ignore invalide TLS certificate for uploads"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:929 sat_frontends/jp/cmd_list.py:216
+msgid "importer specific options (see importer description)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:934 sat_frontends/jp/cmd_list.py:250
+msgid ""
+"importer data location (see importer description), nothing to show "
+"importer description"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:941
+msgid "Blog upload started"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:944
+msgid "Blog uploaded successfully"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:965
+msgid ""
+"\n"
+"To redirect old URLs to new ones, put the following lines in your "
+"sat.conf file, in [libervia] section:\n"
+"\n"
+"{conf}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:973
+#, fuzzy, python-format
+msgid "Error while uploading blog: {error_msg}"
+msgstr "Erreur en tentant de rejoindre le salon"
+
+#: sat_frontends/jp/cmd_blog.py:982 sat_frontends/jp/cmd_list.py:274
+msgid "{name} argument can't be used without location argument"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:1037
+msgid "Error while trying to import a blog: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_blog.py:1050
+msgid "blog/microblog management"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:40
+#, python-format
+msgid "storage location (default: %(default)s)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:48
+#, python-format
+msgid "bookmarks type (default: %(default)s)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:54
+msgid "list bookmarks"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:94
+msgid "remove a bookmark"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:99 sat_frontends/jp/cmd_bookmarks.py:131
+msgid "jid (for muc bookmark) or url of to remove"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:105
+msgid "delete bookmark without confirmation"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:110
+#, fuzzy, python-format
+msgid "Are you sure to delete this bookmark?"
+msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
+
+#: sat_frontends/jp/cmd_bookmarks.py:117
+msgid "can't delete bookmark: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:120
+msgid "bookmark deleted"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:133
+msgid "bookmark name"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:134
+msgid "MUC specific options"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:135
+#, fuzzy
+msgid "nickname"
+msgstr "Surnon"
+
+#: sat_frontends/jp/cmd_bookmarks.py:140
+msgid "join room on profile connection"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:145
+msgid "You can't use --autojoin or --nick with --type url"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:165
+msgid "bookmark successfully added"
+msgstr ""
+
+#: sat_frontends/jp/cmd_bookmarks.py:174
+msgid "manage bookmarks"
+msgstr ""
+
+#: sat_frontends/jp/cmd_debug.py:49
+msgid "call a bridge method"
+msgstr ""
+
+#: sat_frontends/jp/cmd_debug.py:54
+msgid "name of the method to execute"
+msgstr ""
+
+#: sat_frontends/jp/cmd_debug.py:56
+msgid "argument of the method"
+msgstr ""
+
+#: sat_frontends/jp/cmd_debug.py:79
+msgid "Error while executing {method}: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_debug.py:94
+msgid "send a fake signal from backend"
+msgstr ""
+
+#: sat_frontends/jp/cmd_debug.py:99
+msgid "name of the signal to send"
+msgstr ""
+
+#: sat_frontends/jp/cmd_debug.py:100
+msgid "argument of the signal"
+msgstr ""
+
+#: sat_frontends/jp/cmd_debug.py:112
+#, fuzzy, python-format
+msgid "Can't send fake signal: {e}"
+msgstr "message reçu de: %s"
+
+#: sat_frontends/jp/cmd_debug.py:123
+msgid "bridge s(t)imulation"
+msgstr ""
+
+#: sat_frontends/jp/cmd_debug.py:135
+msgid "monitor XML stream"
+msgstr ""
+
+#: sat_frontends/jp/cmd_debug.py:144
+msgid "stream direction filter"
+msgstr ""
+
+#: sat_frontends/jp/cmd_debug.py:195
+msgid "print colours used with your background"
+msgstr ""
+
+#: sat_frontends/jp/cmd_debug.py:226
+msgid "debugging tools"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:38
+msgid "show available encryption algorithms"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:45
+msgid "No encryption plugin registered!"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:47
+msgid "Following encryption algorithms are available: {algos}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:68
+msgid "get encryption session data"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:73
+msgid "jid of the entity to check"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:99
+msgid "start encrypted session with an entity"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:105 sat_frontends/jp/cmd_message.py:77
+msgid "don't replace encryption algorithm if an other one is already used"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:108
+msgid "algorithm name (DEFAULT: choose automatically)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:111
+msgid "algorithm namespace (DEFAULT: choose automatically)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:114
+#: sat_frontends/jp/cmd_encryption.py:153
+#: sat_frontends/jp/cmd_encryption.py:178
+msgid "jid of the entity to stop encrypted session with"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:148
+msgid "stop encrypted session with an entity"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:173
+msgid "get UI to manage trust"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:182
+msgid "algorithm name (DEFAULT: current algorithm)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:185
+msgid "algorithm namespace (DEFAULT: current algorithm)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:220
+msgid "trust manangement"
+msgstr ""
+
+#: sat_frontends/jp/cmd_encryption.py:230
+msgid "encryption sessions handling"
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:47
+msgid "get list of registered events"
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:78
+msgid "get event data"
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:108
+#, fuzzy
+msgid "ID of the PubSub Item"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_event.py:110
+msgid "date of the event"
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:118 sat_frontends/jp/cmd_event.py:257
+#: sat_frontends/jp/cmd_pubsub.py:129
+#, fuzzy
+msgid "configuration field to set"
+msgstr "Connexion..."
+
+#: sat_frontends/jp/cmd_event.py:150
+msgid "create or replace event"
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:170
+msgid "Event created successfuly on node {node}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:181
+msgid "modify an existing event"
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:214 sat_frontends/jp/cmd_event.py:288
+msgid "get event attendance"
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:219
+#, fuzzy
+msgid "bare jid of the invitee"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_event.py:246
+msgid "set event attendance"
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:296
+msgid "show missing people (invited but no R.S.V.P. so far)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:302
+msgid "don't show people which gave R.S.V.P."
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:371
+msgid "Attendees: "
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:374
+msgid " ("
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:376
+msgid "yes: "
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:379
+msgid ", maybe: "
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:383
+msgid "no: "
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:390
+msgid "confirmed guests: "
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:395
+msgid "unconfirmed guests: "
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:401
+msgid "total: "
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:408
+msgid "missing people (no reply): "
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:416
+msgid "you need to use --missing if you use --no-rsvp"
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:489
+msgid "invite someone to the event through email"
+msgstr ""
+
+#: sat_frontends/jp/cmd_event.py:568
+#, fuzzy
+msgid "manage invities"
+msgstr "Initialisation du gestionnaire de mémoire"
+
+#: sat_frontends/jp/cmd_event.py:577
+msgid "event management"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:50
+#, fuzzy
+msgid "send a file to a contact"
+msgstr "Attend qu'un fichier soit envoyé par un contact"
+
+#: sat_frontends/jp/cmd_file.py:55
+#, fuzzy
+msgid "a list of file"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_file.py:57 sat_frontends/jp/cmd_file.py:191
+#: sat_frontends/jp/cmd_message.py:82 sat_frontends/jp/cmd_pipe.py:42
+msgid "the destination jid"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:59
+#, fuzzy
+msgid "make a bzip2 tarball"
+msgstr "Fait un fichier compressé bzip2"
+
+#: sat_frontends/jp/cmd_file.py:79 sat_frontends/jp/cmd_file.py:236
+#: sat_frontends/jp/cmd_file.py:330
+msgid "File copy started"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:82
+#, fuzzy
+msgid "File sent successfully"
+msgstr "Inscription réussie"
+
+#: sat_frontends/jp/cmd_file.py:86
+#, fuzzy
+msgid "The file has been refused by your contact"
+msgstr "Attend qu'un fichier soit envoyé par un contact"
+
+#: sat_frontends/jp/cmd_file.py:88
+#, fuzzy, python-format
+msgid "Error while sending file: {}"
+msgstr "Erreur en tentant de rejoindre le salon"
+
+#: sat_frontends/jp/cmd_file.py:97
+msgid "File request sent to {jid}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:102
+#, fuzzy
+msgid "Can't send file to {jid}"
+msgstr "Impossible de trouver la VCard de %s"
+
+#: sat_frontends/jp/cmd_file.py:109
+#, fuzzy, python-format
+msgid "file {file_} doesn't exist!"
+msgstr "Le fichier [%s] n'existe pas !"
+
+#: sat_frontends/jp/cmd_file.py:114
+#, fuzzy, python-format
+msgid "{file_} is a dir! Please send files inside or use compression"
+msgstr ""
+"[%s] est un répertoire ! Veuillez envoyer les fichiers qu'il contient ou "
+"utiliser la compression."
+
+#: sat_frontends/jp/cmd_file.py:129
+#, fuzzy
+msgid "bz2 is an experimental option, use with caution"
+msgstr ""
+"bz2 est une option expérimentale à un stade de développement peu avancé, "
+"utilisez-là avec prudence"
+
+#: sat_frontends/jp/cmd_file.py:131
+msgid "Starting compression, please wait..."
+msgstr "Lancement de la compression, veuillez patienter..."
+
+#: sat_frontends/jp/cmd_file.py:138
+#, fuzzy, python-format
+msgid "Adding {}"
+msgstr "Ajout de %s"
+
+#: sat_frontends/jp/cmd_file.py:141
+#, fuzzy
+msgid "Done !"
+msgstr "N° de Tél:"
+
+#: sat_frontends/jp/cmd_file.py:183
+#, fuzzy
+msgid "request a file from a contact"
+msgstr "Attend qu'un fichier soit envoyé par un contact"
+
+#: sat_frontends/jp/cmd_file.py:195
+msgid ""
+"destination path where the file will be saved (default: "
+"[current_dir]/[name|hash])"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:204
+#, fuzzy
+msgid "name of the file"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_file.py:210
+#, fuzzy
+msgid "hash of the file"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_file.py:216
+msgid "hash algorithm use for --hash (default: sha-256)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:232 sat_frontends/jp/cmd_file.py:476
+msgid "overwrite existing file without confirmation"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:239 sat_frontends/jp/cmd_file.py:333
+#, fuzzy, python-format
+msgid "File received successfully"
+msgstr "tarot: chien reçu"
+
+#: sat_frontends/jp/cmd_file.py:243
+msgid "The file request has been refused"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:245
+#, fuzzy, python-format
+msgid "Error while requesting file: {}"
+msgstr "Échec de la désinscription: %s"
+
+#: sat_frontends/jp/cmd_file.py:249
+msgid "at least one of --name or --hash must be provided"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:258 sat_frontends/jp/cmd_file.py:510
+msgid "File {path} already exists! Do you want to overwrite?"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:261
+msgid "file request cancelled"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:280
+#, fuzzy, python-format
+msgid "can't request file: {e}"
+msgstr "Échec de la désinscription: %s"
+
+#: sat_frontends/jp/cmd_file.py:293
+#, fuzzy
+msgid "wait for a file to be sent by a contact"
+msgstr "Attend qu'un fichier soit envoyé par un contact"
+
+#: sat_frontends/jp/cmd_file.py:306
+msgid "jids accepted (accept everything if none is specified)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:312
+#, fuzzy
+msgid "accept multiple files (you'll have to stop manually)"
+msgstr "Accepte plusieurs fichiers (vous devrez arrêter le programme à la main)"
+
+#: sat_frontends/jp/cmd_file.py:318
+#, fuzzy
+msgid "force overwritting of existing files (/!\\ name is choosed by sender)"
+msgstr "Force le remplacement des fichiers existants"
+
+#: sat_frontends/jp/cmd_file.py:326
+msgid "destination path (default: working directory)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:337
+msgid "hash checked: {metadata['hash_algo']}:{metadata['hash']}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:340
+msgid "hash is checked but hash value is missing"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:342
+msgid "hash can't be verified"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:345
+#, fuzzy
+msgid "Error while receiving file: {e}"
+msgstr "Erreur en tentant de rejoindre le salon"
+
+#: sat_frontends/jp/cmd_file.py:354 sat_frontends/jp/cmd_pipe.py:111
+msgid "Action has no XMLUI"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:359 sat_frontends/jp/cmd_pipe.py:115
+msgid "Invalid XMLUI received"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:369 sat_frontends/jp/cmd_pipe.py:126
+msgid "Ignoring action without from_jid data"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:374 sat_frontends/jp/cmd_file.py:395
+msgid "ignoring action without progress id"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:379
+msgid "File refused because overwrite is needed"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:397
+msgid "Overwriting needed"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:401
+#, fuzzy
+msgid "Overwrite accepted"
+msgstr "accepté"
+
+#: sat_frontends/jp/cmd_file.py:403
+msgid "Refused to overwrite"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:417
+msgid "invalid \"from_jid\" value received, ignoring: {value}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:424
+msgid "ignoring action without \"from_jid\" value"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:426
+msgid "Confirmation needed for request from an entity not in roster"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:431
+msgid "Sender confirmed because she or he is explicitly expected"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:439
+msgid "Session refused for {from_jid}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:446
+msgid "Given path is not a directory !"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:450
+msgid "waiting for incoming file request"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:461
+msgid "download a file from URI"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:470
+msgid "destination file (DEFAULT: filename from URL)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:478
+msgid "URI of the file to retrieve"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:481
+msgid "File download started"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:484
+msgid "File downloaded successfully"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:487
+#, fuzzy, python-format
+msgid "Error while downloading file: {}"
+msgstr "Erreur en tentant de rejoindre le salon"
+
+#: sat_frontends/jp/cmd_file.py:513
+msgid "file download cancelled"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:534
+msgid "upload a file"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:542
+msgid "encrypt file using AES-GCM"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:544
+msgid "file to upload"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:548
+msgid "jid of upload component (nothing to autodetect)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:553
+msgid "ignore invalide TLS certificate (/!\\ Dangerous /!\\)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:557
+msgid "File upload started"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:560
+msgid "File uploaded successfully"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:566
+msgid "URL to retrieve the file:"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:571
+msgid "Error while uploading file: {}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:593
+#, fuzzy, python-format
+msgid "file {file_} doesn't exist !"
+msgstr "Le fichier [%s] n'existe pas !"
+
+#: sat_frontends/jp/cmd_file.py:597
+msgid "{file_} is a dir! Can't upload a dir"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:633
+msgid "set affiliations for a shared file/directory"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:641 sat_frontends/jp/cmd_file.py:695
+#: sat_frontends/jp/cmd_file.py:747 sat_frontends/jp/cmd_file.py:801
+#: sat_frontends/jp/cmd_file.py:1002
+msgid "namespace of the repository"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:647 sat_frontends/jp/cmd_file.py:701
+#: sat_frontends/jp/cmd_file.py:753 sat_frontends/jp/cmd_file.py:807
+#: sat_frontends/jp/cmd_file.py:1007
+msgid "path to the repository"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:657 sat_frontends/jp/cmd_pubsub.py:453
+msgid "entity/affiliation couple(s)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:661 sat_frontends/jp/cmd_file.py:767
+msgid "jid of file sharing entity"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:687
+msgid "retrieve affiliations of a shared file/directory"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:705 sat_frontends/jp/cmd_file.py:811
+msgid "jid of sharing entity"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:729
+msgid "affiliations management"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:739
+msgid "set configuration for a shared file/directory"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:763 sat_frontends/jp/cmd_pubsub.py:282
+msgid "configuration field to set (required)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:793
+msgid "retrieve configuration of a shared file/directory"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:838
+#, fuzzy
+msgid "file sharing node configuration"
+msgstr "Demande de confirmation pour un transfer de fichier demandée"
+
+#: sat_frontends/jp/cmd_file.py:850
+msgid "retrieve files shared by an entity"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:859
+msgid "path to the directory containing the files"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:865
+msgid "jid of sharing entity (nothing to check our own jid)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:874
+msgid "unknown file type: {type}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:923
+msgid "share a file or directory"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:931
+msgid "virtual name to use (default: use directory/file name)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:941
+msgid "jid of contacts allowed to retrieve the files"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:946
+msgid "share publicly the file(s) (/!\\ *everybody* will be able to access them)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:953
+msgid "path to a file or directory to share"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:978
+msgid "{path} shared under the name \"{name}\""
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:988
+msgid "send invitation for a shared repository"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:996
+#, fuzzy
+msgid "name of the repository"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_file.py:1014
+msgid "type of the repository"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:1019
+msgid "https URL of a image to use as thumbnail"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:1023
+msgid "jid of the file sharing service hosting the repository"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:1027
+#, fuzzy
+msgid "jid of the person to invite"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_file.py:1035
+msgid "only http(s) links are allowed with --thumbnail"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:1053
+msgid "invitation sent to {jid}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:1068
+msgid "files sharing management"
+msgstr ""
+
+#: sat_frontends/jp/cmd_file.py:1077
+msgid "files sending/receiving/management"
+msgstr ""
+
+#: sat_frontends/jp/cmd_forums.py:45
+msgid "edit forums"
+msgstr ""
+
+#: sat_frontends/jp/cmd_forums.py:54 sat_frontends/jp/cmd_forums.py:123
+msgid "forum key (DEFAULT: default forums)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_forums.py:74
+msgid "forums have been edited"
+msgstr ""
+
+#: sat_frontends/jp/cmd_forums.py:115
+msgid "get forums structure"
+msgstr ""
+
+#: sat_frontends/jp/cmd_forums.py:168 sat_frontends/jp/cmd_pubsub.py:733
+#, fuzzy
+msgid "no schema found"
+msgstr "Aucun transport trouvé"
+
+#: sat_frontends/jp/cmd_forums.py:180
+msgid "Forums structure edition"
+msgstr ""
+
+#: sat_frontends/jp/cmd_identity.py:37
+msgid "get identity data"
+msgstr ""
+
+#: sat_frontends/jp/cmd_identity.py:45
+msgid "entity to check"
+msgstr ""
+
+#: sat_frontends/jp/cmd_identity.py:68
+msgid "update identity data"
+msgstr ""
+
+#: sat_frontends/jp/cmd_identity.py:77
+msgid "nicknames of the entity"
+msgstr ""
+
+#: sat_frontends/jp/cmd_identity.py:101
+msgid "identity management"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:38
+msgid "service discovery"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:42
+msgid "entity to discover"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:49
+msgid "type of data to discover"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:51
+msgid "node to use"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:57
+#, fuzzy
+msgid "ignore cache"
+msgstr "fichier [%s] déjà en cache"
+
+#: sat_frontends/jp/cmd_info.py:69
+msgid "category"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:111
+msgid "node"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:116
+msgid "Features"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:118
+#, fuzzy
+msgid "Identities"
+msgstr "Petite"
+
+#: sat_frontends/jp/cmd_info.py:120
+msgid "Extensions"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:122
+msgid "Items"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:151 sat_frontends/jp/cmd_info.py:166
+msgid "error while doing discovery: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:190
+msgid "software version"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:193 sat_frontends/jp/cmd_info.py:258
+#, fuzzy
+msgid "Entity to request"
+msgstr "Demande de suppression de contact"
+
+#: sat_frontends/jp/cmd_info.py:201
+#, fuzzy, python-format
+msgid "error while trying to get version: {e}"
+msgstr "Erreur en tentant de rejoindre le salon"
+
+#: sat_frontends/jp/cmd_info.py:207
+msgid "Software name: {name}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:209
+msgid "Software version: {version}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:211
+msgid "Operating System: {os}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:225
+#, fuzzy
+msgid "running session"
+msgstr "Lancement de l'application"
+
+#: sat_frontends/jp/cmd_info.py:243
+msgid "Error getting session infos: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:253
+msgid "devices of an entity"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:267
+msgid "Error getting devices infos: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_info.py:283
+msgid "Get various pieces of information on entities"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:61
+msgid "encoding of the input data"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:69
+msgid "standard input"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:77
+msgid "short option"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:85
+msgid "long option"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:93
+msgid "positional argument"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:101
+msgid "ignore value"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:107
+msgid "don't actually run commands but echo what would be launched"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:110
+msgid "log stdout to FILE"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:113
+msgid "log stderr to FILE"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:128 sat_frontends/jp/cmd_input.py:193
+msgid "arguments in input data and in arguments sequence don't match"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:155 sat_frontends/jp/cmd_input.py:207
+msgid "values: "
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:161
+msgid "**SKIPPING**\n"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:184
+msgid "Invalid argument, an option type is expected, got {type_}:{name}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:199
+msgid "command {idx}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:252 sat_frontends/primitivus/xmlui.py:461
+msgid "OK"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:254
+msgid "FAILED"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:274
+msgid "comma-separated values"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:283
+msgid "starting row (previous ones will be ignored)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:291
+msgid "split value in several options"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:299
+msgid "action to do on empty value ({choices})"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:314
+msgid "--empty value must be one of {choices}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_input.py:349
+msgid "launch command with external input"
+msgstr ""
+
+#: sat_frontends/jp/cmd_invitation.py:38
+msgid "create and send an invitation"
+msgstr ""
+
+#: sat_frontends/jp/cmd_invitation.py:127
+msgid "you need to specify an email address to send email invitation"
+msgstr ""
+
+#: sat_frontends/jp/cmd_invitation.py:161
+#, fuzzy
+msgid "get invitation data"
+msgstr "Connexion..."
+
+#: sat_frontends/jp/cmd_invitation.py:165
+#: sat_frontends/jp/cmd_invitation.py:225
+#: sat_frontends/jp/cmd_invitation.py:289
+#, fuzzy
+msgid "invitation UUID"
+msgstr "Connexion..."
+
+#: sat_frontends/jp/cmd_invitation.py:170
+msgid "start profile session and retrieve jid"
+msgstr ""
+
+#: sat_frontends/jp/cmd_invitation.py:185
+msgid "can't get invitation data: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_invitation.py:198
+#, fuzzy
+msgid "can't start session: {e}"
+msgstr "Construction du jeu de Tarot"
+
+#: sat_frontends/jp/cmd_invitation.py:208
+msgid "can't retrieve jid: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_invitation.py:221
+#, fuzzy
+msgid "delete guest account"
+msgstr "Enregistrement d'un nouveau compte"
+
+#: sat_frontends/jp/cmd_invitation.py:233
+msgid "can't delete guest account: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_invitation.py:242
+msgid "modify existing invitation"
+msgstr ""
+
+#: sat_frontends/jp/cmd_invitation.py:299
+msgid "you can't set {arg_name} in both optional argument and extra"
+msgstr ""
+
+#: sat_frontends/jp/cmd_invitation.py:314
+msgid "invitations have been modified successfuly"
+msgstr ""
+
+#: sat_frontends/jp/cmd_invitation.py:328
+#, fuzzy
+msgid "list invitations data"
+msgstr "Connexion..."
+
+#: sat_frontends/jp/cmd_invitation.py:346
+msgid "return only invitations linked to this profile"
+msgstr ""
+
+#: sat_frontends/jp/cmd_invitation.py:370
+msgid "invitation of user(s) without XMPP account"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:43 sat_frontends/jp/cmd_list.py:81
+#: sat_frontends/jp/cmd_list.py:150 sat_frontends/jp/cmd_merge_request.py:39
+#: sat_frontends/jp/cmd_merge_request.py:124
+#: sat_frontends/jp/cmd_merge_request.py:169
+msgid "auto"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:45
+msgid "get lists"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:82
+msgid "set a list item"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:94
+msgid "field(s) to set (required)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:101
+msgid "update existing item instead of replacing it (DEFAULT: auto)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:107
+msgid "id, URL of the item to update, or nothing for new item"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:151
+msgid "delete a list item"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:156 sat_frontends/jp/cmd_pubsub.py:884
+#: sat_frontends/jp/cmd_roster.py:135
+msgid "delete without confirmation"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:159 sat_frontends/jp/cmd_pubsub.py:887
+#, fuzzy
+msgid "notify deletion"
+msgstr "Sélection du contrat"
+
+#: sat_frontends/jp/cmd_list.py:163
+msgid "id of the item to delete"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:169
+msgid "You need to specify a list item to delete"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:171
+#, fuzzy, python-format
+msgid "Are you sure to delete list item {item_id} ?"
+msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
+
+#: sat_frontends/jp/cmd_list.py:174 sat_frontends/jp/cmd_pubsub.py:897
+msgid "item deletion cancelled"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:184 sat_frontends/jp/cmd_pubsub.py:907
+#, fuzzy, python-format
+msgid "can't delete item: {e}"
+msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
+
+#: sat_frontends/jp/cmd_list.py:187 sat_frontends/jp/cmd_pubsub.py:910
+msgid "item {item} has been deleted"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:200
+msgid "import tickets from external software/dataset"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:225
+msgid ""
+"specified field in import data will be put in dest field (default: use "
+"same field name, or ignore if it doesn't exist)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:235
+msgid "PubSub service where the items must be uploaded (default: server)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:242
+msgid "PubSub node where the items must be uploaded (default: tickets' defaults)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:257
+msgid "Tickets upload started"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:260
+msgid "Tickets uploaded successfully"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:264
+#, fuzzy, python-format
+msgid "Error while uploading tickets: {error_msg}"
+msgstr "Erreur en tentant de rejoindre le salon"
+
+#: sat_frontends/jp/cmd_list.py:319
+msgid ""
+"fields_map must be specified either preencoded in --option or using "
+"--map, but not both at the same time"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:337
+msgid "Error while trying to import tickets: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_list.py:350
+msgid "pubsub lists handling"
+msgstr ""
+
+#: sat_frontends/jp/cmd_merge_request.py:40
+msgid "publish or update a merge request"
+msgstr ""
+
+#: sat_frontends/jp/cmd_merge_request.py:48
+msgid "id or URL of the request to update, or nothing for a new one"
+msgstr ""
+
+#: sat_frontends/jp/cmd_merge_request.py:55
+#: sat_frontends/jp/cmd_merge_request.py:179
+msgid "path of the repository (DEFAULT: current directory)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_merge_request.py:61
+msgid "publish merge request without confirmation"
+msgstr ""
+
+#: sat_frontends/jp/cmd_merge_request.py:68
+msgid "labels to categorize your request"
+msgstr ""
+
+#: sat_frontends/jp/cmd_merge_request.py:75
+msgid ""
+"You are going to publish your changes to service [{service}], are you "
+"sure ?"
+msgstr ""
+
+#: sat_frontends/jp/cmd_merge_request.py:80
+msgid "merge request publication cancelled"
+msgstr ""
+
+#: sat_frontends/jp/cmd_merge_request.py:105
+msgid "Merge request published at {published_id}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_merge_request.py:110
+msgid "Merge request published"
+msgstr ""
+
+#: sat_frontends/jp/cmd_merge_request.py:125
+#, fuzzy
+msgid "get a merge request"
+msgstr "Demande de changement de statut"
+
+#: sat_frontends/jp/cmd_merge_request.py:170
+#, fuzzy
+msgid "import a merge request"
+msgstr "Demande de changement de statut"
+
+#: sat_frontends/jp/cmd_merge_request.py:209
+msgid "merge-request management"
+msgstr ""
+
+#: sat_frontends/jp/cmd_message.py:34
+#, fuzzy
+msgid "send a message to a contact"
+msgstr "Attend qu'un fichier soit envoyé par un contact"
+
+#: sat_frontends/jp/cmd_message.py:38
+msgid "language of the message"
+msgstr ""
+
+#: sat_frontends/jp/cmd_message.py:44
+#, fuzzy
+msgid ""
+"separate xmpp messages: send one message per line instead of one message "
+"alone."
+msgstr ""
+"Sépare les messages xmpp: envoi un message par ligne plutôt qu'un seul "
+"message global."
+
+#: sat_frontends/jp/cmd_message.py:53
+#, fuzzy
+msgid "add a new line at the beginning of the input (usefull for ascii art ;))"
+msgstr "Ajoute un saut de ligne au début de l'entrée (utile pour l'art ascii ;))"
+
+#: sat_frontends/jp/cmd_message.py:60
+msgid "subject of the message"
+msgstr ""
+
+#: sat_frontends/jp/cmd_message.py:63
+msgid "language of subject"
+msgstr ""
+
+#: sat_frontends/jp/cmd_message.py:70
+msgid "type of the message"
+msgstr ""
+
+#: sat_frontends/jp/cmd_message.py:73
+msgid "encrypt message using given algorithm"
+msgstr ""
+
+#: sat_frontends/jp/cmd_message.py:79
+msgid "XHTML body"
+msgstr ""
+
+#: sat_frontends/jp/cmd_message.py:80
+msgid "rich body"
+msgstr ""
+
+#: sat_frontends/jp/cmd_message.py:195
+msgid "query archives using MAM"
+msgstr ""
+
+#: sat_frontends/jp/cmd_message.py:203
+msgid "start fetching archive from this date (default: from the beginning)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_message.py:207
+msgid "end fetching archive after this date (default: no limit)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_message.py:210
+msgid "retrieve only archives with this jid"
+msgstr ""
+
+#: sat_frontends/jp/cmd_message.py:213
+msgid "maximum number of items to retrieve, using RSM (default: 20))"
+msgstr ""
+
+#: sat_frontends/jp/cmd_message.py:276
+msgid "messages handling"
+msgstr ""
+
+#: sat_frontends/jp/cmd_param.py:32
+#, fuzzy
+msgid "get a parameter value"
+msgstr "Impossible de charger les paramètres !"
+
+#: sat_frontends/jp/cmd_param.py:37 sat_frontends/jp/cmd_param.py:94
+#, fuzzy
+msgid "category of the parameter"
+msgstr "Impossible de charger les paramètres !"
+
+#: sat_frontends/jp/cmd_param.py:39 sat_frontends/jp/cmd_param.py:95
+#: sat_frontends/jp/cmd_param.py:96
+#, fuzzy
+msgid "name of the parameter"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_param.py:45
+msgid "name of the attribute to get"
+msgstr ""
+
+#: sat_frontends/jp/cmd_param.py:48 sat_frontends/jp/cmd_param.py:98
+msgid "security limit"
+msgstr ""
+
+#: sat_frontends/jp/cmd_param.py:62
+#, fuzzy
+msgid "can't find requested parameters: {e}"
+msgstr "Impossible de charger les paramètres !"
+
+#: sat_frontends/jp/cmd_param.py:79
+#, fuzzy
+msgid "can't find requested parameter: {e}"
+msgstr "Impossible de charger les paramètres !"
+
+#: sat_frontends/jp/cmd_param.py:90
+msgid "set a parameter value"
+msgstr ""
+
+#: sat_frontends/jp/cmd_param.py:111
+#, fuzzy
+msgid "can't set requested parameter: {e}"
+msgstr "Mauvais nom de profile"
+
+#: sat_frontends/jp/cmd_param.py:125
+#, fuzzy, python-format
+msgid "save parameters template to xml file"
+msgstr "Impossible de charger le modèle des paramètres !"
+
+#: sat_frontends/jp/cmd_param.py:129
+msgid "output file"
+msgstr ""
+
+#: sat_frontends/jp/cmd_param.py:136
+#, fuzzy, python-format
+msgid "can't save parameters to file: {e}"
+msgstr "Impossible de charger le modèle des paramètres !"
+
+#: sat_frontends/jp/cmd_param.py:140
+#, fuzzy, python-format
+msgid "parameters saved to file {filename}"
+msgstr "Échec de la désinscription: %s"
+
+#: sat_frontends/jp/cmd_param.py:155
+#, fuzzy, python-format
+msgid "load parameters template from xml file"
+msgstr "Impossible de charger le modèle des paramètres !"
+
+#: sat_frontends/jp/cmd_param.py:159
+#, fuzzy
+msgid "input file"
+msgstr "Envoi un fichier"
+
+#: sat_frontends/jp/cmd_param.py:166
+#, fuzzy, python-format
+msgid "can't load parameters from file: {e}"
+msgstr "Impossible de charger le modèle des paramètres !"
+
+#: sat_frontends/jp/cmd_param.py:170
+#, fuzzy, python-format
+msgid "parameters loaded from file {filename}"
+msgstr "Échec de la désinscription: %s"
+
+#: sat_frontends/jp/cmd_param.py:182
+#, fuzzy
+msgid "Save/load parameters template"
+msgstr "Impossible de charger le modèle des paramètres !"
+
+#: sat_frontends/jp/cmd_ping.py:29
+msgid "ping XMPP entity"
+msgstr ""
+
+#: sat_frontends/jp/cmd_ping.py:32
+msgid "jid to ping"
+msgstr ""
+
+#: sat_frontends/jp/cmd_ping.py:34
+msgid "output delay only (in s)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_ping.py:41
+msgid "can't do the ping: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pipe.py:38
+msgid "send a pipe a stream"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pipe.py:97
+#, fuzzy
+msgid "receive a pipe stream"
+msgstr "Lancement du flux"
+
+#: sat_frontends/jp/cmd_pipe.py:104
+msgid "Jids accepted (none means \"accept everything\")"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pipe.py:159
+msgid "stream piping through XMPP"
+msgstr ""
+
+#: sat_frontends/jp/cmd_profile.py:33
+#, fuzzy
+msgid "The name of the profile"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_profile.py:51
+msgid "You need to use either --connect or --start-session"
+msgstr ""
+
+#: sat_frontends/jp/cmd_profile.py:78
+#, fuzzy
+msgid "the name of the profile"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_profile.py:81
+msgid "the password of the profile"
+msgstr ""
+
+#: sat_frontends/jp/cmd_profile.py:83 sat_frontends/jp/cmd_profile.py:238
+#, fuzzy
+msgid "the jid of the profile"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_profile.py:86
+msgid "the password of the XMPP account (use profile password if not specified)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_profile.py:93 sat_frontends/jp/cmd_profile.py:247
+msgid "connect this profile automatically when backend starts"
+msgstr ""
+
+#: sat_frontends/jp/cmd_profile.py:97
+msgid "set to component import name (entry point) if this is a component"
+msgstr ""
+
+#: sat_frontends/jp/cmd_profile.py:154
+msgid "delete profile without confirmation"
+msgstr ""
+
+#: sat_frontends/jp/cmd_profile.py:174
+#, fuzzy
+msgid "get information about a profile"
+msgstr "Demande de contacts pour un profile inexistant"
+
+#: sat_frontends/jp/cmd_profile.py:180
+msgid "show the XMPP password IN CLEAR TEXT"
+msgstr ""
+
+#: sat_frontends/jp/cmd_profile.py:184
+#, fuzzy
+msgid "XMPP password"
+msgstr "Mot de passe:"
+
+#: sat_frontends/jp/cmd_profile.py:185
+msgid "autoconnect (backend)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_profile.py:209
+msgid "get clients profiles only"
+msgstr ""
+
+#: sat_frontends/jp/cmd_profile.py:229
+msgid "modify an existing profile"
+msgstr ""
+
+#: sat_frontends/jp/cmd_profile.py:234
+#, fuzzy
+msgid "change the password of the profile"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_profile.py:237
+msgid "disable profile password (dangerous!)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_profile.py:240
+msgid "change the password of the XMPP account"
+msgstr ""
+
+#: sat_frontends/jp/cmd_profile.py:243
+#, fuzzy
+msgid "set as default profile"
+msgstr "Veuillez entrer le nom du nouveau profile"
+
+#: sat_frontends/jp/cmd_profile.py:280
+#, fuzzy
+msgid "profile commands"
+msgstr "Mauvais nom de profile"
+
+#: sat_frontends/jp/cmd_pubsub.py:59
+#, fuzzy
+msgid "retrieve node configuration"
+msgstr "Connexion..."
+
+#: sat_frontends/jp/cmd_pubsub.py:68
+msgid "data key to filter"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:116
+#, fuzzy
+msgid "create a node"
+msgstr "Veuillez entrer le nom du nouveau profile"
+
+#: sat_frontends/jp/cmd_pubsub.py:135 sat_frontends/jp/cmd_pubsub.py:288
+msgid "don't prepend \"pubsub#\" prefix to field names"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:158
+msgid "can't create node: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:162
+#, fuzzy
+msgid "node created successfully: "
+msgstr "Inscription réussie"
+
+#: sat_frontends/jp/cmd_pubsub.py:176
+msgid "purge a node (i.e. remove all items from it)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:184
+#, fuzzy
+msgid "purge node without confirmation"
+msgstr "désinscription confirmée pour [%s]"
+
+#: sat_frontends/jp/cmd_pubsub.py:190
+msgid ""
+"Are you sure to purge PEP node [{node}]? This will delete ALL items from "
+"it!"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:195
+msgid ""
+"Are you sure to delete node [{node}] on service [{service}]? This will "
+"delete ALL items from it!"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:199
+msgid "node purge cancelled"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:208
+msgid "can't purge node: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:211
+msgid "node [{node}] purged successfully"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:223
+#, fuzzy
+msgid "delete a node"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_pubsub.py:231
+msgid "delete node without confirmation"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:237
+#, fuzzy, python-format
+msgid "Are you sure to delete PEP node [{node}] ?"
+msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
+
+#: sat_frontends/jp/cmd_pubsub.py:241
+#, fuzzy, python-format
+msgid "Are you sure to delete node [{node}] on service [{service}]?"
+msgstr ""
+"Êtes vous sûr de vouloir inscrire le nouveau compte [%(user)s] au serveur"
+" %(server)s ?"
+
+#: sat_frontends/jp/cmd_pubsub.py:244
+msgid "node deletion cancelled"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:256
+msgid "node [{node}] deleted successfully"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:270
+#, fuzzy
+msgid "set node configuration"
+msgstr "Connexion..."
+
+#: sat_frontends/jp/cmd_pubsub.py:309
+#, fuzzy
+msgid "node configuration successful"
+msgstr "Inscription réussie"
+
+#: sat_frontends/jp/cmd_pubsub.py:320
+msgid "import raw XML to a node"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:327 sat_frontends/jp/cmd_pubsub.py:1608
+msgid "do a pubsub admin request, needed to change publisher"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:332
+msgid ""
+"path to the XML file with data to import. The file must contain whole XML"
+" of each item to import."
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:359
+msgid "You are not using list of pubsub items, we can't import this file"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:370
+msgid "Items are imported without using admin mode, publisher can't be changed"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:391
+msgid "items published with id(s) {items_ids}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:396 sat_frontends/jp/cmd_pubsub.py:1641
+msgid "items published"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:409
+msgid "retrieve node affiliations (for node owner)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:439
+msgid "set affiliations (for node owner)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:469
+msgid "affiliations have been set"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:481
+msgid "set or retrieve node affiliations"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:494
+msgid "retrieve node subscriptions (for node owner)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:532
+#, fuzzy, python-format
+msgid "subscription must be one of {}"
+msgstr "inscription approuvée pour [%s]"
+
+#: sat_frontends/jp/cmd_pubsub.py:548
+msgid "set/modify subscriptions (for node owner)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:563
+msgid "entity/subscription couple(s)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:578
+#, fuzzy, python-format
+msgid "subscriptions have been set"
+msgstr "inscription approuvée pour [%s]"
+
+#: sat_frontends/jp/cmd_pubsub.py:590
+msgid "get or modify node subscriptions"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:603
+msgid "set/replace a schema"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:607
+msgid "schema to set (must be XML)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:621 sat_frontends/jp/cmd_pubsub.py:656
+msgid "schema has been set"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:637
+msgid "edit a schema"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:709
+msgid "get schema"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:742
+msgid "data schema manipulation"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:761
+msgid "node handling"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:774
+msgid "publish a new item or update an existing one"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:783
+msgid "id, URL of the item to update, keyword, or nothing for new item"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:805
+#, fuzzy, python-format
+msgid "can't send item: {e}"
+msgstr "message reçu de: %s"
+
+#: sat_frontends/jp/cmd_pubsub.py:827
+msgid "get pubsub item(s)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:835
+#, fuzzy
+msgid "subscription id"
+msgstr "demande d'inscription pour [%s]"
+
+#: sat_frontends/jp/cmd_pubsub.py:879
+#, fuzzy
+msgid "delete an item"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_pubsub.py:892
+msgid "You need to specify an item to delete"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:894
+#, fuzzy, python-format
+msgid "Are you sure to delete item {item_id} ?"
+msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
+
+#: sat_frontends/jp/cmd_pubsub.py:924
+msgid "edit an existing or new pubsub item"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:968
+msgid "Item has not payload"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:992
+msgid "rename a pubsub item"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1024
+msgid "subscribe to a node"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1039
+msgid "can't subscribe to node: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1042
+#, fuzzy
+msgid "subscription done"
+msgstr "demande d'inscription pour [%s]"
+
+#: sat_frontends/jp/cmd_pubsub.py:1044
+msgid "subscription id: {sub_id}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1059
+msgid "unsubscribe from a node"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1073
+msgid "can't unsubscribe from node: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1076
+#, fuzzy, python-format
+msgid "subscription removed"
+msgstr "inscription approuvée pour [%s]"
+
+#: sat_frontends/jp/cmd_pubsub.py:1088
+msgid "retrieve all subscriptions on a service"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1102
+msgid "can't retrieve subscriptions: {e}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1117
+msgid "retrieve all affiliations on a service"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1160
+msgid "search items corresponding to filters"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1185
+msgid "maximum depth of recursion (will search linked nodes if > 0, DEFAULT: 0)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1197
+msgid "maximum number of items to get per node ({} to get all items, DEFAULT: 30)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1207
+msgid "namespace to use for xpath"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1216
+msgid "filters"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1217
+msgid "only items corresponding to following filters will be kept"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1226
+msgid "full text filter, item must contain this string (XML included)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1235
+msgid "like --text but using a regular expression"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1244
+msgid "filter items which has elements matching this xpath"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1253
+msgid ""
+"Python expression which much return a bool (True to keep item, False to "
+"reject it). \"item\" is raw text item, \"item_xml\" is lxml's "
+"etree.Element"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1266
+msgid "filters flags"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1267
+msgid "filters modifiers (change behaviour of following filters)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1278
+msgid "(don't) ignore case in following filters (DEFAULT: case sensitive)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1289
+msgid "(don't) invert effect of following filters (DEFAULT: don't invert)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1300
+msgid "(don't) use DOTALL option for regex (DEFAULT: don't use)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1311
+msgid "keep only the matching part of the item"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1320
+msgid "action to do on found items (DEFAULT: print)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1383
+msgid ""
+"item doesn't looks like XML, you have probably used --only-matching "
+"somewhere before and we have no more XML"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1420
+msgid "--only-matching used with fixed --text string, are you sure?"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1441
+msgid "can't use xpath: {reason}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1476
+#, fuzzy, python-format
+msgid "unknown filter type {type}"
+msgstr "Type d'action inconnu"
+
+#: sat_frontends/jp/cmd_pubsub.py:1534
+msgid "executed command failed with exit code {ret}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1566
+msgid "Command can only be used with {actions} actions"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1572
+msgid "you need to specify a command to execute"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1575
+msgid "empty node is not handled yet"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1596
+msgid "modify items of a node using an external command/script"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1603
+msgid "apply transformation (DEFAULT: do a dry run)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1614
+msgid "if command return a non zero exit code, ignore the item and continue"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1622
+msgid "get all items by looping over all pages using RSM"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1626
+msgid ""
+"path to the command to use. Will be called repetitivly with an item as "
+"input. Output (full item XML) will be used as new one. Return \"DELETE\" "
+"string to delete the item, and \"SKIP\" to ignore it"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1636
+msgid "items published with ids {item_ids}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1659
+msgid "Can't retrieve all items, RSM metadata not available"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1664
+msgid "Can't retrieve all items, bad RSM metadata: {msg}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1670
+msgid "All items transformed"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1674
+msgid "Retrieving next page ({page_idx}/{page_total})"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1712
+msgid "Duplicate found on item {item_id}, we have probably handled all items."
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1749
+msgid "Deleting item {item_id}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1766
+msgid "Skipping item {item_id}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1860 sat_frontends/jp/cmd_uri.py:53
+msgid "build URI"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1868
+msgid "profile (used when no server is specified)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1908
+msgid "create a Pubsub hook"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1917
+msgid "hook type"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1923
+msgid "make hook persistent across restarts"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1927
+msgid "argument of the hook (depend of the type)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1936
+msgid "{path} is not a file"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1965
+msgid "delete a Pubsub hook"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1974
+msgid "hook type to remove, empty to remove all (DEFAULT: remove all)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:1981
+msgid "argument of the hook to remove, empty to remove all (DEFAULT: remove all)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:2001
+msgid "{nb_deleted} hook(s) have been deleted"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:2013
+#, fuzzy
+msgid "list hooks of a profile"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_pubsub.py:2029
+#, fuzzy
+msgid "No hook found."
+msgstr "Aucune donnée trouvée"
+
+#: sat_frontends/jp/cmd_pubsub.py:2043
+msgid "trigger action on Pubsub notifications"
+msgstr ""
+
+#: sat_frontends/jp/cmd_pubsub.py:2067
+msgid "PubSub nodes/items management"
+msgstr ""
+
+#: sat_frontends/jp/cmd_roster.py:36
+msgid "retrieve the roster entities"
+msgstr ""
+
+#: sat_frontends/jp/cmd_roster.py:89
+msgid "set metadata for a roster entity"
+msgstr ""
+
+#: sat_frontends/jp/cmd_roster.py:93
+msgid "name to use for this entity"
+msgstr ""
+
+#: sat_frontends/jp/cmd_roster.py:96
+msgid "groups for this entity"
+msgstr ""
+
+#: sat_frontends/jp/cmd_roster.py:99
+msgid "replace all metadata instead of adding them"
+msgstr ""
+
+#: sat_frontends/jp/cmd_roster.py:101 sat_frontends/jp/cmd_roster.py:138
+#, fuzzy
+msgid "jid of the roster entity"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/jp/cmd_roster.py:131
+#, fuzzy, python-format
+msgid "remove an entity from roster"
+msgstr "supppression du contact %s"
+
+#: sat_frontends/jp/cmd_roster.py:142
+#, fuzzy, python-format
+msgid "Are you sure to delete {entity} fril your roster?"
+msgstr "Êtes vous sûr de vouloir supprimer %s de votre liste de contacts ?"
+
+#: sat_frontends/jp/cmd_roster.py:145
+msgid "entity deletion cancelled"
+msgstr ""
+
+#: sat_frontends/jp/cmd_roster.py:158
+msgid "Show statistics about a roster"
+msgstr ""
+
+#: sat_frontends/jp/cmd_roster.py:226
+msgid "purge the roster from its contacts with no subscription"
+msgstr ""
+
+#: sat_frontends/jp/cmd_roster.py:231
+#, fuzzy, python-format
+msgid "also purge contacts with no 'from' subscription"
+msgstr "Le contact %s a refusé votre inscription"
+
+#: sat_frontends/jp/cmd_roster.py:234
+#, fuzzy, python-format
+msgid "also purge contacts with no 'to' subscription"
+msgstr "Le contact %s a refusé votre inscription"
+
+#: sat_frontends/jp/cmd_roster.py:306
+msgid "do a full resynchronisation of roster with server"
+msgstr ""
+
+#: sat_frontends/jp/cmd_roster.py:318
+msgid "Roster resynchronized"
+msgstr ""
+
+#: sat_frontends/jp/cmd_roster.py:327
+msgid "Manage an entity's roster"
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:33
+msgid ""
+"Welcome to {app_name} shell, the Salut à Toi shell !\n"
+"\n"
+"This enrironment helps you using several {app_name} commands with similar"
+" parameters.\n"
+"\n"
+"To quit, just enter \"quit\" or press C-d.\n"
+"Enter \"help\" or \"?\" to know what to do\n"
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:48
+msgid "launch jp in shell (REPL) mode"
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:63
+msgid "bad command path"
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:104
+msgid "COMMAND {external}=> {args}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:105
+msgid "(external) "
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:149
+#, fuzzy
+msgid "Shell commands:"
+msgstr "Mauvais nom de profile"
+
+#: sat_frontends/jp/cmd_shell.py:152
+#, fuzzy
+msgid "Action commands:"
+msgstr "Mauvais nom de profile"
+
+#: sat_frontends/jp/cmd_shell.py:172
+msgid "verbose mode is {status}"
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:173
+msgid "ENABLED"
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:173
+msgid "DISABLED"
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:214
+msgid "arg profile={profile} (logged profile)"
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:236
+msgid "no argument in USE"
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:238
+msgid "arguments in USE:"
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:274
+msgid "argument {name} not found"
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:280
+msgid "argument {name} removed"
+msgstr ""
+
+#: sat_frontends/jp/cmd_shell.py:288
+msgid "good bye!"
+msgstr ""
+
+#: sat_frontends/jp/cmd_uri.py:37
+msgid "parse URI"
+msgstr ""
+
+#: sat_frontends/jp/cmd_uri.py:42
+msgid "XMPP URI to parse"
+msgstr ""
+
+#: sat_frontends/jp/cmd_uri.py:57
+msgid "URI type"
+msgstr ""
+
+#: sat_frontends/jp/cmd_uri.py:58
+msgid "URI path"
+msgstr ""
+
+#: sat_frontends/jp/cmd_uri.py:66
+msgid "URI fields"
+msgstr ""
+
+#: sat_frontends/jp/cmd_uri.py:80
+msgid "XMPP URI parsing/generation"
+msgstr ""
+
+#: sat_frontends/jp/common.py:437
+msgid "no item found at all, we create a new one"
+msgstr ""
+
+#: sat_frontends/jp/common.py:440
+msgid "item \"{item}\" not found, we create a new item withthis id"
+msgstr ""
+
+#: sat_frontends/jp/common.py:458
+msgid "item \"{item}\" found, we edit it"
+msgstr ""
+
+#: sat_frontends/jp/common.py:785
+msgid "No {key} URI specified for this project, please specify service and node"
+msgstr ""
+
+#: sat_frontends/jp/common.py:821
+msgid "Invalid URI found: {uri}"
+msgstr ""
+
+#: sat_frontends/jp/loops.py:28
+msgid "User interruption: good bye"
+msgstr "Interrompu par l'utilisateur: au revoir"
+
+#: sat_frontends/jp/output_template.py:53
+msgid "Can't find requested template: {template_path}"
+msgstr ""
+
+#: sat_frontends/jp/output_template.py:74
+msgid ""
+"no default template set for this command, you need to specify a template "
+"using --oo template=[path/to/template.html]"
+msgstr ""
+
+#: sat_frontends/jp/output_template.py:89
+msgid "Can't parse template, please check its syntax"
+msgstr ""
+
+#: sat_frontends/jp/output_template.py:109
+msgid ""
+"Browser opening requested.\n"
+"Temporary files are put in the following directory, you'll have to delete"
+" it yourself once finished viewing: {}"
+msgstr ""
+
+#: sat_frontends/jp/output_xml.py:56
+msgid ""
+"Pygments is not available, syntax highlighting is not possible. Please "
+"install if from http://pygments.org or with pip install pygments"
+msgstr ""
+
+#: sat_frontends/jp/xml_tools.py:50
+msgid "Can't parse the payload XML in input: {msg}"
+msgstr ""
+
+#: sat_frontends/jp/xml_tools.py:62
+msgid "<item> can only have one child element (the payload)"
+msgstr ""
+
+#: sat_frontends/jp/xmlui_manager.py:224
+msgid "(enter: {value})"
+msgstr ""
+
+#: sat_frontends/jp/xmlui_manager.py:318
+msgid "your choice (0-{limit_max}): "
+msgstr ""
+
+#: sat_frontends/jp/xmlui_manager.py:348
+msgid "your choice (0,1): "
+msgstr ""
+
+#: sat_frontends/primitivus/base.py:90
+#, fuzzy, python-format
+msgid "Error while sending message ({})"
+msgstr "Erreur en tentant de rejoindre le salon"
+
+#: sat_frontends/primitivus/base.py:135
+msgid "Please specify the globbing pattern to search for"
+msgstr ""
+
+#: sat_frontends/primitivus/base.py:377
+#, fuzzy
+msgid "Configuration Error"
+msgstr "Connexion..."
+
+#: sat_frontends/primitivus/base.py:377
+msgid ""
+"Something went wrong while reading the configuration, please check "
+":messages"
+msgstr ""
+
+#: sat_frontends/primitivus/base.py:504
+msgid "Pleeeeasse, I can't even breathe !"
+msgstr "Pitiééééééééé, je ne peux même pas respirer !"
+
+#: sat_frontends/primitivus/base.py:534
+#: sat_frontends/primitivus/profile_manager.py:64
+#, fuzzy
+msgid "Connect"
+msgstr "Connexion..."
+
+#: sat_frontends/primitivus/base.py:536
+#, fuzzy
+msgid "Parameters"
+msgstr "&Paramètres"
+
+#: sat_frontends/primitivus/base.py:537 sat_frontends/primitivus/base.py:851
+msgid "About"
+msgstr "À propos"
+
+#: sat_frontends/primitivus/base.py:538
+#, fuzzy
+msgid "Exit"
+msgstr "Quitter"
+
+#: sat_frontends/primitivus/base.py:542
+msgid "Join room"
+msgstr "Rejoindre un salon"
+
+#: sat_frontends/primitivus/base.py:547
+#, fuzzy
+msgid "Main menu"
+msgstr "Construction des menus"
+
+#: sat_frontends/primitivus/base.py:658
+msgid "{app}: a new event has just happened{entity}"
+msgstr ""
+
+#: sat_frontends/primitivus/base.py:736
+#, fuzzy
+msgid "Chat menu"
+msgstr "Construction des menus"
+
+#: sat_frontends/primitivus/base.py:790
+#, fuzzy
+msgid "Unmanaged action"
+msgstr "Tab inconnu"
+
+#: sat_frontends/primitivus/base.py:801
+#, fuzzy
+msgid "unkown"
+msgstr "Messagerie inconnue"
+
+#: sat_frontends/primitivus/base.py:831
+#, fuzzy, python-format
+msgid "Can't get parameters (%s)"
+msgstr "Impossible de charger les paramètres !"
+
+#: sat_frontends/primitivus/base.py:846
+msgid "Entering a MUC room"
+msgstr "Entrée dans le salon MUC"
+
+#: sat_frontends/primitivus/base.py:846
+#, fuzzy
+msgid "Please enter MUC's JID"
+msgstr "Veuillez entrer le JID de votre nouveau contact"
+
+#: sat_frontends/primitivus/chat.py:40
+msgid "{} occupants"
+msgstr ""
+
+#: sat_frontends/primitivus/chat.py:381
+msgid "Game"
+msgstr "Jeu"
+
+#: sat_frontends/primitivus/chat.py:502
+msgid "You have been mentioned by {nick} in {room}"
+msgstr ""
+
+#: sat_frontends/primitivus/chat.py:513
+msgid "{entity} is talking to you"
+msgstr ""
+
+#: sat_frontends/primitivus/chat.py:612
+msgid "Results for searching the globbing pattern: {}"
+msgstr ""
+
+#: sat_frontends/primitivus/chat.py:618
+msgid "Type ':history <lines>' to reset the chat history"
+msgstr ""
+
+#: sat_frontends/primitivus/chat.py:652
+#, python-format
+msgid "Primitivus: %s is talking to you"
+msgstr ""
+
+#: sat_frontends/primitivus/chat.py:656
+#, fuzzy, python-format
+msgid "Primitivus: %(user)s mentioned you in room '%(room)s'"
+msgstr "L'utilisateur %(nick)s a rejoint le salon (%(room_id)s)"
+
+#: sat_frontends/primitivus/chat.py:666
+#, fuzzy
+msgid "Can't start game"
+msgstr "Construction du jeu de Tarot"
+
+#: sat_frontends/primitivus/chat.py:667
+msgid "You need to be exactly 4 peoples in the room to start a Tarot game"
+msgstr ""
+"Vous devez être exactement 4 personnes dans le salon pour commencer un "
+"jeu de Tarot"
+
+#: sat_frontends/primitivus/chat.py:698
+msgid "Change title"
+msgstr ""
+
+#: sat_frontends/primitivus/chat.py:699
+#, fuzzy
+msgid "Enter the new title"
+msgstr "Veuillez entrer le nom du nouveau profile"
+
+#: sat_frontends/primitivus/game_tarot.py:290
+msgid "Please choose your contrat"
+msgstr "Veuillez choisir votre contrat"
+
+#: sat_frontends/primitivus/game_tarot.py:311
+msgid "You win \\o/"
+msgstr "Victoire \\o/"
+
+#: sat_frontends/primitivus/game_tarot.py:311
+msgid "You loose :("
+msgstr "Vous perdez :("
+
+#: sat_frontends/primitivus/game_tarot.py:331
+msgid "Cards played are invalid !"
+msgstr "Les cartes jouées sont invalides !"
+
+#: sat_frontends/primitivus/game_tarot.py:369
+msgid "Do you put these cards in chien ?"
+msgstr "Voulez-vous placer ces cartes au chien ?"
+
+#: sat_frontends/primitivus/profile_manager.py:36
+#, fuzzy
+msgid "Login:"
+msgstr "Identifiant"
+
+#: sat_frontends/primitivus/profile_manager.py:37
+msgid "Password:"
+msgstr "Mot de passe:"
+
+#: sat_frontends/primitivus/profile_manager.py:48
+msgid "New"
+msgstr "Nouveau"
+
+#: sat_frontends/primitivus/profile_manager.py:49
+msgid "Delete"
+msgstr "Suppression"
+
+#: sat_frontends/primitivus/profile_manager.py:81
+#, fuzzy
+msgid "Profile Manager"
+msgstr "Mauvais nom de profile"
+
+#: sat_frontends/primitivus/profile_manager.py:142
+msgid "Can't create profile"
+msgstr ""
+
+#: sat_frontends/primitivus/profile_manager.py:150
+#, fuzzy
+msgid "New profile"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/primitivus/profile_manager.py:151
+#, fuzzy
+msgid "Please enter a new profile name"
+msgstr "Veuillez entrer le nom du nouveau profile"
+
+#: sat_frontends/primitivus/profile_manager.py:160
+#, fuzzy, python-format
+msgid "Are you sure you want to delete the profile {} ?"
+msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
+
+#: sat_frontends/primitivus/progress.py:37
+msgid "Clear progress list"
+msgstr "Effacer la liste"
+
+#: sat_frontends/primitivus/status.py:57
+msgid "Set your presence"
+msgstr ""
+
+#: sat_frontends/primitivus/status.py:67
+msgid "Set your status"
+msgstr ""
+
+#: sat_frontends/primitivus/status.py:68
+msgid "New status"
+msgstr ""
+
+#: sat_frontends/primitivus/xmlui.py:78
+#, fuzzy
+msgid "Unknown div_char"
+msgstr "Type d'action inconnu"
+
+#: sat_frontends/primitivus/xmlui.py:456
+msgid "Submit"
+msgstr "Envoyer"
+
+#: sat_frontends/primitivus/xmlui.py:458 sat_frontends/primitivus/xmlui.py:473
+msgid "Cancel"
+msgstr "Annuler"
+
+#: sat_frontends/quick_frontend/constants.py:31
+msgid "Away from keyboard"
+msgstr ""
+
+#: sat_frontends/quick_frontend/constants.py:33
+msgid "Extended away"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_app.py:85
+msgid "Error while trying to get autodisconnect param, ignoring: {}"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_app.py:200
+#, fuzzy
+msgid "Can't get profile parameter: {msg}"
+msgstr "Mauvais nom de profile"
+
+#: sat_frontends/quick_frontend/quick_app.py:324
+msgid "Can't get namespaces map: {msg}"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_app.py:330
+msgid "Can't retrieve encryption plugins: {msg}"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_app.py:376
+msgid "Error while initialising bridge: {}"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_app.py:662
+#, fuzzy, python-format
+msgid "Can't connect profile [%s]"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/quick_frontend/quick_app.py:723
+#, fuzzy
+msgid "Connected"
+msgstr "Connexion..."
+
+#: sat_frontends/quick_frontend/quick_app.py:739
+#, fuzzy
+msgid "Disconnected"
+msgstr "Déconnexion..."
+
+#: sat_frontends/quick_frontend/quick_app.py:1154
+#, fuzzy, python-format
+msgid "The contact {contact} has accepted your subscription"
+msgstr "Le contact %s a accepté votre inscription"
+
+#: sat_frontends/quick_frontend/quick_app.py:1157
+#: sat_frontends/quick_frontend/quick_app.py:1176
+#, fuzzy
+msgid "Subscription confirmation"
+msgstr "désinscription confirmée pour [%s]"
+
+#: sat_frontends/quick_frontend/quick_app.py:1162
+#, fuzzy, python-format
+msgid "The contact {contact} has refused your subscription"
+msgstr "Le contact %s a refusé votre inscription"
+
+#: sat_frontends/quick_frontend/quick_app.py:1165
+#, fuzzy
+msgid "Subscription refusal"
+msgstr "demande d'inscription pour [%s]"
+
+#: sat_frontends/quick_frontend/quick_app.py:1172
+#, fuzzy, python-format
+msgid ""
+"The contact {contact} wants to subscribe to your presence.\n"
+"Do you accept ?"
+msgstr ""
+"Le contact %s veut s'inscrire à vos informations de présence\n"
+"Acceptez vous ?"
+
+#: sat_frontends/quick_frontend/quick_app.py:1229
+#, python-format
+msgid "param update: [%(namespace)s] %(name)s = %(value)s"
+msgstr "Le paramètre [%(namespace)s] %(name)s vaut désormais %(value)s"
+
+#: sat_frontends/quick_frontend/quick_app.py:1233
+#, python-format
+msgid "Changing JID to %s"
+msgstr "Changement du JID pour %s"
+
+#: sat_frontends/quick_frontend/quick_chat.py:624
+#, fuzzy
+msgid "now we print the history"
+msgstr "Maintenant on affiche l'historique"
+
+#: sat_frontends/quick_frontend/quick_chat.py:626
+#, fuzzy
+msgid " ({} messages)"
+msgstr "Messages"
+
+#: sat_frontends/quick_frontend/quick_chat.py:683
+#, fuzzy
+msgid "Can't get history: {}"
+msgstr "Impossible de charger l'historique !"
+
+#: sat_frontends/quick_frontend/quick_chat.py:705
+msgid "Can't get encryption state: {reason}"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_chat.py:775
+msgid "message encryption started with {target} using {encryption}"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_chat.py:780
+msgid "message encryption stopped with {target} (was using {encryption})"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_chat.py:833
+msgid "<= {nick} has left the room ({count})"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_chat.py:837
+msgid "<=> {nick} re-entered the room ({count})"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_contact_list.py:611
+#, fuzzy
+msgid "Trying to delete an unknow entity [{}]"
+msgstr "Tentative d'accès à un profile inconnu"
+
+#: sat_frontends/quick_frontend/quick_contact_list.py:664
+msgid "received presence from entity without resource: {}"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_contact_management.py:73
+#, fuzzy
+msgid "Trying to get attribute for an unknown contact"
+msgstr "Tentative d'assigner un paramètre à un profile inconnu"
+
+#: sat_frontends/quick_frontend/quick_contact_management.py:89
+msgid "INTERNAL ERROR: Key log.error"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_contact_management.py:101
+#, fuzzy, python-format
+msgid "Trying to update an unknown contact: %s"
+msgstr "Tentative d'accès à un profile inconnu"
+
+#: sat_frontends/quick_frontend/quick_games.py:84
+msgid ""
+"A {game} activity between {players} has been started, but you couldn't "
+"take part because your client doesn't support it."
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_games.py:87
+msgid "{game} Game"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_profile_manager.py:116
+#, fuzzy, python-format
+msgid "Trying to plug an unknown profile key ({})"
+msgstr "Tentative d'appel d'un profile inconnue"
+
+#: sat_frontends/quick_frontend/quick_profile_manager.py:118
+#, fuzzy
+msgid "Profile plugging in error"
+msgstr "Mauvais nom de profile"
+
+#: sat_frontends/quick_frontend/quick_profile_manager.py:133
+#, fuzzy
+msgid "Can't get profile parameter"
+msgstr "Mauvais nom de profile"
+
+#: sat_frontends/quick_frontend/quick_profile_manager.py:144
+#, fuzzy
+msgid "A profile with this name already exists"
+msgstr "Ce nom de profile existe déjà"
+
+#: sat_frontends/quick_frontend/quick_profile_manager.py:146
+msgid "Profile creation cancelled by backend"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_profile_manager.py:148
+#, fuzzy
+msgid "You profile name is not valid"
+msgstr "Ce profile n'est pas utilisé"
+
+#: sat_frontends/quick_frontend/quick_profile_manager.py:152
+#, fuzzy
+msgid "Can't create profile ({})"
+msgstr "Vous essayer de connecter un profile qui n'existe pas"
+
+#: sat_frontends/quick_frontend/quick_profile_manager.py:172
+msgid "You can't connect manually and automatically at the same time"
+msgstr ""
+
+#: sat_frontends/quick_frontend/quick_profile_manager.py:180
+msgid "No profile selected"
+msgstr "Aucun profile sélectionné"
+
+#: sat_frontends/quick_frontend/quick_profile_manager.py:181
+#, fuzzy
+msgid "You need to create and select at least one profile before connecting"
+msgstr ""
+"Vous devez sélectionner un profile ou en créer un nouveau avant de vous "
+"connecter."
+
+#: sat_frontends/quick_frontend/quick_utils.py:40
+#, fuzzy
+msgid ""
+"\n"
+"    %prog [options]\n"
+"\n"
+"    %prog --help for options list\n"
+"    "
+msgstr ""
+"\n"
+"        %prog [options] [FICHIER1 FICHIER2 ...] JID\n"
+"        %prog -w [options] [JID1 JID2 ...]\n"
+"\n"
+"        %prog --help pour la liste des options\n"
+"        "
+
+#: sat_frontends/quick_frontend/quick_utils.py:49
+msgid "Select the profile to use"
+msgstr "Veuillez sélectionner le profile à utiliser"
+
+#: sat_frontends/tools/xmlui.py:233
+msgid "Nothing to submit"
+msgstr ""
+
+#: sat_frontends/tools/xmlui.py:449
+msgid "XMLUI can have only one main container"
+msgstr ""
+
+#: sat_frontends/tools/xmlui.py:514
+#, fuzzy, python-format
+msgid "Unknown container [%s], using default one"
+msgstr "Disposition inconnue, utilisation de celle par defaut"
+
+#: sat_frontends/tools/xmlui.py:527
+msgid "Internal Error, container has not _xmluiAppend method"
+msgstr ""
+
+#: sat_frontends/tools/xmlui.py:674
+#, fuzzy, python-format
+msgid "FIXME FIXME FIXME: widget type [%s] is not implemented"
+msgstr "CORRIGEZ-MOI: actionResult n'est pas implémenté"
+
+#: sat_frontends/tools/xmlui.py:678
+#, fuzzy, python-format
+msgid "FIXME FIXME FIXME: type [%s] is not implemented"
+msgstr "CORRIGEZ-MOI: actionResult n'est pas implémenté"
+
+#: sat_frontends/tools/xmlui.py:696
+#, python-format
+msgid "No change listener on [%s]"
+msgstr ""
+
+#: sat_frontends/tools/xmlui.py:722
+#, fuzzy, python-format
+msgid "Unknown tag [%s]"
+msgstr "Type d'action inconnu"
+
+#: sat_frontends/tools/xmlui.py:780
+#, fuzzy
+msgid "No callback_id found"
+msgstr "Aucun transport trouvé"
+
+#: sat_frontends/tools/xmlui.py:813
+#, python-format
+msgid "FIXME: XMLUI internal action [%s] is not implemented"
+msgstr ""
+
+#: sat_frontends/tools/xmlui.py:909 sat_frontends/tools/xmlui.py:921
+#: sat_frontends/tools/xmlui.py:971 sat_frontends/tools/xmlui.py:983
+msgid "The form data is not sent back, the type is not managed properly"
+msgstr ""
+"Les données du formulaire ne sont pas envoyées, il y a une erreur dans la"
+" gestion du type"
+
+#: sat_frontends/tools/xmlui.py:915 sat_frontends/tools/xmlui.py:977
+msgid "Cancelling form"
+msgstr "Annulation du formulaire"
+
+#: sat_frontends/tools/xmlui.py:1096
+msgid "XMLUI class already registered for {type_}, ignoring"
+msgstr ""
+
+#: sat_frontends/tools/xmlui.py:1135
+msgid "You must register classes with registerClass before creating a XMLUI"
+msgstr ""
+
Binary file i18n/fr/LC_MESSAGES/sat.mo has changed
--- a/i18n/fr/LC_MESSAGES/sat.po	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,9225 +0,0 @@
-# French translations for Libervia.
-# Copyright (C) 2021 ORGANIZATION
-# This file is distributed under the same license as the Libervia project.
-# FIRST AUTHOR <EMAIL@ADDRESS>, 2021.
-#
-#, fuzzy
-msgid ""
-msgstr ""
-"Project-Id-Version:  0.0.2\n"
-"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2021-06-15 10:11+0200\n"
-"PO-Revision-Date: 2010-03-05 19:24+1100\n"
-"Last-Translator: Goffi <goffi@goffi.org>\n"
-"Language: fr\n"
-"Language-Team: French <goffi@goffi.org>\n"
-"Plural-Forms: nplurals=2; plural=(n > 1)\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=utf-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.9.0\n"
-
-#: sat/bridge/bridge_constructor/constructors/dbus/dbus_core_template.py:273
-#: sat/bridge/bridge_constructor/constructors/dbus/dbus_frontend_template.py:85
-#: sat/bridge/bridge_constructor/generated/dbus_bridge.py:85
-#: sat/bridge/dbus_bridge.py:747 sat_frontends/bridge/dbus_bridge.py:85
-msgid ""
-"D-Bus is not launched, please see README to see instructions on how to "
-"launch it"
-msgstr ""
-
-#: sat/bridge/bridge_constructor/constructors/dbus/dbus_frontend_template.py:99
-#: sat/bridge/bridge_constructor/generated/dbus_bridge.py:99
-#: sat_frontends/bridge/dbus_bridge.py:99
-#, fuzzy
-msgid "Unknown interface"
-msgstr "Type d'action inconnu"
-
-#: sat/core/sat_main.py:212
-#, fuzzy
-msgid "Memory initialised"
-msgstr "Le flux XML est initialisé"
-
-#: sat/core/sat_main.py:219
-msgid "Could not initialize backend: {reason}"
-msgstr ""
-
-#: sat/core/sat_main.py:227
-msgid "Backend is ready"
-msgstr ""
-
-#: sat/core/sat_main.py:238
-msgid "Following profiles will be connected automatically: {profiles}"
-msgstr ""
-
-#: sat/core/sat_main.py:251
-#, fuzzy
-msgid "Can't autoconnect profile {profile}: {reason}"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat/core/sat_main.py:321
-msgid ""
-"Can't import plugin [{path}]:\n"
-"{error}"
-msgstr ""
-
-#: sat/core/sat_main.py:340
-msgid "{type} type must be used with {mode} mode, ignoring plugin"
-msgstr ""
-
-#: sat/core/sat_main.py:349
-msgid ""
-"Name conflict for import name [{import_name}], can't import plugin "
-"[{name}]"
-msgstr ""
-
-#: sat/core/sat_main.py:385
-msgid "Recommended plugin not found: {}"
-msgstr ""
-
-#: sat/core/sat_main.py:406
-msgid "Can't import plugin {name}: {error}"
-msgstr ""
-
-#: sat/core/sat_main.py:478
-#, fuzzy
-msgid "already connected !"
-msgstr "Vous  n'êtes pas connecté !"
-
-#: sat/core/sat_main.py:495
-msgid "not connected !"
-msgstr "Vous  n'êtes pas connecté !"
-
-#: sat/core/sat_main.py:591
-msgid "Trying to remove reference to a client not referenced"
-msgstr ""
-
-#: sat/core/sat_main.py:604
-msgid "running app"
-msgstr "Lancement de l'application"
-
-#: sat/core/sat_main.py:608
-msgid "stopping app"
-msgstr "Arrêt de l'application"
-
-#: sat/core/sat_main.py:646
-msgid "profile_key must not be empty"
-msgstr ""
-
-#: sat/core/sat_main.py:666
-msgid "Unexpected error: {failure_}"
-msgstr ""
-
-#: sat/core/sat_main.py:921
-msgid "asking connection status for a non-existant profile"
-msgstr "demande de l'état de connexion pour un profile qui n'existe pas"
-
-#: sat/core/sat_main.py:1020
-#, fuzzy, python-format
-msgid "subsciption request [%(subs_type)s] for %(jid)s"
-msgstr "demande d'inscription [%(type)s] pour %(jid)s"
-
-#: sat/core/sat_main.py:1162
-msgid "Can't find features for service {service_jid}, ignoring"
-msgstr ""
-
-#: sat/core/sat_main.py:1221
-msgid "Can't retrieve {full_jid} infos, ignoring"
-msgstr ""
-
-#: sat/core/sat_main.py:1292
-msgid "Trying to remove an unknow progress callback"
-msgstr "Tentative d'effacement d'une callback de progression inconnue."
-
-#: sat/core/sat_main.py:1382
-#, fuzzy
-msgid "id already registered"
-msgstr "Vous êtes maintenant désinscrit"
-
-#: sat/core/sat_main.py:1424
-#, fuzzy
-msgid "trying to launch action with a non-existant profile"
-msgstr "Tentative d'ajout d'un contact à un profile inexistant"
-
-#: sat/core/sat_main.py:1520
-#, fuzzy
-msgid "A menu with the same path and type already exists"
-msgstr "Ce nom de profile existe déjà"
-
-#: sat/core/sat_main.py:1619
-#, fuzzy
-msgid "help_string"
-msgstr "enregistrement"
-
-#: sat/core/xmpp.py:196
-#, fuzzy
-msgid "Can't parse port value, using default value"
-msgstr "Pas de modèle de paramètres, utilisation du modèle par défaut"
-
-#: sat/core/xmpp.py:223
-msgid "We'll use the stable resource {resource}"
-msgstr ""
-
-#: sat/core/xmpp.py:255
-msgid "setting plugins parents"
-msgstr "Configuration des parents des extensions"
-
-#: sat/core/xmpp.py:275
-#, fuzzy
-msgid "Plugins initialisation error"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/core/xmpp.py:297
-msgid "Error while disconnecting: {}"
-msgstr ""
-
-#: sat/core/xmpp.py:301
-#, fuzzy
-msgid "{profile} identified"
-msgstr "Aucun profile sélectionné"
-
-#: sat/core/xmpp.py:309
-msgid "XML stream is initialized"
-msgstr "Le flux XML est initialisé"
-
-#: sat/core/xmpp.py:317
-#, fuzzy, python-format
-msgid "********** [{profile}] CONNECTED **********"
-msgstr "********** [%s] CONNECTÉ **********"
-
-#: sat/core/xmpp.py:343
-#, python-format
-msgid "ERROR: XMPP connection failed for profile '%(profile)s': %(reason)sprofile"
-msgstr ""
-
-#: sat/core/xmpp.py:398
-msgid "stopping connection because of network disabled"
-msgstr ""
-
-#: sat/core/xmpp.py:421
-msgid "network is available, trying to connect"
-msgstr ""
-
-#: sat/core/xmpp.py:445
-#, fuzzy, python-format
-msgid "********** [{profile}] DISCONNECTED **********"
-msgstr "********** [%s] CONNECTÉ **********"
-
-#: sat/core/xmpp.py:464
-msgid ""
-"Your server certificate is not valid (its identity can't be checked).\n"
-"\n"
-"This should never happen and may indicate that somebody is trying to spy "
-"on you.\n"
-"Please contact your server administrator."
-msgstr ""
-
-#: sat/core/xmpp.py:515
-#, fuzzy
-msgid "Disconnecting..."
-msgstr "Déconnexion..."
-
-#: sat/core/xmpp.py:688
-#, fuzzy, python-format
-msgid "Sending message (type {type}, to {to})"
-msgstr "Envoi du message jabber à %s"
-
-#: sat/core/xmpp.py:696
-msgid ""
-"Triggers, storage and echo have been inhibited by the 'send_only' "
-"parameter"
-msgstr ""
-
-#: sat/core/xmpp.py:762
-#, fuzzy, python-format
-msgid "No message found"
-msgstr "message reçu de: %s"
-
-#: sat/core/xmpp.py:814
-msgid "invalid data used for host: {data}"
-msgstr ""
-
-#: sat/core/xmpp.py:839
-msgid ""
-"Certificate validation is deactivated, this is unsecure and somebody may "
-"be spying on you. If you have no good reason to disable certificate "
-"validation, please activate \"Check certificate\" in your settings in "
-"\"Connection\" tab."
-msgstr ""
-
-#: sat/core/xmpp.py:843
-msgid "Security notice"
-msgstr ""
-
-#: sat/core/xmpp.py:978
-msgid "The requested entry point ({entry_point}) is not available"
-msgstr ""
-
-#: sat/core/xmpp.py:1016
-msgid ""
-"Plugin {current_name} is needed for {entry_name}, but it doesn't handle "
-"component mode"
-msgstr ""
-
-#: sat/core/xmpp.py:1024
-msgid "invalid plugin mode"
-msgstr ""
-
-#: sat/core/xmpp.py:1128
-msgid "parseMessage used with a non <message/> stanza, ignoring: {xml}"
-msgstr ""
-
-#: sat/core/xmpp.py:1140
-msgid "received <message> with a wrong namespace: {xml}"
-msgstr ""
-
-#: sat/core/xmpp.py:1226
-#, fuzzy, python-format
-msgid "got message from: {from_}"
-msgstr "message reçu de: %s"
-
-#: sat/core/xmpp.py:1341
-msgid "There's no subscription between you and [{}]!"
-msgstr ""
-
-#: sat/core/xmpp.py:1346
-msgid "You are not subscribed to [{}]!"
-msgstr ""
-
-#: sat/core/xmpp.py:1348
-msgid "[{}] is not subscribed to you!"
-msgstr ""
-
-#: sat/core/xmpp.py:1384
-msgid "our server support roster versioning, we use it"
-msgstr ""
-
-#: sat/core/xmpp.py:1390
-msgid "no roster in cache, we start fresh"
-msgstr ""
-
-#: sat/core/xmpp.py:1394
-msgid "We have roster v{version} in cache"
-msgstr ""
-
-#: sat/core/xmpp.py:1405
-msgid "our server doesn't support roster versioning"
-msgstr ""
-
-#: sat/core/xmpp.py:1462
-msgid "adding {entity} to roster"
-msgstr ""
-
-#: sat/core/xmpp.py:1486
-#, fuzzy, python-format
-msgid "removing {entity} from roster"
-msgstr "supppression du contact %s"
-
-#: sat/core/xmpp.py:1640
-#, python-format
-msgid "presence update for [%(entity)s] (unavailable, statuses=%(statuses)s)"
-msgstr ""
-"Mise à jour de l'information de présence pour [%(entity)s] (unavailable, "
-"statuses=%(statuses)s)"
-
-#: sat/core/xmpp.py:1724
-#, fuzzy
-msgid "sending automatic \"from\" subscription request"
-msgstr "envoi automatique de la demande d'inscription \"to\""
-
-#: sat/core/xmpp.py:1732
-#, python-format
-msgid "subscription approved for [%s]"
-msgstr "inscription approuvée pour [%s]"
-
-#: sat/core/xmpp.py:1736
-#, fuzzy, python-format
-msgid "unsubscription confirmed for [%s]"
-msgstr "demande de désinscription pour [%s]"
-
-#: sat/core/xmpp.py:1741
-#, fuzzy, python-format
-msgid "subscription request from [%s]"
-msgstr "inscription approuvée pour [%s]"
-
-#: sat/core/xmpp.py:1747
-#, fuzzy
-msgid "sending automatic subscription acceptance"
-msgstr "envoi automatique de la demande d'inscription \"to\""
-
-#: sat/core/xmpp.py:1759
-#, python-format
-msgid "unsubscription asked for [%s]"
-msgstr "demande de désinscription pour [%s]"
-
-#: sat/core/xmpp.py:1763
-#, fuzzy
-msgid "automatic contact deletion"
-msgstr "Sélection du contrat"
-
-#: sat/memory/cache.py:69
-msgid "Can't read metadata file at {path}"
-msgstr ""
-
-#: sat/memory/cache.py:80
-msgid "Invalid cache metadata at {path}"
-msgstr ""
-
-#: sat/memory/cache.py:87
-msgid "cache {cache_file!r} references an inexisting file: {filepath!r}"
-msgstr ""
-
-#: sat/memory/cache.py:102
-msgid "following file is missing while purging cache: {path}"
-msgstr ""
-
-#: sat/memory/cache.py:200
-msgid "missing filename for cache {uid!r}"
-msgstr ""
-
-#: sat/memory/cache.py:207
-msgid "missing file referenced in cache {uid!r}: {filename}"
-msgstr ""
-
-#: sat/memory/disco.py:95
-msgid ""
-"no feature/identity found in disco element (hash: {cap_hash}), ignoring: "
-"{xml}"
-msgstr ""
-
-#: sat/memory/disco.py:274
-#, python-format
-msgid "Error while requesting [%(jid)s]: %(error)s"
-msgstr ""
-
-#: sat/memory/disco.py:338
-msgid "received an item without jid"
-msgstr ""
-
-#: sat/memory/disco.py:410
-msgid "Capability hash generated: [{cap_hash}]"
-msgstr ""
-
-#: sat/memory/disco.py:459
-msgid "invalid item (no jid)"
-msgstr ""
-
-#: sat/memory/encryption.py:71
-msgid "Could not restart {namespace!r} encryption with {entity}: {err}"
-msgstr ""
-
-#: sat/memory/encryption.py:74
-msgid "encryption sessions restored"
-msgstr ""
-
-#: sat/memory/encryption.py:116
-msgid "Encryption plugin registered: {name}"
-msgstr ""
-
-#: sat/memory/encryption.py:127
-msgid "Can't find requested encryption plugin: {namespace}"
-msgstr ""
-
-#: sat/memory/encryption.py:148
-msgid "Can't find a plugin with the name \"{name}\"."
-msgstr ""
-
-#: sat/memory/encryption.py:213
-msgid "No encryption plugin is registered, an encryption session can't be started"
-msgstr ""
-
-#: sat/memory/encryption.py:226
-msgid "Session with {bare_jid} is already encrypted with {name}. Nothing to do."
-msgstr ""
-
-#: sat/memory/encryption.py:237
-msgid ""
-"Session with {bare_jid} is already encrypted with {name}. Please stop "
-"encryption session before changing algorithm."
-msgstr ""
-
-#: sat/memory/encryption.py:249
-msgid "No resource found for {destinee}, can't encrypt with {name}"
-msgstr ""
-
-#: sat/memory/encryption.py:251
-msgid "No resource specified to encrypt with {name}, using {destinee}."
-msgstr ""
-
-#: sat/memory/encryption.py:257
-msgid "{name} encryption must be used with bare jids."
-msgstr ""
-
-#: sat/memory/encryption.py:261
-msgid "Encryption session has been set for {entity_jid} with {encryption_name}"
-msgstr ""
-
-#: sat/memory/encryption.py:268
-msgid ""
-"Encryption session started: your messages with {destinee} are now end to "
-"end encrypted using {name} algorithm."
-msgstr ""
-
-#: sat/memory/encryption.py:273
-msgid "Message are encrypted only for {nb_devices} device(s): {devices_list}."
-msgstr ""
-
-#: sat/memory/encryption.py:291
-msgid "There is no encryption session with this entity."
-msgstr ""
-
-#: sat/memory/encryption.py:295
-msgid ""
-"The encryption session is not run with the expected plugin: encrypted "
-"with {current_name} and was expecting {expected_name}"
-msgstr ""
-
-#: sat/memory/encryption.py:304
-msgid ""
-"There is a session for the whole entity (i.e. all devices of the entity),"
-" not a directed one. Please use bare jid if you want to stop the whole "
-"encryption with this entity."
-msgstr ""
-
-#: sat/memory/encryption.py:312
-msgid "There is no directed session with this entity."
-msgstr ""
-
-#: sat/memory/encryption.py:327
-msgid "encryption session stopped with entity {entity}"
-msgstr ""
-
-#: sat/memory/encryption.py:335
-msgid ""
-"Encryption session finished: your messages with {destinee} are NOT end to"
-" end encrypted anymore.\n"
-"Your server administrators or {destinee} server administrators will be "
-"able to read them."
-msgstr ""
-
-#: sat/memory/encryption.py:389 sat/memory/encryption.py:397
-#: sat/memory/encryption.py:404
-#, fuzzy
-msgid "Encryption"
-msgstr "Connexion..."
-
-#: sat/memory/encryption.py:389
-msgid "unencrypted (plain text)"
-msgstr ""
-
-#: sat/memory/encryption.py:392
-msgid "End encrypted session"
-msgstr ""
-
-#: sat/memory/encryption.py:400
-msgid "Start {name} session"
-msgstr ""
-
-#: sat/memory/encryption.py:404
-msgid "⛨ {name} trust"
-msgstr ""
-
-#: sat/memory/encryption.py:407
-msgid "Manage {name} trust"
-msgstr ""
-
-#: sat/memory/encryption.py:470
-msgid "Starting e2e session with {peer_jid} as we receive encrypted messages"
-msgstr ""
-
-#: sat/memory/memory.py:230
-msgid "Memory manager init"
-msgstr "Initialisation du gestionnaire de mémoire"
-
-#: sat/memory/memory.py:249
-#, fuzzy
-msgid "Loading default params template"
-msgstr "Impossible de charger le modèle des paramètres !"
-
-#: sat/memory/memory.py:281
-#, python-format
-msgid "Parameters loaded from file: %s"
-msgstr ""
-
-#: sat/memory/memory.py:284
-#, fuzzy, python-format
-msgid "Can't load parameters from file: %s"
-msgstr "Impossible de charger le modèle des paramètres !"
-
-#: sat/memory/memory.py:299
-#, fuzzy, python-format
-msgid "Parameters saved to file: %s"
-msgstr "Échec de la désinscription: %s"
-
-#: sat/memory/memory.py:302
-#, fuzzy, python-format
-msgid "Can't save parameters to file: %s"
-msgstr "Impossible de charger le modèle des paramètres !"
-
-#: sat/memory/memory.py:404
-msgid "Authentication failure of profile {profile}"
-msgstr ""
-
-#: sat/memory/memory.py:431
-#, fuzzy, python-format
-msgid "[%s] Profile session purge"
-msgstr "Ce profile n'est pas utilisé"
-
-#: sat/memory/memory.py:437
-#, python-format
-msgid "Trying to purge roster status cache for a profile not in memory: [%s]"
-msgstr ""
-
-#: sat/memory/memory.py:451
-msgid "requesting no profiles at all"
-msgstr ""
-
-#: sat/memory/memory.py:508
-msgid "Can't find component {component} entry point"
-msgstr ""
-
-#: sat/memory/memory.py:996
-msgid "Need a bare jid to delete all resources"
-msgstr ""
-
-#: sat/memory/memory.py:1028
-#, python-format
-msgid "Trying to encrypt a value for %s while the personal key is undefined!"
-msgstr ""
-
-#: sat/memory/memory.py:1048
-#, python-format
-msgid "Trying to decrypt a value for %s while the personal key is undefined!"
-msgstr ""
-
-#: sat/memory/memory.py:1069
-#, python-format
-msgid "Personal data (%(ns)s, %(key)s) has been successfuly encrypted"
-msgstr ""
-
-#: sat/memory/memory.py:1097
-msgid "Asking waiting subscriptions for a non-existant profile"
-msgstr "Demande des inscriptions en attente pour un profile inexistant"
-
-#: sat/memory/memory.py:1218
-msgid "invalid permission"
-msgstr ""
-
-#: sat/memory/memory.py:1249
-#, fuzzy
-msgid "unknown access type: {type}"
-msgstr "Type d'action inconnu"
-
-#: sat/memory/memory.py:1284
-msgid "You can't use path and parent at the same time"
-msgstr ""
-
-#: sat/memory/memory.py:1288
-msgid "\"..\" or \".\" can't be used in path"
-msgstr ""
-
-#: sat/memory/memory.py:1307
-msgid "Several directories found, this should not happen"
-msgstr ""
-
-#: sat/memory/memory.py:1766
-msgid "Can't delete directory, it is not empty"
-msgstr ""
-
-#: sat/memory/memory.py:1778
-msgid "deleting file {name} with hash {file_hash}"
-msgstr ""
-
-#: sat/memory/memory.py:1787
-msgid "no reference left to {file_path}, deleting"
-msgstr ""
-
-#: sat/memory/params.py:85 sat_frontends/primitivus/base.py:533
-msgid "General"
-msgstr "Général"
-
-#: sat/memory/params.py:86
-#, fuzzy
-msgid "Connection"
-msgstr "Connexion..."
-
-#: sat/memory/params.py:88
-msgid "Chat history limit"
-msgstr ""
-
-#: sat/memory/params.py:90
-msgid "Show offline contacts"
-msgstr ""
-
-#: sat/memory/params.py:92
-msgid "Show empty groups"
-msgstr ""
-
-#: sat/memory/params.py:95
-#, fuzzy
-msgid "Connect on backend startup"
-msgstr "Connexion au démarrage des frontends"
-
-#: sat/memory/params.py:96
-#, fuzzy
-msgid "Connect on frontend startup"
-msgstr "Connexion au démarrage des frontends"
-
-#: sat/memory/params.py:97
-#, fuzzy
-msgid "Disconnect on frontend closure"
-msgstr "Déconnexion à la fermeture des frontends"
-
-#: sat/memory/params.py:98
-msgid "Check certificate (don't uncheck if unsure)"
-msgstr ""
-
-#: sat/memory/params.py:163
-#, fuzzy, python-format
-msgid "Trying to purge cache of a profile not in memory: [%s]"
-msgstr "Tentative d'appel d'un profile inconnue"
-
-#: sat/memory/params.py:188
-#, fuzzy
-msgid "The profile name already exists"
-msgstr "Ce nom de profile existe déjà"
-
-#: sat/memory/params.py:203
-#, fuzzy
-msgid "Trying to delete an unknown profile"
-msgstr "Tentative d'accès à un profile inconnu"
-
-#: sat/memory/params.py:209
-#, fuzzy
-msgid "Trying to delete a connected profile"
-msgstr "Tentative de suppression d'un contact pour un profile inexistant"
-
-#: sat/memory/params.py:228
-msgid "No default profile, returning first one"
-msgstr "Pas de profile par défaut, envoi du premier"
-
-#: sat/memory/params.py:234
-#, fuzzy
-msgid "No profile exist yet"
-msgstr "Aucun profile sélectionné"
-
-#: sat/memory/params.py:244
-#, fuzzy, python-format
-msgid "Trying to access an unknown profile (%s)"
-msgstr "Tentative d'accès à un profile inconnu"
-
-#: sat/memory/params.py:338
-msgid "Trying to register frontends parameters with no specified app: aborted"
-msgstr ""
-
-#: sat/memory/params.py:347
-#, python-format
-msgid "Trying to register twice frontends parameters for %(app)s: abortedapp"
-msgstr ""
-
-#: sat/memory/params.py:363
-#, python-format
-msgid "Can't determine default value for [%(category)s/%(name)s]: %(reason)s"
-msgstr ""
-"Impossible de déterminer la valeur par défaut pour "
-"[%(category)s/%(name)s]: %(reason)s"
-
-#: sat/memory/params.py:385 sat/memory/params.py:563 sat/memory/params.py:624
-#, python-format
-msgid "Requested param [%(name)s] in category [%(category)s] doesn't exist !"
-msgstr ""
-"Le paramètre demandé  [%(name)s] dans la catégorie [%(category)s] "
-"n'existe pas !"
-
-#: sat/memory/params.py:440
-#, python-format
-msgid ""
-"Unset parameter (%(cat)s, %(param)s) of type list will use the default "
-"option '%(value)s'"
-msgstr ""
-
-#: sat/memory/params.py:448
-#, python-format
-msgid "Parameter (%(cat)s, %(param)s) of type list has no default option!"
-msgstr ""
-
-#: sat/memory/params.py:455
-#, python-format
-msgid ""
-"Parameter (%(cat)s, %(param)s) of type list has more than one default "
-"option!"
-msgstr ""
-
-#: sat/memory/params.py:585
-msgid "Requesting a param for an non-existant profile"
-msgstr "Demande d'un paramètre pour un profile inconnu"
-
-#: sat/memory/params.py:589
-#, fuzzy
-msgid "Requesting synchronous param for not connected profile"
-msgstr "Demande d'un paramètre pour un profile inconnu"
-
-#: sat/memory/params.py:633
-#, python-format
-msgid ""
-"Trying to get parameter '%(param)s' in category '%(cat)s' without "
-"authorization!!!param"
-msgstr ""
-
-#: sat/memory/params.py:649
-#, fuzzy
-msgid "Requesting a param for a non-existant profile"
-msgstr "Demande d'un paramètre pour un profile inconnu"
-
-#: sat/memory/params.py:962
-#, fuzzy
-msgid "Trying to set parameter for an unknown profile"
-msgstr "Tentative d'accès à un profile inconnu"
-
-#: sat/memory/params.py:968
-#, python-format
-msgid "Requesting an unknown parameter (%(category)s/%(name)s)"
-msgstr "Demande d'un paramètre inconnu: (%(category)s/%(name)s)"
-
-#: sat/memory/params.py:974
-msgid ""
-"{profile!r} is trying to set parameter {name!r} in category {category!r} "
-"without authorization!!!"
-msgstr ""
-
-#: sat/memory/params.py:992
-msgid ""
-"Trying to set parameter {name} in category {category} withan non-integer "
-"value"
-msgstr ""
-
-#: sat/memory/params.py:1011
-#, fuzzy, python-format
-msgid "Setting parameter (%(category)s, %(name)s) = %(value)s"
-msgstr "Demande d'un paramètre inconnu: (%(category)s/%(name)s)"
-
-#: sat/memory/params.py:1043
-msgid "Trying to encrypt a password while the personal key is undefined!"
-msgstr ""
-
-#: sat/memory/persistent.py:45
-msgid "PersistentDict can't be used before memory initialisation"
-msgstr ""
-
-#: sat/memory/persistent.py:175
-msgid "Calling load on LazyPersistentBinaryDict while it's not needed"
-msgstr ""
-
-#: sat/memory/sqlite.py:163
-msgid ""
-"too many db tries, we abandon! Error message: {msg}\n"
-"query was {query}"
-msgstr ""
-
-#: sat/memory/sqlite.py:166
-msgid "exception while running query, retrying ({try_}): {msg}"
-msgstr ""
-
-#: sat/memory/sqlite.py:188
-msgid ""
-"too many interaction tries, we abandon! Error message: {msg}\n"
-"interaction method was: {interaction}\n"
-"interaction arguments were: {args}"
-msgstr ""
-
-#: sat/memory/sqlite.py:191
-msgid "exception while running interaction, retrying ({try_}): {msg}"
-msgstr ""
-
-#: sat/memory/sqlite.py:210
-msgid "Connecting database"
-msgstr ""
-
-#: sat/memory/sqlite.py:223
-#, fuzzy
-msgid "The database is new, creating the tables"
-msgstr "Ce nom de profile existe déjà"
-
-#: sat/memory/sqlite.py:337
-#, fuzzy, python-format
-msgid "Can't delete profile [%s]"
-msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
-
-#: sat/memory/sqlite.py:354
-#, fuzzy, python-format
-msgid "Profile [%s] deleted"
-msgstr "Aucun profile sélectionné"
-
-#: sat/memory/sqlite.py:370
-#, fuzzy
-msgid "loading general parameters from database"
-msgstr "Impossible de charger les paramètres généraux !"
-
-#: sat/memory/sqlite.py:385
-#, fuzzy
-msgid "loading individual parameters from database"
-msgstr "Impossible de charger les paramètres individuels !"
-
-#: sat/memory/sqlite.py:426
-#, fuzzy, python-format
-msgid "Can't set general parameter (%(category)s/%(name)s) in databasecategory"
-msgstr "Demande d'un paramètre inconnu: (%(category)s/%(name)s)"
-
-#: sat/memory/sqlite.py:439
-#, fuzzy, python-format
-msgid ""
-"Can't set individual parameter (%(category)s/%(name)s) for [%(profile)s] "
-"in databasecategory"
-msgstr ""
-"Impossible de déterminer la valeur par défaut pour "
-"[%(category)s/%(name)s]: %(reason)s"
-
-#: sat/memory/sqlite.py:459
-msgid "Can't save following {key} in history (uid: {uid}, lang:{lang}): {value}"
-msgstr ""
-
-#: sat/memory/sqlite.py:473
-msgid ""
-"Can't save following thread in history (uid: {uid}): thread: {thread}), "
-"parent:{parent}"
-msgstr ""
-
-#: sat/memory/sqlite.py:498
-msgid ""
-"Can't save following message in history: from [{from_jid}] to [{to_jid}] "
-"(uid: {uid})"
-msgstr ""
-
-#: sat/memory/sqlite.py:701
-msgid ""
-"Can't {operation} data in database for namespace "
-"{namespace}{and_key}{for_profile}: {msg}"
-msgstr ""
-
-#: sat/memory/sqlite.py:752
-msgid ""
-"getting {type}{binary} private values from database for namespace "
-"{namespace}{keys}"
-msgstr ""
-
-#: sat/memory/sqlite.py:986
-msgid "Can't save file metadata for [{profile}]: {reason}"
-msgstr ""
-
-#: sat/memory/sqlite.py:1025
-msgid "table not updated, probably due to race condition, trying again ({tries})"
-msgstr ""
-
-#: sat/memory/sqlite.py:1027
-msgid "Can't update file table"
-msgstr ""
-
-#: sat/memory/sqlite.py:1132
-msgid ""
-"Your local schema is up-to-date, but database versions mismatch, fixing "
-"it..."
-msgstr ""
-
-#: sat/memory/sqlite.py:1142
-msgid ""
-"There is a schema mismatch, but as we are on a dev version, database will"
-" be updated"
-msgstr ""
-
-#: sat/memory/sqlite.py:1146
-msgid ""
-"schema version is up-to-date, but local schema differ from expected "
-"current schema"
-msgstr ""
-
-#: sat/memory/sqlite.py:1149
-#, python-format
-msgid ""
-"Here are the commands that should fix the situation, use at your own risk"
-" (do a backup before modifying database), you can go to SàT's MUC room at"
-" sat@chat.jabberfr.org for help\n"
-"### SQL###\n"
-"%s\n"
-"### END SQL ###\n"
-msgstr ""
-
-#: sat/memory/sqlite.py:1153
-msgid ""
-"You database version is higher than the one used in this SàT version, are"
-" you using several version at the same time? We can't run SàT with this "
-"database."
-msgstr ""
-
-#: sat/memory/sqlite.py:1161
-msgid ""
-"Database content needs a specific processing, local database will be "
-"updated"
-msgstr ""
-
-#: sat/memory/sqlite.py:1163
-msgid "Database schema has changed, local database will be updated"
-msgstr ""
-
-#: sat/plugins/plugin_adhoc_dbus.py:91
-#, fuzzy
-msgid "Add D-Bus management to Ad-Hoc commands"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_adhoc_dbus.py:98
-#, fuzzy
-msgid "plugin Ad-Hoc D-Bus initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_adhoc_dbus.py:127
-msgid "Media Players"
-msgstr ""
-
-#: sat/plugins/plugin_adhoc_dbus.py:255
-#, fuzzy
-msgid "Command selection"
-msgstr "Sélection du contrat"
-
-#: sat/plugins/plugin_adhoc_dbus.py:298
-#, fuzzy
-msgid "Updated"
-msgstr "mise à jour de %s"
-
-#: sat/plugins/plugin_adhoc_dbus.py:302
-msgid "Command sent"
-msgstr ""
-
-#: sat/plugins/plugin_adhoc_dbus.py:367
-msgid "Can't retrieve remote controllers on {device_jid}: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_adhoc_dbus.py:405
-#, fuzzy
-msgid "No media player found."
-msgstr "Aucune donnée trouvée"
-
-#: sat/plugins/plugin_adhoc_dbus.py:409 sat/plugins/plugin_adhoc_dbus.py:451
-#, fuzzy
-msgid "Media Player Selection"
-msgstr "Sélection du contrat"
-
-#: sat/plugins/plugin_adhoc_dbus.py:414
-msgid "Ignoring MPRIS bus without suffix"
-msgstr ""
-
-#: sat/plugins/plugin_adhoc_dbus.py:428
-msgid "missing media_player value"
-msgstr ""
-
-#: sat/plugins/plugin_adhoc_dbus.py:431
-msgid ""
-"Media player ad-hoc command trying to use non MPRIS bus. Hack attempt? "
-"Refused bus: {bus_name}"
-msgstr ""
-
-#: sat/plugins/plugin_adhoc_dbus.py:434
-msgid "Invalid player name."
-msgstr ""
-
-#: sat/plugins/plugin_adhoc_dbus.py:440
-msgid "Can't get D-Bus proxy: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_adhoc_dbus.py:441
-msgid "Media player is not available anymore"
-msgstr ""
-
-#: sat/plugins/plugin_adhoc_dbus.py:460
-msgid "Can't retrieve attribute {name}: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_blog_import.py:45
-msgid ""
-"Blog import management:\n"
-"This plugin manage the different blog importers which can register to it,"
-" and handle generic importing tasks."
-msgstr ""
-
-#: sat/plugins/plugin_blog_import.py:64
-#, fuzzy
-msgid "plugin Blog Import initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_blog_import_dokuwiki.py:58
-msgid "Blog importer for Dokuwiki blog engine."
-msgstr ""
-
-#: sat/plugins/plugin_blog_import_dokuwiki.py:61
-msgid "import posts from Dokuwiki blog engine"
-msgstr ""
-
-#: sat/plugins/plugin_blog_import_dokuwiki.py:63
-msgid ""
-"This importer handle Dokuwiki blog engine.\n"
-"\n"
-"To use it, you need an admin access to a running Dokuwiki website\n"
-"(local or on the Internet). The importer retrieves the data using\n"
-"the XMLRPC Dokuwiki API.\n"
-"\n"
-"You can specify a namespace (that could be a namespace directory\n"
-"or a single post) or leave it empty to use the root namespace \"/\"\n"
-"and import all the posts.\n"
-"\n"
-"You can specify a new media repository to modify the internal\n"
-"media links and make them point to the URL of your choice, but\n"
-"note that the upload is not done automatically: a temporary\n"
-"directory will be created on your local drive and you will\n"
-"need to upload it yourself to your repository via SSH or FTP.\n"
-"\n"
-"Following options are recognized:\n"
-"\n"
-"location: DokuWiki site URL\n"
-"user: DokuWiki admin user\n"
-"passwd: DokuWiki admin password\n"
-"namespace: DokuWiki namespace to import (default: root namespace \"/\")\n"
-"media_repo: URL to the new remote media repository (default: none)\n"
-"limit: maximal number of posts to import (default: 100)\n"
-"\n"
-"Example of usage (with jp frontend):\n"
-"\n"
-"jp import dokuwiki -p dave --pwd xxxxxx --connect\n"
-"    http://127.0.1.1 -o user souliane -o passwd qwertz\n"
-"    -o namespace public:2015:10\n"
-"    -o media_repo http://media.diekulturvermittlung.at\n"
-"\n"
-"This retrieves the 100 last blog posts from http://127.0.1.1 that\n"
-"are inside the namespace \"public:2015:10\" using the Dokuwiki user\n"
-"\"souliane\", and it imports them to sat profile dave's microblog node.\n"
-"Internal Dokuwiki media that were hosted on http://127.0.1.1 are now\n"
-"pointing to http://media.diekulturvermittlung.at.\n"
-msgstr ""
-
-#: sat/plugins/plugin_blog_import_dokuwiki.py:351
-#, fuzzy
-msgid "plugin Dokuwiki Import initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_blog_import_dokuwiki.py:383
-msgid ""
-"DokuWiki media files will be *downloaded* to {temp_dir} - to finish the "
-"import you have to upload them *manually* to {media_repo}"
-msgstr ""
-
-#: sat/plugins/plugin_blog_import_dokuwiki.py:389
-msgid ""
-"DokuWiki media files will be *uploaded* to the XMPP server. Hyperlinks to"
-" these media may not been updated though."
-msgstr ""
-
-#: sat/plugins/plugin_blog_import_dokuwiki.py:393
-msgid ""
-"DokuWiki media files will *stay* on {location} - some of them may be "
-"protected by DokuWiki ACL and will not be accessible."
-msgstr ""
-
-#: sat/plugins/plugin_blog_import_dotclear.py:42
-msgid "Blog importer for Dotclear blog engine."
-msgstr ""
-
-#: sat/plugins/plugin_blog_import_dotclear.py:45
-msgid "import posts from Dotclear blog engine"
-msgstr ""
-
-#: sat/plugins/plugin_blog_import_dotclear.py:47
-msgid ""
-"This importer handle Dotclear blog engine.\n"
-"\n"
-"To use it, you'll need to export your blog to a flat file.\n"
-"You must go in your admin interface and select Plugins/Maintenance then "
-"Backup.\n"
-"Export only one blog if you have many, i.e. select \"Download database of"
-" current blog\"\n"
-"Depending on your configuration, your may need to use Import/Export "
-"plugin and export as a flat file.\n"
-"\n"
-"location: you must use the absolute path to your backup for the location "
-"parameter\n"
-msgstr ""
-
-#: sat/plugins/plugin_blog_import_dotclear.py:266
-#, fuzzy
-msgid "plugin Dotclear Import initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_comp_file_sharing.py:69
-msgid "Component hosting and sharing files"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing.py:79
-msgid ""
-"You are over quota, your maximum allowed size is {quota} and you are "
-"already using {used_space}, you can't upload {file_size} more."
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing.py:350
-#, fuzzy
-msgid "File Sharing initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_comp_file_sharing.py:431
-#: sat/plugins/plugin_comp_file_sharing_management.py:422
-msgid "Can't create thumbnail: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing.py:454
-msgid "Reusing already generated hash"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing.py:485
-msgid "Can't get thumbnail for {final_path}: {e}"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing.py:574
-msgid "{peer_jid} is trying to access an unauthorized file: {name}"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing.py:582
-msgid "no matching file found ({file_data})"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:43
-msgid ""
-"Experimental handling of file management for file sharing. This plugins "
-"allows to change permissions of stored files/directories or remove them."
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:72
-#, fuzzy
-msgid "File Sharing Management plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:185
-#, fuzzy
-msgid "file not found"
-msgstr "Aucun profile sélectionné"
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:187
-#: sat/plugins/plugin_comp_file_sharing_management.py:192
-#: sat/plugins/plugin_comp_file_sharing_management.py:474
-msgid "forbidden"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:191
-msgid "Only owner can manage files"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:258
-msgid "Please select permissions for this directory"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:260
-msgid "Please select permissions for this file"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:305
-msgid "Can't use read_allowed values: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:332
-msgid "management session done"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:358
-msgid ""
-"Are you sure to delete directory {name} and all files and directories "
-"under it?"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:362
-#, fuzzy, python-format
-msgid "Are you sure to delete file {name}?"
-msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:387
-#, fuzzy, python-format
-msgid "file deleted"
-msgstr "Aucun profile sélectionné"
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:465
-msgid "thumbnails generated"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:481
-msgid "You are currently using {size_used} on {size_quota}"
-msgstr ""
-
-#: sat/plugins/plugin_comp_file_sharing_management.py:484
-msgid "unlimited quota"
-msgstr ""
-
-#: sat/plugins/plugin_dbg_manhole.py:39
-msgid "Debug plugin to have a telnet server"
-msgstr ""
-
-#: sat/plugins/plugin_dbg_manhole.py:53
-msgid ""
-"/!\\ Manhole debug server activated, be sure to not use it in production,"
-" this is dangerous /!\\"
-msgstr ""
-
-#: sat/plugins/plugin_dbg_manhole.py:55
-msgid "You can connect to manhole server using telnet on port {port}"
-msgstr ""
-
-#: sat/plugins/plugin_exp_command_export.py:39
-#, fuzzy
-msgid "Implementation of command export"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_exp_command_export.py:92
-#, fuzzy
-msgid "Plugin command export initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_exp_events.py:50
-msgid "Experimental implementation of XMPP events management"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:60
-#, fuzzy
-msgid "Event plugin initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_exp_events.py:177
-msgid "no src found for image"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:187
-#, fuzzy
-msgid "no {uri_type} element found!"
-msgstr "Aucun profile sélectionné"
-
-#: sat/plugins/plugin_exp_events.py:189
-msgid "incomplete {uri_type} element"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:191
-msgid "bad {uri_type} element"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:231
-#, fuzzy
-msgid "No event element has been found"
-msgstr "Aucune donnée trouvée"
-
-#: sat/plugins/plugin_exp_events.py:233
-msgid "No event with this id has been found"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:290
-msgid "event_id must be set"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:333
-msgid "The given URI is not valid: {uri}"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:354
-#: sat/plugins/plugin_exp_list_of_interest.py:100
-#, fuzzy
-msgid "requested node already exists"
-msgstr "Ce nom de profile existe déjà"
-
-#: sat/plugins/plugin_exp_events.py:373
-msgid "missing node"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:426
-msgid "No event found in item {item_id}, ignoring"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:519
-msgid "no data found for {item_id} (service: {service}, node: {node})"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:542 sat/plugins/plugin_exp_events.py:623
-msgid "\"XEP-0277\" (blog) plugin is needed for this feature"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:548
-msgid "got event data"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:579
-msgid "affiliation set on blog and comments nodes"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:619
-msgid "\"Invitations\" plugin is needed for this feature"
-msgstr ""
-
-#: sat/plugins/plugin_exp_events.py:632
-#, fuzzy
-msgid "invitation created"
-msgstr "Connexion..."
-
-#: sat/plugins/plugin_exp_invitation.py:44
-#, fuzzy
-msgid "Experimental handling of invitations"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_exp_invitation.py:57
-#, fuzzy
-msgid "Invitation plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_exp_invitation.py:251
-msgid "Can't get item linked with invitation: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_exp_invitation.py:256
-msgid "Invitation was linking to a non existing item"
-msgstr ""
-
-#: sat/plugins/plugin_exp_invitation.py:262
-msgid "Can't retrieve namespace of invitation: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_exp_invitation.py:281
-msgid "Bad invitation, ignoring"
-msgstr ""
-
-#: sat/plugins/plugin_exp_invitation.py:321
-msgid "No handler for namespace \"{namespace}\", invitation ignored"
-msgstr ""
-
-#: sat/plugins/plugin_exp_invitation_file.py:39
-msgid "Experimental handling of invitations for file sharing"
-msgstr ""
-
-#: sat/plugins/plugin_exp_invitation_file.py:46
-#, fuzzy
-msgid "File Sharing Invitation plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_exp_invitation_file.py:85
-#: sat/plugins/plugin_exp_invitation_file.py:92
-msgid "file sharing"
-msgstr ""
-
-#: sat/plugins/plugin_exp_invitation_file.py:87
-msgid "photo album"
-msgstr ""
-
-#: sat/plugins/plugin_exp_invitation_file.py:93
-msgid ""
-"{profile} has received an invitation for a files repository "
-"({type_human}) with namespace {sharing_ns!r} at path [{path}]"
-msgstr ""
-
-#: sat/plugins/plugin_exp_invitation_pubsub.py:42
-msgid "Invitations for pubsub based features"
-msgstr ""
-
-#: sat/plugins/plugin_exp_invitation_pubsub.py:49
-#, fuzzy
-msgid "Pubsub Invitation plugin initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_exp_jingle_stream.py:52
-msgid "Jingle Stream plugin"
-msgstr ""
-
-#: sat/plugins/plugin_exp_jingle_stream.py:55
-#, fuzzy, python-format
-msgid "{peer} wants to send you a stream, do you accept ?"
-msgstr ""
-"Le contact %(jid)s veut vous envoyer le fichier %(filename)s\n"
-"Êtes vous d'accord ?"
-
-#: sat/plugins/plugin_exp_jingle_stream.py:56
-#, fuzzy
-msgid "Stream Request"
-msgstr "Gestion des paramètres"
-
-#: sat/plugins/plugin_exp_jingle_stream.py:123
-msgid "stream can't be used with multiple consumers"
-msgstr ""
-
-#: sat/plugins/plugin_exp_jingle_stream.py:170
-#, fuzzy
-msgid "No client connected, can't send data"
-msgstr "Connexion du client SOCKS 5 démarrée"
-
-#: sat/plugins/plugin_exp_jingle_stream.py:180
-#, fuzzy
-msgid "Plugin Stream initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_exp_jingle_stream.py:270
-msgid "given port is invalid"
-msgstr ""
-
-#: sat/plugins/plugin_exp_lang_detect.py:45
-msgid "Detect and set message language when unknown"
-msgstr ""
-
-#: sat/plugins/plugin_exp_lang_detect.py:48
-#: sat/plugins/plugin_misc_watched.py:43 sat/plugins/plugin_xep_0249.py:73
-msgid "Misc"
-msgstr "Divers"
-
-#: sat/plugins/plugin_exp_lang_detect.py:50
-msgid "language detection"
-msgstr ""
-
-#: sat/plugins/plugin_exp_lang_detect.py:66
-#, fuzzy
-msgid "Language detection plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_exp_list_of_interest.py:46
-msgid "Experimental handling of interesting XMPP locations"
-msgstr ""
-
-#: sat/plugins/plugin_exp_list_of_interest.py:56
-#, fuzzy
-msgid "List of Interest plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_exp_list_of_interest.py:287
-msgid "Missing interest element: {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_exp_parrot.py:40
-msgid "Implementation of parrot mode (repeat messages between 2 entities)"
-msgstr ""
-
-#: sat/plugins/plugin_exp_parrot.py:56
-#, fuzzy
-msgid "Plugin Parrot initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_exp_parrot.py:63 sat/plugins/plugin_xep_0045.py:150
-#: sat/plugins/plugin_xep_0048.py:102 sat/plugins/plugin_xep_0092.py:61
-#: sat/plugins/plugin_xep_0199.py:56 sat/plugins/plugin_xep_0249.py:95
-#: sat/plugins/plugin_xep_0384.py:476
-msgid "Text commands not available"
-msgstr ""
-
-#: sat/plugins/plugin_exp_pubsub_admin.py:40
-msgid ""
-"\\Implementation of Pubsub Administrator\n"
-"This allows a pubsub administrator to overwrite completly items, "
-"including publisher.\n"
-"Specially useful when importing a node."
-msgstr ""
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:40
-msgid "Experimental plugin to launch on action on Pubsub notifications"
-msgstr ""
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:56
-#, fuzzy
-msgid "PubSub Hook initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:93
-msgid "node manager already set for {node}"
-msgstr ""
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:101
-msgid "node manager installed on {node}"
-msgstr ""
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:107
-#, fuzzy
-msgid "trying to remove a {node} without hook"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:112
-msgid "hook removed"
-msgstr ""
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:114
-msgid "node still needed for an other hook"
-msgstr ""
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:119
-msgid "{hook_type} is not handled"
-msgstr ""
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:123
-#: sat/plugins/plugin_exp_pubsub_hook.py:167
-msgid "{hook_type} hook type not implemented yet"
-msgstr ""
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:139
-msgid "{persistent} hook installed on {node} for {profile}"
-msgstr ""
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:140
-msgid "persistent"
-msgstr ""
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:140
-msgid "temporary"
-msgstr ""
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:173
-msgid "Can't load Pubsub hook at node {node}, it will be removed: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_exp_pubsub_hook.py:185
-msgid "Error while running Pubsub hook for node {node}: {msg}"
-msgstr ""
-
-#: sat/plugins/plugin_import.py:41
-msgid "Generic import plugin, base for specialized importers"
-msgstr ""
-
-#: sat/plugins/plugin_import.py:49
-#, fuzzy
-msgid "plugin Import initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_import.py:67
-msgid "initializing {name} import handler"
-msgstr ""
-
-#: sat/plugins/plugin_import.py:158
-msgid "invalid json option: {option}"
-msgstr ""
-
-#: sat/plugins/plugin_import.py:296
-msgid "uploading subitems"
-msgstr ""
-
-#: sat/plugins/plugin_import.py:327
-#, fuzzy
-msgid "An {handler_name} importer with the name {name} already exist"
-msgstr "Ce nom de profile existe déjà"
-
-#: sat/plugins/plugin_merge_req_mercurial.py:37
-msgid "Merge request handler for Mercurial"
-msgstr ""
-
-#: sat/plugins/plugin_merge_req_mercurial.py:40
-msgid "handle Mercurial repository"
-msgstr ""
-
-#: sat/plugins/plugin_merge_req_mercurial.py:71
-msgid "Mercurial merge request handler initialization"
-msgstr ""
-
-#: sat/plugins/plugin_merge_req_mercurial.py:75
-msgid "Mercurial executable (hg) not found, can't use Mercurial handler"
-msgstr ""
-
-#: sat/plugins/plugin_merge_req_mercurial.py:116
-msgid "invalid changeset signature"
-msgstr ""
-
-#: sat/plugins/plugin_merge_req_mercurial.py:136
-msgid "unexpected time data: {data}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:50
-msgid "Libervia account creation"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:75
-msgid ""
-"Welcome to Libervia, the web interface of Salut à Toi.\n"
-"\n"
-"Your account on {domain} has been successfully created.\n"
-"This is a demonstration version to show you the current status of the "
-"project.\n"
-"It is still under development, please keep it in mind!\n"
-"\n"
-"Here is your connection information:\n"
-"\n"
-"Login on {domain}: {profile}\n"
-"Jabber ID (JID): {jid}\n"
-"Your password has been chosen by yourself during registration.\n"
-"\n"
-"In the beginning, you have nobody to talk to. To find some contacts, you "
-"may use the users' directory:\n"
-"    - make yourself visible in \"Service / Directory subscription\".\n"
-"    - search for people with \"Contacts\" / Search directory\".\n"
-"\n"
-"Any feedback welcome. Thank you!\n"
-"\n"
-"Salut à Toi association\n"
-"https://www.salut-a-toi.org\n"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:109
-#, fuzzy
-msgid "Plugin Account initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_account.py:294
-msgid "Failed to send account creation confirmation to {email}: {msg}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:313
-msgid "New Libervia account created"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:339
-msgid "Your Libervia account has been created"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:363
-msgid "xmpp_domain needs to be set in sat.conf. Using \"{default}\" meanwhile"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:379
-msgid "Manage your account"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:385
-#, fuzzy
-msgid "Change your password"
-msgstr "Sauvegarde du nouveau mot de passe"
-
-#: sat/plugins/plugin_misc_account.py:387
-msgid "Current profile password"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:389
-#, fuzzy
-msgid "New password"
-msgstr "Sauvegarde du nouveau mot de passe"
-
-#: sat/plugins/plugin_misc_account.py:391
-#, fuzzy
-msgid "New password (again)"
-msgstr "Sauvegarde du nouveau mot de passe"
-
-#: sat/plugins/plugin_misc_account.py:431 sat/stdui/ui_profile_manager.py:73
-#, fuzzy
-msgid "The provided profile password doesn't match."
-msgstr "Le fichier [%s] n'existe pas !"
-
-#: sat/plugins/plugin_misc_account.py:432
-msgid "Attempt failure"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:477
-msgid "The values entered for the new password are not equal."
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:495
-#, fuzzy
-msgid "Change your password?"
-msgstr "Sauvegarde du nouveau mot de passe"
-
-#: sat/plugins/plugin_misc_account.py:500
-msgid ""
-"Note for advanced users: this will actually change both your SàT profile "
-"password AND your XMPP account password."
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:504
-msgid "Continue with changing the password?"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:528
-#: sat/plugins/plugin_misc_register_account.py:133
-#, fuzzy
-msgid "Confirmation"
-msgstr "Connexion..."
-
-#: sat/plugins/plugin_misc_account.py:529
-msgid "Your password has been changed."
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:533
-#: sat/plugins/plugin_misc_account.py:606
-#: sat/plugins/plugin_misc_account.py:716 sat_frontends/primitivus/base.py:790
-#: sat_frontends/primitivus/base.py:831
-#: sat_frontends/quick_frontend/quick_profile_manager.py:133
-msgid "Error"
-msgstr "Erreur"
-
-#: sat/plugins/plugin_misc_account.py:535
-#, python-format
-msgid "Your password could not be changed: %s"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:548
-#, fuzzy
-msgid "Delete your account?"
-msgstr "Enregistrement d'un nouveau compte"
-
-#: sat/plugins/plugin_misc_account.py:551
-msgid ""
-"If you confirm this dialog, you will be disconnected and then your XMPP "
-"account AND your SàT profile will both be DELETED."
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:555
-msgid "contact list, messages history, blog posts and commentsGROUPBLOG"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:558
-msgid "contact list and messages history"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:561
-#, python-format
-msgid ""
-"All your data stored on %(server)s, including your %(target)s will be "
-"erased."
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:567
-#: sat/plugins/plugin_misc_account.py:642
-#: sat/plugins/plugin_misc_account.py:658
-#: sat/plugins/plugin_misc_account.py:674
-msgid ""
-"There is no other confirmation dialog, this is the very last one! Are you"
-" sure?"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:608
-#, python-format
-msgid "Your XMPP account could not be deleted: %s"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:628
-msgid "Delete all your (micro-)blog posts and comments?"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:632
-msgid ""
-"If you confirm this dialog, all the (micro-)blog data you submitted will "
-"be erased."
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:637
-msgid "These are the public and private posts and comments you sent to any group."
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:649
-msgid "Delete all your (micro-)blog posts?"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:653
-msgid ""
-"If you confirm this dialog, all the public and private posts you sent to "
-"any group will be erased."
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:665
-msgid "Delete all your (micro-)blog comments?"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:669
-msgid ""
-"If you confirm this dialog, all the public and private comments you made "
-"on other people's posts will be erased."
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:689
-msgid "blog posts and comments"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:694
-msgid "blog posts"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:699
-msgid "comments"
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:705
-#, fuzzy
-msgid "Deletion confirmation"
-msgstr "désinscription confirmée pour [%s]"
-
-#: sat/plugins/plugin_misc_account.py:707
-#, python-format
-msgid "Your %(target)s have been deleted."
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:709
-msgid ""
-"Known issue of the demo version: you need to refresh the page to make the"
-" deleted posts actually disappear."
-msgstr ""
-
-#: sat/plugins/plugin_misc_account.py:718
-#, python-format
-msgid "Your %(target)s could not be deleted: %(message)s"
-msgstr ""
-
-#: sat/plugins/plugin_misc_android.py:50
-msgid "Manage Android platform specificities, like pause or notifications"
-msgstr ""
-
-#: sat/plugins/plugin_misc_android.py:92
-msgid "sound on notifications"
-msgstr ""
-
-#: sat/plugins/plugin_misc_android.py:94
-#, fuzzy
-msgid "Normal"
-msgstr "Général"
-
-#: sat/plugins/plugin_misc_android.py:95 sat/plugins/plugin_misc_android.py:103
-msgid "Never"
-msgstr ""
-
-#: sat/plugins/plugin_misc_android.py:99
-msgid "Vibrate on notifications"
-msgstr ""
-
-#: sat/plugins/plugin_misc_android.py:101
-#, fuzzy
-msgid "Always"
-msgstr "Chercher les transports"
-
-#: sat/plugins/plugin_misc_android.py:102
-msgid "In vibrate mode"
-msgstr ""
-
-#: sat/plugins/plugin_misc_android.py:243
-#, fuzzy
-msgid "plugin Android initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_android.py:362
-#, fuzzy
-msgid "new message from {contact}"
-msgstr "Attend qu'un fichier soit envoyé par un contact"
-
-#: sat/plugins/plugin_misc_app_manager.py:64
-msgid ""
-"Applications Manager\n"
-"\n"
-"Manage external applications using packagers, OS "
-"virtualization/containers or other\n"
-"software management tools.\n"
-msgstr ""
-
-#: sat/plugins/plugin_misc_app_manager.py:80
-#, fuzzy
-msgid "plugin Applications Manager initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_app_manager.py:166
-msgid ""
-"No value found for \"public_url\", using \"example.org\" for now, please "
-"set the proper value in libervia.conf"
-msgstr ""
-
-#: sat/plugins/plugin_misc_app_manager.py:170
-msgid ""
-"invalid value for \"public_url\" ({value}), it musts not start with "
-"schema (\"http\"), ignoring it and using \"example.org\" instead"
-msgstr ""
-
-#: sat/plugins/plugin_misc_attach.py:43
-msgid "Attachments handler"
-msgstr ""
-
-#: sat/plugins/plugin_misc_attach.py:53
-#, fuzzy
-msgid "plugin Attach initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_attach.py:109
-msgid "Can't resize attachment of unknown type: {attachment}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_attach.py:125
-msgid "Attachment {path!r} has been resized at {new_path!r}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_attach.py:129
-msgid "Can't resize attachment of type {main_type!r}: {attachment}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_attach.py:143
-msgid "No plugin can handle attachment with {destinee}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_attach.py:210
-msgid "certificate check disabled for upload, this is dangerous!"
-msgstr ""
-
-#: sat/plugins/plugin_misc_debug.py:35
-msgid "Set of method to make development and debugging easier"
-msgstr ""
-
-#: sat/plugins/plugin_misc_debug.py:41
-#, fuzzy
-msgid "Plugin Debug initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_download.py:43
-msgid "File download management"
-msgstr ""
-
-#: sat/plugins/plugin_misc_download.py:50
-#, fuzzy
-msgid "plugin Download initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_download.py:95
-msgid "Can't download file: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_download.py:99 sat_frontends/jp/cmd_file.py:498
-#, fuzzy
-msgid "Can't download file"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat/plugins/plugin_misc_download.py:176
-msgid "certificate check disabled for download, this is dangerous!"
-msgstr ""
-
-#: sat/plugins/plugin_misc_download.py:187
-msgid "Can't download URI {uri}: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:44
-msgid "invitation of people without XMPP account"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:59
-msgid "You have been invited by {host_name} to {app_name}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:60
-msgid ""
-"Hello {name}!\n"
-"\n"
-"You have received an invitation from {host_name} to participate to "
-"\"{app_name}\".\n"
-"To join, you just have to click on the following URL:\n"
-"{url}\n"
-"\n"
-"Please note that this URL should not be shared with anybody!\n"
-"If you want more details on {app_name}, you can check {app_url}.\n"
-"\n"
-"Welcome!\n"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:76
-#, fuzzy
-msgid "plugin Invitations initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_email_invitation.py:105
-msgid "You can't use following key(s) in extra, they are reserved: {}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:198
-msgid "You can't use following key(s) in both args and extra: {}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:207
-msgid "You need to provide a main email address before using emails_extra"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:213
-msgid "You need to provide url_template if you use default message body"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:216
-#, fuzzy
-msgid "creating an invitation"
-msgstr "Connexion..."
-
-#: sat/plugins/plugin_misc_email_invitation.py:237
-msgid "You need to specify xmpp_domain in sat.conf"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:251
-msgid "Can't create XMPP account"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:254
-msgid "requested jid already exists, trying with {}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:265
-msgid "account {jid_} created"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:317
-msgid "somebody"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:345
-msgid "Not all arguments have been consumed: {}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_email_invitation.py:443
-msgid "Skipping reserved key {key}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_extra_pep.py:38
-msgid "Display messages from extra PEP services"
-msgstr ""
-
-#: sat/plugins/plugin_misc_extra_pep.py:69
-#, fuzzy
-msgid "Plugin Extra PEP initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_file.py:45
-msgid ""
-"File Tansfer Management:\n"
-"This plugin manage the various ways of sending a file, and choose the "
-"best one."
-msgstr ""
-
-#: sat/plugins/plugin_misc_file.py:52
-msgid "Please select a file to send to {peer}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_file.py:53
-msgid "File sending"
-msgstr ""
-
-#: sat/plugins/plugin_misc_file.py:54
-msgid ""
-"{peer} wants to send the file \"{name}\" to you:\n"
-"{desc}\n"
-"\n"
-"The file has a size of {size_human}\n"
-"\n"
-"Do you accept ?"
-msgstr ""
-
-#: sat/plugins/plugin_misc_file.py:58
-#, fuzzy
-msgid "Confirm file transfer"
-msgstr "Transfert de fichier"
-
-#: sat/plugins/plugin_misc_file.py:59
-msgid "File {} already exists, are you sure you want to overwrite ?"
-msgstr ""
-
-#: sat/plugins/plugin_misc_file.py:60
-#, fuzzy
-msgid "File exists"
-msgstr "Aucun profile sélectionné"
-
-#: sat/plugins/plugin_misc_file.py:70
-#, fuzzy
-msgid "plugin File initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_file.py:82
-#, fuzzy
-msgid "Action"
-msgstr "Connexion..."
-
-#: sat/plugins/plugin_misc_file.py:82
-#, fuzzy
-msgid "send file"
-msgstr "Envoi un fichier"
-
-#: sat/plugins/plugin_misc_file.py:85
-#, fuzzy
-msgid "Send a file"
-msgstr "Envoi un fichier"
-
-#: sat/plugins/plugin_misc_file.py:121
-msgid "{name} method will be used to send the file"
-msgstr ""
-
-#: sat/plugins/plugin_misc_file.py:132
-msgid "Can't send {filepath} to {peer_jid} with {method_name}: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_file.py:166 sat/plugins/plugin_xep_0100.py:101
-msgid "Invalid JID"
-msgstr ""
-
-#: sat/plugins/plugin_misc_forums.py:36
-#, fuzzy
-msgid "forums management"
-msgstr "Initialisation du gestionnaire de mémoire"
-
-#: sat/plugins/plugin_misc_forums.py:43
-msgid "forums management plugin"
-msgstr ""
-
-#: sat/plugins/plugin_misc_forums.py:54
-#, fuzzy
-msgid "forums plugin initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_forums.py:97
-msgid "forums arguments must be a list of forums"
-msgstr ""
-
-#: sat/plugins/plugin_misc_forums.py:109
-msgid "A forum item must be a dictionary"
-msgstr ""
-
-#: sat/plugins/plugin_misc_forums.py:114
-msgid "following forum name is not unique: {name}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_forums.py:116
-msgid "creating missing forum node"
-msgstr ""
-
-#: sat/plugins/plugin_misc_forums.py:130
-msgid "Unknown forum attribute: {key}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_forums.py:136
-msgid "forum need a title or a name"
-msgstr ""
-
-#: sat/plugins/plugin_misc_forums.py:138
-msgid "forum need uri or sub-forums"
-msgstr ""
-
-#: sat/plugins/plugin_misc_forums.py:154
-msgid "missing <forums> element"
-msgstr ""
-
-#: sat/plugins/plugin_misc_forums.py:160
-msgid "Unexpected element: {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_forums.py:168
-msgid "Following attributes are unknown: {unknown}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_forums.py:176
-msgid "invalid forum, ignoring: {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_forums.py:180
-msgid "unkown forums sub element: {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_groupblog.py:53
-#, fuzzy
-msgid "Implementation of microblogging fine permissions"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_misc_groupblog.py:61
-#, fuzzy
-msgid "Group blog plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_misc_groupblog.py:80
-msgid "Server is not able to manage item-access pubsub, we can't use group blog"
-msgstr ""
-
-#: sat/plugins/plugin_misc_groupblog.py:86
-msgid "Server can manage group blogs"
-msgstr ""
-
-#: sat/plugins/plugin_misc_identity.py:49
-msgid "Identity manager"
-msgstr ""
-
-#: sat/plugins/plugin_misc_identity.py:58
-#, fuzzy
-msgid "Plugin Identity initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_identity.py:293
-#: sat/plugins/plugin_misc_identity.py:365
-msgid "No callback registered for {metadata_name}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_identity.py:316
-msgid "Error while trying to get {metadata_name} with {callback}: {e}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_identity.py:376
-msgid "Error while trying to set {metadata_name} with {callback}: {e}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_identity.py:691
-msgid "Can't set metadata {metadata_name!r}: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_ip.py:57
-msgid "This plugin help to discover our external IP address."
-msgstr ""
-
-#: sat/plugins/plugin_misc_ip.py:64
-msgid "Allow external get IP"
-msgstr ""
-
-#: sat/plugins/plugin_misc_ip.py:67
-msgid "Confirm external site request"
-msgstr ""
-
-#: sat/plugins/plugin_misc_ip.py:68
-msgid ""
-"To facilitate data transfer, we need to contact a website.\n"
-"A request will be done on {page}\n"
-"That means that administrators of {domain} can know that you use "
-"\"{app_name}\" and your IP Address.\n"
-"\n"
-"IP address is an identifier to locate you on Internet (similar to a phone"
-" number).\n"
-"\n"
-"Do you agree to do this request ?\n"
-msgstr ""
-
-#: sat/plugins/plugin_misc_ip.py:100
-#, fuzzy
-msgid "plugin IP discovery initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_lists.py:39
-msgid "Pubsub Lists"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:47
-msgid "Pubsub lists management plugin"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:52
-msgid "TODO List"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:63 sat/plugins/plugin_misc_lists.py:113
-#: sat/plugins/plugin_misc_lists.py:156
-msgid "status"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:67
-msgid "to do"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:71
-#, fuzzy
-msgid "in progress"
-msgstr "Progression: "
-
-#: sat/plugins/plugin_misc_lists.py:75
-#, fuzzy
-msgid "done"
-msgstr "En ligne"
-
-#: sat/plugins/plugin_misc_lists.py:83 sat/plugins/plugin_misc_lists.py:180
-msgid "priority"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:87 sat/plugins/plugin_misc_lists.py:184
-msgid "major"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:91 sat/plugins/plugin_misc_lists.py:188
-#, fuzzy
-msgid "normal"
-msgstr "Général"
-
-#: sat/plugins/plugin_misc_lists.py:95 sat/plugins/plugin_misc_lists.py:192
-msgid "minor"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:106
-msgid "Grocery List"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:109 sat_frontends/jp/cmd_info.py:69
-#: sat_frontends/jp/cmd_info.py:111
-#, fuzzy
-msgid "name"
-msgstr "Jeu"
-
-#: sat/plugins/plugin_misc_lists.py:110
-msgid "quantity"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:117
-msgid "to buy"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:121
-#, fuzzy
-msgid "bought"
-msgstr "À propos"
-
-#: sat/plugins/plugin_misc_lists.py:130
-msgid "Tickets"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:140 sat_frontends/jp/cmd_info.py:69
-msgid "type"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:144
-msgid "bug"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:148
-#, fuzzy
-msgid "feature request"
-msgstr "Gestion des paramètres"
-
-#: sat/plugins/plugin_misc_lists.py:160
-#, fuzzy
-msgid "queued"
-msgstr "refusé"
-
-#: sat/plugins/plugin_misc_lists.py:164
-msgid "started"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:168
-msgid "review"
-msgstr ""
-
-#: sat/plugins/plugin_misc_lists.py:172
-#, fuzzy
-msgid "closed"
-msgstr "fermeture"
-
-#: sat/plugins/plugin_misc_lists.py:208
-#, fuzzy
-msgid "Pubsub lists plugin initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_merge_requests.py:35
-msgid "Merge requests management"
-msgstr ""
-
-#: sat/plugins/plugin_misc_merge_requests.py:42
-msgid "Merge requests management plugin"
-msgstr ""
-
-#: sat/plugins/plugin_misc_merge_requests.py:69
-#, fuzzy
-msgid "Merge requests plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_misc_merge_requests.py:121
-#, fuzzy
-msgid "a handler with name {name} already exists!"
-msgstr "Ce nom de profile existe déjà"
-
-#: sat/plugins/plugin_misc_merge_requests.py:134
-msgid ""
-"merge requests of type {type} are already handled by {old_handler}, "
-"ignoring {new_handler}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_merge_requests.py:241
-msgid "repository must be specified"
-msgstr ""
-
-#: sat/plugins/plugin_misc_merge_requests.py:244
-msgid "{field} is set by backend, you must not set it in frontend"
-msgstr ""
-
-#: sat/plugins/plugin_misc_merge_requests.py:253
-msgid "{name} handler will be used"
-msgstr ""
-
-#: sat/plugins/plugin_misc_merge_requests.py:256
-msgid "repository {path} can't be handled by any installed handler"
-msgstr ""
-
-#: sat/plugins/plugin_misc_merge_requests.py:259
-msgid "no handler for this repository has been found"
-msgstr ""
-
-#: sat/plugins/plugin_misc_merge_requests.py:265
-msgid "No handler of this name found"
-msgstr ""
-
-#: sat/plugins/plugin_misc_merge_requests.py:269
-msgid "export data is empty, do you have any change to send?"
-msgstr ""
-
-#: sat/plugins/plugin_misc_merge_requests.py:312
-msgid "No handler can handle data type \"{type}\""
-msgstr ""
-
-#: sat/plugins/plugin_misc_merge_requests.py:348
-msgid "No handler found to import {data_type}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_merge_requests.py:350
-msgid "Importing patch [{item_id}] using {name} handler"
-msgstr ""
-
-#: sat/plugins/plugin_misc_nat_port.py:45
-msgid "Automatic NAT port mapping using UPnP"
-msgstr ""
-
-#: sat/plugins/plugin_misc_nat_port.py:62
-#, fuzzy
-msgid "plugin NAT Port initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_nat_port.py:177
-msgid "addportmapping error: {msg}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_nat_port.py:215
-msgid "error while trying to map ports: {msg}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_quiz.py:42
-#, fuzzy
-msgid "Implementation of Quiz game"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_misc_quiz.py:55
-#, fuzzy
-msgid "Plugin Quiz initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_quiz.py:345
-msgid ""
-"Bienvenue dans cette partie rapide de quizz, le premier à atteindre le "
-"score de 9 remporte le jeu\n"
-"\n"
-"Attention, tu es prêt ?"
-msgstr ""
-
-#: sat/plugins/plugin_misc_quiz.py:380 sat/plugins/plugin_misc_tarot.py:664
-#, python-format
-msgid "Player %(player)s is ready to start [status: %(status)s]"
-msgstr "Le joueur %(player)s est prêt à commencer [statut: %(status)s]"
-
-#: sat/plugins/plugin_misc_quiz.py:456 sat/plugins/plugin_misc_radiocol.py:353
-#, fuzzy, python-format
-msgid "Unmanaged game element: %s"
-msgstr "élément de jeu de carte inconnu: %s"
-
-#: sat/plugins/plugin_misc_radiocol.py:57
-#, fuzzy
-msgid "Implementation of radio collective"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_misc_radiocol.py:76
-#, fuzzy
-msgid "Radio collective initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_radiocol.py:180
-msgid ""
-"The uploaded file has been rejected, only Ogg Vorbis and MP3 songs are "
-"accepted."
-msgstr ""
-
-#: sat/plugins/plugin_misc_radiocol.py:210
-msgid "No more participants in the radiocol: cleaning data"
-msgstr ""
-
-#: sat/plugins/plugin_misc_radiocol.py:249
-msgid "INTERNAL ERROR: can't find full path of the song to delete"
-msgstr ""
-
-#: sat/plugins/plugin_misc_radiocol.py:258
-#, python-format
-msgid "INTERNAL ERROR: can't find %s on the file system"
-msgstr ""
-
-#: sat/plugins/plugin_misc_register_account.py:41
-#, fuzzy
-msgid "Register XMPP account"
-msgstr "Enregistrement d'un nouveau compte"
-
-#: sat/plugins/plugin_misc_register_account.py:49
-#, fuzzy
-msgid "Plugin Register Account initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_register_account.py:76
-msgid "Missing values"
-msgstr ""
-
-#: sat/plugins/plugin_misc_register_account.py:78
-#, fuzzy
-msgid "No user JID or password given: can't register new account."
-msgstr ""
-"L'utilisateur, le mot de passe ou le serveur n'ont pas été spécifiés, "
-"impossible d'inscrire un nouveau compte."
-
-#: sat/plugins/plugin_misc_register_account.py:87
-msgid "Register new account"
-msgstr "Enregistrement d'un nouveau compte"
-
-#: sat/plugins/plugin_misc_register_account.py:92
-msgid "Do you want to register a new XMPP account {jid}?"
-msgstr ""
-
-#: sat/plugins/plugin_misc_register_account.py:134
-#, fuzzy
-msgid "Registration successful."
-msgstr "Inscription réussie"
-
-#: sat/plugins/plugin_misc_register_account.py:138
-msgid "Failure"
-msgstr ""
-
-#: sat/plugins/plugin_misc_register_account.py:139
-#, fuzzy, python-format
-msgid "Registration failed: %s"
-msgstr "Échec de l'inscription: %s"
-
-#: sat/plugins/plugin_misc_register_account.py:143
-#, fuzzy
-msgid "Username already exists, please choose an other one."
-msgstr "Ce nom d'utilisateur existe déjà, veuillez en choisir un autre"
-
-#: sat/plugins/plugin_misc_room_game.py:49
-msgid "Base class for MUC games"
-msgstr ""
-
-#: sat/plugins/plugin_misc_room_game.py:221
-#, python-format
-msgid "%(user)s not allowed to join the game %(game)s in %(room)s"
-msgstr ""
-
-#: sat/plugins/plugin_misc_room_game.py:380
-#, python-format
-msgid "%(user)s not allowed to invite for the game %(game)s in %(room)s"
-msgstr ""
-
-#: sat/plugins/plugin_misc_room_game.py:433
-#, python-format
-msgid "Still waiting for %(users)s before starting the game %(game)s in %(room)s"
-msgstr ""
-
-#: sat/plugins/plugin_misc_room_game.py:472
-#, python-format
-msgid "Preparing room for %s game"
-msgstr ""
-
-#: sat/plugins/plugin_misc_room_game.py:475
-#, fuzzy
-msgid "Unknown profile"
-msgstr "Afficher profile"
-
-#: sat/plugins/plugin_misc_room_game.py:583
-#, fuzzy, python-format
-msgid "%(game)s game already created in room %(room)s"
-msgstr "%(profile)s est déjà dans le salon %(room_jid)s"
-
-#: sat/plugins/plugin_misc_room_game.py:589
-#, python-format
-msgid "%(game)s game in room %(room)s can only be created by %(user)s"
-msgstr ""
-
-#: sat/plugins/plugin_misc_room_game.py:610
-#, fuzzy, python-format
-msgid "Creating %(game)s game in room %(room)s"
-msgstr "Construction du jeu de Tarot"
-
-#: sat/plugins/plugin_misc_room_game.py:615
-#: sat/plugins/plugin_misc_room_game.py:646
-#: sat/plugins/plugin_misc_tarot.py:581
-#, python-format
-msgid "profile %s is unknown"
-msgstr "le profil %s est inconnu"
-
-#: sat/plugins/plugin_misc_room_game.py:661
-#, python-format
-msgid "new round for %s game"
-msgstr ""
-
-#: sat/plugins/plugin_misc_static_blog.py:44
-msgid "Plugin for static blogs"
-msgstr ""
-
-#: sat/plugins/plugin_misc_static_blog.py:66
-#, fuzzy
-msgid "Page title"
-msgstr "Petite"
-
-#: sat/plugins/plugin_misc_static_blog.py:68
-msgid "Banner URL"
-msgstr ""
-
-#: sat/plugins/plugin_misc_static_blog.py:70
-msgid "Background image URL"
-msgstr ""
-
-#: sat/plugins/plugin_misc_static_blog.py:72
-msgid "Keywords"
-msgstr ""
-
-#: sat/plugins/plugin_misc_static_blog.py:74
-msgid "Description"
-msgstr ""
-
-#: sat/plugins/plugin_misc_static_blog.py:97 sat/plugins/plugin_sec_otr.py:508
-#: sat/plugins/plugin_sec_otr.py:542 sat/plugins/plugin_sec_otr.py:568
-#: sat/plugins/plugin_sec_otr.py:592
-msgid "jid key is not present !"
-msgstr ""
-
-#: sat/plugins/plugin_misc_static_blog.py:102
-msgid "Not available"
-msgstr ""
-
-#: sat/plugins/plugin_misc_static_blog.py:104
-msgid "Retrieving a blog from an external domain is not implemented yet."
-msgstr ""
-
-#: sat/plugins/plugin_misc_tarot.py:47
-#, fuzzy
-msgid "Implementation of Tarot card game"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_misc_tarot.py:60
-#, fuzzy
-msgid "Plugin Tarot initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_tarot.py:78
-msgid "Passe"
-msgstr "Passe"
-
-#: sat/plugins/plugin_misc_tarot.py:79
-msgid "Petite"
-msgstr "Petite"
-
-#: sat/plugins/plugin_misc_tarot.py:80
-msgid "Garde"
-msgstr "Garde"
-
-#: sat/plugins/plugin_misc_tarot.py:81
-msgid "Garde Sans"
-msgstr "Garde Sans"
-
-#: sat/plugins/plugin_misc_tarot.py:82
-msgid "Garde Contre"
-msgstr "Garde Contre"
-
-#: sat/plugins/plugin_misc_tarot.py:171
-msgid "contrat selection"
-msgstr "Sélection du contrat"
-
-#: sat/plugins/plugin_misc_tarot.py:189
-msgid "scores"
-msgstr "points"
-
-#: sat/plugins/plugin_misc_tarot.py:273 sat/plugins/plugin_misc_tarot.py:313
-#, python-format
-msgid ""
-"Player %(excuse_owner)s give %(card_waited)s to %(player_waiting)s for "
-"Excuse compensation"
-msgstr ""
-"Le joueur %(excuse_owner)s donne %(card_waited)s à %(player_waiting)s en "
-"compensation pour l'Excuse"
-
-#: sat/plugins/plugin_misc_tarot.py:327
-#, python-format
-msgid ""
-"%(excuse_owner)s keep the Excuse but has not card to give, %(winner)s is "
-"waiting for one"
-msgstr ""
-"%(excuse_owner)s garde l'Excuse mais n'a aucune carte à donner, "
-"%(winner)s en attend une"
-
-#: sat/plugins/plugin_misc_tarot.py:338
-#: sat_frontends/primitivus/game_tarot.py:309
-msgid "Draw game"
-msgstr ""
-
-#: sat/plugins/plugin_misc_tarot.py:341 sat/plugins/plugin_misc_tarot.py:436
-#, python-format
-msgid ""
-"\n"
-"--\n"
-"%(player)s:\n"
-"score for this game ==> %(score_game)i\n"
-"total score ==> %(total_score)i"
-msgstr ""
-"\n"
-"--\n"
-"%(player)s:\n"
-"points pour cette partie ==> %(score_game)i\n"
-"point au total ==> %(total_score)i"
-
-#: sat/plugins/plugin_misc_tarot.py:397
-#, fuzzy
-msgid "INTERNAL ERROR: contrat not managed (mispelled ?)"
-msgstr "ERREUR INTERNE: contrat inconnu (mal orthographié ?)"
-
-#: sat/plugins/plugin_misc_tarot.py:422
-#, fuzzy, python-format
-msgid ""
-"The attacker (%(attaquant)s) makes %(points)i and needs to make "
-"%(point_limit)i (%(nb_bouts)s oulder%(plural)s%(separator)s%(bouts)s): "
-"(s)he %(victory)s"
-msgstr ""
-"L'attaquant (%(attaquant)s) fait %(points)i et joue pour %(point_limit)i "
-"(%(nb_bouts)s bout%(plural)s%(separator)s%(bouts)s): il %(victory)s"
-
-#: sat/plugins/plugin_misc_tarot.py:507
-msgid "Internal error: unmanaged game stage"
-msgstr "ERREUR INTERNE: état de jeu inconnu"
-
-#: sat/plugins/plugin_misc_tarot.py:530 sat/plugins/plugin_misc_tarot.py:562
-msgid "session id doesn't exist, session has probably expired"
-msgstr ""
-
-#: sat/plugins/plugin_misc_tarot.py:540
-#, python-format
-msgid "contrat [%(contrat)s] choosed by %(profile)s"
-msgstr "contrat [%(contrat)s] choisi par %(profile)s"
-
-#: sat/plugins/plugin_misc_tarot.py:584
-#, fuzzy, python-format
-msgid "Cards played by %(profile)s: [%(cards)s]"
-msgstr "Cartes jouées par %(profile)s: [%(cards)s]"
-
-#: sat/plugins/plugin_misc_tarot.py:709
-msgid "Everybody is passing, round ended"
-msgstr ""
-
-#: sat/plugins/plugin_misc_tarot.py:723
-#, python-format
-msgid "%(player)s win the bid with %(contrat)s"
-msgstr "%(player)s remporte l'enchère avec %(contrat)s"
-
-#: sat/plugins/plugin_misc_tarot.py:751
-#, fuzzy
-msgid "tarot: chien received"
-msgstr "tarot: chien reçu"
-
-#: sat/plugins/plugin_misc_tarot.py:828
-#, python-format
-msgid "The winner of this trick is %s"
-msgstr "le vainqueur de cette main est %s"
-
-#: sat/plugins/plugin_misc_tarot.py:896
-#, fuzzy, python-format
-msgid "Unmanaged error type: %s"
-msgstr "type d'erreur inconnu: %s"
-
-#: sat/plugins/plugin_misc_tarot.py:898
-#, python-format
-msgid "Unmanaged card game element: %s"
-msgstr "élément de jeu de carte inconnu: %s"
-
-#: sat/plugins/plugin_misc_text_commands.py:40
-msgid "IRC like text commands"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:60
-msgid ""
-"Type '/help' to get a list of the available commands. If you didn't want "
-"to use a command, please start your message with '//' to escape the "
-"slash."
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:66
-#, fuzzy
-msgid "Text commands initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_misc_text_commands.py:162
-#, python-format
-msgid "Skipping not callable [%s] attribute"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:166
-msgid "Skipping cmd_ method"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:173
-msgid "Conflict for command [{old_name}], renaming it to [{new_name}]"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:180
-#, python-format
-msgid "Registered text command [%s]"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:244
-#, fuzzy, python-format
-msgid "Invalid command /%s. "
-msgstr "Mauvais nom de profile"
-
-#: sat/plugins/plugin_misc_text_commands.py:277
-#, fuzzy, python-format
-msgid "Unknown command /%s. "
-msgstr "Type d'action inconnu"
-
-#: sat/plugins/plugin_misc_text_commands.py:286
-msgid "group discussions"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:288
-msgid "one to one discussions"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:290
-msgid "/{command} command only applies in {context}."
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:374
-msgid "Invalid jid, can't whois"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:380
-#, python-format
-msgid "whois for %(jid)s"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:436
-msgid "Invalid command name [{}]\n"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:457
-#, python-format
-msgid ""
-"Text commands available:\n"
-"%s"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:462
-msgid ""
-"/{name}: {short_help}\n"
-"{syntax}{args_help}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_commands.py:465
-msgid " syntax: {}\n"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_syntaxes.py:43 sat/test/constants.py:56
-#, fuzzy
-msgid "Composition"
-msgstr "Connexion..."
-
-#: sat/plugins/plugin_misc_text_syntaxes.py:142
-msgid "Management of various text syntaxes (XHTML-IM, Markdown, etc)"
-msgstr ""
-
-#: sat/plugins/plugin_misc_text_syntaxes.py:184
-#, fuzzy
-msgid "Text syntaxes plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_misc_upload.py:41
-msgid "File upload management"
-msgstr ""
-
-#: sat/plugins/plugin_misc_upload.py:45
-#, fuzzy
-msgid "Please select a file to upload"
-msgstr "Veuillez entrer le nom du nouveau profile"
-
-#: sat/plugins/plugin_misc_upload.py:46
-msgid "File upload"
-msgstr ""
-
-#: sat/plugins/plugin_misc_upload.py:53
-#, fuzzy
-msgid "plugin Upload initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_upload.py:92
-msgid "Can't upload file: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_misc_upload.py:96 sat_frontends/jp/cmd_file.py:586
-msgid "Can't upload file"
-msgstr ""
-
-#: sat/plugins/plugin_misc_uri_finder.py:32
-msgid "URI finder"
-msgstr ""
-
-#: sat/plugins/plugin_misc_uri_finder.py:39
-msgid ""
-"    Plugin to find URIs in well know location.\n"
-"    This allows to retrieve settings to work with a project (e.g. pubsub "
-"node used for merge-requests).\n"
-"    "
-msgstr ""
-
-#: sat/plugins/plugin_misc_uri_finder.py:52
-#, fuzzy
-msgid "URI finder plugin initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_uri_finder.py:87
-msgid "Ignoring already found uri for key \"{key}\""
-msgstr ""
-
-#: sat/plugins/plugin_misc_watched.py:37
-msgid "Watch for entities presence, and send notification accordingly"
-msgstr ""
-
-#: sat/plugins/plugin_misc_watched.py:45
-#, fuzzy, python-format
-msgid "Watched entity {entity} is connected"
-msgstr "Vous êtes déjà connecté !"
-
-#: sat/plugins/plugin_misc_watched.py:62
-#, fuzzy
-msgid "Watched initialisation"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_misc_welcome.py:34
-msgid "Plugin which manage welcome message and things to to on first connection."
-msgstr ""
-
-#: sat/plugins/plugin_misc_welcome.py:42
-msgid "Display welcome message"
-msgstr ""
-
-#: sat/plugins/plugin_misc_welcome.py:43
-msgid "Welcome to Libervia/Salut à Toi"
-msgstr ""
-
-#: sat/plugins/plugin_misc_welcome.py:46
-msgid ""
-"Welcome to a free (as in freedom) network!\n"
-"\n"
-"If you have any trouble, or you want to help us for the bug hunting, you "
-"can contact us in real time chat by using the “Help / Official chat room”"
-"  menu.\n"
-"\n"
-"To use Libervia, you'll need to add contacts, either people you know, or "
-"people you discover by using the “Contacts / Search directory” menu.\n"
-"\n"
-"We hope that you'll enjoy using this project.\n"
-"\n"
-"The Libervia/Salut à Toi Team\n"
-msgstr ""
-
-#: sat/plugins/plugin_misc_welcome.py:75
-#, fuzzy
-msgid "plugin Welcome initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_xmllog.py:36
-msgid "Send raw XML logs to bridge"
-msgstr ""
-
-#: sat/plugins/plugin_misc_xmllog.py:51
-#, fuzzy
-msgid "Activate XML log"
-msgstr "Lancement du flux"
-
-#: sat/plugins/plugin_misc_xmllog.py:55
-#, fuzzy
-msgid "Plugin XML Log initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_misc_xmllog.py:69
-msgid "XML log activated"
-msgstr ""
-
-#: sat/plugins/plugin_misc_xmllog.py:81
-#, fuzzy
-msgid "INTERNAL ERROR: Unmanaged XML type"
-msgstr "ERREUR INTERNE: contrat inconnu (mal orthographié ?)"
-
-#: sat/plugins/plugin_sec_aesgcm.py:48
-msgid ""
-"    Implementation of AES-GCM scheme, a way to encrypt files (not "
-"official XMPP standard).\n"
-"    See https://xmpp.org/extensions/inbox/omemo-media-sharing.html for "
-"details\n"
-"    "
-msgstr ""
-
-#: sat/plugins/plugin_sec_aesgcm.py:63
-#, fuzzy
-msgid "AESGCM plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_sec_otr.py:50
-#, fuzzy
-msgid "Implementation of OTR"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_sec_otr.py:55
-msgid "OTR"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:56
-msgid ""
-"To authenticate your correspondent, you need to give your below "
-"fingerprint *BY AN EXTERNAL CANAL* (i.e. not in this chat), and check "
-"that the one he gives you is the same as below. If there is a mismatch, "
-"there can be a spy between you!"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:61
-msgid ""
-"You private key is used to encrypt messages for your correspondent, "
-"nobody except you must know it, if you are in doubt, you should drop it!"
-"\n"
-"\n"
-"Are you sure you want to drop your private key?"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:67
-msgid "Some of advanced features are disabled !"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:169
-#, python-format
-msgid "/!\\ conversation with %(other_jid)s is now UNENCRYPTED"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:182
-#, fuzzy
-msgid "trusted"
-msgstr "refusé"
-
-#: sat/plugins/plugin_sec_otr.py:182
-#, fuzzy
-msgid "untrusted"
-msgstr "refusé"
-
-#: sat/plugins/plugin_sec_otr.py:185
-msgid "{trusted} OTR conversation with {other_jid} REFRESHED"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:189
-msgid ""
-"{trusted} encrypted OTR conversation started with {other_jid}\n"
-"{extra_info}"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:201
-msgid "OTR conversation with {other_jid} is FINISHED"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:209
-msgid "Unknown OTR state"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:249
-msgid "Save is called but privkey is None !"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:308
-#, fuzzy
-msgid "OTR plugin initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_sec_otr.py:418
-msgid "You have no private key yet, start an OTR conversation to have one"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:424
-msgid "No private key"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:436
-msgid ""
-"Your fingerprint is:\n"
-"{fingerprint}\n"
-"\n"
-"Start an OTR conversation to have your correspondent one."
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:442 sat/plugins/plugin_xep_0384.py:687
-msgid "Fingerprint"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:453
-msgid "Your correspondent {correspondent} is now TRUSTED"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:459
-msgid "Your correspondent {correspondent} is now UNTRUSTED"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:477
-msgid "Authentication ({entity_jid})"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:483
-msgid ""
-"Your own fingerprint is:\n"
-"{fingerprint}"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:486
-msgid ""
-"Your correspondent fingerprint should be:\n"
-"{fingerprint}"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:492
-msgid "Is your correspondent fingerprint the same as here ?"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:494
-msgid "yes"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:494
-msgid "no"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:520
-msgid ""
-"Can't start an OTR session, there is already an encrypted session with "
-"{name}"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:598
-msgid "You don't have a private key yet !"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:611
-msgid "Your private key has been dropped"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:620
-msgid "Confirm private key drop"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:649
-msgid "WARNING: received unencrypted data in a supposedly encrypted context"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:656
-msgid "WARNING: received OTR encrypted data in an unencrypted context"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:662
-msgid "WARNING: received OTR error message: {msg}"
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:668
-#, fuzzy, python-format
-msgid "Error while trying de decrypt OTR message: {msg}"
-msgstr "Erreur en tentant de rejoindre le salon"
-
-#: sat/plugins/plugin_sec_otr.py:780
-msgid ""
-"Your message was not sent because your correspondent closed the encrypted"
-" conversation on his/her side. Either close your own side, or refresh the"
-" session."
-msgstr ""
-
-#: sat/plugins/plugin_sec_otr.py:785
-msgid "Message discarded because closed encryption channel"
-msgstr ""
-
-#: sat/plugins/plugin_syntax_wiki_dotclear.py:40
-#, fuzzy
-msgid "Implementation of Dotclear wiki syntax"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_syntax_wiki_dotclear.py:664
-#, fuzzy
-msgid "Dotclear wiki syntax plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_tickets_import.py:38
-msgid ""
-"Tickets import management:\n"
-"This plugin manage the different tickets importers which can register to "
-"it, and handle generic importing tasks."
-msgstr ""
-
-#: sat/plugins/plugin_tickets_import.py:57
-#, fuzzy
-msgid "plugin Tickets Import initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_tickets_import.py:111
-msgid "comments_uri key will be generated and must not be used by importer"
-msgstr ""
-
-#: sat/plugins/plugin_tickets_import.py:115
-msgid "{key} must be a list"
-msgstr ""
-
-#: sat/plugins/plugin_tickets_import.py:174
-msgid "mapping option must be a dictionary"
-msgstr ""
-
-#: sat/plugins/plugin_tickets_import.py:179
-msgid "keys and values of mapping must be sources and destinations ticket fields"
-msgstr ""
-
-#: sat/plugins/plugin_tickets_import_bugzilla.py:41
-msgid "Tickets importer for Bugzilla"
-msgstr ""
-
-#: sat/plugins/plugin_tickets_import_bugzilla.py:44
-msgid "import tickets from Bugzilla xml export file"
-msgstr ""
-
-#: sat/plugins/plugin_tickets_import_bugzilla.py:46
-msgid ""
-"This importer handle Bugzilla xml export file.\n"
-"\n"
-"To use it, you'll need to export tickets using XML.\n"
-"Tickets will be uploaded with the same ID as for Bugzilla, any existing "
-"ticket with this ID will be replaced.\n"
-"\n"
-"location: you must use the absolute path to your .xml file\n"
-msgstr ""
-
-#: sat/plugins/plugin_tickets_import_bugzilla.py:128
-#, fuzzy
-msgid "Bugilla Import plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_tmp_directory_subscription.py:37
-#, fuzzy
-msgid "Implementation of directory subscription"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_tmp_directory_subscription.py:47
-#, fuzzy
-msgid "Directory subscription plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_tmp_directory_subscription.py:50
-#: sat/plugins/plugin_xep_0050.py:315 sat/plugins/plugin_xep_0100.py:84
-msgid "Service"
-msgstr ""
-
-#: sat/plugins/plugin_tmp_directory_subscription.py:50
-msgid "Directory subscription"
-msgstr ""
-
-#: sat/plugins/plugin_tmp_directory_subscription.py:53
-msgid "User directory subscription"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0020.py:46
-#, fuzzy
-msgid "Implementation of Feature Negotiation"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0020.py:52
-#, fuzzy
-msgid "Plugin XEP_0020 initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0020.py:104
-msgid "More than one value choosed for {}, keeping the first one"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0033.py:66
-#, fuzzy
-msgid "Implementation of Extended Stanza Addressing"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0033.py:76
-#, fuzzy
-msgid "Extended Stanza Addressing plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0033.py:97
-msgid "XEP-0033 is being used but the server doesn't support it!"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0033.py:103
-#, fuzzy
-msgid " or "
-msgstr "Formulaire"
-
-#: sat/plugins/plugin_xep_0033.py:105
-#, python-format
-msgid ""
-"Stanzas using XEP-0033 should be addressed to %(expected)s, not "
-"%(current)s!"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0033.py:111
-msgid "TODO: addressing has been fixed by the backend... fix it in the frontend!"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:56
-#, fuzzy
-msgid "Implementation of Multi-User Chat"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0045.py:89
-#, fuzzy
-msgid "Plugin XEP_0045 initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0045.py:145
-msgid "MUC"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:145
-#, fuzzy
-msgid "configure"
-msgstr " Configurer l'application"
-
-#: sat/plugins/plugin_xep_0045.py:146
-#, fuzzy
-msgid "Configure Multi-User Chat room"
-msgstr "Implémentation de l'initialisation de flux pour le transfert de fichier "
-
-#: sat/plugins/plugin_xep_0045.py:194
-msgid ""
-"Received non delayed message in a room before its initialisation: "
-"state={state}, msg={msg}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:216 sat/plugins/plugin_xep_0045.py:224
-#: sat/plugins/plugin_xep_0045.py:880
-msgid "This room has not been joined"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:283
-msgid "Room joining cancelled by user"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:288
-msgid "Rooms in {}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:303
-msgid "room locked !"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:306
-#, fuzzy
-msgid "Error while configuring the room: {failure_}"
-msgstr "Erreur en tentant de rejoindre le salon"
-
-#: sat/plugins/plugin_xep_0045.py:322
-msgid "Room {} is restricted"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:323
-msgid "This room is restricted, please enter the password"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:332
-#, fuzzy, python-format
-msgid "Error while joining the room {room}{suffix}"
-msgstr "Erreur en tentant de rejoindre le salon"
-
-#: sat/plugins/plugin_xep_0045.py:334
-msgid "Group chat error"
-msgstr "Erreur de salon de discussion"
-
-#: sat/plugins/plugin_xep_0045.py:401
-msgid "room_jid key is not present !"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:406
-msgid "No configuration available for this room"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:440 sat/plugins/plugin_xep_0045.py:442
-msgid "Session ID doesn't exist, session has probably expired."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:441
-#, fuzzy
-msgid "Room configuration failed"
-msgstr "confirmation de type Oui/Non demandée"
-
-#: sat/plugins/plugin_xep_0045.py:447
-#, fuzzy
-msgid "Room configuration succeed"
-msgstr "confirmation de type Oui/Non demandée"
-
-#: sat/plugins/plugin_xep_0045.py:448
-msgid "The new settings have been saved."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:509
-msgid "No MUC service found on main server"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:538
-msgid ""
-"Invalid room identifier: {room_id}'. Please give a room short or full "
-"identifier like 'room' or 'room@{muc_service}'."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:558
-#, fuzzy, python-format
-msgid "{profile} is already in room {room_jid}"
-msgstr "%(profile)s est déjà dans le salon %(room_jid)s"
-
-#: sat/plugins/plugin_xep_0045.py:561
-#, fuzzy, python-format
-msgid "[{profile}] is joining room {room} with nick {nick}"
-msgstr "[%(profile)s] rejoint %(room)s avec %(nick)s"
-
-#: sat/plugins/plugin_xep_0045.py:729
-msgid "You must provide a member's nick to kick."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:738
-msgid "You have kicked {}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:740 sat/plugins/plugin_xep_0045.py:776
-msgid " for the following reason: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:763
-msgid "You must provide a valid JID to ban, like in '/ban contact@example.net'"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:774
-msgid "You have banned {}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:802
-msgid ""
-"You must provide a valid JID to affiliate, like in '/affiliate "
-"contact@example.net member'"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:808
-#, python-format
-msgid "You must provide a valid affiliation: %s"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:815
-msgid "New affiliation for {entity}: {affiliation}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:862
-msgid "No known default MUC service {unparsed}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:867
-#, fuzzy, python-format
-msgid "{} is not a valid JID!"
-msgstr "%s n'est pas un JID valide !"
-
-#: sat/plugins/plugin_xep_0045.py:885
-#, fuzzy, python-format
-msgid "Nickname: %s"
-msgstr "fichier enregistré dans %s"
-
-#: sat/plugins/plugin_xep_0045.py:887
-#, python-format
-msgid "Entity: %s"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:889
-#, python-format
-msgid "Affiliation: %s"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:891
-#, fuzzy, python-format
-msgid "Role: %s"
-msgstr "Profile:"
-
-#: sat/plugins/plugin_xep_0045.py:893
-#, fuzzy, python-format
-msgid "Status: %s"
-msgstr "Sélection du contrat"
-
-#: sat/plugins/plugin_xep_0045.py:895
-#, python-format
-msgid "Show: %s"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:960
-msgid ""
-"room {room} is not in expected state: room is in state {current_state} "
-"while we were expecting {expected_state}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:1093
-msgid "No message received while offline in {room_jid}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:1097
-msgid "We have received {num_mess} message(s) in {room_jid} while offline."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:1141
-msgid "missing nick in presence: {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:1217
-#, fuzzy, python-format
-msgid "user {nick} has joined room {room_id}"
-msgstr "L'utilisateur %(nick)s a rejoint le salon (%(room_id)s)"
-
-#: sat/plugins/plugin_xep_0045.py:1234
-msgid "=> {} has joined the room"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:1253
-#, fuzzy, python-format
-msgid "Room ({room}) left ({profile})"
-msgstr "contrat [%(contrat)s] choisi par %(profile)s"
-
-#: sat/plugins/plugin_xep_0045.py:1267
-#, fuzzy, python-format
-msgid "user {nick} left room {room_id}"
-msgstr "L'utilisateur %(nick)s a quitté le salon (%(room_id)s)"
-
-#: sat/plugins/plugin_xep_0045.py:1279
-msgid "<= {} has left the room"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:1342
-msgid "received history in unexpected state in room {room} (state: {state})"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:1350
-msgid "storing the unexpected message anyway, to avoid loss"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0045.py:1437
-#, fuzzy, python-format
-msgid "New subject for room ({room_id}): {subject}"
-msgstr "Nouveau sujet pour le salon (%(room_id)s): %(subject)s"
-
-#: sat/plugins/plugin_xep_0047.py:62
-#, fuzzy
-msgid "Implementation of In-Band Bytestreams"
-msgstr "Implémentation du « SOCKS5 Bytestreams » (flux d'octets SOCKS5)"
-
-#: sat/plugins/plugin_xep_0047.py:71
-#, fuzzy
-msgid "In-Band Bytestreams plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0047.py:162
-msgid "IBB stream opening"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0047.py:171
-#, python-format
-msgid "Ignoring unexpected IBB transfer: %s"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0047.py:176
-msgid "sended jid inconsistency (man in the middle attack attempt ?)"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0047.py:206
-msgid "IBB stream closing"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0047.py:228
-#, fuzzy
-msgid "Received data for an unknown session id"
-msgstr "Confirmation inconnue reçue"
-
-#: sat/plugins/plugin_xep_0047.py:236
-msgid ""
-"sended jid inconsistency (man in the middle attack attempt ?)\n"
-"initial={initial}\n"
-"given={given}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0047.py:246
-msgid "Sequence error"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0047.py:261
-msgid "Invalid base64 data"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0048.py:45
-#, fuzzy
-msgid "Implementation of bookmarks"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0048.py:58
-#, fuzzy
-msgid "Bookmarks plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0048.py:63 sat_frontends/primitivus/base.py:540
-msgid "Groups"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0048.py:63
-msgid "Bookmarks"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0048.py:66
-msgid "Use and manage bookmarks"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0048.py:147
-msgid "Private XML storage not available"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0048.py:260
-#, fuzzy
-msgid "No room jid selected"
-msgstr "Aucun profile sélectionné"
-
-#: sat/plugins/plugin_xep_0048.py:280
-msgid "Bookmarks manager"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0048.py:310 sat_frontends/jp/cmd_bookmarks.py:126
-msgid "add a bookmark"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0048.py:312
-#, fuzzy
-msgid "Name"
-msgstr "Jeu"
-
-#: sat/plugins/plugin_xep_0048.py:314 sat_frontends/jp/cmd_profile.py:175
-msgid "jid"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0048.py:316
-msgid "Nickname"
-msgstr "Surnon"
-
-#: sat/plugins/plugin_xep_0048.py:318
-msgid "Autojoin"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0048.py:321 sat_frontends/primitivus/xmlui.py:470
-msgid "Save"
-msgstr "Sauvegarder"
-
-#: sat/plugins/plugin_xep_0048.py:367
-msgid "Bookmarks will be local only"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0048.py:368
-#, python-format
-msgid "Type selected for \"auto\" storage: %s"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0048.py:500
-msgid "Bad arguments"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0048.py:509
-#, python-format
-msgid "All [%s] bookmarks are being removed"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0048.py:520
-msgid "Bookmark added"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0049.py:37
-#, fuzzy
-msgid "Implementation of private XML storage"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0049.py:45
-#, fuzzy
-msgid "Plugin XEP-0049 initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0050.py:51
-#: sat_frontends/quick_frontend/constants.py:29
-msgid "Online"
-msgstr "En ligne"
-
-#: sat/plugins/plugin_xep_0050.py:52
-msgid "Away"
-msgstr "Absent"
-
-#: sat/plugins/plugin_xep_0050.py:53
-#: sat_frontends/quick_frontend/constants.py:30
-msgid "Free for chat"
-msgstr "Libre pour discuter"
-
-#: sat/plugins/plugin_xep_0050.py:54
-#: sat_frontends/quick_frontend/constants.py:32
-msgid "Do not disturb"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0050.py:55
-msgid "Left"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0050.py:56 sat_frontends/primitivus/base.py:535
-#, fuzzy
-msgid "Disconnect"
-msgstr "Déconnexion..."
-
-#: sat/plugins/plugin_xep_0050.py:67
-#, fuzzy
-msgid "Implementation of Ad-Hoc Commands"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0050.py:126
-#, fuzzy, python-format
-msgid "The groups [{group}] is unknown for profile [{profile}])"
-msgstr "Tentative d'accès à un profile inconnu"
-
-#: sat/plugins/plugin_xep_0050.py:284
-#, fuzzy
-msgid "plugin XEP-0050 initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0050.py:315
-#, fuzzy
-msgid "Commands"
-msgstr "Mauvais nom de profile"
-
-#: sat/plugins/plugin_xep_0050.py:318
-msgid "Execute ad-hoc commands"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0050.py:329
-msgid "Status"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0050.py:364
-msgid "Missing command element"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0050.py:379
-#, fuzzy
-msgid "Please select a command"
-msgstr "Veuillez entrer le nom du nouveau profile"
-
-#: sat/plugins/plugin_xep_0050.py:397
-#, fuzzy, python-format
-msgid "Invalid note type [%s], using info"
-msgstr "Type d'action inconnu"
-
-#: sat/plugins/plugin_xep_0050.py:408
-msgid "WARNING"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0050.py:409
-#, fuzzy
-msgid "ERROR"
-msgstr "Erreur"
-
-#: sat/plugins/plugin_xep_0050.py:457
-msgid "No known payload found in ad-hoc command result, aborting"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0050.py:464
-#, fuzzy
-msgid "No payload found"
-msgstr "Aucune donnée trouvée"
-
-#: sat/plugins/plugin_xep_0050.py:574
-#, fuzzy
-msgid "Please enter target jid"
-msgstr "Veuillez entrer le JID de votre nouveau contact"
-
-#: sat/plugins/plugin_xep_0050.py:588
-#, fuzzy
-msgid "status selection"
-msgstr "Sélection du contrat"
-
-#: sat/plugins/plugin_xep_0050.py:618
-msgid "Status updated"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0054.py:64
-msgid "Implementation of vcard-temp"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0054.py:84
-msgid "Plugin XEP_0054 initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0054.py:99
-msgid "No avatar in cache for {profile}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0054.py:137
-msgid "Decoding binary"
-msgstr "Décodage des données"
-
-#: sat/plugins/plugin_xep_0054.py:242
-msgid "vCard element not found for {entity_jid}: {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0054.py:287
-msgid "Can't get vCard for {entity_jid}: {e}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0054.py:291
-msgid "VCard found"
-msgstr "VCard trouvée"
-
-#: sat/plugins/plugin_xep_0055.py:53
-#, fuzzy
-msgid "Implementation of Jabber Search"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0055.py:70
-#, fuzzy
-msgid "Jabber search plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0055.py:100 sat/stdui/ui_contact_list.py:39
-#: sat/stdui/ui_contact_list.py:45 sat/stdui/ui_contact_list.py:51
-#: sat_frontends/primitivus/base.py:539
-#: sat_frontends/primitivus/contact_list.py:50
-#, fuzzy
-msgid "Contacts"
-msgstr "&Contacts"
-
-#: sat/plugins/plugin_xep_0055.py:100
-msgid "Search directory"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:103
-msgid "Search user directory"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:143
-#, fuzzy, python-format
-msgid "Search users"
-msgstr "Remplacement de l'utilisateur %s"
-
-#: sat/plugins/plugin_xep_0055.py:174
-msgid "Search for"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:181
-msgid "Simple search"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:191 sat/plugins/plugin_xep_0055.py:305
-msgid "Search"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:226
-msgid "Advanced search"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:246
-msgid "Search on"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:248
-msgid "Other service"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:256
-msgid "Refresh fields"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:260
-msgid "Displaying the search form for"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:341
-msgid "Search results"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:346
-msgid "The search gave no result"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:386 sat/plugins/plugin_xep_0055.py:493
-msgid "No query element found"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:391 sat/plugins/plugin_xep_0055.py:498
-msgid "No data form found"
-msgstr "Aucune donnée trouvée"
-
-#: sat/plugins/plugin_xep_0055.py:403
-#, fuzzy, python-format
-msgid "Fields request failure: %s"
-msgstr "Échec de l'inscription: %s"
-
-#: sat/plugins/plugin_xep_0055.py:478
-msgid "The search could not be performed"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0055.py:510
-#, fuzzy, python-format
-msgid "Search request failure: %s"
-msgstr "Échec de la désinscription: %s"
-
-#: sat/plugins/plugin_xep_0059.py:42
-#, fuzzy
-msgid "Implementation of Result Set Management"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0059.py:52
-#, fuzzy
-msgid "Result Set Management plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0059.py:65
-msgid "rsm_max can't be negative"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0060.py:56
-#, fuzzy
-msgid "Implementation of PubSub Protocol"
-msgstr "Implémentation du protocole de transports"
-
-#: sat/plugins/plugin_xep_0060.py:95
-#, fuzzy
-msgid "PubSub plugin initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0060.py:323
-msgid "Can't retrieve pubsub_service from conf, we'll use first one that we find"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0060.py:487
-msgid "Can't parse items: {msg}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0060.py:556
-msgid "Invalid item: {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0060.py:572
-msgid ""
-"Can't use publish-options ({options}) on node {node}, re-publishing "
-"without them: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0060.py:905 sat/plugins/plugin_xep_0060.py:948
-msgid "Invalid result: missing <affiliations> element: {}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0060.py:916 sat/plugins/plugin_xep_0060.py:961
-msgid "Invalid result: bad <affiliation> element: {}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0060.py:1284
-msgid "Invalid result: missing <subscriptions> element: {}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0060.py:1289
-msgid "Invalid result: {}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0060.py:1299
-msgid "Invalid result: bad <subscription> element: {}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0065.py:90
-msgid "Implementation of SOCKS5 Bytestreams"
-msgstr "Implémentation du « SOCKS5 Bytestreams » (flux d'octets SOCKS5)"
-
-#: sat/plugins/plugin_xep_0065.py:528
-msgid "File transfer completed, closing connection"
-msgstr "Transfert de fichier terminé, fermeture de la connexion"
-
-#: sat/plugins/plugin_xep_0065.py:695
-#, python-format
-msgid "Socks 5 client connection lost (reason: %s)"
-msgstr "Connexion du client SOCKS5 perdue (raison: %s)"
-
-#: sat/plugins/plugin_xep_0065.py:723
-msgid "Plugin XEP_0065 initialization"
-msgstr "Initialisation du plugin XEP_0065"
-
-#: sat/plugins/plugin_xep_0065.py:781
-#, fuzzy, python-format
-msgid "Socks5 Stream server launched on port {}"
-msgstr "Lancement du serveur de flux Socks5 sur le port %d"
-
-#: sat/plugins/plugin_xep_0070.py:56
-#, fuzzy
-msgid "Implementation of HTTP Requests via XMPP"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0070.py:66
-#, fuzzy
-msgid "Plugin XEP_0070 initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0070.py:79
-msgid "XEP-0070 Verifying HTTP Requests via XMPP (iq)"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0070.py:88
-msgid "XEP-0070 Verifying HTTP Requests via XMPP (message)"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0070.py:98
-#, fuzzy
-msgid "Auth confirmation"
-msgstr "Connexion..."
-
-#: sat/plugins/plugin_xep_0070.py:99
-msgid ""
-"{auth_url} needs to validate your identity, do you agree?\n"
-"Validation code : {auth_id}\n"
-"\n"
-"Please check that this code is the same as on {auth_url}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0070.py:117
-msgid "XEP-0070 reply iq"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0070.py:122
-msgid "XEP-0070 reply message"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0070.py:127
-msgid "XEP-0070 reply error"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0071.py:55
-#, fuzzy
-msgid "Implementation of XHTML-IM"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0071.py:94
-#, fuzzy
-msgid "XHTML-IM plugin initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0071.py:223
-msgid "Can't have XHTML and rich content at the same time"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0077.py:41
-msgid "Implementation of in-band registration"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0077.py:54
-#, fuzzy
-msgid "Registration asked for {jid}"
-msgstr "Éched de l'insciption (%s)"
-
-#: sat/plugins/plugin_xep_0077.py:79
-msgid "Stream started with {server}, now registering"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0077.py:85
-#, fuzzy, python-format
-msgid "Registration answer: {}"
-msgstr "réponse à la demande d'inscription: %s"
-
-#: sat/plugins/plugin_xep_0077.py:89
-#, fuzzy, python-format
-msgid "Registration failure: {}"
-msgstr "Échec de l'inscription: %s"
-
-#: sat/plugins/plugin_xep_0077.py:116
-msgid "Plugin XEP_0077 initialization"
-msgstr "Initialisation du plugin XEP_0077"
-
-#: sat/plugins/plugin_xep_0077.py:176
-#, fuzzy
-msgid "Can't find data form"
-msgstr "Impossible de trouver la VCard de %s"
-
-#: sat/plugins/plugin_xep_0077.py:178
-msgid "This gateway can't be managed by SàT, sorry :("
-msgstr "Ce transport ne peut être gérée par SàT, désolé :("
-
-#: sat/plugins/plugin_xep_0077.py:202 sat/plugins/plugin_xep_0077.py:212
-#, python-format
-msgid "Registration failure: %s"
-msgstr "Échec de l'inscription: %s"
-
-#: sat/plugins/plugin_xep_0077.py:206
-#, python-format
-msgid "registration answer: %s"
-msgstr "réponse à la demande d'inscription: %s"
-
-#: sat/plugins/plugin_xep_0077.py:215
-msgid "Username already exists, please choose an other one"
-msgstr "Ce nom d'utilisateur existe déjà, veuillez en choisir un autre"
-
-#: sat/plugins/plugin_xep_0077.py:229
-#, fuzzy, python-format
-msgid "Asking registration for {}"
-msgstr "Demande d'enregistrement pour [%s]"
-
-#: sat/plugins/plugin_xep_0085.py:55
-#, fuzzy
-msgid "Implementation of Chat State Notifications Protocol"
-msgstr "Implémentation du protocole de transports"
-
-#: sat/plugins/plugin_xep_0085.py:97
-msgid "Enable chat state notifications"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0085.py:101
-#, fuzzy
-msgid "Chat State Notifications plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0092.py:42
-#, fuzzy
-msgid "Implementation of Software Version"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0092.py:48
-#, fuzzy
-msgid "Plugin XEP_0092 initialization"
-msgstr "Initialisation du plugin XEP_0096"
-
-#: sat/plugins/plugin_xep_0092.py:119
-#, fuzzy, python-format
-msgid "Client name: %s"
-msgstr "fichier enregistré dans %s"
-
-#: sat/plugins/plugin_xep_0092.py:121
-#, python-format
-msgid "Client version: %s"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0092.py:123
-#, fuzzy, python-format
-msgid "Operating system: %s"
-msgstr "réponse à la demande d'inscription: %s"
-
-#: sat/plugins/plugin_xep_0092.py:128
-msgid "Software version not available"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0092.py:130
-msgid "Client software version request timeout"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0095.py:41
-#, fuzzy
-msgid "Implementation of Stream Initiation"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0095.py:54
-#, fuzzy
-msgid "Plugin XEP_0095 initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0095.py:84
-#, fuzzy
-msgid "XEP-0095 Stream initiation"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0095.py:127
-msgid "sending stream initiation accept answer"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0095.py:168
-#, python-format
-msgid "Stream Session ID: %s"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0096.py:48
-msgid "Implementation of SI File Transfer"
-msgstr "Implémentation de l'initialisation de flux pour le transfert de fichier "
-
-#: sat/plugins/plugin_xep_0096.py:55
-#, fuzzy
-msgid "Stream Initiation"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0096.py:58
-msgid "Plugin XEP_0096 initialization"
-msgstr "Initialisation du plugin XEP_0096"
-
-#: sat/plugins/plugin_xep_0096.py:129
-msgid "XEP-0096 file transfer requested"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0096.py:377
-#, fuzzy, python-format
-msgid "The contact {} has refused your file"
-msgstr "Le contact %s a refusé votre inscription"
-
-#: sat/plugins/plugin_xep_0096.py:378
-#, fuzzy
-msgid "File refused"
-msgstr "refusé"
-
-#: sat/plugins/plugin_xep_0096.py:381
-msgid "Error during file transfer"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0096.py:382
-msgid ""
-"Something went wrong during the file transfer session initialisation: "
-"{reason}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0096.py:385
-#, fuzzy
-msgid "File transfer error"
-msgstr "Transfert de fichier"
-
-#: sat/plugins/plugin_xep_0096.py:394
-#, fuzzy, python-format
-msgid "transfer {sid} successfuly finished [{profile}]"
-msgstr "Transfert [%s] refusé"
-
-#: sat/plugins/plugin_xep_0096.py:402
-msgid "transfer {sid} failed [{profile}]: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:37
-msgid "Implementation of Gateways protocol"
-msgstr "Implémentation du protocole de transports"
-
-#: sat/plugins/plugin_xep_0100.py:40
-#, fuzzy
-msgid ""
-"Be careful ! Gateways allow you to use an external IM (legacy IM), so you"
-" can see your contact as XMPP contacts.\n"
-"But when you do this, all your messages go throught the external legacy "
-"IM server, it is a huge privacy issue (i.e.: all your messages throught "
-"the gateway can be monitored, recorded, analysed by the external server, "
-"most of time a private company)."
-msgstr ""
-"Soyez prudent ! Les transports vous permettent d'utiliser une messagerie "
-"externe, de façon à pouvoir afficher vos contacts comme des contacts "
-"jabber.\n"
-"Mais si vous faites cela, tous vos messages passeront par les serveurs de"
-" la messagerie externe, c'est un gros problème pour votre vie privée "
-"(comprenez: tous vos messages à travers le transport pourront être "
-"affichés, enregistrés, analysés par ces serveurs externes, la plupart du "
-"temps une entreprise privée)."
-
-#: sat/plugins/plugin_xep_0100.py:48
-msgid "Internet Relay Chat"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:49
-msgid "XMPP"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:50
-msgid "Tencent QQ"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:51
-msgid "SIP/SIMPLE"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:52
-msgid "ICQ"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:53
-msgid "Yahoo! Messenger"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:54
-msgid "Gadu-Gadu"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:55
-msgid "AOL Instant Messenger"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:56
-msgid "Windows Live Messenger"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:62
-msgid "Gateways plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0100.py:84
-#, fuzzy
-msgid "Gateways"
-msgstr "Chercher les transports"
-
-#: sat/plugins/plugin_xep_0100.py:87
-#, fuzzy
-msgid "Find gateways"
-msgstr "Chercher les transports"
-
-#: sat/plugins/plugin_xep_0100.py:108
-#, fuzzy, python-format
-msgid "Gateways manager (%s)"
-msgstr "Gestionnaire de transport"
-
-#: sat/plugins/plugin_xep_0100.py:121
-#, python-format
-msgid "Failed (%s)"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:134
-#, fuzzy
-msgid "Use external XMPP server"
-msgstr "Utiliser un autre serveur XMPP:"
-
-#: sat/plugins/plugin_xep_0100.py:136
-msgid "Go !"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:143
-#, fuzzy
-msgid "No gateway index selected"
-msgstr "Aucun profile sélectionné"
-
-#: sat/plugins/plugin_xep_0100.py:158
-#, python-format
-msgid ""
-"INTERNAL ERROR: identity category should always be \"gateway\" in "
-"_getTypeString, got \"%s\""
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:166
-msgid "Unknown IM"
-msgstr "Messagerie inconnue"
-
-#: sat/plugins/plugin_xep_0100.py:170
-msgid "Registration successful, doing the rest"
-msgstr "Inscription réussie, lancement du reste de la procédure"
-
-#: sat/plugins/plugin_xep_0100.py:195
-msgid "Timeout"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:211
-#, fuzzy, python-format
-msgid "Found gateway [%(jid)s]: %(identity_name)s"
-msgstr "Transport trouvé (%(jid)s): %(identity)s"
-
-#: sat/plugins/plugin_xep_0100.py:222
-#, python-format
-msgid "Skipping [%(jid)s] which is not a gateway"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0100.py:231
-msgid "No gateway found"
-msgstr "Aucun transport trouvé"
-
-#: sat/plugins/plugin_xep_0100.py:236
-#, python-format
-msgid "item found: %s"
-msgstr "object trouvé: %s"
-
-#: sat/plugins/plugin_xep_0100.py:260
-#, fuzzy, python-format
-msgid "find gateways (target = %(target)s, profile = %(profile)s)"
-msgstr "transports trouvée (cible = %s)"
-
-#: sat/plugins/plugin_xep_0106.py:38
-msgid "(Un)escape JID to use disallowed chars in local parts"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0115.py:50
-#, fuzzy
-msgid "Implementation of entity capabilities"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0115.py:58
-#, fuzzy
-msgid "Plugin XEP_0115 initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0115.py:73
-msgid "Caps optimisation enabled"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0115.py:76
-msgid "Caps optimisation not available"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0115.py:154
-#, python-format
-msgid "Received invalid capabilities tag: %s"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0115.py:170
-msgid ""
-"Unknown hash method for entity capabilities: [{hash_method}] (entity: "
-"{entity_jid}, node: {node})"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0115.py:183
-msgid ""
-"Computed hash differ from given hash:\n"
-"given: [{given}]\n"
-"computed: [{computed}]\n"
-"(entity: {entity_jid}, node: {node})"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0115.py:205
-msgid "Couldn't retrieve disco info for {jid}: {error}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0163.py:42
-#, fuzzy
-msgid "Implementation of Personal Eventing Protocol"
-msgstr "Implémentation du protocole de transports"
-
-#: sat/plugins/plugin_xep_0163.py:48
-#, fuzzy
-msgid "PEP plugin initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0163.py:131
-#, fuzzy, python-format
-msgid "Trying to send personal event with an unknown profile key [%s]"
-msgstr "Tentative d'appel d'un profile inconnue"
-
-#: sat/plugins/plugin_xep_0163.py:136
-#, fuzzy
-msgid "Trying to send personal event for an unknown type"
-msgstr "Tentative d'assigner un paramètre à un profile inconnu"
-
-#: sat/plugins/plugin_xep_0163.py:142
-#, fuzzy
-msgid "No item found"
-msgstr "Aucun transport trouvé"
-
-#: sat/plugins/plugin_xep_0163.py:149
-msgid "Can't find mood element in mood event"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0163.py:153
-#, fuzzy
-msgid "No mood found"
-msgstr "Aucune donnée trouvée"
-
-#: sat/plugins/plugin_xep_0166.py:50
-msgid "{entity} want to start a jingle session with you, do you accept ?"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0166.py:60
-#, fuzzy
-msgid "Implementation of Jingle"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0166.py:98
-#, fuzzy
-msgid "plugin Jingle initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0166.py:156
-#, fuzzy, python-format
-msgid "Error while terminating session: {msg}"
-msgstr "Erreur en tentant de rejoindre le salon"
-
-#: sat/plugins/plugin_xep_0166.py:395
-msgid "You can't do a jingle session with yourself"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0166.py:511
-msgid "Confirm Jingle session"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0184.py:71
-#, fuzzy
-msgid "Implementation of Message Delivery Receipts"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0184.py:96
-msgid "Enable message delivery receipts"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0184.py:100
-#, fuzzy
-msgid "Plugin XEP_0184 (message delivery receipts) initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0184.py:136
-msgid "[XEP-0184] Request acknowledgment for message id {}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0184.py:180
-msgid "[XEP-0184] Receive acknowledgment for message id {}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0184.py:190
-msgid "[XEP-0184] Delete waiting acknowledgment for message id {}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:46
-#, fuzzy
-msgid "Implementation of Stream Management"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0198.py:134
-#, fuzzy
-msgid "Plugin Stream Management initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0198.py:144
-msgid "Invalid ack_timeout value, please check your configuration"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:147
-msgid "Ack timeout disabled"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:149
-msgid "Ack timeout set to {timeout}s"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:171
-msgid ""
-"Your server doesn't support stream management ({namespace}), this is used"
-" to improve connection problems detection (like network outages). Please "
-"ask your server administrator to enable this feature."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:302
-msgid ""
-"Connection failed using location given by server (host: {host}, port: "
-"{port}), switching to normal host and port (host: {normal_host}, port: "
-"{normal_port})"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:317
-msgid "Incorrect <enabled/> element received, no \"id\" attribute"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:319
-msgid ""
-"You're server doesn't support session resuming with stream management, "
-"please contact your server administrator to enable it"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:341
-msgid "Invalid location received: {location}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:359
-msgid "Invalid \"max\" attribute"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:361
-msgid "Using default session max value ({max_s} s)."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:363
-msgid "Stream Management enabled"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:367
-msgid "Stream Management enabled, with a resumption time of {res_m:.2f} min"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:382
-msgid ""
-"Stream session resumed (disconnected for {d_time} s, {count} stanza(s) "
-"resent)"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:395
-msgid "Can't use stream management"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:399
-msgid "{msg}: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:408
-msgid "stream resumption not possible, restarting full session"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:495
-msgid "Server returned invalid ack element, disabling stream management: {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:501
-msgid "Server acked more stanzas than we have sent, disabling stream management."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0198.py:511
-msgid "Ack was not received in time, aborting connection"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0199.py:39
-#, fuzzy
-msgid "Implementation of XMPP Ping"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0199.py:49
-#, fuzzy
-msgid "XMPP Ping plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0199.py:109
-msgid "ping error ({err_msg}). Response time: {time} s"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0199.py:123
-msgid "Invalid jid: \"{entity_jid}\""
-msgstr ""
-
-#: sat/plugins/plugin_xep_0199.py:134
-msgid "XMPP PING received from {from_jid} [{profile}]"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0203.py:45
-#, fuzzy
-msgid "Implementation of Delayed Delivery"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0203.py:51
-#, fuzzy
-msgid "Delayed Delivery plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0231.py:48
-msgid "Implementation of bits of binary (used for small images/files)"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0231.py:59
-#, fuzzy
-msgid "plugin Bits of Binary initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0234.py:54
-#, fuzzy
-msgid "Implementation of Jingle File Transfer"
-msgstr "Implémentation de l'initialisation de flux pour le transfert de fichier "
-
-#: sat/plugins/plugin_xep_0234.py:67
-#, fuzzy
-msgid "file transfer"
-msgstr "Transfert de fichier"
-
-#: sat/plugins/plugin_xep_0234.py:70
-#, fuzzy
-msgid "plugin Jingle File Transfer initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0234.py:380
-msgid "hash_algo must be set if file_hash is set"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0234.py:385
-msgid "file_hash must be set if hash_algo is set"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0234.py:419
-msgid "only the following keys are allowed in extra: {keys}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0234.py:454
-msgid "you need to provide at least name or file hash"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0234.py:524
-#, fuzzy
-msgid "File continue is not implemented yet"
-msgstr "getGame n'est pas implémenté dans ce frontend"
-
-#: sat/plugins/plugin_xep_0249.py:55
-#, fuzzy
-msgid "Implementation of Direct MUC Invitations"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0249.py:75
-msgid "Auto-join MUC on invitation"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0249.py:86
-#, fuzzy
-msgid "Plugin XEP_0249 initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0249.py:140
-#, python-format
-msgid "Invitation accepted for room %(room)s [%(profile)s]"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0249.py:155
-msgid "invalid invitation received: {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0249.py:159
-#, python-format
-msgid "Invitation received for room %(room)s [%(profile)s]"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0249.py:170
-msgid "Invitation silently discarded because user is already in the room."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0249.py:181
-#, python-format
-msgid ""
-"An invitation from %(user)s to join the room %(room)s has been declined "
-"according to your personal settings."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0249.py:185 sat/plugins/plugin_xep_0249.py:192
-#, fuzzy
-msgid "MUC invitation"
-msgstr "Connexion..."
-
-#: sat/plugins/plugin_xep_0249.py:188
-#, python-format
-msgid ""
-"You have been invited by %(user)s to join the room %(room)s. Do you "
-"accept?"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0249.py:215
-msgid "You must provide a valid JID to invite, like in '/invite contact@{host}'"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0260.py:51
-#, fuzzy
-msgid "Implementation of Jingle SOCKS5 Bytestreams"
-msgstr "Implémentation du « SOCKS5 Bytestreams » (flux d'octets SOCKS5)"
-
-#: sat/plugins/plugin_xep_0260.py:64
-msgid "plugin Jingle SOCKS5 Bytestreams"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0261.py:47
-#, fuzzy
-msgid "Implementation of Jingle In-Band Bytestreams"
-msgstr "Implémentation du « SOCKS5 Bytestreams » (flux d'octets SOCKS5)"
-
-#: sat/plugins/plugin_xep_0261.py:55
-#, fuzzy
-msgid "plugin Jingle In-Band Bytestreams"
-msgstr "Implémentation du « SOCKS5 Bytestreams » (flux d'octets SOCKS5)"
-
-#: sat/plugins/plugin_xep_0264.py:67
-msgid "Thumbnails handling"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0264.py:79
-#, fuzzy
-msgid "Plugin XEP_0264 initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0277.py:71
-#, fuzzy
-msgid "Implementation of microblogging Protocol"
-msgstr "Implémentation du protocole de transports"
-
-#: sat/plugins/plugin_xep_0277.py:83
-#, fuzzy
-msgid "Microblogging plugin initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0277.py:286
-msgid "Content of type XHTML must declare its namespace!"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0277.py:557
-msgid "Can't have xhtml and rich content at the same time"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0277.py:1041
-#, python-format
-msgid "Microblog node has now access %s"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0277.py:1045
-msgid "Can't set microblog access"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0280.py:39
-#, fuzzy, python-format
-msgid "Message carbons"
-msgstr "message reçu de: %s"
-
-#: sat/plugins/plugin_xep_0280.py:50
-#, fuzzy
-msgid "Implementation of Message Carbons"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0280.py:75
-#, fuzzy
-msgid "Plugin XEP_0280 initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0280.py:102
-msgid "Not activating message carbons as requested in params"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0280.py:107
-msgid "server doesn't handle message carbons"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0280.py:109
-msgid "message carbons available, enabling it"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0280.py:117
-#, fuzzy
-msgid "message carbons activated"
-msgstr ""
-"Barre de progression désactivée\n"
-"--\n"
-
-#: sat/plugins/plugin_xep_0297.py:44
-#, fuzzy
-msgid "Implementation of Stanza Forwarding"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0297.py:52
-#, fuzzy
-msgid "Stanza Forwarding plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0300.py:45
-msgid "Management of cryptographic hashes"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0300.py:66
-#, fuzzy
-msgid "plugin Hashes initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0313.py:51
-#, fuzzy
-msgid "Implementation of Message Archive Management"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0313.py:64
-#, fuzzy
-msgid "Message Archive Management plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0313.py:92
-msgid "It seems that we have no MAM history yet"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0313.py:126
-msgid "missing \"to\" attribute in forwarded message"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0313.py:137
-msgid "missing \"from\" attribute in forwarded message"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0313.py:140
-msgid ""
-"was expecting a message sent by our jid, but this one if from {from_jid},"
-" ignoring\n"
-"{xml}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0313.py:158
-msgid "We have received no message while offline"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0313.py:160
-msgid "We have received {num_mess} message(s) while offline."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0329.py:50
-#, fuzzy
-msgid "Implementation of File Information Sharing"
-msgstr "Implémentation de l'initialisation de flux pour le transfert de fichier "
-
-#: sat/plugins/plugin_xep_0329.py:86
-msgid "path change chars found in name [{name}], hack attempt?"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0329.py:107
-msgid "path can only be set on path nodes"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0329.py:141
-msgid "a node can't have several parents"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0329.py:210
-msgid ""
-"parent dir (\"..\") found in path, hack attempt? path is {path} "
-"[{profile}]"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0329.py:271
-#, fuzzy
-msgid "File Information Sharing initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0329.py:394
-msgid "invalid path: {path}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0329.py:428
-msgid "{peer_jid} requested a file (s)he can't access [{profile}]"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0329.py:461
-#, fuzzy, python-format
-msgid "error while retrieving files: {msg}"
-msgstr "Erreur en tentant de rejoindre le salon"
-
-#: sat/plugins/plugin_xep_0329.py:513
-msgid "ignoring invalid unicode name ({name}): {msg}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0329.py:534
-msgid "unexpected type: {type}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0329.py:573
-#, fuzzy, python-format
-msgid "unknown node type: {type}"
-msgstr "Type d'action inconnu"
-
-#: sat/plugins/plugin_xep_0329.py:711
-msgid "unexpected element, ignoring: {elt}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0329.py:1184
-#, fuzzy, python-format
-msgid "This path doesn't exist!"
-msgstr "Le fichier [%s] n'existe pas !"
-
-#: sat/plugins/plugin_xep_0329.py:1186
-msgid "A path need to be specified"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0329.py:1188
-msgid "access must be a dict"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0329.py:1200
-#, fuzzy
-msgid "Can't find a proper name"
-msgstr "Impossible de trouver la VCard de %s"
-
-#: sat/plugins/plugin_xep_0329.py:1211
-msgid ""
-"A directory with this name is already shared, renamed to {new_name} "
-"[{profile}]"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0334.py:43
-#, fuzzy
-msgid "Implementation of Message Processing Hints"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0334.py:45
-msgid ""
-"             Frontends can use HINT_* constants in mess_data['extra'] in "
-"a serialized 'hints' dict.\n"
-"             Internal plugins can use directly addHint([HINT_* "
-"constant]).\n"
-"             Will set mess_data['extra']['history'] to 'skipped' when no "
-"store is requested and message is not saved in history."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0334.py:65
-#, fuzzy
-msgid "Message Processing Hints plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0346.py:54
-msgid "Handle Pubsub data schemas"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0346.py:60
-#, fuzzy
-msgid "PubSub Schema initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0346.py:208
-msgid "unspecified schema, we need to request it"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0346.py:212
-msgid ""
-"no schema specified, and this node has no schema either, we can't "
-"construct the data form"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0346.py:233
-msgid "Invalid Schema: {msg}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0346.py:246
-msgid "nodeIndentifier needs to be set"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0346.py:310
-msgid "empty node is not allowed"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0346.py:354
-msgid "default_node must be set if nodeIdentifier is not set"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0346.py:457
-#, fuzzy
-msgid "field {name} doesn't exist, ignoring it"
-msgstr "Le fichier [%s] n'existe pas !"
-
-#: sat/plugins/plugin_xep_0346.py:551
-msgid "Can't parse date field: {msg}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0346.py:652
-msgid "Can't get previous item, update ignored: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0346.py:661
-msgid "Can't parse previous item, update ignored: data form not found"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0346.py:719
-msgid "default_node must be set if node is not set"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0346.py:728
-msgid "if extra[\"update\"] is set, item_id must be set too"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0352.py:35
-msgid ""
-"Notify server when frontend is not actively used, to limit traffic and "
-"save bandwidth and battery life"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0352.py:45
-#, fuzzy
-msgid "Client State Indication plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0352.py:63
-msgid "Client State Indication is available on this server"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0352.py:67
-msgid ""
-"Client State Indication is not available on this server, some bandwidth "
-"optimisations can't be used."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0353.py:46
-#, fuzzy
-msgid "Implementation of Jingle Message Initiation"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0353.py:53
-#, fuzzy
-msgid "plugin {name} initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0353.py:122
-msgid "Message initiation with {peer_jid} timed out"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0353.py:166
-msgid ""
-"Somebody not in your contact list ({peer_jid}) wants to do a "
-"\"{human_name}\" session with you, this would leak your presence and "
-"possibly you IP (internet localisation), do you accept?"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0353.py:171
-#, fuzzy
-msgid "Invitation from an unknown contact"
-msgstr "Tentative d'assigner un paramètre à un profile inconnu"
-
-#: sat/plugins/plugin_xep_0353.py:211
-msgid "no pending session found with id {session_id}, did it timed out?"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0359.py:40
-#, fuzzy
-msgid "Implementation of Unique and Stable Stanza IDs"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0359.py:49
-#, fuzzy
-msgid "Unique and Stable Stanza IDs plugin initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/plugins/plugin_xep_0363.py:51
-#, fuzzy
-msgid "Implementation of HTTP File Upload"
-msgstr "Implémentation de l'initialisation de flux pour le transfert de fichier "
-
-#: sat/plugins/plugin_xep_0363.py:83
-#, fuzzy
-msgid "plugin HTTP File Upload initialization"
-msgstr "Initialisation du plugin XEP_0054"
-
-#: sat/plugins/plugin_xep_0363.py:200
-msgid "Can't get upload slot: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0363.py:265
-msgid "upload failed: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0363.py:349
-msgid "Invalid header element: {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0363.py:355
-msgid "Ignoring unauthorised header \"{name}\": {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0363.py:400
-msgid "no service can handle HTTP Upload request: {elt}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0380.py:35
-#, fuzzy
-msgid "Implementation of Explicit Message Encryption"
-msgstr "Implémentation de l'enregistrement en ligne"
-
-#: sat/plugins/plugin_xep_0380.py:94
-msgid ""
-"Message from {sender} is encrypted with {algorithm} and we can't decrypt "
-"it."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0380.py:96
-msgid ""
-"User {sender} sent you an encrypted message (encrypted with {algorithm}),"
-" and we can't decrypt it."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:62
-#, fuzzy
-msgid "Implementation of OMEMO"
-msgstr "Implementation de vcard-temp"
-
-#: sat/plugins/plugin_xep_0384.py:440
-msgid "Security"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:442
-msgid "OMEMO default trust policy"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:443
-msgid "Manual trust (more secure)"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:445
-msgid "Blind Trust Before Verification (more user friendly)"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:449
-msgid "OMEMO plugin initialization (omemo module v{version})"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:453
-msgid ""
-"Your version of omemo module is too old: {v[0]}.{v[1]}.{v[2]} is minimum "
-"required, please update."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:488
-msgid "You need to have OMEMO encryption activated to reset the session"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:503
-msgid "OMEMO session has been reset"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:551
-msgid "device {device} from {peer_jid} is not an auto-trusted device anymore"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:612
-msgid "Can't find bundle for device {device_id} of user {bare_jid}, ignoring"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:642
-#, fuzzy
-msgid "OMEMO trust management"
-msgstr "Initialisation du gestionnaire de mémoire"
-
-#: sat/plugins/plugin_xep_0384.py:645
-msgid ""
-"This is OMEMO trusting system. You'll see below the devices of your "
-"contacts, and a checkbox to trust them or not. A trusted device can read "
-"your messages in plain text, so be sure to only validate devices that you"
-" are sure are belonging to your contact. It's better to do this when you "
-"are next to your contact and her/his device, so you can check the "
-"\"fingerprint\" (the number next to the device) yourself. Do *not* "
-"validate a device if the fingerprint is wrong!"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:655
-msgid "This device ID"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:657
-msgid "This device fingerprint"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:669
-msgid "Automatically trust new devices?"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:683
-#, fuzzy
-msgid "Contact"
-msgstr "&Contacts"
-
-#: sat/plugins/plugin_xep_0384.py:685
-msgid "Device ID"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:691
-msgid "Trust this device?"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:696
-msgid "(automatically trusted)"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:724
-msgid "We have no identity for this device yet, let's generate one"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:749
-msgid "Saving public bundle for this device ({device_id})"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:770
-msgid "OMEMO devices list is stored in more that one items, this is not expected"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:776
-msgid "no list element found in OMEMO devices list"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:782
-msgid "device element is missing \"id\" attribute: {elt}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:785
-msgid "invalid device id: {device_id}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:804
-msgid "there is no node to handle OMEMO devices"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:826
-msgid "Can't set devices: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:850
-msgid "Bundle missing for device {device_id}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:855
-msgid "Can't get bundle for device {device_id}: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:859
-msgid ""
-"no item found in node {node}, can't get public bundle for device "
-"{device_id}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:864
-msgid "more than one item found in {node}, this is not expected"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:878
-msgid "invalid bundle for device {device_id}, ignoring"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:902
-msgid "error while decoding key for device {device_id}: {msg}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:916
-msgid "updating bundle for {device_id}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:949
-msgid "Can't set bundle: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:969
-msgid "Our own device is missing from devices list, fixing it"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:988
-msgid ""
-"Not all destination devices are trusted, unknown devices will be blind "
-"trusted due to the OMEMO Blind Trust Before Verification policy. If you "
-"want a more secure workflow, please activate \"manual\" OMEMO policy in "
-"settings' \"Security\" tab.\n"
-"Following fingerprint have been automatically trusted:\n"
-"{devices}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1010
-msgid ""
-"Not all destination devices are trusted, we can't encrypt message in such"
-" a situation. Please indicate if you trust those devices or not in the "
-"trust manager before we can send this message"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1053
-msgid "discarding untrusted device {device_id} with key {device_key} for {entity}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1095
-msgid ""
-"Can't retrieve bundle for device(s) {devices} of entity {peer}, the "
-"message will not be readable on this/those device(s)"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1100
-msgid ""
-"You're destinee {peer} has missing encryption data on some of his/her "
-"device(s) (bundle on device {devices}), the message won't  be readable on"
-" this/those device."
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1151
-msgid "Too many iterations in encryption loop"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1180
-msgid "Can't encrypt message for {entities}: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1270
-msgid "Invalid OMEMO encrypted stanza, ignoring: {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1276
-msgid "Invalid OMEMO encrypted stanza, missing sender device ID, ignoring: {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1284
-msgid ""
-"This OMEMO encrypted stanza has not been encrypted for our device "
-"(device_id: {device_id}, fingerprint: {fingerprint}): {xml}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1290
-msgid ""
-"An OMEMO message from {sender} has not been encrypted for our device, we "
-"can't decrypt it"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1297
-msgid "Invalid recipient ID: {msg}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1330
-msgid ""
-"Can't decrypt message: {reason}\n"
-"{xml}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1332
-msgid "An OMEMO message from {sender} can't be decrypted: {reason}"
-msgstr ""
-
-#: sat/plugins/plugin_xep_0384.py:1364
-msgid ""
-"Our message with UID {uid} has not been received in time, it has probably"
-" been lost. The message was: {msg!r}"
-msgstr ""
-
-#: sat/plugins/plugin_app_manager_docker/__init__.py:38
-msgid "Applications Manager for Docker"
-msgstr ""
-
-#: sat/plugins/plugin_app_manager_docker/__init__.py:48
-#, fuzzy
-msgid "Docker App Manager initialization"
-msgstr "Initialisation de l'extension pour les transports"
-
-#: sat/stdui/ui_contact_list.py:39 sat/stdui/ui_contact_list.py:42
-#: sat/stdui/ui_contact_list.py:190 sat/stdui/ui_contact_list.py:276
-#, fuzzy
-msgid "Add contact"
-msgstr "&Ajouter un contact"
-
-#: sat/stdui/ui_contact_list.py:45 sat/stdui/ui_contact_list.py:48
-#: sat/stdui/ui_contact_list.py:209
-#, fuzzy
-msgid "Update contact"
-msgstr "&Ajouter un contact"
-
-#: sat/stdui/ui_contact_list.py:51 sat/stdui/ui_contact_list.py:54
-#, fuzzy
-msgid "Remove contact"
-msgstr "Supp&rimer un contact"
-
-#: sat/stdui/ui_contact_list.py:157
-msgid "Select in which groups your contact is:"
-msgstr ""
-
-#: sat/stdui/ui_contact_list.py:172
-msgid "Add group"
-msgstr ""
-
-#: sat/stdui/ui_contact_list.py:174
-msgid "Add"
-msgstr ""
-
-#: sat/stdui/ui_contact_list.py:191
-#, fuzzy, python-format
-msgid "New contact identifier (JID):"
-msgstr "nouveau contact: %s"
-
-#: sat/stdui/ui_contact_list.py:203
-msgid "Nothing to update"
-msgstr ""
-
-#: sat/stdui/ui_contact_list.py:204 sat/stdui/ui_contact_list.py:223
-msgid "Your contact list is empty."
-msgstr ""
-
-#: sat/stdui/ui_contact_list.py:210
-msgid "Which contact do you want to update?"
-msgstr ""
-
-#: sat/stdui/ui_contact_list.py:222
-msgid "Nothing to delete"
-msgstr ""
-
-#: sat/stdui/ui_contact_list.py:228
-#, fuzzy, python-format
-msgid "Who do you want to remove from your contacts?"
-msgstr "Êtes vous sûr de vouloir supprimer %s de votre liste de contacts ?"
-
-#: sat/stdui/ui_contact_list.py:251
-#, fuzzy
-msgid "Delete contact"
-msgstr "&Ajouter un contact"
-
-#: sat/stdui/ui_contact_list.py:253
-#, fuzzy, python-format
-msgid "Are you sure you want to remove %s from your contact list?"
-msgstr "Êtes vous sûr de vouloir supprimer %s de votre liste de contacts ?"
-
-#: sat/stdui/ui_contact_list.py:277
-#, python-format
-msgid "Please enter a valid JID (like \"contact@%s\"):"
-msgstr ""
-
-#: sat/stdui/ui_profile_manager.py:62
-msgid "Profile password for {}"
-msgstr ""
-
-#: sat/stdui/ui_profile_manager.py:72 sat/stdui/ui_profile_manager.py:119
-#, fuzzy
-msgid "Connection error"
-msgstr "Connexion..."
-
-#: sat/stdui/ui_profile_manager.py:76
-#: sat_frontends/quick_frontend/quick_profile_manager.py:171
-#, fuzzy
-msgid "Internal error"
-msgstr "Transfert de fichier"
-
-#: sat/stdui/ui_profile_manager.py:77
-msgid "Internal error: {}"
-msgstr ""
-
-#: sat/stdui/ui_profile_manager.py:121
-#, python-format
-msgid "Can't connect to %s. Please check your connection details."
-msgstr ""
-
-#: sat/stdui/ui_profile_manager.py:127
-#, python-format
-msgid "XMPP password for %(profile)s%(counter)s"
-msgstr ""
-
-#: sat/stdui/ui_profile_manager.py:135
-#, python-format
-msgid ""
-"Can't connect to %s. Please check your connection details or try with "
-"another password."
-msgstr ""
-
-#: sat/test/constants.py:57
-msgid "Enable unibox"
-msgstr ""
-
-#: sat/test/constants.py:58
-msgid "'Wysiwyg' edition"
-msgstr ""
-
-#: sat/test/test_plugin_misc_room_game.py:43
-msgid "Dummy plugin to test room game"
-msgstr ""
-
-#: sat/tools/config.py:53
-#, fuzzy, python-format
-msgid "Testing file %s"
-msgstr "Échec de l'inscription: %s"
-
-#: sat/tools/config.py:72
-msgid "Config auto-update: {option} set to {value} in the file {config_file}."
-msgstr ""
-
-#: sat/tools/config.py:86
-msgid "Can't read main config: {msg}"
-msgstr ""
-
-#: sat/tools/config.py:91
-msgid "Configuration was read from: {filenames}"
-msgstr ""
-
-#: sat/tools/config.py:95
-#, fuzzy, python-format
-msgid "No configuration file found, using default settings"
-msgstr "Disposition inconnue, utilisation de celle par defaut"
-
-#: sat/tools/image.py:35
-msgid "SVG support not available, please install cairosvg: {e}"
-msgstr ""
-
-#: sat/tools/trigger.py:66
-#, python-format
-msgid "There is already a bound priority [%s]"
-msgstr ""
-
-#: sat/tools/trigger.py:69
-#, python-format
-msgid "There is already a trigger with the same priority [%s]"
-msgstr ""
-
-#: sat/tools/video.py:38
-msgid "ffmpeg executable not found, video thumbnails won't be available"
-msgstr ""
-
-#: sat/tools/video.py:56
-msgid "ffmpeg executable is not available, can't generate video thumbnail"
-msgstr ""
-
-#: sat/tools/xml_tools.py:86
-msgid "Fixed field has neither value nor label, ignoring it"
-msgstr ""
-
-#: sat/tools/xml_tools.py:485
-#, fuzzy
-msgid "INTERNAL ERROR: parameters xml not valid"
-msgstr "ERREUR INTERNE: les paramètres doivent avoir un nom"
-
-#: sat/tools/xml_tools.py:495
-msgid "INTERNAL ERROR: params categories must have a name"
-msgstr "ERREUR INTERNE: les catégories des paramètres doivent avoir un nom"
-
-#: sat/tools/xml_tools.py:505
-msgid "INTERNAL ERROR: params must have a name"
-msgstr "ERREUR INTERNE: les paramètres doivent avoir un nom"
-
-#: sat/tools/xml_tools.py:557
-msgid "The 'options' tag is not allowed in parameter of type 'list'!"
-msgstr ""
-
-#: sat/tools/xml_tools.py:655
-msgid "TabElement must be a child of TabsContainer"
-msgstr ""
-
-#: sat/tools/xml_tools.py:760
-msgid "Can't set row index if auto_index is True"
-msgstr ""
-
-#: sat/tools/xml_tools.py:893
-msgid "either items or columns need do be filled"
-msgstr ""
-
-#: sat/tools/xml_tools.py:907
-msgid "Headers lenght doesn't correspond to columns"
-msgstr ""
-
-#: sat/tools/xml_tools.py:954
-msgid "Incorrect number of items in list"
-msgstr ""
-
-#: sat/tools/xml_tools.py:978
-#, fuzzy
-msgid "A widget with the name \"{name}\" already exists."
-msgstr "Ce nom de profile existe déjà"
-
-#: sat/tools/xml_tools.py:1171
-msgid "Value must be an integer"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1186
-msgid "Value must be 0, 1, false or true"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1249
-msgid ""
-"\"multi\" flag and \"selected\" option are not compatible with "
-"\"noselect\" flag"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1258
-msgid "empty \"options\" list"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1277 sat/tools/xml_tools.py:1311
-msgid "invalid styles"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1335
-msgid "DialogElement must be a direct child of TopElement"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1350
-msgid "MessageElement must be a direct child of DialogElement"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1365
-msgid "ButtonsElement must be a direct child of DialogElement"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1379
-msgid "FileElement must be a direct child of DialogElement"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1458
-#, fuzzy, python-format
-msgid "Unknown panel type [%s]"
-msgstr "Type d'action inconnu"
-
-#: sat/tools/xml_tools.py:1460
-msgid "form XMLUI need a submit_id"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1462
-msgid "container argument must be a string"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1465
-msgid "dialog_opt can only be used with dialog panels"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1492
-msgid "createWidget can't be used with dialogs"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1590
-msgid "Submit ID must be filled for this kind of dialog"
-msgstr ""
-
-#: sat/tools/xml_tools.py:1618
-#, fuzzy, python-format
-msgid "Unknown container type [%s]"
-msgstr "Type d'action inconnu"
-
-#: sat/tools/xml_tools.py:1648
-#, fuzzy, python-format
-msgid "Invalid type [{type_}]"
-msgstr "Type d'action inconnu"
-
-#: sat/tools/common/async_process.py:86
-msgid ""
-"Can't complete {name} command (error code: {code}):\n"
-"stderr:\n"
-"{stderr}\n"
-"{stdout}\n"
-msgstr ""
-
-#: sat/tools/common/date_utils.py:76
-msgid "You can't use a direction (+ or -) and \"ago\" at the same time"
-msgstr ""
-
-#: sat/tools/common/template.py:149
-msgid "{site} can't be used as site name, it's reserved."
-msgstr ""
-
-#: sat/tools/common/template.py:157
-msgid "{theme} contain forbidden char. Following chars are forbidden: {reserved}"
-msgstr ""
-
-#: sat/tools/common/template.py:212
-msgid "Unregistered site requested: {site_to_check}"
-msgstr ""
-
-#: sat/tools/common/template.py:241
-msgid ""
-"Absolute template used while unsecure is disabled, hack attempt? "
-"Template: {template}"
-msgstr ""
-
-#: sat/tools/common/template.py:314
-msgid "Invalid attribute, please use one of \"defer\", \"async\" or \"\""
-msgstr ""
-
-#: sat/tools/common/template.py:332
-msgid "Can't find {libary} javascript library"
-msgstr ""
-
-#: sat/tools/common/template.py:389
-msgid ""
-"Can't add \"{name}\" site, it contains forbidden characters. Forbidden "
-"characters are {forbidden}."
-msgstr ""
-
-#: sat/tools/common/template.py:395
-msgid "Can't add \"{name}\" site, it should map to an absolute path"
-msgstr ""
-
-#: sat/tools/common/template.py:416
-msgid "Can't load theme settings at {path}: {e}"
-msgstr ""
-
-#: sat/tools/common/template.py:523
-msgid "Can't find template translation at {path}"
-msgstr ""
-
-#: sat/tools/common/template.py:526
-msgid "{site}Invalid locale name: {msg}"
-msgstr ""
-
-#: sat/tools/common/template.py:529
-msgid "{site}loaded {lang} templates translations"
-msgstr ""
-
-#: sat/tools/common/template.py:560
-msgid "invalid locale value: {msg}"
-msgstr ""
-
-#: sat/tools/common/template.py:569
-msgid "Can't find locale {locale}"
-msgstr ""
-
-#: sat/tools/common/template.py:574
-msgid "Switched to {lang}"
-msgstr ""
-
-#: sat/tools/common/template.py:774 sat_frontends/jp/cmd_event.py:134
-msgid "Can't parse date: {msg}"
-msgstr ""
-
-#: sat/tools/common/template.py:801
-#, fuzzy
-msgid "ignoring field \"{name}\": it doesn't exists"
-msgstr "Le fichier [%s] n'existe pas !"
-
-#: sat_frontends/jp/arg_tools.py:88
-msgid "ignoring {name}={value}, not corresponding to any argument (in USE)"
-msgstr ""
-
-#: sat_frontends/jp/arg_tools.py:95
-msgid "arg {name}={value} (in USE)"
-msgstr ""
-
-#: sat_frontends/jp/base.py:64
-#, fuzzy
-msgid ""
-"ProgressBar not available, please download it at "
-"http://pypi.python.org/pypi/progressbar\n"
-"Progress bar deactivated\n"
-"--\n"
-msgstr ""
-"ProgressBar n'est pas disponible, veuillez le télécharger à "
-"http://pypi.python.org/pypi/progressbar"
-
-#: sat_frontends/jp/base.py:155
-msgid ""
-"Invalid value set for \"background\" ({background}), please check your "
-"settings in libervia.conf"
-msgstr ""
-
-#: sat_frontends/jp/base.py:178
-msgid "Available commands"
-msgstr ""
-
-#: sat_frontends/jp/base.py:287
-#, python-format
-msgid "Use PROFILE profile key (default: %(default)s)"
-msgstr ""
-
-#: sat_frontends/jp/base.py:290
-msgid "Password used to connect profile, if necessary"
-msgstr ""
-
-#: sat_frontends/jp/base.py:297
-msgid "Connect the profile before doing anything else"
-msgstr ""
-
-#: sat_frontends/jp/base.py:307
-msgid "Start a profile session without connecting"
-msgstr ""
-
-#: sat_frontends/jp/base.py:313
-msgid "Show progress bar"
-msgstr "Affiche la barre de progression"
-
-#: sat_frontends/jp/base.py:318
-msgid "Add a verbosity level (can be used multiple times)"
-msgstr ""
-
-#: sat_frontends/jp/base.py:323
-msgid "be quiet (only output machine readable data)"
-msgstr ""
-
-#: sat_frontends/jp/base.py:326
-msgid "draft handling"
-msgstr ""
-
-#: sat_frontends/jp/base.py:328
-msgid "load current draft"
-msgstr ""
-
-#: sat_frontends/jp/base.py:330
-msgid "path to a draft file to retrieve"
-msgstr ""
-
-#: sat_frontends/jp/base.py:346
-msgid "Pubsub URL (xmpp or http)"
-msgstr ""
-
-#: sat_frontends/jp/base.py:348
-#, fuzzy
-msgid "JID of the PubSub service"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/base.py:350
-msgid "PEP service"
-msgstr ""
-
-#: sat_frontends/jp/base.py:352 sat_frontends/jp/base.py:360
-#: sat_frontends/jp/base.py:368
-msgid " (DEFAULT: {default})"
-msgstr ""
-
-#: sat_frontends/jp/base.py:356
-#, fuzzy
-msgid "node to request"
-msgstr "Demande de suppression de contact"
-
-#: sat_frontends/jp/base.py:358
-msgid "standard node"
-msgstr ""
-
-#: sat_frontends/jp/base.py:366
-msgid "last item"
-msgstr ""
-
-#: sat_frontends/jp/base.py:372
-msgid "retrieve last item"
-msgstr ""
-
-#: sat_frontends/jp/base.py:378
-msgid "items to retrieve (DEFAULT: all)"
-msgstr ""
-
-#: sat_frontends/jp/base.py:385
-msgid "maximum number of items to get ({no_limit} to get all items)"
-msgstr ""
-
-#: sat_frontends/jp/base.py:391
-msgid "maximum number of items to get per page (DEFAULT: 10)"
-msgstr ""
-
-#: sat_frontends/jp/base.py:398 sat_frontends/jp/cmd_message.py:217
-msgid "find page after this item"
-msgstr ""
-
-#: sat_frontends/jp/base.py:401 sat_frontends/jp/cmd_message.py:220
-msgid "find page before this item"
-msgstr ""
-
-#: sat_frontends/jp/base.py:404 sat_frontends/jp/cmd_message.py:223
-msgid "index of the page to retrieve"
-msgstr ""
-
-#: sat_frontends/jp/base.py:411
-#, fuzzy
-msgid "MAM filters to use"
-msgstr "Veuillez choisir le fichier à envoyer"
-
-#: sat_frontends/jp/base.py:424
-msgid "how items should be ordered"
-msgstr ""
-
-#: sat_frontends/jp/base.py:454
-msgid "there is already a default output for {type}, ignoring new one"
-msgstr ""
-
-#: sat_frontends/jp/base.py:475
-msgid "The following output options are invalid: {invalid_options}"
-msgstr ""
-
-#: sat_frontends/jp/base.py:499
-msgid "Can't import {module_path} plugin, ignoring it: {e}"
-msgstr ""
-
-#: sat_frontends/jp/base.py:505
-msgid "Missing module for plugin {name}: {missing}"
-msgstr ""
-
-#: sat_frontends/jp/base.py:520
-msgid "Invalid plugin module [{type}] {module}"
-msgstr ""
-
-#: sat_frontends/jp/base.py:552
-msgid "Can't parse HTML page : {msg}"
-msgstr ""
-
-#: sat_frontends/jp/base.py:558
-msgid ""
-"Could not find alternate \"xmpp:\" URI, can't find associated XMPP PubSub"
-" node/item"
-msgstr ""
-
-#: sat_frontends/jp/base.py:576
-msgid "invalid XMPP URL: {url}"
-msgstr ""
-
-#: sat_frontends/jp/base.py:596
-msgid "item specified in URL but not needed in command, ignoring it"
-msgstr ""
-
-#: sat_frontends/jp/base.py:612
-msgid "XMPP URL is not a pubsub one: {url}"
-msgstr ""
-
-#: sat_frontends/jp/base.py:618
-msgid "argument -s/--service is required"
-msgstr ""
-
-#: sat_frontends/jp/base.py:620
-msgid "argument -n/--node is required"
-msgstr ""
-
-#: sat_frontends/jp/base.py:622
-msgid "argument -i/--item is required"
-msgstr ""
-
-#: sat_frontends/jp/base.py:629
-msgid "--item and --item-last can't be used at the same time"
-msgstr ""
-
-#: sat_frontends/jp/base.py:659 sat_frontends/quick_frontend/quick_app.py:370
-msgid "Can't connect to SàT backend, are you sure it's launched ?"
-msgstr "Impossible de se connecter au démon SàT, êtes vous sûr qu'il est lancé ?"
-
-#: sat_frontends/jp/base.py:662 sat_frontends/quick_frontend/quick_app.py:373
-#, fuzzy
-msgid "Can't init bridge"
-msgstr "Construction du jeu de Tarot"
-
-#: sat_frontends/jp/base.py:666
-msgid "Error while initialising bridge: {e}"
-msgstr ""
-
-#: sat_frontends/jp/base.py:714
-msgid "action cancelled by user"
-msgstr ""
-
-#: sat_frontends/jp/base.py:785
-#, python-format
-msgid "%s is not a valid JID !"
-msgstr "%s n'est pas un JID valide !"
-
-#: sat_frontends/jp/base.py:837
-#, fuzzy
-msgid "invalid password"
-msgstr "Sauvegarde du nouveau mot de passe"
-
-#: sat_frontends/jp/base.py:839
-#, fuzzy
-msgid "please enter profile password:"
-msgstr "Veuillez entrer le nom du nouveau profile"
-
-#: sat_frontends/jp/base.py:859
-#, fuzzy, python-format
-msgid "The profile [{profile}] doesn't exist"
-msgstr "Le fichier [%s] n'existe pas !"
-
-#: sat_frontends/jp/base.py:881
-#, fuzzy, python-format
-msgid ""
-"Session for [{profile}] is not started, please start it before using jp, "
-"or use either --start-session or --connect option"
-msgstr "SAT n'est pas connecté, veuillez le connecter avant d'utiliser jp"
-
-#: sat_frontends/jp/base.py:901
-#, fuzzy, python-format
-msgid ""
-"Profile [{profile}] is not connected, please connect it before using jp, "
-"or use --connect option"
-msgstr "SAT n'est pas connecté, veuillez le connecter avant d'utiliser jp"
-
-#: sat_frontends/jp/base.py:1002
-msgid "select output format (default: {})"
-msgstr ""
-
-#: sat_frontends/jp/base.py:1005
-msgid "output specific option"
-msgstr ""
-
-#: sat_frontends/jp/base.py:1111
-msgid "file size is not known, we can't show a progress bar"
-msgstr ""
-
-#: sat_frontends/jp/base.py:1126 sat_frontends/jp/cmd_list.py:304
-msgid "Progress: "
-msgstr "Progression: "
-
-#: sat_frontends/jp/base.py:1156
-#, fuzzy
-msgid "Operation started"
-msgstr "inscription demandée pour"
-
-#: sat_frontends/jp/base.py:1172
-#, fuzzy, python-format
-msgid "Operation successfully finished"
-msgstr "Transfert [%s] refusé"
-
-#: sat_frontends/jp/base.py:1179
-msgid "Error while doing operation: {e}"
-msgstr ""
-
-#: sat_frontends/jp/base.py:1189
-msgid "trying to use output when use_output has not been set"
-msgstr ""
-
-#: sat_frontends/jp/cmd_account.py:42
-msgid "create a XMPP account"
-msgstr ""
-
-#: sat_frontends/jp/cmd_account.py:47
-msgid "jid to create"
-msgstr ""
-
-#: sat_frontends/jp/cmd_account.py:50
-msgid "password of the account"
-msgstr ""
-
-#: sat_frontends/jp/cmd_account.py:55
-msgid "create a profile to use this account (default: don't create profile)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_account.py:63
-msgid "email (usage depends of XMPP server)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_account.py:69
-msgid "server host (IP address or domain, default: use localhost)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_account.py:76
-msgid "server port (default: {port})"
-msgstr ""
-
-#: sat_frontends/jp/cmd_account.py:107
-msgid "XMPP account created"
-msgstr ""
-
-#: sat_frontends/jp/cmd_account.py:113
-#, fuzzy
-msgid "creating profile"
-msgstr "Veuillez entrer le nom du nouveau profile"
-
-#: sat_frontends/jp/cmd_account.py:129
-msgid "Can't create profile {profile} to associate with jid {jid}: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_account.py:142
-#, fuzzy
-msgid "profile created"
-msgstr "Aucun profile sélectionné"
-
-#: sat_frontends/jp/cmd_account.py:183
-msgid "change password for XMPP account"
-msgstr ""
-
-#: sat_frontends/jp/cmd_account.py:188
-#, fuzzy
-msgid "new XMPP password"
-msgstr "Sauvegarde du nouveau mot de passe"
-
-#: sat_frontends/jp/cmd_account.py:207
-msgid "delete a XMPP account"
-msgstr ""
-
-#: sat_frontends/jp/cmd_account.py:215
-msgid "delete account without confirmation"
-msgstr ""
-
-#: sat_frontends/jp/cmd_account.py:236
-msgid "Account deletion cancelled"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:34
-#, fuzzy
-msgid "remote control a software"
-msgstr "Supp&rimer un contact"
-
-#: sat_frontends/jp/cmd_adhoc.py:38
-msgid "software name"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:44
-msgid "jids allowed to use the command"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:51
-msgid "groups allowed to use the command"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:57
-msgid "groups that are *NOT* allowed to use the command"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:63
-msgid "jids that are *NOT* allowed to use the command"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:66
-#, fuzzy
-msgid "loop on the commands"
-msgstr "Mauvais nom de profile"
-
-#: sat_frontends/jp/cmd_adhoc.py:93
-#, fuzzy, python-format
-msgid "No bus name found"
-msgstr "Fonctionnalité trouvée: %s"
-
-#: sat_frontends/jp/cmd_adhoc.py:96
-#, fuzzy, python-format
-msgid "Bus name found: [%s]"
-msgstr "Fonctionnalité trouvée: %s"
-
-#: sat_frontends/jp/cmd_adhoc.py:100
-msgid "Command found: (path:{path}, iface: {iface}) [{command}]"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:112
-msgid "run an Ad-Hoc command"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:120 sat_frontends/jp/cmd_message.py:200
-msgid "jid of the service (default: profile's server"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:128
-msgid "submit form/page"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:137
-msgid "field value"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:143
-msgid "node of the command (default: list commands)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:171
-msgid "list Ad-Hoc commands of a service"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:179
-msgid "jid of the service (default: profile's server)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_adhoc.py:202
-msgid "Ad-hoc commands"
-msgstr ""
-
-#: sat_frontends/jp/cmd_application.py:33
-msgid "list available applications"
-msgstr ""
-
-#: sat_frontends/jp/cmd_application.py:45
-msgid "show applications with this status"
-msgstr ""
-
-#: sat_frontends/jp/cmd_application.py:72
-#, fuzzy
-msgid "start an application"
-msgstr "Sélection du contrat"
-
-#: sat_frontends/jp/cmd_application.py:78
-msgid "name of the application to start"
-msgstr ""
-
-#: sat_frontends/jp/cmd_application.py:98
-msgid "stop a running application"
-msgstr ""
-
-#: sat_frontends/jp/cmd_application.py:106
-msgid "name of the application to stop"
-msgstr ""
-
-#: sat_frontends/jp/cmd_application.py:111
-msgid "identifier of the instance to stop"
-msgstr ""
-
-#: sat_frontends/jp/cmd_application.py:142
-msgid "show data exposed by a running application"
-msgstr ""
-
-#: sat_frontends/jp/cmd_application.py:150
-msgid "name of the application to check"
-msgstr ""
-
-#: sat_frontends/jp/cmd_application.py:155
-msgid "identifier of the instance to check"
-msgstr ""
-
-#: sat_frontends/jp/cmd_application.py:189
-#, fuzzy
-msgid "manage applications"
-msgstr "Tab inconnu"
-
-#: sat_frontends/jp/cmd_avatar.py:38
-msgid "retrieve avatar of an entity"
-msgstr ""
-
-#: sat_frontends/jp/cmd_avatar.py:43 sat_frontends/jp/cmd_identity.py:42
-msgid "do no use cached values"
-msgstr ""
-
-#: sat_frontends/jp/cmd_avatar.py:46
-msgid "show avatar"
-msgstr ""
-
-#: sat_frontends/jp/cmd_avatar.py:48 sat_frontends/jp/cmd_info.py:111
-#, fuzzy
-msgid "entity"
-msgstr "Petite"
-
-#: sat_frontends/jp/cmd_avatar.py:87
-#, fuzzy
-msgid "No avatar found."
-msgstr "Aucune donnée trouvée"
-
-#: sat_frontends/jp/cmd_avatar.py:103
-msgid "set avatar of the profile or an entity"
-msgstr ""
-
-#: sat_frontends/jp/cmd_avatar.py:108
-msgid "entity whose avatar must be changed"
-msgstr ""
-
-#: sat_frontends/jp/cmd_avatar.py:110
-msgid "path to the image to upload"
-msgstr ""
-
-#: sat_frontends/jp/cmd_avatar.py:116
-#, fuzzy, python-format
-msgid "file {path} doesn't exist!"
-msgstr "Le fichier [%s] n'existe pas !"
-
-#: sat_frontends/jp/cmd_avatar.py:125
-msgid "avatar has been set"
-msgstr ""
-
-#: sat_frontends/jp/cmd_avatar.py:134
-msgid "avatar uploading/retrieving"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:129
-msgid "unknown syntax requested ({syntax})"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:138
-#, fuzzy
-msgid "title of the item"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_blog.py:143
-msgid "tag (category) of your item"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:148
-msgid "language of the item (ISO 639 code)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:158
-msgid ""
-"enable comments (default: comments not enabled except if they already "
-"exist)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:168
-msgid "disable comments (will remove comments node if it exist)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:174
-msgid "syntax to use (default: get profile's default syntax)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:211
-msgid "publish a new blog item or update an existing one"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:257
-msgid "get blog item(s)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:267
-msgid "microblog data key(s) to display (default: depend of verbosity)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:462
-msgid "edit an existing or new blog post"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:473
-msgid "launch a blog preview in parallel"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:478
-msgid "add \"publish: False\" to metadata"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:628
-msgid "You need lxml to edit pretty XHTML"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:662
-msgid "rename an blog item"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:666 sat_frontends/jp/cmd_pubsub.py:996
-msgid "new item id to use"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:690
-msgid "preview a blog content"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:700
-msgid "use inotify to handle preview"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:706
-#, fuzzy
-msgid "path to the content file"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_blog.py:810
-#, fuzzy, python-format
-msgid "File \"{file}\" doesn't exist!"
-msgstr "Le fichier [%s] n'existe pas !"
-
-#: sat_frontends/jp/cmd_blog.py:898
-msgid "import an external blog"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:905 sat_frontends/jp/cmd_list.py:207
-msgid "importer name, nothing to display importers list"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:907
-msgid "original blog host"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:911
-msgid "do *NOT* upload images (default: do upload images)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:915
-msgid "do not upload images from this host (default: upload all images)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:920
-msgid "ignore invalide TLS certificate for uploads"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:929 sat_frontends/jp/cmd_list.py:216
-msgid "importer specific options (see importer description)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:934 sat_frontends/jp/cmd_list.py:250
-msgid ""
-"importer data location (see importer description), nothing to show "
-"importer description"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:941
-msgid "Blog upload started"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:944
-msgid "Blog uploaded successfully"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:965
-msgid ""
-"\n"
-"To redirect old URLs to new ones, put the following lines in your "
-"sat.conf file, in [libervia] section:\n"
-"\n"
-"{conf}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:973
-#, fuzzy, python-format
-msgid "Error while uploading blog: {error_msg}"
-msgstr "Erreur en tentant de rejoindre le salon"
-
-#: sat_frontends/jp/cmd_blog.py:982 sat_frontends/jp/cmd_list.py:274
-msgid "{name} argument can't be used without location argument"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:1037
-msgid "Error while trying to import a blog: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_blog.py:1050
-msgid "blog/microblog management"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:40
-#, python-format
-msgid "storage location (default: %(default)s)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:48
-#, python-format
-msgid "bookmarks type (default: %(default)s)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:54
-msgid "list bookmarks"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:94
-msgid "remove a bookmark"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:99 sat_frontends/jp/cmd_bookmarks.py:131
-msgid "jid (for muc bookmark) or url of to remove"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:105
-msgid "delete bookmark without confirmation"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:110
-#, fuzzy, python-format
-msgid "Are you sure to delete this bookmark?"
-msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
-
-#: sat_frontends/jp/cmd_bookmarks.py:117
-msgid "can't delete bookmark: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:120
-msgid "bookmark deleted"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:133
-msgid "bookmark name"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:134
-msgid "MUC specific options"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:135
-#, fuzzy
-msgid "nickname"
-msgstr "Surnon"
-
-#: sat_frontends/jp/cmd_bookmarks.py:140
-msgid "join room on profile connection"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:145
-msgid "You can't use --autojoin or --nick with --type url"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:165
-msgid "bookmark successfully added"
-msgstr ""
-
-#: sat_frontends/jp/cmd_bookmarks.py:174
-msgid "manage bookmarks"
-msgstr ""
-
-#: sat_frontends/jp/cmd_debug.py:49
-msgid "call a bridge method"
-msgstr ""
-
-#: sat_frontends/jp/cmd_debug.py:54
-msgid "name of the method to execute"
-msgstr ""
-
-#: sat_frontends/jp/cmd_debug.py:56
-msgid "argument of the method"
-msgstr ""
-
-#: sat_frontends/jp/cmd_debug.py:79
-msgid "Error while executing {method}: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_debug.py:94
-msgid "send a fake signal from backend"
-msgstr ""
-
-#: sat_frontends/jp/cmd_debug.py:99
-msgid "name of the signal to send"
-msgstr ""
-
-#: sat_frontends/jp/cmd_debug.py:100
-msgid "argument of the signal"
-msgstr ""
-
-#: sat_frontends/jp/cmd_debug.py:112
-#, fuzzy, python-format
-msgid "Can't send fake signal: {e}"
-msgstr "message reçu de: %s"
-
-#: sat_frontends/jp/cmd_debug.py:123
-msgid "bridge s(t)imulation"
-msgstr ""
-
-#: sat_frontends/jp/cmd_debug.py:135
-msgid "monitor XML stream"
-msgstr ""
-
-#: sat_frontends/jp/cmd_debug.py:144
-msgid "stream direction filter"
-msgstr ""
-
-#: sat_frontends/jp/cmd_debug.py:195
-msgid "print colours used with your background"
-msgstr ""
-
-#: sat_frontends/jp/cmd_debug.py:226
-msgid "debugging tools"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:38
-msgid "show available encryption algorithms"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:45
-msgid "No encryption plugin registered!"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:47
-msgid "Following encryption algorithms are available: {algos}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:68
-msgid "get encryption session data"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:73
-msgid "jid of the entity to check"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:99
-msgid "start encrypted session with an entity"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:105 sat_frontends/jp/cmd_message.py:77
-msgid "don't replace encryption algorithm if an other one is already used"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:108
-msgid "algorithm name (DEFAULT: choose automatically)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:111
-msgid "algorithm namespace (DEFAULT: choose automatically)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:114
-#: sat_frontends/jp/cmd_encryption.py:153
-#: sat_frontends/jp/cmd_encryption.py:178
-msgid "jid of the entity to stop encrypted session with"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:148
-msgid "stop encrypted session with an entity"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:173
-msgid "get UI to manage trust"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:182
-msgid "algorithm name (DEFAULT: current algorithm)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:185
-msgid "algorithm namespace (DEFAULT: current algorithm)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:220
-msgid "trust manangement"
-msgstr ""
-
-#: sat_frontends/jp/cmd_encryption.py:230
-msgid "encryption sessions handling"
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:47
-msgid "get list of registered events"
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:78
-msgid "get event data"
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:108
-#, fuzzy
-msgid "ID of the PubSub Item"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_event.py:110
-msgid "date of the event"
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:118 sat_frontends/jp/cmd_event.py:257
-#: sat_frontends/jp/cmd_pubsub.py:129
-#, fuzzy
-msgid "configuration field to set"
-msgstr "Connexion..."
-
-#: sat_frontends/jp/cmd_event.py:150
-msgid "create or replace event"
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:170
-msgid "Event created successfuly on node {node}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:181
-msgid "modify an existing event"
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:214 sat_frontends/jp/cmd_event.py:288
-msgid "get event attendance"
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:219
-#, fuzzy
-msgid "bare jid of the invitee"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_event.py:246
-msgid "set event attendance"
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:296
-msgid "show missing people (invited but no R.S.V.P. so far)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:302
-msgid "don't show people which gave R.S.V.P."
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:371
-msgid "Attendees: "
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:374
-msgid " ("
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:376
-msgid "yes: "
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:379
-msgid ", maybe: "
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:383
-msgid "no: "
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:390
-msgid "confirmed guests: "
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:395
-msgid "unconfirmed guests: "
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:401
-msgid "total: "
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:408
-msgid "missing people (no reply): "
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:416
-msgid "you need to use --missing if you use --no-rsvp"
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:489
-msgid "invite someone to the event through email"
-msgstr ""
-
-#: sat_frontends/jp/cmd_event.py:568
-#, fuzzy
-msgid "manage invities"
-msgstr "Initialisation du gestionnaire de mémoire"
-
-#: sat_frontends/jp/cmd_event.py:577
-msgid "event management"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:50
-#, fuzzy
-msgid "send a file to a contact"
-msgstr "Attend qu'un fichier soit envoyé par un contact"
-
-#: sat_frontends/jp/cmd_file.py:55
-#, fuzzy
-msgid "a list of file"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_file.py:57 sat_frontends/jp/cmd_file.py:191
-#: sat_frontends/jp/cmd_message.py:82 sat_frontends/jp/cmd_pipe.py:42
-msgid "the destination jid"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:59
-#, fuzzy
-msgid "make a bzip2 tarball"
-msgstr "Fait un fichier compressé bzip2"
-
-#: sat_frontends/jp/cmd_file.py:79 sat_frontends/jp/cmd_file.py:236
-#: sat_frontends/jp/cmd_file.py:330
-msgid "File copy started"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:82
-#, fuzzy
-msgid "File sent successfully"
-msgstr "Inscription réussie"
-
-#: sat_frontends/jp/cmd_file.py:86
-#, fuzzy
-msgid "The file has been refused by your contact"
-msgstr "Attend qu'un fichier soit envoyé par un contact"
-
-#: sat_frontends/jp/cmd_file.py:88
-#, fuzzy, python-format
-msgid "Error while sending file: {}"
-msgstr "Erreur en tentant de rejoindre le salon"
-
-#: sat_frontends/jp/cmd_file.py:97
-msgid "File request sent to {jid}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:102
-#, fuzzy
-msgid "Can't send file to {jid}"
-msgstr "Impossible de trouver la VCard de %s"
-
-#: sat_frontends/jp/cmd_file.py:109
-#, fuzzy, python-format
-msgid "file {file_} doesn't exist!"
-msgstr "Le fichier [%s] n'existe pas !"
-
-#: sat_frontends/jp/cmd_file.py:114
-#, fuzzy, python-format
-msgid "{file_} is a dir! Please send files inside or use compression"
-msgstr ""
-"[%s] est un répertoire ! Veuillez envoyer les fichiers qu'il contient ou "
-"utiliser la compression."
-
-#: sat_frontends/jp/cmd_file.py:129
-#, fuzzy
-msgid "bz2 is an experimental option, use with caution"
-msgstr ""
-"bz2 est une option expérimentale à un stade de développement peu avancé, "
-"utilisez-là avec prudence"
-
-#: sat_frontends/jp/cmd_file.py:131
-msgid "Starting compression, please wait..."
-msgstr "Lancement de la compression, veuillez patienter..."
-
-#: sat_frontends/jp/cmd_file.py:138
-#, fuzzy, python-format
-msgid "Adding {}"
-msgstr "Ajout de %s"
-
-#: sat_frontends/jp/cmd_file.py:141
-#, fuzzy
-msgid "Done !"
-msgstr "N° de Tél:"
-
-#: sat_frontends/jp/cmd_file.py:183
-#, fuzzy
-msgid "request a file from a contact"
-msgstr "Attend qu'un fichier soit envoyé par un contact"
-
-#: sat_frontends/jp/cmd_file.py:195
-msgid ""
-"destination path where the file will be saved (default: "
-"[current_dir]/[name|hash])"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:204
-#, fuzzy
-msgid "name of the file"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_file.py:210
-#, fuzzy
-msgid "hash of the file"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_file.py:216
-msgid "hash algorithm use for --hash (default: sha-256)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:232 sat_frontends/jp/cmd_file.py:476
-msgid "overwrite existing file without confirmation"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:239 sat_frontends/jp/cmd_file.py:333
-#, fuzzy, python-format
-msgid "File received successfully"
-msgstr "tarot: chien reçu"
-
-#: sat_frontends/jp/cmd_file.py:243
-msgid "The file request has been refused"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:245
-#, fuzzy, python-format
-msgid "Error while requesting file: {}"
-msgstr "Échec de la désinscription: %s"
-
-#: sat_frontends/jp/cmd_file.py:249
-msgid "at least one of --name or --hash must be provided"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:258 sat_frontends/jp/cmd_file.py:510
-msgid "File {path} already exists! Do you want to overwrite?"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:261
-msgid "file request cancelled"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:280
-#, fuzzy, python-format
-msgid "can't request file: {e}"
-msgstr "Échec de la désinscription: %s"
-
-#: sat_frontends/jp/cmd_file.py:293
-#, fuzzy
-msgid "wait for a file to be sent by a contact"
-msgstr "Attend qu'un fichier soit envoyé par un contact"
-
-#: sat_frontends/jp/cmd_file.py:306
-msgid "jids accepted (accept everything if none is specified)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:312
-#, fuzzy
-msgid "accept multiple files (you'll have to stop manually)"
-msgstr "Accepte plusieurs fichiers (vous devrez arrêter le programme à la main)"
-
-#: sat_frontends/jp/cmd_file.py:318
-#, fuzzy
-msgid "force overwritting of existing files (/!\\ name is choosed by sender)"
-msgstr "Force le remplacement des fichiers existants"
-
-#: sat_frontends/jp/cmd_file.py:326
-msgid "destination path (default: working directory)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:337
-msgid "hash checked: {metadata['hash_algo']}:{metadata['hash']}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:340
-msgid "hash is checked but hash value is missing"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:342
-msgid "hash can't be verified"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:345
-#, fuzzy
-msgid "Error while receiving file: {e}"
-msgstr "Erreur en tentant de rejoindre le salon"
-
-#: sat_frontends/jp/cmd_file.py:354 sat_frontends/jp/cmd_pipe.py:111
-msgid "Action has no XMLUI"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:359 sat_frontends/jp/cmd_pipe.py:115
-msgid "Invalid XMLUI received"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:369 sat_frontends/jp/cmd_pipe.py:126
-msgid "Ignoring action without from_jid data"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:374 sat_frontends/jp/cmd_file.py:395
-msgid "ignoring action without progress id"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:379
-msgid "File refused because overwrite is needed"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:397
-msgid "Overwriting needed"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:401
-#, fuzzy
-msgid "Overwrite accepted"
-msgstr "accepté"
-
-#: sat_frontends/jp/cmd_file.py:403
-msgid "Refused to overwrite"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:417
-msgid "invalid \"from_jid\" value received, ignoring: {value}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:424
-msgid "ignoring action without \"from_jid\" value"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:426
-msgid "Confirmation needed for request from an entity not in roster"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:431
-msgid "Sender confirmed because she or he is explicitly expected"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:439
-msgid "Session refused for {from_jid}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:446
-msgid "Given path is not a directory !"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:450
-msgid "waiting for incoming file request"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:461
-msgid "download a file from URI"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:470
-msgid "destination file (DEFAULT: filename from URL)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:478
-msgid "URI of the file to retrieve"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:481
-msgid "File download started"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:484
-msgid "File downloaded successfully"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:487
-#, fuzzy, python-format
-msgid "Error while downloading file: {}"
-msgstr "Erreur en tentant de rejoindre le salon"
-
-#: sat_frontends/jp/cmd_file.py:513
-msgid "file download cancelled"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:534
-msgid "upload a file"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:542
-msgid "encrypt file using AES-GCM"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:544
-msgid "file to upload"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:548
-msgid "jid of upload component (nothing to autodetect)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:553
-msgid "ignore invalide TLS certificate (/!\\ Dangerous /!\\)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:557
-msgid "File upload started"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:560
-msgid "File uploaded successfully"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:566
-msgid "URL to retrieve the file:"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:571
-msgid "Error while uploading file: {}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:593
-#, fuzzy, python-format
-msgid "file {file_} doesn't exist !"
-msgstr "Le fichier [%s] n'existe pas !"
-
-#: sat_frontends/jp/cmd_file.py:597
-msgid "{file_} is a dir! Can't upload a dir"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:633
-msgid "set affiliations for a shared file/directory"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:641 sat_frontends/jp/cmd_file.py:695
-#: sat_frontends/jp/cmd_file.py:747 sat_frontends/jp/cmd_file.py:801
-#: sat_frontends/jp/cmd_file.py:1002
-msgid "namespace of the repository"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:647 sat_frontends/jp/cmd_file.py:701
-#: sat_frontends/jp/cmd_file.py:753 sat_frontends/jp/cmd_file.py:807
-#: sat_frontends/jp/cmd_file.py:1007
-msgid "path to the repository"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:657 sat_frontends/jp/cmd_pubsub.py:453
-msgid "entity/affiliation couple(s)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:661 sat_frontends/jp/cmd_file.py:767
-msgid "jid of file sharing entity"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:687
-msgid "retrieve affiliations of a shared file/directory"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:705 sat_frontends/jp/cmd_file.py:811
-msgid "jid of sharing entity"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:729
-msgid "affiliations management"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:739
-msgid "set configuration for a shared file/directory"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:763 sat_frontends/jp/cmd_pubsub.py:282
-msgid "configuration field to set (required)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:793
-msgid "retrieve configuration of a shared file/directory"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:838
-#, fuzzy
-msgid "file sharing node configuration"
-msgstr "Demande de confirmation pour un transfer de fichier demandée"
-
-#: sat_frontends/jp/cmd_file.py:850
-msgid "retrieve files shared by an entity"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:859
-msgid "path to the directory containing the files"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:865
-msgid "jid of sharing entity (nothing to check our own jid)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:874
-msgid "unknown file type: {type}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:923
-msgid "share a file or directory"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:931
-msgid "virtual name to use (default: use directory/file name)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:941
-msgid "jid of contacts allowed to retrieve the files"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:946
-msgid "share publicly the file(s) (/!\\ *everybody* will be able to access them)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:953
-msgid "path to a file or directory to share"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:978
-msgid "{path} shared under the name \"{name}\""
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:988
-msgid "send invitation for a shared repository"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:996
-#, fuzzy
-msgid "name of the repository"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_file.py:1014
-msgid "type of the repository"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:1019
-msgid "https URL of a image to use as thumbnail"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:1023
-msgid "jid of the file sharing service hosting the repository"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:1027
-#, fuzzy
-msgid "jid of the person to invite"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_file.py:1035
-msgid "only http(s) links are allowed with --thumbnail"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:1053
-msgid "invitation sent to {jid}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:1068
-msgid "files sharing management"
-msgstr ""
-
-#: sat_frontends/jp/cmd_file.py:1077
-msgid "files sending/receiving/management"
-msgstr ""
-
-#: sat_frontends/jp/cmd_forums.py:45
-msgid "edit forums"
-msgstr ""
-
-#: sat_frontends/jp/cmd_forums.py:54 sat_frontends/jp/cmd_forums.py:123
-msgid "forum key (DEFAULT: default forums)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_forums.py:74
-msgid "forums have been edited"
-msgstr ""
-
-#: sat_frontends/jp/cmd_forums.py:115
-msgid "get forums structure"
-msgstr ""
-
-#: sat_frontends/jp/cmd_forums.py:168 sat_frontends/jp/cmd_pubsub.py:733
-#, fuzzy
-msgid "no schema found"
-msgstr "Aucun transport trouvé"
-
-#: sat_frontends/jp/cmd_forums.py:180
-msgid "Forums structure edition"
-msgstr ""
-
-#: sat_frontends/jp/cmd_identity.py:37
-msgid "get identity data"
-msgstr ""
-
-#: sat_frontends/jp/cmd_identity.py:45
-msgid "entity to check"
-msgstr ""
-
-#: sat_frontends/jp/cmd_identity.py:68
-msgid "update identity data"
-msgstr ""
-
-#: sat_frontends/jp/cmd_identity.py:77
-msgid "nicknames of the entity"
-msgstr ""
-
-#: sat_frontends/jp/cmd_identity.py:101
-msgid "identity management"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:38
-msgid "service discovery"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:42
-msgid "entity to discover"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:49
-msgid "type of data to discover"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:51
-msgid "node to use"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:57
-#, fuzzy
-msgid "ignore cache"
-msgstr "fichier [%s] déjà en cache"
-
-#: sat_frontends/jp/cmd_info.py:69
-msgid "category"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:111
-msgid "node"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:116
-msgid "Features"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:118
-#, fuzzy
-msgid "Identities"
-msgstr "Petite"
-
-#: sat_frontends/jp/cmd_info.py:120
-msgid "Extensions"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:122
-msgid "Items"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:151 sat_frontends/jp/cmd_info.py:166
-msgid "error while doing discovery: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:190
-msgid "software version"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:193 sat_frontends/jp/cmd_info.py:258
-#, fuzzy
-msgid "Entity to request"
-msgstr "Demande de suppression de contact"
-
-#: sat_frontends/jp/cmd_info.py:201
-#, fuzzy, python-format
-msgid "error while trying to get version: {e}"
-msgstr "Erreur en tentant de rejoindre le salon"
-
-#: sat_frontends/jp/cmd_info.py:207
-msgid "Software name: {name}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:209
-msgid "Software version: {version}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:211
-msgid "Operating System: {os}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:225
-#, fuzzy
-msgid "running session"
-msgstr "Lancement de l'application"
-
-#: sat_frontends/jp/cmd_info.py:243
-msgid "Error getting session infos: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:253
-msgid "devices of an entity"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:267
-msgid "Error getting devices infos: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_info.py:283
-msgid "Get various pieces of information on entities"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:61
-msgid "encoding of the input data"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:69
-msgid "standard input"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:77
-msgid "short option"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:85
-msgid "long option"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:93
-msgid "positional argument"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:101
-msgid "ignore value"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:107
-msgid "don't actually run commands but echo what would be launched"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:110
-msgid "log stdout to FILE"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:113
-msgid "log stderr to FILE"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:128 sat_frontends/jp/cmd_input.py:193
-msgid "arguments in input data and in arguments sequence don't match"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:155 sat_frontends/jp/cmd_input.py:207
-msgid "values: "
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:161
-msgid "**SKIPPING**\n"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:184
-msgid "Invalid argument, an option type is expected, got {type_}:{name}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:199
-msgid "command {idx}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:252 sat_frontends/primitivus/xmlui.py:461
-msgid "OK"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:254
-msgid "FAILED"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:274
-msgid "comma-separated values"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:283
-msgid "starting row (previous ones will be ignored)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:291
-msgid "split value in several options"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:299
-msgid "action to do on empty value ({choices})"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:314
-msgid "--empty value must be one of {choices}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_input.py:349
-msgid "launch command with external input"
-msgstr ""
-
-#: sat_frontends/jp/cmd_invitation.py:38
-msgid "create and send an invitation"
-msgstr ""
-
-#: sat_frontends/jp/cmd_invitation.py:127
-msgid "you need to specify an email address to send email invitation"
-msgstr ""
-
-#: sat_frontends/jp/cmd_invitation.py:161
-#, fuzzy
-msgid "get invitation data"
-msgstr "Connexion..."
-
-#: sat_frontends/jp/cmd_invitation.py:165
-#: sat_frontends/jp/cmd_invitation.py:225
-#: sat_frontends/jp/cmd_invitation.py:289
-#, fuzzy
-msgid "invitation UUID"
-msgstr "Connexion..."
-
-#: sat_frontends/jp/cmd_invitation.py:170
-msgid "start profile session and retrieve jid"
-msgstr ""
-
-#: sat_frontends/jp/cmd_invitation.py:185
-msgid "can't get invitation data: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_invitation.py:198
-#, fuzzy
-msgid "can't start session: {e}"
-msgstr "Construction du jeu de Tarot"
-
-#: sat_frontends/jp/cmd_invitation.py:208
-msgid "can't retrieve jid: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_invitation.py:221
-#, fuzzy
-msgid "delete guest account"
-msgstr "Enregistrement d'un nouveau compte"
-
-#: sat_frontends/jp/cmd_invitation.py:233
-msgid "can't delete guest account: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_invitation.py:242
-msgid "modify existing invitation"
-msgstr ""
-
-#: sat_frontends/jp/cmd_invitation.py:299
-msgid "you can't set {arg_name} in both optional argument and extra"
-msgstr ""
-
-#: sat_frontends/jp/cmd_invitation.py:314
-msgid "invitations have been modified successfuly"
-msgstr ""
-
-#: sat_frontends/jp/cmd_invitation.py:328
-#, fuzzy
-msgid "list invitations data"
-msgstr "Connexion..."
-
-#: sat_frontends/jp/cmd_invitation.py:346
-msgid "return only invitations linked to this profile"
-msgstr ""
-
-#: sat_frontends/jp/cmd_invitation.py:370
-msgid "invitation of user(s) without XMPP account"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:43 sat_frontends/jp/cmd_list.py:81
-#: sat_frontends/jp/cmd_list.py:150 sat_frontends/jp/cmd_merge_request.py:39
-#: sat_frontends/jp/cmd_merge_request.py:124
-#: sat_frontends/jp/cmd_merge_request.py:169
-msgid "auto"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:45
-msgid "get lists"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:82
-msgid "set a list item"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:94
-msgid "field(s) to set (required)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:101
-msgid "update existing item instead of replacing it (DEFAULT: auto)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:107
-msgid "id, URL of the item to update, or nothing for new item"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:151
-msgid "delete a list item"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:156 sat_frontends/jp/cmd_pubsub.py:884
-#: sat_frontends/jp/cmd_roster.py:135
-msgid "delete without confirmation"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:159 sat_frontends/jp/cmd_pubsub.py:887
-#, fuzzy
-msgid "notify deletion"
-msgstr "Sélection du contrat"
-
-#: sat_frontends/jp/cmd_list.py:163
-msgid "id of the item to delete"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:169
-msgid "You need to specify a list item to delete"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:171
-#, fuzzy, python-format
-msgid "Are you sure to delete list item {item_id} ?"
-msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
-
-#: sat_frontends/jp/cmd_list.py:174 sat_frontends/jp/cmd_pubsub.py:897
-msgid "item deletion cancelled"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:184 sat_frontends/jp/cmd_pubsub.py:907
-#, fuzzy, python-format
-msgid "can't delete item: {e}"
-msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
-
-#: sat_frontends/jp/cmd_list.py:187 sat_frontends/jp/cmd_pubsub.py:910
-msgid "item {item} has been deleted"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:200
-msgid "import tickets from external software/dataset"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:225
-msgid ""
-"specified field in import data will be put in dest field (default: use "
-"same field name, or ignore if it doesn't exist)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:235
-msgid "PubSub service where the items must be uploaded (default: server)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:242
-msgid "PubSub node where the items must be uploaded (default: tickets' defaults)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:257
-msgid "Tickets upload started"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:260
-msgid "Tickets uploaded successfully"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:264
-#, fuzzy, python-format
-msgid "Error while uploading tickets: {error_msg}"
-msgstr "Erreur en tentant de rejoindre le salon"
-
-#: sat_frontends/jp/cmd_list.py:319
-msgid ""
-"fields_map must be specified either preencoded in --option or using "
-"--map, but not both at the same time"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:337
-msgid "Error while trying to import tickets: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_list.py:350
-msgid "pubsub lists handling"
-msgstr ""
-
-#: sat_frontends/jp/cmd_merge_request.py:40
-msgid "publish or update a merge request"
-msgstr ""
-
-#: sat_frontends/jp/cmd_merge_request.py:48
-msgid "id or URL of the request to update, or nothing for a new one"
-msgstr ""
-
-#: sat_frontends/jp/cmd_merge_request.py:55
-#: sat_frontends/jp/cmd_merge_request.py:179
-msgid "path of the repository (DEFAULT: current directory)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_merge_request.py:61
-msgid "publish merge request without confirmation"
-msgstr ""
-
-#: sat_frontends/jp/cmd_merge_request.py:68
-msgid "labels to categorize your request"
-msgstr ""
-
-#: sat_frontends/jp/cmd_merge_request.py:75
-msgid ""
-"You are going to publish your changes to service [{service}], are you "
-"sure ?"
-msgstr ""
-
-#: sat_frontends/jp/cmd_merge_request.py:80
-msgid "merge request publication cancelled"
-msgstr ""
-
-#: sat_frontends/jp/cmd_merge_request.py:105
-msgid "Merge request published at {published_id}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_merge_request.py:110
-msgid "Merge request published"
-msgstr ""
-
-#: sat_frontends/jp/cmd_merge_request.py:125
-#, fuzzy
-msgid "get a merge request"
-msgstr "Demande de changement de statut"
-
-#: sat_frontends/jp/cmd_merge_request.py:170
-#, fuzzy
-msgid "import a merge request"
-msgstr "Demande de changement de statut"
-
-#: sat_frontends/jp/cmd_merge_request.py:209
-msgid "merge-request management"
-msgstr ""
-
-#: sat_frontends/jp/cmd_message.py:34
-#, fuzzy
-msgid "send a message to a contact"
-msgstr "Attend qu'un fichier soit envoyé par un contact"
-
-#: sat_frontends/jp/cmd_message.py:38
-msgid "language of the message"
-msgstr ""
-
-#: sat_frontends/jp/cmd_message.py:44
-#, fuzzy
-msgid ""
-"separate xmpp messages: send one message per line instead of one message "
-"alone."
-msgstr ""
-"Sépare les messages xmpp: envoi un message par ligne plutôt qu'un seul "
-"message global."
-
-#: sat_frontends/jp/cmd_message.py:53
-#, fuzzy
-msgid "add a new line at the beginning of the input (usefull for ascii art ;))"
-msgstr "Ajoute un saut de ligne au début de l'entrée (utile pour l'art ascii ;))"
-
-#: sat_frontends/jp/cmd_message.py:60
-msgid "subject of the message"
-msgstr ""
-
-#: sat_frontends/jp/cmd_message.py:63
-msgid "language of subject"
-msgstr ""
-
-#: sat_frontends/jp/cmd_message.py:70
-msgid "type of the message"
-msgstr ""
-
-#: sat_frontends/jp/cmd_message.py:73
-msgid "encrypt message using given algorithm"
-msgstr ""
-
-#: sat_frontends/jp/cmd_message.py:79
-msgid "XHTML body"
-msgstr ""
-
-#: sat_frontends/jp/cmd_message.py:80
-msgid "rich body"
-msgstr ""
-
-#: sat_frontends/jp/cmd_message.py:195
-msgid "query archives using MAM"
-msgstr ""
-
-#: sat_frontends/jp/cmd_message.py:203
-msgid "start fetching archive from this date (default: from the beginning)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_message.py:207
-msgid "end fetching archive after this date (default: no limit)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_message.py:210
-msgid "retrieve only archives with this jid"
-msgstr ""
-
-#: sat_frontends/jp/cmd_message.py:213
-msgid "maximum number of items to retrieve, using RSM (default: 20))"
-msgstr ""
-
-#: sat_frontends/jp/cmd_message.py:276
-msgid "messages handling"
-msgstr ""
-
-#: sat_frontends/jp/cmd_param.py:32
-#, fuzzy
-msgid "get a parameter value"
-msgstr "Impossible de charger les paramètres !"
-
-#: sat_frontends/jp/cmd_param.py:37 sat_frontends/jp/cmd_param.py:94
-#, fuzzy
-msgid "category of the parameter"
-msgstr "Impossible de charger les paramètres !"
-
-#: sat_frontends/jp/cmd_param.py:39 sat_frontends/jp/cmd_param.py:95
-#: sat_frontends/jp/cmd_param.py:96
-#, fuzzy
-msgid "name of the parameter"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_param.py:45
-msgid "name of the attribute to get"
-msgstr ""
-
-#: sat_frontends/jp/cmd_param.py:48 sat_frontends/jp/cmd_param.py:98
-msgid "security limit"
-msgstr ""
-
-#: sat_frontends/jp/cmd_param.py:62
-#, fuzzy
-msgid "can't find requested parameters: {e}"
-msgstr "Impossible de charger les paramètres !"
-
-#: sat_frontends/jp/cmd_param.py:79
-#, fuzzy
-msgid "can't find requested parameter: {e}"
-msgstr "Impossible de charger les paramètres !"
-
-#: sat_frontends/jp/cmd_param.py:90
-msgid "set a parameter value"
-msgstr ""
-
-#: sat_frontends/jp/cmd_param.py:111
-#, fuzzy
-msgid "can't set requested parameter: {e}"
-msgstr "Mauvais nom de profile"
-
-#: sat_frontends/jp/cmd_param.py:125
-#, fuzzy, python-format
-msgid "save parameters template to xml file"
-msgstr "Impossible de charger le modèle des paramètres !"
-
-#: sat_frontends/jp/cmd_param.py:129
-msgid "output file"
-msgstr ""
-
-#: sat_frontends/jp/cmd_param.py:136
-#, fuzzy, python-format
-msgid "can't save parameters to file: {e}"
-msgstr "Impossible de charger le modèle des paramètres !"
-
-#: sat_frontends/jp/cmd_param.py:140
-#, fuzzy, python-format
-msgid "parameters saved to file {filename}"
-msgstr "Échec de la désinscription: %s"
-
-#: sat_frontends/jp/cmd_param.py:155
-#, fuzzy, python-format
-msgid "load parameters template from xml file"
-msgstr "Impossible de charger le modèle des paramètres !"
-
-#: sat_frontends/jp/cmd_param.py:159
-#, fuzzy
-msgid "input file"
-msgstr "Envoi un fichier"
-
-#: sat_frontends/jp/cmd_param.py:166
-#, fuzzy, python-format
-msgid "can't load parameters from file: {e}"
-msgstr "Impossible de charger le modèle des paramètres !"
-
-#: sat_frontends/jp/cmd_param.py:170
-#, fuzzy, python-format
-msgid "parameters loaded from file {filename}"
-msgstr "Échec de la désinscription: %s"
-
-#: sat_frontends/jp/cmd_param.py:182
-#, fuzzy
-msgid "Save/load parameters template"
-msgstr "Impossible de charger le modèle des paramètres !"
-
-#: sat_frontends/jp/cmd_ping.py:29
-msgid "ping XMPP entity"
-msgstr ""
-
-#: sat_frontends/jp/cmd_ping.py:32
-msgid "jid to ping"
-msgstr ""
-
-#: sat_frontends/jp/cmd_ping.py:34
-msgid "output delay only (in s)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_ping.py:41
-msgid "can't do the ping: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pipe.py:38
-msgid "send a pipe a stream"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pipe.py:97
-#, fuzzy
-msgid "receive a pipe stream"
-msgstr "Lancement du flux"
-
-#: sat_frontends/jp/cmd_pipe.py:104
-msgid "Jids accepted (none means \"accept everything\")"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pipe.py:159
-msgid "stream piping through XMPP"
-msgstr ""
-
-#: sat_frontends/jp/cmd_profile.py:33
-#, fuzzy
-msgid "The name of the profile"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_profile.py:51
-msgid "You need to use either --connect or --start-session"
-msgstr ""
-
-#: sat_frontends/jp/cmd_profile.py:78
-#, fuzzy
-msgid "the name of the profile"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_profile.py:81
-msgid "the password of the profile"
-msgstr ""
-
-#: sat_frontends/jp/cmd_profile.py:83 sat_frontends/jp/cmd_profile.py:238
-#, fuzzy
-msgid "the jid of the profile"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_profile.py:86
-msgid "the password of the XMPP account (use profile password if not specified)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_profile.py:93 sat_frontends/jp/cmd_profile.py:247
-msgid "connect this profile automatically when backend starts"
-msgstr ""
-
-#: sat_frontends/jp/cmd_profile.py:97
-msgid "set to component import name (entry point) if this is a component"
-msgstr ""
-
-#: sat_frontends/jp/cmd_profile.py:154
-msgid "delete profile without confirmation"
-msgstr ""
-
-#: sat_frontends/jp/cmd_profile.py:174
-#, fuzzy
-msgid "get information about a profile"
-msgstr "Demande de contacts pour un profile inexistant"
-
-#: sat_frontends/jp/cmd_profile.py:180
-msgid "show the XMPP password IN CLEAR TEXT"
-msgstr ""
-
-#: sat_frontends/jp/cmd_profile.py:184
-#, fuzzy
-msgid "XMPP password"
-msgstr "Mot de passe:"
-
-#: sat_frontends/jp/cmd_profile.py:185
-msgid "autoconnect (backend)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_profile.py:209
-msgid "get clients profiles only"
-msgstr ""
-
-#: sat_frontends/jp/cmd_profile.py:229
-msgid "modify an existing profile"
-msgstr ""
-
-#: sat_frontends/jp/cmd_profile.py:234
-#, fuzzy
-msgid "change the password of the profile"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_profile.py:237
-msgid "disable profile password (dangerous!)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_profile.py:240
-msgid "change the password of the XMPP account"
-msgstr ""
-
-#: sat_frontends/jp/cmd_profile.py:243
-#, fuzzy
-msgid "set as default profile"
-msgstr "Veuillez entrer le nom du nouveau profile"
-
-#: sat_frontends/jp/cmd_profile.py:280
-#, fuzzy
-msgid "profile commands"
-msgstr "Mauvais nom de profile"
-
-#: sat_frontends/jp/cmd_pubsub.py:59
-#, fuzzy
-msgid "retrieve node configuration"
-msgstr "Connexion..."
-
-#: sat_frontends/jp/cmd_pubsub.py:68
-msgid "data key to filter"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:116
-#, fuzzy
-msgid "create a node"
-msgstr "Veuillez entrer le nom du nouveau profile"
-
-#: sat_frontends/jp/cmd_pubsub.py:135 sat_frontends/jp/cmd_pubsub.py:288
-msgid "don't prepend \"pubsub#\" prefix to field names"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:158
-msgid "can't create node: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:162
-#, fuzzy
-msgid "node created successfully: "
-msgstr "Inscription réussie"
-
-#: sat_frontends/jp/cmd_pubsub.py:176
-msgid "purge a node (i.e. remove all items from it)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:184
-#, fuzzy
-msgid "purge node without confirmation"
-msgstr "désinscription confirmée pour [%s]"
-
-#: sat_frontends/jp/cmd_pubsub.py:190
-msgid ""
-"Are you sure to purge PEP node [{node}]? This will delete ALL items from "
-"it!"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:195
-msgid ""
-"Are you sure to delete node [{node}] on service [{service}]? This will "
-"delete ALL items from it!"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:199
-msgid "node purge cancelled"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:208
-msgid "can't purge node: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:211
-msgid "node [{node}] purged successfully"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:223
-#, fuzzy
-msgid "delete a node"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_pubsub.py:231
-msgid "delete node without confirmation"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:237
-#, fuzzy, python-format
-msgid "Are you sure to delete PEP node [{node}] ?"
-msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
-
-#: sat_frontends/jp/cmd_pubsub.py:241
-#, fuzzy, python-format
-msgid "Are you sure to delete node [{node}] on service [{service}]?"
-msgstr ""
-"Êtes vous sûr de vouloir inscrire le nouveau compte [%(user)s] au serveur"
-" %(server)s ?"
-
-#: sat_frontends/jp/cmd_pubsub.py:244
-msgid "node deletion cancelled"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:256
-msgid "node [{node}] deleted successfully"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:270
-#, fuzzy
-msgid "set node configuration"
-msgstr "Connexion..."
-
-#: sat_frontends/jp/cmd_pubsub.py:309
-#, fuzzy
-msgid "node configuration successful"
-msgstr "Inscription réussie"
-
-#: sat_frontends/jp/cmd_pubsub.py:320
-msgid "import raw XML to a node"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:327 sat_frontends/jp/cmd_pubsub.py:1608
-msgid "do a pubsub admin request, needed to change publisher"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:332
-msgid ""
-"path to the XML file with data to import. The file must contain whole XML"
-" of each item to import."
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:359
-msgid "You are not using list of pubsub items, we can't import this file"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:370
-msgid "Items are imported without using admin mode, publisher can't be changed"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:391
-msgid "items published with id(s) {items_ids}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:396 sat_frontends/jp/cmd_pubsub.py:1641
-msgid "items published"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:409
-msgid "retrieve node affiliations (for node owner)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:439
-msgid "set affiliations (for node owner)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:469
-msgid "affiliations have been set"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:481
-msgid "set or retrieve node affiliations"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:494
-msgid "retrieve node subscriptions (for node owner)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:532
-#, fuzzy, python-format
-msgid "subscription must be one of {}"
-msgstr "inscription approuvée pour [%s]"
-
-#: sat_frontends/jp/cmd_pubsub.py:548
-msgid "set/modify subscriptions (for node owner)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:563
-msgid "entity/subscription couple(s)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:578
-#, fuzzy, python-format
-msgid "subscriptions have been set"
-msgstr "inscription approuvée pour [%s]"
-
-#: sat_frontends/jp/cmd_pubsub.py:590
-msgid "get or modify node subscriptions"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:603
-msgid "set/replace a schema"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:607
-msgid "schema to set (must be XML)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:621 sat_frontends/jp/cmd_pubsub.py:656
-msgid "schema has been set"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:637
-msgid "edit a schema"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:709
-msgid "get schema"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:742
-msgid "data schema manipulation"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:761
-msgid "node handling"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:774
-msgid "publish a new item or update an existing one"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:783
-msgid "id, URL of the item to update, keyword, or nothing for new item"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:805
-#, fuzzy, python-format
-msgid "can't send item: {e}"
-msgstr "message reçu de: %s"
-
-#: sat_frontends/jp/cmd_pubsub.py:827
-msgid "get pubsub item(s)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:835
-#, fuzzy
-msgid "subscription id"
-msgstr "demande d'inscription pour [%s]"
-
-#: sat_frontends/jp/cmd_pubsub.py:879
-#, fuzzy
-msgid "delete an item"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_pubsub.py:892
-msgid "You need to specify an item to delete"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:894
-#, fuzzy, python-format
-msgid "Are you sure to delete item {item_id} ?"
-msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
-
-#: sat_frontends/jp/cmd_pubsub.py:924
-msgid "edit an existing or new pubsub item"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:968
-msgid "Item has not payload"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:992
-msgid "rename a pubsub item"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1024
-msgid "subscribe to a node"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1039
-msgid "can't subscribe to node: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1042
-#, fuzzy
-msgid "subscription done"
-msgstr "demande d'inscription pour [%s]"
-
-#: sat_frontends/jp/cmd_pubsub.py:1044
-msgid "subscription id: {sub_id}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1059
-msgid "unsubscribe from a node"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1073
-msgid "can't unsubscribe from node: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1076
-#, fuzzy, python-format
-msgid "subscription removed"
-msgstr "inscription approuvée pour [%s]"
-
-#: sat_frontends/jp/cmd_pubsub.py:1088
-msgid "retrieve all subscriptions on a service"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1102
-msgid "can't retrieve subscriptions: {e}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1117
-msgid "retrieve all affiliations on a service"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1160
-msgid "search items corresponding to filters"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1185
-msgid "maximum depth of recursion (will search linked nodes if > 0, DEFAULT: 0)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1197
-msgid "maximum number of items to get per node ({} to get all items, DEFAULT: 30)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1207
-msgid "namespace to use for xpath"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1216
-msgid "filters"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1217
-msgid "only items corresponding to following filters will be kept"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1226
-msgid "full text filter, item must contain this string (XML included)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1235
-msgid "like --text but using a regular expression"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1244
-msgid "filter items which has elements matching this xpath"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1253
-msgid ""
-"Python expression which much return a bool (True to keep item, False to "
-"reject it). \"item\" is raw text item, \"item_xml\" is lxml's "
-"etree.Element"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1266
-msgid "filters flags"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1267
-msgid "filters modifiers (change behaviour of following filters)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1278
-msgid "(don't) ignore case in following filters (DEFAULT: case sensitive)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1289
-msgid "(don't) invert effect of following filters (DEFAULT: don't invert)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1300
-msgid "(don't) use DOTALL option for regex (DEFAULT: don't use)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1311
-msgid "keep only the matching part of the item"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1320
-msgid "action to do on found items (DEFAULT: print)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1383
-msgid ""
-"item doesn't looks like XML, you have probably used --only-matching "
-"somewhere before and we have no more XML"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1420
-msgid "--only-matching used with fixed --text string, are you sure?"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1441
-msgid "can't use xpath: {reason}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1476
-#, fuzzy, python-format
-msgid "unknown filter type {type}"
-msgstr "Type d'action inconnu"
-
-#: sat_frontends/jp/cmd_pubsub.py:1534
-msgid "executed command failed with exit code {ret}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1566
-msgid "Command can only be used with {actions} actions"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1572
-msgid "you need to specify a command to execute"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1575
-msgid "empty node is not handled yet"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1596
-msgid "modify items of a node using an external command/script"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1603
-msgid "apply transformation (DEFAULT: do a dry run)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1614
-msgid "if command return a non zero exit code, ignore the item and continue"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1622
-msgid "get all items by looping over all pages using RSM"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1626
-msgid ""
-"path to the command to use. Will be called repetitivly with an item as "
-"input. Output (full item XML) will be used as new one. Return \"DELETE\" "
-"string to delete the item, and \"SKIP\" to ignore it"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1636
-msgid "items published with ids {item_ids}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1659
-msgid "Can't retrieve all items, RSM metadata not available"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1664
-msgid "Can't retrieve all items, bad RSM metadata: {msg}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1670
-msgid "All items transformed"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1674
-msgid "Retrieving next page ({page_idx}/{page_total})"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1712
-msgid "Duplicate found on item {item_id}, we have probably handled all items."
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1749
-msgid "Deleting item {item_id}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1766
-msgid "Skipping item {item_id}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1860 sat_frontends/jp/cmd_uri.py:53
-msgid "build URI"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1868
-msgid "profile (used when no server is specified)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1908
-msgid "create a Pubsub hook"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1917
-msgid "hook type"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1923
-msgid "make hook persistent across restarts"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1927
-msgid "argument of the hook (depend of the type)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1936
-msgid "{path} is not a file"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1965
-msgid "delete a Pubsub hook"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1974
-msgid "hook type to remove, empty to remove all (DEFAULT: remove all)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:1981
-msgid "argument of the hook to remove, empty to remove all (DEFAULT: remove all)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:2001
-msgid "{nb_deleted} hook(s) have been deleted"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:2013
-#, fuzzy
-msgid "list hooks of a profile"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_pubsub.py:2029
-#, fuzzy
-msgid "No hook found."
-msgstr "Aucune donnée trouvée"
-
-#: sat_frontends/jp/cmd_pubsub.py:2043
-msgid "trigger action on Pubsub notifications"
-msgstr ""
-
-#: sat_frontends/jp/cmd_pubsub.py:2067
-msgid "PubSub nodes/items management"
-msgstr ""
-
-#: sat_frontends/jp/cmd_roster.py:36
-msgid "retrieve the roster entities"
-msgstr ""
-
-#: sat_frontends/jp/cmd_roster.py:89
-msgid "set metadata for a roster entity"
-msgstr ""
-
-#: sat_frontends/jp/cmd_roster.py:93
-msgid "name to use for this entity"
-msgstr ""
-
-#: sat_frontends/jp/cmd_roster.py:96
-msgid "groups for this entity"
-msgstr ""
-
-#: sat_frontends/jp/cmd_roster.py:99
-msgid "replace all metadata instead of adding them"
-msgstr ""
-
-#: sat_frontends/jp/cmd_roster.py:101 sat_frontends/jp/cmd_roster.py:138
-#, fuzzy
-msgid "jid of the roster entity"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/jp/cmd_roster.py:131
-#, fuzzy, python-format
-msgid "remove an entity from roster"
-msgstr "supppression du contact %s"
-
-#: sat_frontends/jp/cmd_roster.py:142
-#, fuzzy, python-format
-msgid "Are you sure to delete {entity} fril your roster?"
-msgstr "Êtes vous sûr de vouloir supprimer %s de votre liste de contacts ?"
-
-#: sat_frontends/jp/cmd_roster.py:145
-msgid "entity deletion cancelled"
-msgstr ""
-
-#: sat_frontends/jp/cmd_roster.py:158
-msgid "Show statistics about a roster"
-msgstr ""
-
-#: sat_frontends/jp/cmd_roster.py:226
-msgid "purge the roster from its contacts with no subscription"
-msgstr ""
-
-#: sat_frontends/jp/cmd_roster.py:231
-#, fuzzy, python-format
-msgid "also purge contacts with no 'from' subscription"
-msgstr "Le contact %s a refusé votre inscription"
-
-#: sat_frontends/jp/cmd_roster.py:234
-#, fuzzy, python-format
-msgid "also purge contacts with no 'to' subscription"
-msgstr "Le contact %s a refusé votre inscription"
-
-#: sat_frontends/jp/cmd_roster.py:306
-msgid "do a full resynchronisation of roster with server"
-msgstr ""
-
-#: sat_frontends/jp/cmd_roster.py:318
-msgid "Roster resynchronized"
-msgstr ""
-
-#: sat_frontends/jp/cmd_roster.py:327
-msgid "Manage an entity's roster"
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:33
-msgid ""
-"Welcome to {app_name} shell, the Salut à Toi shell !\n"
-"\n"
-"This enrironment helps you using several {app_name} commands with similar"
-" parameters.\n"
-"\n"
-"To quit, just enter \"quit\" or press C-d.\n"
-"Enter \"help\" or \"?\" to know what to do\n"
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:48
-msgid "launch jp in shell (REPL) mode"
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:63
-msgid "bad command path"
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:104
-msgid "COMMAND {external}=> {args}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:105
-msgid "(external) "
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:149
-#, fuzzy
-msgid "Shell commands:"
-msgstr "Mauvais nom de profile"
-
-#: sat_frontends/jp/cmd_shell.py:152
-#, fuzzy
-msgid "Action commands:"
-msgstr "Mauvais nom de profile"
-
-#: sat_frontends/jp/cmd_shell.py:172
-msgid "verbose mode is {status}"
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:173
-msgid "ENABLED"
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:173
-msgid "DISABLED"
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:214
-msgid "arg profile={profile} (logged profile)"
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:236
-msgid "no argument in USE"
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:238
-msgid "arguments in USE:"
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:274
-msgid "argument {name} not found"
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:280
-msgid "argument {name} removed"
-msgstr ""
-
-#: sat_frontends/jp/cmd_shell.py:288
-msgid "good bye!"
-msgstr ""
-
-#: sat_frontends/jp/cmd_uri.py:37
-msgid "parse URI"
-msgstr ""
-
-#: sat_frontends/jp/cmd_uri.py:42
-msgid "XMPP URI to parse"
-msgstr ""
-
-#: sat_frontends/jp/cmd_uri.py:57
-msgid "URI type"
-msgstr ""
-
-#: sat_frontends/jp/cmd_uri.py:58
-msgid "URI path"
-msgstr ""
-
-#: sat_frontends/jp/cmd_uri.py:66
-msgid "URI fields"
-msgstr ""
-
-#: sat_frontends/jp/cmd_uri.py:80
-msgid "XMPP URI parsing/generation"
-msgstr ""
-
-#: sat_frontends/jp/common.py:437
-msgid "no item found at all, we create a new one"
-msgstr ""
-
-#: sat_frontends/jp/common.py:440
-msgid "item \"{item}\" not found, we create a new item withthis id"
-msgstr ""
-
-#: sat_frontends/jp/common.py:458
-msgid "item \"{item}\" found, we edit it"
-msgstr ""
-
-#: sat_frontends/jp/common.py:785
-msgid "No {key} URI specified for this project, please specify service and node"
-msgstr ""
-
-#: sat_frontends/jp/common.py:821
-msgid "Invalid URI found: {uri}"
-msgstr ""
-
-#: sat_frontends/jp/loops.py:28
-msgid "User interruption: good bye"
-msgstr "Interrompu par l'utilisateur: au revoir"
-
-#: sat_frontends/jp/output_template.py:53
-msgid "Can't find requested template: {template_path}"
-msgstr ""
-
-#: sat_frontends/jp/output_template.py:74
-msgid ""
-"no default template set for this command, you need to specify a template "
-"using --oo template=[path/to/template.html]"
-msgstr ""
-
-#: sat_frontends/jp/output_template.py:89
-msgid "Can't parse template, please check its syntax"
-msgstr ""
-
-#: sat_frontends/jp/output_template.py:109
-msgid ""
-"Browser opening requested.\n"
-"Temporary files are put in the following directory, you'll have to delete"
-" it yourself once finished viewing: {}"
-msgstr ""
-
-#: sat_frontends/jp/output_xml.py:56
-msgid ""
-"Pygments is not available, syntax highlighting is not possible. Please "
-"install if from http://pygments.org or with pip install pygments"
-msgstr ""
-
-#: sat_frontends/jp/xml_tools.py:50
-msgid "Can't parse the payload XML in input: {msg}"
-msgstr ""
-
-#: sat_frontends/jp/xml_tools.py:62
-msgid "<item> can only have one child element (the payload)"
-msgstr ""
-
-#: sat_frontends/jp/xmlui_manager.py:224
-msgid "(enter: {value})"
-msgstr ""
-
-#: sat_frontends/jp/xmlui_manager.py:318
-msgid "your choice (0-{limit_max}): "
-msgstr ""
-
-#: sat_frontends/jp/xmlui_manager.py:348
-msgid "your choice (0,1): "
-msgstr ""
-
-#: sat_frontends/primitivus/base.py:90
-#, fuzzy, python-format
-msgid "Error while sending message ({})"
-msgstr "Erreur en tentant de rejoindre le salon"
-
-#: sat_frontends/primitivus/base.py:135
-msgid "Please specify the globbing pattern to search for"
-msgstr ""
-
-#: sat_frontends/primitivus/base.py:377
-#, fuzzy
-msgid "Configuration Error"
-msgstr "Connexion..."
-
-#: sat_frontends/primitivus/base.py:377
-msgid ""
-"Something went wrong while reading the configuration, please check "
-":messages"
-msgstr ""
-
-#: sat_frontends/primitivus/base.py:504
-msgid "Pleeeeasse, I can't even breathe !"
-msgstr "Pitiééééééééé, je ne peux même pas respirer !"
-
-#: sat_frontends/primitivus/base.py:534
-#: sat_frontends/primitivus/profile_manager.py:64
-#, fuzzy
-msgid "Connect"
-msgstr "Connexion..."
-
-#: sat_frontends/primitivus/base.py:536
-#, fuzzy
-msgid "Parameters"
-msgstr "&Paramètres"
-
-#: sat_frontends/primitivus/base.py:537 sat_frontends/primitivus/base.py:851
-msgid "About"
-msgstr "À propos"
-
-#: sat_frontends/primitivus/base.py:538
-#, fuzzy
-msgid "Exit"
-msgstr "Quitter"
-
-#: sat_frontends/primitivus/base.py:542
-msgid "Join room"
-msgstr "Rejoindre un salon"
-
-#: sat_frontends/primitivus/base.py:547
-#, fuzzy
-msgid "Main menu"
-msgstr "Construction des menus"
-
-#: sat_frontends/primitivus/base.py:658
-msgid "{app}: a new event has just happened{entity}"
-msgstr ""
-
-#: sat_frontends/primitivus/base.py:736
-#, fuzzy
-msgid "Chat menu"
-msgstr "Construction des menus"
-
-#: sat_frontends/primitivus/base.py:790
-#, fuzzy
-msgid "Unmanaged action"
-msgstr "Tab inconnu"
-
-#: sat_frontends/primitivus/base.py:801
-#, fuzzy
-msgid "unkown"
-msgstr "Messagerie inconnue"
-
-#: sat_frontends/primitivus/base.py:831
-#, fuzzy, python-format
-msgid "Can't get parameters (%s)"
-msgstr "Impossible de charger les paramètres !"
-
-#: sat_frontends/primitivus/base.py:846
-msgid "Entering a MUC room"
-msgstr "Entrée dans le salon MUC"
-
-#: sat_frontends/primitivus/base.py:846
-#, fuzzy
-msgid "Please enter MUC's JID"
-msgstr "Veuillez entrer le JID de votre nouveau contact"
-
-#: sat_frontends/primitivus/chat.py:40
-msgid "{} occupants"
-msgstr ""
-
-#: sat_frontends/primitivus/chat.py:381
-msgid "Game"
-msgstr "Jeu"
-
-#: sat_frontends/primitivus/chat.py:502
-msgid "You have been mentioned by {nick} in {room}"
-msgstr ""
-
-#: sat_frontends/primitivus/chat.py:513
-msgid "{entity} is talking to you"
-msgstr ""
-
-#: sat_frontends/primitivus/chat.py:612
-msgid "Results for searching the globbing pattern: {}"
-msgstr ""
-
-#: sat_frontends/primitivus/chat.py:618
-msgid "Type ':history <lines>' to reset the chat history"
-msgstr ""
-
-#: sat_frontends/primitivus/chat.py:652
-#, python-format
-msgid "Primitivus: %s is talking to you"
-msgstr ""
-
-#: sat_frontends/primitivus/chat.py:656
-#, fuzzy, python-format
-msgid "Primitivus: %(user)s mentioned you in room '%(room)s'"
-msgstr "L'utilisateur %(nick)s a rejoint le salon (%(room_id)s)"
-
-#: sat_frontends/primitivus/chat.py:666
-#, fuzzy
-msgid "Can't start game"
-msgstr "Construction du jeu de Tarot"
-
-#: sat_frontends/primitivus/chat.py:667
-msgid "You need to be exactly 4 peoples in the room to start a Tarot game"
-msgstr ""
-"Vous devez être exactement 4 personnes dans le salon pour commencer un "
-"jeu de Tarot"
-
-#: sat_frontends/primitivus/chat.py:698
-msgid "Change title"
-msgstr ""
-
-#: sat_frontends/primitivus/chat.py:699
-#, fuzzy
-msgid "Enter the new title"
-msgstr "Veuillez entrer le nom du nouveau profile"
-
-#: sat_frontends/primitivus/game_tarot.py:290
-msgid "Please choose your contrat"
-msgstr "Veuillez choisir votre contrat"
-
-#: sat_frontends/primitivus/game_tarot.py:311
-msgid "You win \\o/"
-msgstr "Victoire \\o/"
-
-#: sat_frontends/primitivus/game_tarot.py:311
-msgid "You loose :("
-msgstr "Vous perdez :("
-
-#: sat_frontends/primitivus/game_tarot.py:331
-msgid "Cards played are invalid !"
-msgstr "Les cartes jouées sont invalides !"
-
-#: sat_frontends/primitivus/game_tarot.py:369
-msgid "Do you put these cards in chien ?"
-msgstr "Voulez-vous placer ces cartes au chien ?"
-
-#: sat_frontends/primitivus/profile_manager.py:36
-#, fuzzy
-msgid "Login:"
-msgstr "Identifiant"
-
-#: sat_frontends/primitivus/profile_manager.py:37
-msgid "Password:"
-msgstr "Mot de passe:"
-
-#: sat_frontends/primitivus/profile_manager.py:48
-msgid "New"
-msgstr "Nouveau"
-
-#: sat_frontends/primitivus/profile_manager.py:49
-msgid "Delete"
-msgstr "Suppression"
-
-#: sat_frontends/primitivus/profile_manager.py:81
-#, fuzzy
-msgid "Profile Manager"
-msgstr "Mauvais nom de profile"
-
-#: sat_frontends/primitivus/profile_manager.py:142
-msgid "Can't create profile"
-msgstr ""
-
-#: sat_frontends/primitivus/profile_manager.py:150
-#, fuzzy
-msgid "New profile"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/primitivus/profile_manager.py:151
-#, fuzzy
-msgid "Please enter a new profile name"
-msgstr "Veuillez entrer le nom du nouveau profile"
-
-#: sat_frontends/primitivus/profile_manager.py:160
-#, fuzzy, python-format
-msgid "Are you sure you want to delete the profile {} ?"
-msgstr "Êtes vous sûr de vouloir supprimer le profile [%s] ?"
-
-#: sat_frontends/primitivus/progress.py:37
-msgid "Clear progress list"
-msgstr "Effacer la liste"
-
-#: sat_frontends/primitivus/status.py:57
-msgid "Set your presence"
-msgstr ""
-
-#: sat_frontends/primitivus/status.py:67
-msgid "Set your status"
-msgstr ""
-
-#: sat_frontends/primitivus/status.py:68
-msgid "New status"
-msgstr ""
-
-#: sat_frontends/primitivus/xmlui.py:78
-#, fuzzy
-msgid "Unknown div_char"
-msgstr "Type d'action inconnu"
-
-#: sat_frontends/primitivus/xmlui.py:456
-msgid "Submit"
-msgstr "Envoyer"
-
-#: sat_frontends/primitivus/xmlui.py:458 sat_frontends/primitivus/xmlui.py:473
-msgid "Cancel"
-msgstr "Annuler"
-
-#: sat_frontends/quick_frontend/constants.py:31
-msgid "Away from keyboard"
-msgstr ""
-
-#: sat_frontends/quick_frontend/constants.py:33
-msgid "Extended away"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_app.py:85
-msgid "Error while trying to get autodisconnect param, ignoring: {}"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_app.py:200
-#, fuzzy
-msgid "Can't get profile parameter: {msg}"
-msgstr "Mauvais nom de profile"
-
-#: sat_frontends/quick_frontend/quick_app.py:324
-msgid "Can't get namespaces map: {msg}"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_app.py:330
-msgid "Can't retrieve encryption plugins: {msg}"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_app.py:376
-msgid "Error while initialising bridge: {}"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_app.py:662
-#, fuzzy, python-format
-msgid "Can't connect profile [%s]"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/quick_frontend/quick_app.py:723
-#, fuzzy
-msgid "Connected"
-msgstr "Connexion..."
-
-#: sat_frontends/quick_frontend/quick_app.py:739
-#, fuzzy
-msgid "Disconnected"
-msgstr "Déconnexion..."
-
-#: sat_frontends/quick_frontend/quick_app.py:1154
-#, fuzzy, python-format
-msgid "The contact {contact} has accepted your subscription"
-msgstr "Le contact %s a accepté votre inscription"
-
-#: sat_frontends/quick_frontend/quick_app.py:1157
-#: sat_frontends/quick_frontend/quick_app.py:1176
-#, fuzzy
-msgid "Subscription confirmation"
-msgstr "désinscription confirmée pour [%s]"
-
-#: sat_frontends/quick_frontend/quick_app.py:1162
-#, fuzzy, python-format
-msgid "The contact {contact} has refused your subscription"
-msgstr "Le contact %s a refusé votre inscription"
-
-#: sat_frontends/quick_frontend/quick_app.py:1165
-#, fuzzy
-msgid "Subscription refusal"
-msgstr "demande d'inscription pour [%s]"
-
-#: sat_frontends/quick_frontend/quick_app.py:1172
-#, fuzzy, python-format
-msgid ""
-"The contact {contact} wants to subscribe to your presence.\n"
-"Do you accept ?"
-msgstr ""
-"Le contact %s veut s'inscrire à vos informations de présence\n"
-"Acceptez vous ?"
-
-#: sat_frontends/quick_frontend/quick_app.py:1229
-#, python-format
-msgid "param update: [%(namespace)s] %(name)s = %(value)s"
-msgstr "Le paramètre [%(namespace)s] %(name)s vaut désormais %(value)s"
-
-#: sat_frontends/quick_frontend/quick_app.py:1233
-#, python-format
-msgid "Changing JID to %s"
-msgstr "Changement du JID pour %s"
-
-#: sat_frontends/quick_frontend/quick_chat.py:624
-#, fuzzy
-msgid "now we print the history"
-msgstr "Maintenant on affiche l'historique"
-
-#: sat_frontends/quick_frontend/quick_chat.py:626
-#, fuzzy
-msgid " ({} messages)"
-msgstr "Messages"
-
-#: sat_frontends/quick_frontend/quick_chat.py:683
-#, fuzzy
-msgid "Can't get history: {}"
-msgstr "Impossible de charger l'historique !"
-
-#: sat_frontends/quick_frontend/quick_chat.py:705
-msgid "Can't get encryption state: {reason}"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_chat.py:775
-msgid "message encryption started with {target} using {encryption}"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_chat.py:780
-msgid "message encryption stopped with {target} (was using {encryption})"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_chat.py:833
-msgid "<= {nick} has left the room ({count})"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_chat.py:837
-msgid "<=> {nick} re-entered the room ({count})"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_contact_list.py:611
-#, fuzzy
-msgid "Trying to delete an unknow entity [{}]"
-msgstr "Tentative d'accès à un profile inconnu"
-
-#: sat_frontends/quick_frontend/quick_contact_list.py:664
-msgid "received presence from entity without resource: {}"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_contact_management.py:73
-#, fuzzy
-msgid "Trying to get attribute for an unknown contact"
-msgstr "Tentative d'assigner un paramètre à un profile inconnu"
-
-#: sat_frontends/quick_frontend/quick_contact_management.py:89
-msgid "INTERNAL ERROR: Key log.error"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_contact_management.py:101
-#, fuzzy, python-format
-msgid "Trying to update an unknown contact: %s"
-msgstr "Tentative d'accès à un profile inconnu"
-
-#: sat_frontends/quick_frontend/quick_games.py:84
-msgid ""
-"A {game} activity between {players} has been started, but you couldn't "
-"take part because your client doesn't support it."
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_games.py:87
-msgid "{game} Game"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_profile_manager.py:116
-#, fuzzy, python-format
-msgid "Trying to plug an unknown profile key ({})"
-msgstr "Tentative d'appel d'un profile inconnue"
-
-#: sat_frontends/quick_frontend/quick_profile_manager.py:118
-#, fuzzy
-msgid "Profile plugging in error"
-msgstr "Mauvais nom de profile"
-
-#: sat_frontends/quick_frontend/quick_profile_manager.py:133
-#, fuzzy
-msgid "Can't get profile parameter"
-msgstr "Mauvais nom de profile"
-
-#: sat_frontends/quick_frontend/quick_profile_manager.py:144
-#, fuzzy
-msgid "A profile with this name already exists"
-msgstr "Ce nom de profile existe déjà"
-
-#: sat_frontends/quick_frontend/quick_profile_manager.py:146
-msgid "Profile creation cancelled by backend"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_profile_manager.py:148
-#, fuzzy
-msgid "You profile name is not valid"
-msgstr "Ce profile n'est pas utilisé"
-
-#: sat_frontends/quick_frontend/quick_profile_manager.py:152
-#, fuzzy
-msgid "Can't create profile ({})"
-msgstr "Vous essayer de connecter un profile qui n'existe pas"
-
-#: sat_frontends/quick_frontend/quick_profile_manager.py:172
-msgid "You can't connect manually and automatically at the same time"
-msgstr ""
-
-#: sat_frontends/quick_frontend/quick_profile_manager.py:180
-msgid "No profile selected"
-msgstr "Aucun profile sélectionné"
-
-#: sat_frontends/quick_frontend/quick_profile_manager.py:181
-#, fuzzy
-msgid "You need to create and select at least one profile before connecting"
-msgstr ""
-"Vous devez sélectionner un profile ou en créer un nouveau avant de vous "
-"connecter."
-
-#: sat_frontends/quick_frontend/quick_utils.py:40
-#, fuzzy
-msgid ""
-"\n"
-"    %prog [options]\n"
-"\n"
-"    %prog --help for options list\n"
-"    "
-msgstr ""
-"\n"
-"        %prog [options] [FICHIER1 FICHIER2 ...] JID\n"
-"        %prog -w [options] [JID1 JID2 ...]\n"
-"\n"
-"        %prog --help pour la liste des options\n"
-"        "
-
-#: sat_frontends/quick_frontend/quick_utils.py:49
-msgid "Select the profile to use"
-msgstr "Veuillez sélectionner le profile à utiliser"
-
-#: sat_frontends/tools/xmlui.py:233
-msgid "Nothing to submit"
-msgstr ""
-
-#: sat_frontends/tools/xmlui.py:449
-msgid "XMLUI can have only one main container"
-msgstr ""
-
-#: sat_frontends/tools/xmlui.py:514
-#, fuzzy, python-format
-msgid "Unknown container [%s], using default one"
-msgstr "Disposition inconnue, utilisation de celle par defaut"
-
-#: sat_frontends/tools/xmlui.py:527
-msgid "Internal Error, container has not _xmluiAppend method"
-msgstr ""
-
-#: sat_frontends/tools/xmlui.py:674
-#, fuzzy, python-format
-msgid "FIXME FIXME FIXME: widget type [%s] is not implemented"
-msgstr "CORRIGEZ-MOI: actionResult n'est pas implémenté"
-
-#: sat_frontends/tools/xmlui.py:678
-#, fuzzy, python-format
-msgid "FIXME FIXME FIXME: type [%s] is not implemented"
-msgstr "CORRIGEZ-MOI: actionResult n'est pas implémenté"
-
-#: sat_frontends/tools/xmlui.py:696
-#, python-format
-msgid "No change listener on [%s]"
-msgstr ""
-
-#: sat_frontends/tools/xmlui.py:722
-#, fuzzy, python-format
-msgid "Unknown tag [%s]"
-msgstr "Type d'action inconnu"
-
-#: sat_frontends/tools/xmlui.py:780
-#, fuzzy
-msgid "No callback_id found"
-msgstr "Aucun transport trouvé"
-
-#: sat_frontends/tools/xmlui.py:813
-#, python-format
-msgid "FIXME: XMLUI internal action [%s] is not implemented"
-msgstr ""
-
-#: sat_frontends/tools/xmlui.py:909 sat_frontends/tools/xmlui.py:921
-#: sat_frontends/tools/xmlui.py:971 sat_frontends/tools/xmlui.py:983
-msgid "The form data is not sent back, the type is not managed properly"
-msgstr ""
-"Les données du formulaire ne sont pas envoyées, il y a une erreur dans la"
-" gestion du type"
-
-#: sat_frontends/tools/xmlui.py:915 sat_frontends/tools/xmlui.py:977
-msgid "Cancelling form"
-msgstr "Annulation du formulaire"
-
-#: sat_frontends/tools/xmlui.py:1096
-msgid "XMLUI class already registered for {type_}, ignoring"
-msgstr ""
-
-#: sat_frontends/tools/xmlui.py:1135
-msgid "You must register classes with registerClass before creating a XMLUI"
-msgstr ""
-
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/VERSION	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1 @@
+0.9.0D
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/__init__.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+import os.path
+from sat_tmp import wokkel
+
+version_file = os.path.join(os.path.dirname(__file__), "VERSION")
+with open(version_file) as f:
+    __version__ = f.read().strip()
+
+if not wokkel.installed:
+    wokkel.install()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/base_constructor.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,364 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""base constructor class"""
+
+from libervia.backend.bridge.bridge_constructor.constants import Const as C
+from configparser import NoOptionError
+import sys
+import os
+import os.path
+import re
+from importlib import import_module
+
+
+class ParseError(Exception):
+    # Used when the signature parsing is going wrong (invalid signature ?)
+    pass
+
+
+class Constructor(object):
+    NAME = None  # used in arguments parsing, filename will be used if not set
+    # following attribute are used by default generation method
+    # they can be set to dict of strings using python formatting syntax
+    # dict keys will be used to select part to replace (e.g. "signals" key will
+    # replace ##SIGNALS_PART## in template), while the value is the format
+    # keys starting with "signal" will be used for signals, while ones starting with
+    # "method" will be used for methods
+    #  check D-Bus constructor for an example
+    CORE_FORMATS = None
+    CORE_TEMPLATE = None
+    CORE_DEST = None
+    FRONTEND_FORMATS = None
+    FRONTEND_TEMPLATE = None
+    FRONTEND_DEST = None
+
+    # set to False if your bridge needs only core
+    FRONTEND_ACTIVATE = True
+
+    def __init__(self, bridge_template, options):
+        self.bridge_template = bridge_template
+        self.args = options
+
+    @property
+    def constructor_dir(self):
+        constructor_mod = import_module(self.__module__)
+        return os.path.dirname(constructor_mod.__file__)
+
+    def getValues(self, name):
+        """Return values of a function in a dict
+        @param name: Name of the function to get
+        @return: dict, each key has the config value or None if the value is not set"""
+        function = {}
+        for option in ["type", "category", "sig_in", "sig_out", "doc"]:
+            try:
+                value = self.bridge_template.get(name, option)
+            except NoOptionError:
+                value = None
+            function[option] = value
+        return function
+
+    def get_default(self, name):
+        """Return default values of a function in a dict
+        @param name: Name of the function to get
+        @return: dict, each key is the integer param number (no key if no default value)"""
+        default_dict = {}
+        def_re = re.compile(r"param_(\d+)_default")
+
+        for option in self.bridge_template.options(name):
+            match = def_re.match(option)
+            if match:
+                try:
+                    idx = int(match.group(1))
+                except ValueError:
+                    raise ParseError(
+                        "Invalid value [%s] for parameter number" % match.group(1)
+                    )
+                default_dict[idx] = self.bridge_template.get(name, option)
+
+        return default_dict
+
+    def getFlags(self, name):
+        """Return list of flags set for this function
+
+        @param name: Name of the function to get
+        @return: List of flags (string)
+        """
+        flags = []
+        for option in self.bridge_template.options(name):
+            if option in C.DECLARATION_FLAGS:
+                flags.append(option)
+        return flags
+
+    def get_arguments_doc(self, name):
+        """Return documentation of arguments
+        @param name: Name of the function to get
+        @return: dict, each key is the integer param number (no key if no argument doc), value is a tuple (name, doc)"""
+        doc_dict = {}
+        option_re = re.compile(r"doc_param_(\d+)")
+        value_re = re.compile(r"^(\w+): (.*)$", re.MULTILINE | re.DOTALL)
+        for option in self.bridge_template.options(name):
+            if option == "doc_return":
+                doc_dict["return"] = self.bridge_template.get(name, option)
+                continue
+            match = option_re.match(option)
+            if match:
+                try:
+                    idx = int(match.group(1))
+                except ValueError:
+                    raise ParseError(
+                        "Invalid value [%s] for parameter number" % match.group(1)
+                    )
+                value_match = value_re.match(self.bridge_template.get(name, option))
+                if not value_match:
+                    raise ParseError("Invalid value for parameter doc [%i]" % idx)
+                doc_dict[idx] = (value_match.group(1), value_match.group(2))
+        return doc_dict
+
+    def get_doc(self, name):
+        """Return documentation of the method
+        @param name: Name of the function to get
+        @return: string documentation, or None"""
+        if self.bridge_template.has_option(name, "doc"):
+            return self.bridge_template.get(name, "doc")
+        return None
+
+    def arguments_parser(self, signature):
+        """Generator which return individual arguments signatures from a global signature"""
+        start = 0
+        i = 0
+
+        while i < len(signature):
+            if signature[i] not in ["b", "y", "n", "i", "x", "q", "u", "t", "d", "s",
+                                    "a"]:
+                raise ParseError("Unmanaged attribute type [%c]" % signature[i])
+
+            if signature[i] == "a":
+                i += 1
+                if (
+                    signature[i] != "{" and signature[i] != "("
+                ):  # FIXME: must manage tuples out of arrays
+                    i += 1
+                    yield signature[start:i]
+                    start = i
+                    continue  # we have a simple type for the array
+                opening_car = signature[i]
+                assert opening_car in ["{", "("]
+                closing_car = "}" if opening_car == "{" else ")"
+                opening_count = 1
+                while True:  # we have a dict or a list of tuples
+                    i += 1
+                    if i >= len(signature):
+                        raise ParseError("missing }")
+                    if signature[i] == opening_car:
+                        opening_count += 1
+                    if signature[i] == closing_car:
+                        opening_count -= 1
+                        if opening_count == 0:
+                            break
+            i += 1
+            yield signature[start:i]
+            start = i
+
+    def get_arguments(self, signature, name=None, default=None, unicode_protect=False):
+        """Return arguments to user given a signature
+
+        @param signature: signature in the short form (using s,a,i,b etc)
+        @param name: dictionary of arguments name like given by get_arguments_doc
+        @param default: dictionary of default values, like given by get_default
+        @param unicode_protect: activate unicode protection on strings (return strings as unicode(str))
+        @return (str): arguments that correspond to a signature (e.g.: "sss" return "arg1, arg2, arg3")
+        """
+        idx = 0
+        attr_string = []
+
+        for arg in self.arguments_parser(signature):
+            attr_string.append(
+                (
+                    "str(%(name)s)%(default)s"
+                    if (unicode_protect and arg == "s")
+                    else "%(name)s%(default)s"
+                )
+                % {
+                    "name": name[idx][0] if (name and idx in name) else "arg_%i" % idx,
+                    "default": "=" + default[idx] if (default and idx in default) else "",
+                }
+            )
+            # give arg_1, arg2, etc or name1, name2=default, etc.
+            # give unicode(arg_1), unicode(arg_2), etc. if unicode_protect is set and arg is a string
+            idx += 1
+
+        return ", ".join(attr_string)
+
+    def get_template_path(self, template_file):
+        """return template path corresponding to file name
+
+        @param template_file(str): name of template file
+        """
+        return os.path.join(self.constructor_dir, template_file)
+
+    def core_completion_method(self, completion, function, default, arg_doc, async_):
+        """override this method to extend completion"""
+        pass
+
+    def core_completion_signal(self, completion, function, default, arg_doc, async_):
+        """override this method to extend completion"""
+        pass
+
+    def frontend_completion_method(self, completion, function, default, arg_doc, async_):
+        """override this method to extend completion"""
+        pass
+
+    def frontend_completion_signal(self, completion, function, default, arg_doc, async_):
+        """override this method to extend completion"""
+        pass
+
+    def generate(self, side):
+        """generate bridge
+
+        call generate_core_side or generateFrontendSide if they exists
+        else call generic self._generate method
+        """
+        try:
+            if side == "core":
+                method = self.generate_core_side
+            elif side == "frontend":
+                if not self.FRONTEND_ACTIVATE:
+                    print("This constructor only handle core, please use core side")
+                    sys.exit(1)
+                method = self.generateFrontendSide
+        except AttributeError:
+            self._generate(side)
+        else:
+            method()
+
+    def _generate(self, side):
+        """generate the backend
+
+        this is a generic method which will use formats found in self.CORE_SIGNAL_FORMAT
+        and self.CORE_METHOD_FORMAT (standard format method will be used)
+        @param side(str): core or frontend
+        """
+        side_vars = []
+        for var in ("FORMATS", "TEMPLATE", "DEST"):
+            attr = "{}_{}".format(side.upper(), var)
+            value = getattr(self, attr)
+            if value is None:
+                raise NotImplementedError
+            side_vars.append(value)
+
+        FORMATS, TEMPLATE, DEST = side_vars
+        del side_vars
+
+        parts = {part.upper(): [] for part in FORMATS}
+        sections = self.bridge_template.sections()
+        sections.sort()
+        for section in sections:
+            function = self.getValues(section)
+            print(("Adding %s %s" % (section, function["type"])))
+            default = self.get_default(section)
+            arg_doc = self.get_arguments_doc(section)
+            async_ = "async" in self.getFlags(section)
+            completion = {
+                "sig_in": function["sig_in"] or "",
+                "sig_out": function["sig_out"] or "",
+                "category": "plugin" if function["category"] == "plugin" else "core",
+                "name": section,
+                # arguments with default values
+                "args": self.get_arguments(
+                    function["sig_in"], name=arg_doc, default=default
+                ),
+                "args_no_default": self.get_arguments(function["sig_in"], name=arg_doc),
+            }
+
+            extend_method = getattr(
+                self, "{}_completion_{}".format(side, function["type"])
+            )
+            extend_method(completion, function, default, arg_doc, async_)
+
+            for part, fmt in FORMATS.items():
+                if (part.startswith(function["type"])
+                    or part.startswith(f"async_{function['type']}")):
+                    parts[part.upper()].append(fmt.format(**completion))
+
+        # at this point, signals_part, methods_part and direct_calls should be filled,
+        # we just have to place them in the right part of the template
+        bridge = []
+        const_override = {
+            env[len(C.ENV_OVERRIDE) :]: v
+            for env, v in os.environ.items()
+            if env.startswith(C.ENV_OVERRIDE)
+        }
+        template_path = self.get_template_path(TEMPLATE)
+        try:
+            with open(template_path) as template:
+                for line in template:
+
+                    for part, extend_list in parts.items():
+                        if line.startswith("##{}_PART##".format(part)):
+                            bridge.extend(extend_list)
+                            break
+                    else:
+                        # the line is not a magic part replacement
+                        if line.startswith("const_"):
+                            const_name = line[len("const_") : line.find(" = ")].strip()
+                            if const_name in const_override:
+                                print(("const {} overriden".format(const_name)))
+                                bridge.append(
+                                    "const_{} = {}".format(
+                                        const_name, const_override[const_name]
+                                    )
+                                )
+                                continue
+                        bridge.append(line.replace("\n", ""))
+        except IOError:
+            print(("can't open template file [{}]".format(template_path)))
+            sys.exit(1)
+
+        # now we write to final file
+        self.final_write(DEST, bridge)
+
+    def final_write(self, filename, file_buf):
+        """Write the final generated file in [dest dir]/filename
+
+        @param filename: name of the file to generate
+        @param file_buf: list of lines (stings) of the file
+        """
+        if os.path.exists(self.args.dest_dir) and not os.path.isdir(self.args.dest_dir):
+            print(
+                "The destination dir [%s] can't be created: a file with this name already exists !"
+            )
+            sys.exit(1)
+        try:
+            if not os.path.exists(self.args.dest_dir):
+                os.mkdir(self.args.dest_dir)
+            full_path = os.path.join(self.args.dest_dir, filename)
+            if os.path.exists(full_path) and not self.args.force:
+                print((
+                    "The destination file [%s] already exists ! Use --force to overwrite it"
+                    % full_path
+                ))
+            try:
+                with open(full_path, "w") as dest_file:
+                    dest_file.write("\n".join(file_buf))
+            except IOError:
+                print(("Can't open destination file [%s]" % full_path))
+        except OSError:
+            print("It's not possible to generate the file, check your permissions")
+            exit(1)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/bridge_constructor.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from libervia.backend.bridge import bridge_constructor
+from libervia.backend.bridge.bridge_constructor.constants import Const as C
+from libervia.backend.bridge.bridge_constructor import constructors, base_constructor
+import argparse
+from configparser import ConfigParser as Parser
+from importlib import import_module
+import os
+import os.path
+
+# consts
+__version__ = C.APP_VERSION
+
+
+class BridgeConstructor(object):
+    def import_constructors(self):
+        constructors_dir = os.path.dirname(constructors.__file__)
+        self.protocoles = {}
+        for dir_ in os.listdir(constructors_dir):
+            init_path = os.path.join(constructors_dir, dir_, "__init__.py")
+            constructor_path = os.path.join(constructors_dir, dir_, "constructor.py")
+            module_path = "libervia.backend.bridge.bridge_constructor.constructors.{}.constructor".format(
+                dir_
+            )
+            if os.path.isfile(init_path) and os.path.isfile(constructor_path):
+                mod = import_module(module_path)
+                for attr in dir(mod):
+                    obj = getattr(mod, attr)
+                    if not isinstance(obj, type):
+                        continue
+                    if issubclass(obj, base_constructor.Constructor):
+                        name = obj.NAME or dir_
+                        self.protocoles[name] = obj
+                        break
+        if not self.protocoles:
+            raise ValueError("no protocole constructor found")
+
+    def parse_args(self):
+        """Check command line options"""
+        parser = argparse.ArgumentParser(
+            description=C.DESCRIPTION,
+            formatter_class=argparse.RawDescriptionHelpFormatter,
+        )
+
+        parser.add_argument("--version", action="version", version=__version__)
+        default_protocole = (
+            C.DEFAULT_PROTOCOLE
+            if C.DEFAULT_PROTOCOLE in self.protocoles
+            else self.protocoles[0]
+        )
+        parser.add_argument(
+            "-p",
+            "--protocole",
+            choices=sorted(self.protocoles),
+            default=default_protocole,
+            help="generate bridge using PROTOCOLE (default: %(default)s)",
+        )  # (default: %s, possible values: [%s])" % (DEFAULT_PROTOCOLE, ", ".join(MANAGED_PROTOCOLES)))
+        parser.add_argument(
+            "-s",
+            "--side",
+            choices=("core", "frontend"),
+            default="core",
+            help="which side of the bridge do you want to make ?",
+        )  # (default: %default, possible values: [core, frontend])")
+        default_template = os.path.join(
+            os.path.dirname(bridge_constructor.__file__), "bridge_template.ini"
+        )
+        parser.add_argument(
+            "-t",
+            "--template",
+            type=argparse.FileType(),
+            default=default_template,
+            help="use TEMPLATE to generate bridge (default: %(default)s)",
+        )
+        parser.add_argument(
+            "-f",
+            "--force",
+            action="store_true",
+            help=("force overwritting of existing files"),
+        )
+        parser.add_argument(
+            "-d", "--debug", action="store_true", help=("add debug information printing")
+        )
+        parser.add_argument(
+            "--no-unicode",
+            action="store_false",
+            dest="unicode",
+            help=("remove unicode type protection from string results"),
+        )
+        parser.add_argument(
+            "--flags", nargs="+", default=[], help=("constructors' specific flags")
+        )
+        parser.add_argument(
+            "--dest-dir",
+            default=C.DEST_DIR_DEFAULT,
+            help=(
+                "directory when the generated files will be written (default: %(default)s)"
+            ),
+        )
+
+        return parser.parse_args()
+
+    def go(self):
+        self.import_constructors()
+        args = self.parse_args()
+        template_parser = Parser()
+        try:
+            template_parser.read_file(args.template)
+        except IOError:
+            print("The template file doesn't exist or is not accessible")
+            exit(1)
+        constructor = self.protocoles[args.protocole](template_parser, args)
+        constructor.generate(args.side)
+
+
+if __name__ == "__main__":
+    bc = BridgeConstructor()
+    bc.go()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/bridge_template.ini	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1026 @@
+[DEFAULT]
+doc_profile=profile: Name of the profile.
+doc_profile_key=profile_key: Profile key which can be either a magic (eg: @DEFAULT@) or the name of an existing profile.
+doc_security_limit=security_limit: -1 means no security, 0 is the maximum security then the higher the less secure
+
+;signals
+
+[connected]
+type=signal
+category=core
+sig_in=ss
+doc=Connection is done
+doc_param_0=jid_s: the JID that we were assigned by the server, as the resource might differ from the JID we asked for.
+doc_param_1=%(doc_profile)s
+
+[disconnected]
+type=signal
+category=core
+sig_in=s
+doc=Connection is finished or lost
+doc_param_0=%(doc_profile)s
+
+[contact_new]
+type=signal
+category=core
+sig_in=sa{ss}ass
+doc=New contact received in roster
+doc_param_0=contact_jid: JID which has just been added
+doc_param_1=attributes: Dictionary of attributes where keys are:
+ - name: name of the contact
+ - to: "True" if the contact give its presence information to us
+ - from: "True" if contact is registred to our presence information
+ - ask: "True" is subscription is pending
+doc_param_2=groups: Roster's groups where the contact is
+doc_param_3=%(doc_profile)s
+
+[message_new]
+type=signal
+category=core
+sig_in=sdssa{ss}a{ss}sss
+doc=A message has been received
+doc_param_0=uid: unique ID of the message (id specific to SàT, this it *NOT* an XMPP id)
+doc_param_1=timestamp: when the message was sent (or declared sent for delayed messages)
+doc_param_2=from_jid: JID where the message is comming from
+doc_param_3=to_jid: JID where the message must be sent
+doc_param_4=message: message itself, can be in several languages (key is language code or '' for default)
+doc_param_5=subject: subject of the message, can be in several languages (key is language code or '' for default)
+doc_param_6=mess_type: Type of the message (cf RFC 6121 §5.2.2) + C.MESS_TYPE_INFO (system info)
+doc_param_7=extra: extra message information, can have data added by plugins and/or:
+  - thread: id of the thread
+  - thread_parent: id of the parent of the current thread
+  - received_timestamp: date of receiption for delayed messages
+  - delay_sender: entity which has originally sent or which has delayed the message
+  - info_type: subtype for info messages
+doc_param_8=%(doc_profile)s
+
+[message_encryption_started]
+type=signal
+category=core
+sig_in=sss
+doc=A message encryption session has been started
+doc_param_0=to_jid: JID of the recipient (bare jid if it's encrypted for all devices)
+doc_param_1=encryption_data: (JSON_OBJ) data of the encryption algorithm used, encoded as a json object.
+    it always has the following keys:
+        - name: human readable name of the algorithm
+        - namespace: namespace of the encryption plugin
+    following keys are present if suitable:
+        - directed_devices: list or resource where session is encrypted
+doc_param_2=%(doc_profile_key)s
+
+[message_encryption_stopped]
+type=signal
+category=core
+sig_in=sa{ss}s
+doc=A message encryption session has been stopped
+doc_param_0=to_jid: JID of the recipient (full jid if it's only stopped for one device)
+doc_param_1=encryption_data: data of the encryption algorithm stopped, has a least following keys:
+    - name: human readable name of the algorithm
+    - namespace: namespace of the encryption plugin
+doc_param_2=%(doc_profile_key)s
+
+[presence_update]
+type=signal
+category=core
+sig_in=ssia{ss}s
+doc=Somebody changed his presence information.
+doc_param_0=entity_jid: JID from which we have presence informatios
+doc_param_1=show: availability status (see RFC 6121 §4.7.2.1)
+doc_param_2=priority: Priority level of the ressource (see RFC 6121 §4.7.2.3)
+doc_param_3=statuses: Natural language description of the availability status (see RFC 6121 §4.7.2.2)
+doc_param_4=%(doc_profile)s
+
+[subscribe]
+type=signal
+category=core
+sig_in=sss
+doc=Somebody wants to be added in roster
+doc_param_0=sub_type: Subscription states (see RFC 6121 §3)
+doc_param_1=entity_jid: JID from which the subscription is coming
+doc_param_2=%(doc_profile)s
+
+[param_update]
+type=signal
+category=core
+sig_in=ssss
+doc=A parameter has been changed
+doc_param_0=name: Name of the updated parameter
+doc_param_1=value: New value of the parameter
+doc_param_2=category: Category of the updated parameter
+doc_param_3=%(doc_profile)s
+
+[contact_deleted]
+type=signal
+category=core
+sig_in=ss
+doc=A contact has been supressed from roster
+doc_param_0=entity_jid: JID of the contact removed from roster
+doc_param_1=%(doc_profile)s
+
+[action_new]
+type=signal
+category=core
+sig_in=ssis
+doc=A frontend action is requested
+doc_param_0=action_data: a serialised dict where key can be:
+    - xmlui: a XMLUI describing the action
+    - progress: a progress id
+    - meta_*: meta information on the action, used to make automation more easy,
+        some are defined below
+    - meta_from_jid: origin of the request
+    - meta_type: type of the request, can be one of:
+        - C.META_TYPE_FILE: a file transfer request validation
+        - C.META_TYPE_OVERWRITE: a file overwriting confirmation
+    - meta_progress_id: progress id linked to this action
+doc_param_1=id: action id
+    This id can be used later by frontends to announce to other ones that the action is managed and can now be ignored.
+doc_param_2=%(doc_security_limit)s
+doc_param_3=%(doc_profile)s
+
+[entity_data_updated]
+type=signal
+category=core
+sig_in=ssss
+doc=An entity's data has been updated
+doc_param_0=jid: entity's bare jid
+doc_param_1=name: Name of the updated value
+doc_param_2=value: New value
+doc_param_3=%(doc_profile)s
+
+[progress_started]
+type=signal
+category=core
+sig_in=sa{ss}s
+doc=A progressing operation has just started
+doc_param_0=id: id of the progression operation
+doc_param_1=metadata: dict of progress metadata, key can be:
+    - name: name of the progression, full path for a file
+    - direction: "in" for incoming data, "out" else
+    - type: type of the progression:
+        C.META_TYPE_FILE: file transfer
+doc_param_2=%(doc_profile)s
+
+[progress_finished]
+type=signal
+category=core
+sig_in=sa{ss}s
+doc=A progressing operation is finished
+doc_param_0=id: id of the progression operation
+doc_param_1=metadata: dict of progress status metadata, key can be:
+    - hash: value of the computed hash
+    - hash_algo: alrorithm used to compute hash
+    - hash_verified: C.BOOL_TRUE if hash is verified and OK
+        C.BOOL_FALSE if hash was not received ([progress_error] will be used if there is a mismatch)
+    - url: url linked to the progression (e.g. download url after a file upload)
+doc_param_2=%(doc_profile)s
+
+[progress_error]
+type=signal
+category=core
+sig_in=sss
+doc=There was an error during progressing operation
+doc_param_0=id: id of the progression operation
+doc_param_1=error: error message
+doc_param_2=%(doc_profile)s
+
+[_debug]
+type=signal
+category=core
+sig_in=sa{ss}s
+doc=Debug method, useful for developers
+doc_param_0=action: action to do
+doc_param_1=params: action parameters
+doc_param_2=%(doc_profile)s
+
+;methods
+
+[ready_get]
+async=
+type=method
+category=core
+sig_in=
+sig_out=
+doc=Return when backend is initialised
+
+[version_get]
+type=method
+category=core
+sig_in=
+sig_out=s
+doc=Get "Salut à Toi" full version
+
+[features_get]
+type=method
+category=core
+sig_in=s
+sig_out=a{sa{ss}}
+doc=Get available features and plugins
+ features can changes for differents profiles, e.g. because of differents server capabilities
+doc_param_0=%(doc_profile_key)s
+doc_return=dictionary of available features:
+ plugin import name is used as key, data is an other dict managed by the plugin
+async=
+
+[profile_name_get]
+type=method
+category=core
+sig_in=s
+sig_out=s
+param_0_default="@DEFAULT@"
+doc=Get real profile name from profile key
+doc_param_0=%(doc_profile_key)s
+doc_return=Real profile name
+
+[profiles_list_get]
+type=method
+category=core
+sig_in=bb
+sig_out=as
+param_0_default=True
+param_1_default=False
+doc_param_0=clients: get clients profiles
+doc_param_1=components: get components profiles
+doc=Get list of profiles
+
+[profile_set_default]
+type=method
+category=core
+sig_in=s
+sig_out=
+doc_param_0=%(doc_profile)s
+doc=Set default profile
+
+[entity_data_get]
+type=method
+category=core
+sig_in=sass
+sig_out=a{ss}
+doc=Get data in cache for an entity
+doc_param_0=jid: entity's bare jid
+doc_param_1=keys: list of keys to get
+doc_param_2=%(doc_profile)s
+doc_return=dictionary of asked key,
+ if key doesn't exist, the resulting dictionary will not have the key
+
+[entities_data_get]
+type=method
+category=core
+sig_in=asass
+sig_out=a{sa{ss}}
+doc=Get data in cache for several entities at once
+doc_param_0=jids: list of entities bare jid, or empty list to have all jids in cache
+doc_param_1=keys: list of keys to get
+doc_param_2=%(doc_profile)s
+doc_return=dictionary with jids as keys and dictionary of asked key as values
+ values are serialised
+ if key doesn't exist for a jid, the resulting dictionary will not have it
+
+[profile_create]
+async=
+type=method
+category=core
+sig_in=sss
+sig_out=
+param_1_default=''
+param_2_default=''
+doc=Create a new profile
+doc_param_0=%(doc_profile)s
+doc_param_1=password: password of the profile
+doc_param_2=component: set to component entry point if it is a component, else use empty string
+doc_return=callback is called when profile actually exists in database and memory
+errback is called with error constant as parameter:
+ - ConflictError: the profile name already exists
+ - CancelError: profile creation canceled
+ - NotFound: component entry point is not available
+
+[profile_delete_async]
+async=
+type=method
+category=core
+sig_in=s
+sig_out=
+doc=Delete a profile
+doc_param_0=%(doc_profile)s
+doc_return=callback is called when profile has been deleted from database and memory
+errback is called with error constant as parameter:
+ - ProfileUnknownError: the profile name is unknown
+ - ConnectedProfileError: a connected profile would not be deleted
+
+[connect]
+async=
+type=method
+category=core
+sig_in=ssa{ss}
+sig_out=b
+param_0_default="@DEFAULT@"
+param_1_default=''
+param_2_default={}
+doc=Connect a profile
+doc_param_0=%(doc_profile_key)s
+doc_param_1=password: the SàT profile password
+doc_param_2=options: connection options
+doc_return=a deferred boolean or failure:
+    - boolean if the profile authentication succeed:
+        - True if the XMPP connection was already established
+        - False if the XMPP connection has been initiated (it may still fail)
+    - failure if the profile authentication failed
+
+[profile_start_session]
+async=
+type=method
+category=core
+sig_in=ss
+sig_out=b
+param_0_default=''
+param_1_default="@DEFAULT@"
+doc=Start a profile session without connecting it (if it's not already the case)
+doc_param_0=password: the SàT profile password
+doc_param_1=%(doc_profile_key)s
+doc_return=D(bool):
+        - True if the profile session was already started
+        - False else
+
+[profile_is_session_started]
+type=method
+category=core
+sig_in=s
+sig_out=b
+param_0_default="@DEFAULT@"
+doc=Tell if a profile session is loaded
+doc_param_0=%(doc_profile_key)s
+
+[disconnect]
+async=
+type=method
+category=core
+sig_in=s
+sig_out=
+param_0_default="@DEFAULT@"
+doc=Disconnect a profile
+doc_param_0=%(doc_profile_key)s
+
+[is_connected]
+type=method
+category=core
+sig_in=s
+sig_out=b
+param_0_default="@DEFAULT@"
+doc=Tell if a profile is connected
+doc_param_0=%(doc_profile_key)s
+
+[contact_get]
+async=
+type=method
+category=core
+sig_in=ss
+sig_out=(a{ss}as)
+param_1_default="@DEFAULT@"
+doc=Return informations in roster about a contact
+doc_param_1=%(doc_profile_key)s
+doc_return=tuple with the following values:
+ - list of attributes as in [contact_new]
+ - groups where the contact is
+
+[contacts_get]
+async=
+type=method
+category=core
+sig_in=s
+sig_out=a(sa{ss}as)
+param_0_default="@DEFAULT@"
+doc=Return information about all contacts (the roster)
+doc_param_0=%(doc_profile_key)s
+doc_return=array of tuples with the following values:
+ - JID of the contact
+ - list of attributes as in [contact_new]
+ - groups where the contact is
+
+[contacts_get_from_group]
+type=method
+category=core
+sig_in=ss
+sig_out=as
+param_1_default="@DEFAULT@"
+doc=Return information about all contacts
+doc_param_0=group: name of the group to check
+doc_param_1=%(doc_profile_key)s
+doc_return=array of jids
+
+[main_resource_get]
+type=method
+category=core
+sig_in=ss
+sig_out=s
+param_1_default="@DEFAULT@"
+doc=Return the last resource connected for a contact
+doc_param_0=contact_jid: jid of the contact
+doc_param_1=%(doc_profile_key)s
+doc_return=the resource connected of the contact with highest priority, or ""
+
+[presence_statuses_get]
+type=method
+category=core
+sig_in=s
+sig_out=a{sa{s(sia{ss})}}
+param_0_default="@DEFAULT@"
+doc=Return presence information of all contacts
+doc_param_0=%(doc_profile_key)s
+doc_return=Dict of presence with bare JID of contact as key, and value as follow:
+ A dict where key is the resource and the value is a tuple with (show, priority, statuses) as for [presence_update]
+
+[sub_waiting_get]
+type=method
+category=core
+sig_in=s
+sig_out=a{ss}
+param_0_default="@DEFAULT@"
+doc=Get subscription requests in queue
+doc_param_0=%(doc_profile_key)s
+doc_return=Dict where contact JID is the key, and value is the subscription type
+
+[message_send]
+async=
+type=method
+category=core
+sig_in=sa{ss}a{ss}sss
+sig_out=
+param_2_default={}
+param_3_default="auto"
+param_4_default={}
+param_5_default="@NONE@"
+doc=Send a message
+doc_param_0=to_jid: JID of the recipient
+doc_param_1=message: body of the message:
+    key is the language of the body, use '' when unknown
+doc_param_2=subject: Subject of the message
+    key is the language of the subject, use '' when unknown
+doc_param_3=mess_type: Type of the message (cf RFC 6121 §5.2.2) or "auto" for automatic type detection
+doc_param_4=extra: (serialised) optional data that can be used by a plugin to build more specific messages 
+doc_param_5=%(doc_profile_key)s
+
+[message_encryption_start]
+async=
+type=method
+category=core
+sig_in=ssbs
+sig_out=
+param_1_default=''
+param_2_default=False
+param_3_default="@NONE@"
+doc=Start an encryption session
+doc_param_0=to_jid: JID of the recipient (bare jid if it must be encrypted for all devices)
+doc_param_1=namespace: namespace of the encryption algorithm to use
+doc_param_2=replace: if True and an encryption session already exists, it will be replaced by this one
+    else a ConflictError will be raised
+doc_param_3=%(doc_profile_key)s
+
+[message_encryption_stop]
+async=
+type=method
+category=core
+sig_in=ss
+sig_out=
+doc=Stop an encryption session
+doc_param_0=to_jid: JID of the recipient (full jid if encryption must be stopped for one device only)
+doc_param_1=%(doc_profile_key)s
+
+[message_encryption_get]
+type=method
+category=core
+sig_in=ss
+sig_out=s
+doc=Retrieve encryption data for a given entity
+doc_param_0=to_jid: bare JID of the recipient
+doc_param_1=%(doc_profile_key)s
+doc_return=(JSON_OBJ) empty string if session is unencrypted, else a json encoded objects.
+    In case of dict, following keys are always present:
+        - name: human readable name of the encryption algorithm
+        - namespace: namespace of the plugin
+    following key can be present if suitable:
+        - directed_devices: list or resource where session is encrypted
+
+[encryption_namespace_get]
+type=method
+category=core
+sig_in=s
+sig_out=s
+doc=Get algorithm namespace from its name
+
+[encryption_plugins_get]
+type=method
+category=core
+sig_in=
+sig_out=s
+doc=Retrieve registered plugins for encryption
+
+[encryption_trust_ui_get]
+async=
+type=method
+category=core
+sig_in=sss
+sig_out=s
+doc=Get XMLUI to manage trust for given encryption algorithm
+doc_param_0=to_jid: bare JID of entity to manage
+doc_param_1=namespace: namespace of the algorithm to manage
+doc_param_2=%(doc_profile_key)s
+doc_return=(XMLUI) UI of the trust management
+
+[presence_set]
+type=method
+category=core
+sig_in=ssa{ss}s
+sig_out=
+param_0_default=''
+param_1_default=''
+param_2_default={}
+param_3_default="@DEFAULT@"
+doc=Set presence information for the profile
+doc_param_0=to_jid: the JID to who we send the presence data (emtpy string for broadcast)
+doc_param_1=show: as for [presence_update]
+doc_param_2=statuses: as for [presence_update]
+doc_param_3=%(doc_profile_key)s
+
+[subscription]
+type=method
+category=core
+sig_in=sss
+sig_out=
+param_2_default="@DEFAULT@"
+doc=Send subscription request/answer to a contact
+doc_param_0=sub_type: as for [subscribe]
+doc_param_1=entity: as for [subscribe]
+doc_param_2=%(doc_profile_key)s
+
+[config_get]
+type=method
+category=core
+sig_in=ss
+sig_out=s
+doc=get main configuration option
+doc_param_0=section: section of the configuration file (empty string for DEFAULT)
+doc_param_1=name: name of the option
+
+[param_set]
+type=method
+category=core
+sig_in=sssis
+sig_out=
+param_3_default=-1
+param_4_default="@DEFAULT@"
+doc=Change a parameter
+doc_param_0=name: Name of the parameter to change
+doc_param_1=value: New Value of the parameter
+doc_param_2=category: Category of the parameter to change
+doc_param_3=%(doc_security_limit)s
+doc_param_4=%(doc_profile_key)s
+
+[param_get_a]
+type=method
+category=core
+sig_in=ssss
+sig_out=s
+param_2_default="value"
+param_3_default="@DEFAULT@"
+doc=Helper method to get a parameter's attribute *when profile is connected*
+doc_param_0=name: as for [param_set]
+doc_param_1=category: as for [param_set]
+doc_param_2=attribute: Name of the attribute
+doc_param_3=%(doc_profile_key)s
+
+[private_data_get]
+async=
+type=method
+category=core
+sig_in=sss
+sig_out=s
+doc=Retrieve private data
+doc_param_0=namespace: unique namespace to use
+doc_param_1=key: key of the data to set
+doc_param_2=%(doc_profile_key)s
+doc_return=serialised data
+
+[private_data_set]
+async=
+type=method
+category=core
+sig_in=ssss
+sig_out=
+doc=Store private data
+doc_param_0=namespace: unique namespace to use
+doc_param_1=key: key of the data to set
+doc_param_2=data: serialised data
+doc_param_3=%(doc_profile_key)s
+
+[private_data_delete]
+async=
+type=method
+category=core
+sig_in=sss
+sig_out=
+doc=Delete private data
+doc_param_0=namespace: unique namespace to use
+doc_param_1=key: key of the data to delete
+doc_param_3=%(doc_profile_key)s
+
+[param_get_a_async]
+async=
+type=method
+category=core
+sig_in=sssis
+sig_out=s
+param_2_default="value"
+param_3_default=-1
+param_4_default="@DEFAULT@"
+doc=Helper method to get a parameter's attribute
+doc_param_0=name: as for [param_set]
+doc_param_1=category: as for [param_set]
+doc_param_2=attribute: Name of the attribute
+doc_param_3=%(doc_security_limit)s
+doc_param_4=%(doc_profile_key)s
+
+[params_values_from_category_get_async]
+async=
+type=method
+category=code
+sig_in=sisss
+sig_out=a{ss}
+param_1_default=-1
+param_2_default=""
+param_3_default=""
+param_4_default="@DEFAULT@"
+doc=Get "attribute" for all params of a category
+doc_param_0=category: as for [param_set]
+doc_param_1=%(doc_security_limit)s
+doc_param_2=app: name of the frontend requesting the parameters, or '' to get all parameters
+doc_param_3=extra: extra options/filters
+doc_param_4=%(doc_profile_key)s
+
+[param_ui_get]
+async=
+type=method
+category=core
+sig_in=isss
+sig_out=s
+param_0_default=-1
+param_1_default=''
+param_2_default=''
+param_3_default="@DEFAULT@"
+doc=Return a SàT XMLUI for parameters, eventually restrict the result to the parameters concerning a given frontend
+doc_param_0=%(doc_security_limit)s
+doc_param_1=app: name of the frontend requesting the parameters, or '' to get all parameters
+doc_param_2=extra: extra options/filters
+doc_param_3=%(doc_profile_key)s
+
+[params_categories_get]
+type=method
+category=core
+sig_in=
+sig_out=as
+doc=Get all categories currently existing in parameters
+doc_return=list of categories
+
+[params_register_app]
+type=method
+category=core
+sig_in=sis
+sig_out=
+param_1_default=-1
+param_2_default=''
+doc=Register frontend's specific parameters
+doc_param_0=xml: XML definition of the parameters to be added
+doc_param_1=%(doc_security_limit)s
+doc_param_2=app: name of the frontend registering the parameters
+
+[history_get]
+async=
+type=method
+category=core
+sig_in=ssiba{ss}s
+sig_out=a(sdssa{ss}a{ss}ss)
+param_3_default=True
+param_4_default=''
+param_5_default="@NONE@"
+doc=Get history of a communication between two entities
+doc_param_0=from_jid: source JID (bare jid for catch all, full jid else)
+doc_param_1=to_jid: dest JID (bare jid for catch all, full jid else)
+doc_param_2=limit: max number of history elements to get (0 for the whole history)
+doc_param_3=between: True if we want history between the two jids (in both direction), False if we only want messages from from_jid to to_jid
+doc_param_4=filters: patterns to filter the history results, can be:
+    - body: pattern must be in message body
+    - search: pattern must be in message body or source resource
+    - types: type must be one of those, values are separated by spaces
+    - not_types: type must not be one of those, values are separated by spaces
+    - before_uid: check only message received before message with given uid
+doc_param_5=%(doc_profile)s
+doc_return=Ordered list (by timestamp) of data as in [message_new] (without final profile)
+
+[contact_add]
+type=method
+category=core
+sig_in=ss
+sig_out=
+param_1_default="@DEFAULT@"
+doc=Add a contact to profile's roster
+doc_param_0=entity_jid: JID to add to roster
+doc_param_1=%(doc_profile_key)s
+
+[contact_update]
+type=method
+category=core
+sig_in=ssass
+sig_out=
+param_3_default="@DEFAULT@"
+doc=update a contact in profile's roster
+doc_param_0=entity_jid: JID update in roster
+doc_param_1=name: roster's name for the entity
+doc_param_2=groups: list of group where the entity is
+doc_param_3=%(doc_profile_key)s
+
+[contact_del]
+async=
+type=method
+category=core
+sig_in=ss
+sig_out=
+param_1_default="@DEFAULT@"
+doc=Remove a contact from profile's roster
+doc_param_0=entity_jid: JID to remove from roster
+doc_param_1=%(doc_profile_key)s
+
+[roster_resync]
+async=
+type=method
+category=core
+sig_in=s
+sig_out=
+param_0_default="@DEFAULT@"
+doc=Do a full resynchronisation of roster with server
+doc_param_0=%(doc_profile_key)s
+
+[action_launch]
+async=
+type=method
+category=core
+sig_in=sss
+sig_out=s
+param_2_default="@DEFAULT@"
+doc=Launch a registred action
+doc_param_0=callback_id: id of the registred callback
+doc_param_1=data: optional data
+doc_param_2=%(doc_profile_key)s
+doc_return=dict where key can be:
+    - xmlui: a XMLUI need to be displayed
+
+[actions_get]
+type=method
+category=core
+sig_in=s
+sig_out=a(ssi)
+param_0_default="@DEFAULT@"
+doc=Get all not yet answered actions
+doc_param_0=%(doc_profile_key)s
+doc_return=list of data as for [action_new] (without the profile)
+
+[progress_get]
+type=method
+category=core
+sig_in=ss
+sig_out=a{ss}
+doc=Get progress information for an action
+doc_param_0=id: id of the progression status
+doc_param_1=%(doc_profile)s
+doc_return=dict with progress informations:
+ - position: current position
+ - size: end position (optional if not known)
+ other metadata may be present
+
+[progress_get_all_metadata]
+type=method
+category=core
+sig_in=s
+sig_out=a{sa{sa{ss}}}
+doc=Get all active progress informations
+doc_param_0=%(doc_profile)s or C.PROF_KEY_ALL for all profiles
+doc_return= a dict which map profile to progress_dict
+    progress_dict map progress_id to progress_metadata
+    progress_metadata is the same dict as sent by [progress_started]
+
+[progress_get_all]
+type=method
+category=core
+sig_in=s
+sig_out=a{sa{sa{ss}}}
+doc=Get all active progress informations
+doc_param_0=%(doc_profile)s or C.PROF_KEY_ALL for all profiles
+doc_return= a dict which map profile to progress_dict
+    progress_dict map progress_id to progress_data
+    progress_data is the same dict as returned by [progress_get]
+
+[menus_get]
+type=method
+category=core
+sig_in=si
+sig_out=a(ssasasa{ss})
+doc=Get all additional menus
+doc_param_0=language: language in which the menu should be translated (empty string for default)
+doc_param_1=security_limit: %(doc_security_limit)s
+doc_return=list of tuple with the following value:
+ - menu_id: menu id (same as callback id)
+ - menu_type: Type which can be:
+    * NORMAL: Classical application menu
+ - menu_path: raw path of the menu
+ - menu_path_i18n: translated path of the menu
+ - extra: extra data, like icon name
+
+[menu_launch]
+async=
+type=method
+category=core
+sig_in=sasa{ss}is
+sig_out=a{ss}
+doc=Launch a registred menu
+doc_param_0=menu_type: type of the menu (C.MENU_*)
+doc_param_1=path: canonical (untranslated) path of the menu
+doc_param_2=data: optional data
+doc_param_3=%(doc_security_limit)s
+doc_param_4=%(doc_profile_key)s
+doc_return=dict where key can be:
+    - xmlui: a XMLUI need to be displayed
+
+[menu_help_get]
+type=method
+category=core
+sig_in=ss
+sig_out=s
+param_2="NORMAL"
+doc=Get help information for a menu
+doc_param_0=menu_id: id of the menu (same as callback_id)
+doc_param_1=language: language in which the menu should be translated (empty string for default)
+doc_return=Translated help string
+
+[disco_infos]
+async=
+type=method
+category=core
+sig_in=ssbs
+sig_out=(asa(sss)a{sa(a{ss}as)})
+param_1_default=u''
+param_2_default=True
+param_3_default="@DEFAULT@"
+doc=Discover infos on an entity
+doc_param_0=entity_jid: JID to discover
+doc_param_1=node: node to use
+doc_param_2=use_cache: use cached data if available
+doc_param_3=%(doc_profile_key)s
+doc_return=discovery data:
+ - list of features
+ - list of identities (category, type, name)
+ - dictionary of extensions (FORM_TYPE as key), with value of:
+    - list of field which are:
+        - dictionary key/value where key can be:
+            * var
+            * label
+            * type
+            * desc
+        - list of values
+
+[disco_items]
+async=
+type=method
+category=core
+sig_in=ssbs
+sig_out=a(sss)
+param_1_default=u''
+param_2_default=True
+param_3_default="@DEFAULT@"
+doc=Discover items of an entity
+doc_param_0=entity_jid: JID to discover
+doc_param_1=node: node to use
+doc_param_2=use_cache: use cached data if available
+doc_param_3=%(doc_profile_key)s
+doc_return=array of tuple (entity, node identifier, name)
+
+[disco_find_by_features]
+async=
+type=method
+category=core
+sig_in=asa(ss)bbbbbs
+sig_out=(a{sa(sss)}a{sa(sss)}a{sa(sss)})
+param_2_default=False
+param_3_default=True
+param_4_default=True
+param_5_default=True
+param_6_default=False
+param_7_default="@DEFAULT@"
+doc=Discover items of an entity
+doc_param_0=namespaces: namespaces of the features to check
+doc_param_1=identities: identities to filter
+doc_param_2=bare_jid: if True only retrieve bare jids
+    if False, retrieve full jids of connected resources
+doc_param_3=service: True to check server's services
+doc_param_4=roster: True to check connected devices from people in roster
+doc_param_5=own_jid: True to check profile's jid
+doc_param_6=local_device: True to check device on which the backend is running
+doc_param_7=%(doc_profile_key)s
+doc_return=tuple of maps of found entities full jids to their identities. Maps are in this order:
+ - services entities
+ - own entities (i.e. entities linked to profile's jid)
+ - roster entities
+
+[params_template_save]
+type=method
+category=core
+sig_in=s
+sig_out=b
+doc=Save parameters template to xml file
+doc_param_0=filename: output filename
+doc_return=boolean (True in case of success)
+
+[params_template_load]
+type=method
+category=core
+sig_in=s
+sig_out=b
+doc=Load parameters template from xml file
+doc_param_0=filename: input filename
+doc_return=boolean (True in case of success)
+
+[session_infos_get]
+async=
+type=method
+category=core
+sig_in=s
+sig_out=a{ss}
+doc=Get various informations on current profile session
+doc_param_0=%(doc_profile_key)s
+doc_return=session informations, with at least the following keys:
+    jid: current full jid
+    started: date of creation of the session (Epoch time)
+
+[devices_infos_get]
+async=
+type=method
+category=core
+sig_in=ss
+sig_out=s
+doc=Get various informations on an entity devices
+doc_param_0=bare_jid: get data on known devices from this entity
+    empty string to get devices of the profile
+doc_param_1=%(doc_profile_key)s
+doc_return=list of known devices, where each item is a dict with a least following keys:
+    resource: device resource
+
+[namespaces_get]
+type=method
+category=core
+sig_in=
+sig_out=a{ss}
+doc=Get a dict to short name => whole namespaces
+doc_return=namespaces mapping
+
+[image_check]
+type=method
+category=core
+sig_in=s
+sig_out=s
+doc=Analyze an image a return a report
+doc_return=serialized report
+
+[image_resize]
+async=
+type=method
+category=core
+sig_in=sii
+sig_out=s
+doc=Create a new image with desired size
+doc_param_0=image_path: path of the image to resize
+doc_param_1=width: width of the new image
+doc_param_2=height: height of the new image
+doc_return=path of the new image with desired size
+    the image must be deleted once not needed anymore
+
+[image_generate_preview]
+async=
+type=method
+category=core
+sig_in=ss
+sig_out=s
+doc=Generate a preview of an image in cache
+doc_param_0=image_path: path of the original image
+doc_param_1=%(doc_profile_key)s
+doc_return=path to the preview in cache
+
+[image_convert]
+async=
+type=method
+category=core
+sig_in=ssss
+sig_out=s
+doc=Convert an image to an other format
+doc_param_0=source: path of the image to convert
+doc_param_1=dest: path to the location where the new image must be stored.
+    Empty string to generate a file in cache, unique to the source
+doc_param_3=extra: serialised extra
+doc_param_4=profile_key: either profile_key or empty string to use common cache
+    this parameter is used only when dest is empty
+doc_return=path to the new converted image
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constants.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core import constants
+
+
+class Const(constants.Const):
+
+    NAME = "bridge_constructor"
+    DEST_DIR_DEFAULT = "generated"
+    DESCRIPTION = """{name} Copyright (C) 2009-2021 Jérôme Poisson (aka Goffi)
+
+    This script construct a SàT bridge using the given protocol
+
+    This program comes with ABSOLUTELY NO WARRANTY;
+    This is free software, and you are welcome to redistribute it
+    under certain conditions.
+    """.format(
+        name=NAME, version=constants.Const.APP_VERSION
+    )
+    #  TODO: move protocoles in separate files (plugins?)
+    DEFAULT_PROTOCOLE = "dbus"
+
+    # flags used method/signal declaration (not to be confused with constructor flags)
+    DECLARATION_FLAGS = ["deprecated", "async"]
+
+    ENV_OVERRIDE = "SAT_BRIDGE_CONST_"  # Prefix used to override a constant
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constructors/dbus-xml/constructor.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.bridge.bridge_constructor import base_constructor
+from xml.dom import minidom
+import sys
+
+
+class DbusXmlConstructor(base_constructor.Constructor):
+    """Constructor for DBus XML syntaxt (used by Qt frontend)"""
+
+    def __init__(self, bridge_template, options):
+        base_constructor.Constructor.__init__(self, bridge_template, options)
+
+        self.template = "dbus_xml_template.xml"
+        self.core_dest = "org.libervia.sat.xml"
+        self.default_annotation = {
+            "a{ss}": "StringDict",
+            "a(sa{ss}as)": "QList<Contact>",
+            "a{i(ss)}": "HistoryT",
+            "a(sss)": "QList<MenuT>",
+            "a{sa{s(sia{ss})}}": "PresenceStatusT",
+        }
+
+    def generate_core_side(self):
+        try:
+            doc = minidom.parse(self.get_template_path(self.template))
+            interface_elt = doc.getElementsByTagName("interface")[0]
+        except IOError:
+            print("Can't access template")
+            sys.exit(1)
+        except IndexError:
+            print("Template error")
+            sys.exit(1)
+
+        sections = self.bridge_template.sections()
+        sections.sort()
+        for section in sections:
+            function = self.getValues(section)
+            print(("Adding %s %s" % (section, function["type"])))
+            new_elt = doc.createElement(
+                "method" if function["type"] == "method" else "signal"
+            )
+            new_elt.setAttribute("name", section)
+
+            idx = 0
+            args_doc = self.get_arguments_doc(section)
+            for arg in self.arguments_parser(function["sig_in"] or ""):
+                arg_elt = doc.createElement("arg")
+                arg_elt.setAttribute(
+                    "name", args_doc[idx][0] if idx in args_doc else "arg_%i" % idx
+                )
+                arg_elt.setAttribute("type", arg)
+                _direction = "in" if function["type"] == "method" else "out"
+                arg_elt.setAttribute("direction", _direction)
+                new_elt.appendChild(arg_elt)
+                if "annotation" in self.args.flags:
+                    if arg in self.default_annotation:
+                        annot_elt = doc.createElement("annotation")
+                        annot_elt.setAttribute(
+                            "name", "com.trolltech.QtDBus.QtTypeName.In%d" % idx
+                        )
+                        annot_elt.setAttribute("value", self.default_annotation[arg])
+                        new_elt.appendChild(annot_elt)
+                idx += 1
+
+            if function["sig_out"]:
+                arg_elt = doc.createElement("arg")
+                arg_elt.setAttribute("type", function["sig_out"])
+                arg_elt.setAttribute("direction", "out")
+                new_elt.appendChild(arg_elt)
+                if "annotation" in self.args.flags:
+                    if function["sig_out"] in self.default_annotation:
+                        annot_elt = doc.createElement("annotation")
+                        annot_elt.setAttribute(
+                            "name", "com.trolltech.QtDBus.QtTypeName.Out0"
+                        )
+                        annot_elt.setAttribute(
+                            "value", self.default_annotation[function["sig_out"]]
+                        )
+                        new_elt.appendChild(annot_elt)
+
+            interface_elt.appendChild(new_elt)
+
+        # now we write to final file
+        self.final_write(self.core_dest, [doc.toprettyxml()])
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constructors/dbus-xml/dbus_xml_template.xml	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,4 @@
+<node>
+  <interface name="org.libervia.Libervia.core">
+  </interface>
+</node>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constructors/dbus/constructor.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.bridge.bridge_constructor import base_constructor
+
+
+class DbusConstructor(base_constructor.Constructor):
+    NAME = "dbus"
+    CORE_TEMPLATE = "dbus_core_template.py"
+    CORE_DEST = "dbus_bridge.py"
+    CORE_FORMATS = {
+        "methods_declarations": """\
+        Method('{name}', arguments='{sig_in}', returns='{sig_out}'),""",
+
+        "methods": """\
+    def dbus_{name}(self, {args}):
+        {debug}return self._callback("{name}", {args_no_default})\n""",
+
+        "signals_declarations": """\
+        Signal('{name}', '{sig_in}'),""",
+
+        "signals": """\
+    def {name}(self, {args}):
+        self._obj.emitSignal("{name}", {args})\n""",
+    }
+
+    FRONTEND_TEMPLATE = "dbus_frontend_template.py"
+    FRONTEND_DEST = CORE_DEST
+    FRONTEND_FORMATS = {
+        "methods": """\
+    def {name}(self, {args}{async_comma}{async_args}):
+        {error_handler}{blocking_call}{debug}return {result}\n""",
+        "async_methods": """\
+    def {name}(self{async_comma}{args}):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
+        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
+        self.db_{category}_iface.{name}({args_result}{async_comma}timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
+        {debug}return fut\n""",
+    }
+
+    def core_completion_signal(self, completion, function, default, arg_doc, async_):
+        completion["category"] = completion["category"].upper()
+        completion["body"] = (
+            "pass"
+            if not self.args.debug
+            else 'log.debug ("{}")'.format(completion["name"])
+        )
+
+    def core_completion_method(self, completion, function, default, arg_doc, async_):
+        completion.update(
+            {
+                "debug": (
+                    "" if not self.args.debug
+                    else f'log.debug ("{completion["name"]}")\n{8 * " "}'
+                )
+            }
+        )
+
+    def frontend_completion_method(self, completion, function, default, arg_doc, async_):
+        completion.update(
+            {
+                # XXX: we can manage blocking call in the same way as async one: if callback is None the call will be blocking
+                "debug": ""
+                if not self.args.debug
+                else 'log.debug ("%s")\n%s' % (completion["name"], 8 * " "),
+                "args_result": self.get_arguments(function["sig_in"], name=arg_doc),
+                "async_args": "callback=None, errback=None",
+                "async_comma": ", " if function["sig_in"] else "",
+                "error_handler": """if callback is None:
+            error_handler = None
+        else:
+            if errback is None:
+                errback = log.error
+            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
+        """,
+            }
+        )
+        if async_:
+            completion["blocking_call"] = ""
+            completion[
+                "async_args_result"
+            ] = "timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler"
+        else:
+            # XXX: To have a blocking call, we must have not reply_handler, so we test if callback exists, and add reply_handler only in this case
+            completion[
+                "blocking_call"
+            ] = """kwargs={}
+        if callback is not None:
+            kwargs['timeout'] = const_TIMEOUT
+            kwargs['reply_handler'] = callback
+            kwargs['error_handler'] = error_handler
+        """
+            completion["async_args_result"] = "**kwargs"
+        result = (
+            "self.db_%(category)s_iface.%(name)s(%(args_result)s%(async_comma)s%(async_args_result)s)"
+            % completion
+        )
+        completion["result"] = (
+            "str(%s)" if self.args.unicode and function["sig_out"] == "s" else "%s"
+        ) % result
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constructors/dbus/dbus_core_template.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+
+# Libervia communication bridge
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from types import MethodType
+from functools import partialmethod
+from twisted.internet import defer, reactor
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.exceptions import BridgeInitError
+from libervia.backend.tools import config
+from txdbus import client, objects, error
+from txdbus.interface import DBusInterface, Method, Signal
+
+
+log = getLogger(__name__)
+
+# Interface prefix
+const_INT_PREFIX = config.config_get(
+    config.parse_main_conf(),
+    "",
+    "bridge_dbus_int_prefix",
+    "org.libervia.Libervia")
+const_ERROR_PREFIX = const_INT_PREFIX + ".error"
+const_OBJ_PATH = "/org/libervia/Libervia/bridge"
+const_CORE_SUFFIX = ".core"
+const_PLUGIN_SUFFIX = ".plugin"
+
+
+class ParseError(Exception):
+    pass
+
+
+class DBusException(Exception):
+    pass
+
+
+class MethodNotRegistered(DBusException):
+    dbusErrorName = const_ERROR_PREFIX + ".MethodNotRegistered"
+
+
+class GenericException(DBusException):
+    def __init__(self, twisted_error):
+        """
+
+        @param twisted_error (Failure): instance of twisted Failure
+        error message is used to store a repr of message and condition in a tuple,
+        so it can be evaluated by the frontend bridge.
+        """
+        try:
+            # twisted_error.value is a class
+            class_ = twisted_error.value().__class__
+        except TypeError:
+            # twisted_error.value is an instance
+            class_ = twisted_error.value.__class__
+            data = twisted_error.getErrorMessage()
+            try:
+                data = (data, twisted_error.value.condition)
+            except AttributeError:
+                data = (data,)
+        else:
+            data = (str(twisted_error),)
+        self.dbusErrorName = ".".join(
+            (const_ERROR_PREFIX, class_.__module__, class_.__name__)
+        )
+        super(GenericException, self).__init__(repr(data))
+
+    @classmethod
+    def create_and_raise(cls, exc):
+        raise cls(exc)
+
+
+class DBusObject(objects.DBusObject):
+
+    core_iface = DBusInterface(
+        const_INT_PREFIX + const_CORE_SUFFIX,
+##METHODS_DECLARATIONS_PART##
+##SIGNALS_DECLARATIONS_PART##
+    )
+    plugin_iface = DBusInterface(
+        const_INT_PREFIX + const_PLUGIN_SUFFIX
+    )
+
+    dbusInterfaces = [core_iface, plugin_iface]
+
+    def __init__(self, path):
+        super().__init__(path)
+        log.debug("Init DBusObject...")
+        self.cb = {}
+
+    def register_method(self, name, cb):
+        self.cb[name] = cb
+
+    def _callback(self, name, *args, **kwargs):
+        """Call the callback if it exists, raise an exception else"""
+        try:
+            cb = self.cb[name]
+        except KeyError:
+            raise MethodNotRegistered
+        else:
+            d = defer.maybeDeferred(cb, *args, **kwargs)
+            d.addErrback(GenericException.create_and_raise)
+            return d
+
+##METHODS_PART##
+
+class bridge:
+
+    def __init__(self):
+        log.info("Init DBus...")
+        self._obj = DBusObject(const_OBJ_PATH)
+
+    async def post_init(self):
+        try:
+            conn = await client.connect(reactor)
+        except error.DBusException as e:
+            if e.errName == "org.freedesktop.DBus.Error.NotSupported":
+                log.error(
+                    _(
+                        "D-Bus is not launched, please see README to see instructions on "
+                        "how to launch it"
+                    )
+                )
+            raise BridgeInitError(str(e))
+
+        conn.exportObject(self._obj)
+        await conn.requestBusName(const_INT_PREFIX)
+
+##SIGNALS_PART##
+    def register_method(self, name, callback):
+        log.debug(f"registering DBus bridge method [{name}]")
+        self._obj.register_method(name, callback)
+
+    def emit_signal(self, name, *args):
+        self._obj.emitSignal(name, *args)
+
+    def add_method(
+            self, name, int_suffix, in_sign, out_sign, method, async_=False, doc={}
+    ):
+        """Dynamically add a method to D-Bus bridge"""
+        # FIXME: doc parameter is kept only temporary, the time to remove it from calls
+        log.debug(f"Adding method {name!r} to D-Bus bridge")
+        self._obj.plugin_iface.addMethod(
+            Method(name, arguments=in_sign, returns=out_sign)
+        )
+        # we have to create a method here instead of using partialmethod, because txdbus
+        # uses __func__ which doesn't work with partialmethod
+        def caller(self_, *args, **kwargs):
+            return self_._callback(name, *args, **kwargs)
+        setattr(self._obj, f"dbus_{name}", MethodType(caller, self._obj))
+        self.register_method(name, method)
+
+    def add_signal(self, name, int_suffix, signature, doc={}):
+        """Dynamically add a signal to D-Bus bridge"""
+        log.debug(f"Adding signal {name!r} to D-Bus bridge")
+        self._obj.plugin_iface.addSignal(Signal(name, signature))
+        setattr(bridge, name, partialmethod(bridge.emit_signal, name))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constructors/dbus/dbus_frontend_template.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,223 @@
+#!/usr/bin/env python3
+
+# SàT communication bridge
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import asyncio
+import dbus
+import ast
+from libervia.backend.core.i18n import _
+from libervia.backend.tools import config
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.exceptions import BridgeExceptionNoService, BridgeInitError
+from dbus.mainloop.glib import DBusGMainLoop
+from .bridge_frontend import BridgeException
+
+
+DBusGMainLoop(set_as_default=True)
+log = getLogger(__name__)
+
+
+# Interface prefix
+const_INT_PREFIX = config.config_get(
+    config.parse_main_conf(),
+    "",
+    "bridge_dbus_int_prefix",
+    "org.libervia.Libervia")
+const_ERROR_PREFIX = const_INT_PREFIX + ".error"
+const_OBJ_PATH = '/org/libervia/Libervia/bridge'
+const_CORE_SUFFIX = ".core"
+const_PLUGIN_SUFFIX = ".plugin"
+const_TIMEOUT = 120
+
+
+def dbus_to_bridge_exception(dbus_e):
+    """Convert a DBusException to a BridgeException.
+
+    @param dbus_e (DBusException)
+    @return: BridgeException
+    """
+    full_name = dbus_e.get_dbus_name()
+    if full_name.startswith(const_ERROR_PREFIX):
+        name = dbus_e.get_dbus_name()[len(const_ERROR_PREFIX) + 1:]
+    else:
+        name = full_name
+    # XXX: dbus_e.args doesn't contain the original DBusException args, but we
+    # receive its serialized form in dbus_e.args[0]. From that we can rebuild
+    # the original arguments list thanks to ast.literal_eval (secure eval).
+    message = dbus_e.get_dbus_message()  # similar to dbus_e.args[0]
+    try:
+        message, condition = ast.literal_eval(message)
+    except (SyntaxError, ValueError, TypeError):
+        condition = ''
+    return BridgeException(name, message, condition)
+
+
+class bridge:
+
+    def bridge_connect(self, callback, errback):
+        try:
+            self.sessions_bus = dbus.SessionBus()
+            self.db_object = self.sessions_bus.get_object(const_INT_PREFIX,
+                                                          const_OBJ_PATH)
+            self.db_core_iface = dbus.Interface(self.db_object,
+                                                dbus_interface=const_INT_PREFIX + const_CORE_SUFFIX)
+            self.db_plugin_iface = dbus.Interface(self.db_object,
+                                                  dbus_interface=const_INT_PREFIX + const_PLUGIN_SUFFIX)
+        except dbus.exceptions.DBusException as e:
+            if e._dbus_error_name in ('org.freedesktop.DBus.Error.ServiceUnknown',
+                                      'org.freedesktop.DBus.Error.Spawn.ExecFailed'):
+                errback(BridgeExceptionNoService())
+            elif e._dbus_error_name == 'org.freedesktop.DBus.Error.NotSupported':
+                log.error(_("D-Bus is not launched, please see README to see instructions on how to launch it"))
+                errback(BridgeInitError)
+            else:
+                errback(e)
+        else:
+            callback()
+        #props = self.db_core_iface.getProperties()
+
+    def register_signal(self, functionName, handler, iface="core"):
+        if iface == "core":
+            self.db_core_iface.connect_to_signal(functionName, handler)
+        elif iface == "plugin":
+            self.db_plugin_iface.connect_to_signal(functionName, handler)
+        else:
+            log.error(_('Unknown interface'))
+
+    def __getattribute__(self, name):
+        """ usual __getattribute__ if the method exists, else try to find a plugin method """
+        try:
+            return object.__getattribute__(self, name)
+        except AttributeError:
+            # The attribute is not found, we try the plugin proxy to find the requested method
+
+            def get_plugin_method(*args, **kwargs):
+                # We first check if we have an async call. We detect this in two ways:
+                #   - if we have the 'callback' and 'errback' keyword arguments
+                #   - or if the last two arguments are callable
+
+                async_ = False
+                args = list(args)
+
+                if kwargs:
+                    if 'callback' in kwargs:
+                        async_ = True
+                        _callback = kwargs.pop('callback')
+                        _errback = kwargs.pop('errback', lambda failure: log.error(str(failure)))
+                    try:
+                        args.append(kwargs.pop('profile'))
+                    except KeyError:
+                        try:
+                            args.append(kwargs.pop('profile_key'))
+                        except KeyError:
+                            pass
+                    # at this point, kwargs should be empty
+                    if kwargs:
+                        log.warning("unexpected keyword arguments, they will be ignored: {}".format(kwargs))
+                elif len(args) >= 2 and callable(args[-1]) and callable(args[-2]):
+                    async_ = True
+                    _errback = args.pop()
+                    _callback = args.pop()
+
+                method = getattr(self.db_plugin_iface, name)
+
+                if async_:
+                    kwargs['timeout'] = const_TIMEOUT
+                    kwargs['reply_handler'] = _callback
+                    kwargs['error_handler'] = lambda err: _errback(dbus_to_bridge_exception(err))
+
+                try:
+                    return method(*args, **kwargs)
+                except ValueError as e:
+                    if e.args[0].startswith("Unable to guess signature"):
+                        # XXX: if frontend is started too soon after backend, the
+                        #   inspection misses methods (notably plugin dynamically added
+                        #   methods). The following hack works around that by redoing the
+                        #   cache of introspected methods signatures.
+                        log.debug("using hack to work around inspection issue")
+                        proxy = self.db_plugin_iface.proxy_object
+                        IN_PROGRESS = proxy.INTROSPECT_STATE_INTROSPECT_IN_PROGRESS
+                        proxy._introspect_state = IN_PROGRESS
+                        proxy._Introspect()
+                        return self.db_plugin_iface.get_dbus_method(name)(*args, **kwargs)
+                    raise e
+
+            return get_plugin_method
+
+##METHODS_PART##
+
+class AIOBridge(bridge):
+
+    def register_signal(self, functionName, handler, iface="core"):
+        loop = asyncio.get_running_loop()
+        async_handler = lambda *args: asyncio.run_coroutine_threadsafe(handler(*args), loop)
+        return super().register_signal(functionName, async_handler, iface)
+
+    def __getattribute__(self, name):
+        """ usual __getattribute__ if the method exists, else try to find a plugin method """
+        try:
+            return object.__getattribute__(self, name)
+        except AttributeError:
+            # The attribute is not found, we try the plugin proxy to find the requested method
+            def get_plugin_method(*args, **kwargs):
+                loop = asyncio.get_running_loop()
+                fut = loop.create_future()
+                method = getattr(self.db_plugin_iface, name)
+                reply_handler = lambda ret=None: loop.call_soon_threadsafe(
+                    fut.set_result, ret)
+                error_handler = lambda err: loop.call_soon_threadsafe(
+                    fut.set_exception, dbus_to_bridge_exception(err))
+                try:
+                    method(
+                        *args,
+                        **kwargs,
+                        timeout=const_TIMEOUT,
+                        reply_handler=reply_handler,
+                        error_handler=error_handler
+                    )
+                except ValueError as e:
+                    if e.args[0].startswith("Unable to guess signature"):
+                        # same hack as for bridge.__getattribute__
+                        log.warning("using hack to work around inspection issue")
+                        proxy = self.db_plugin_iface.proxy_object
+                        IN_PROGRESS = proxy.INTROSPECT_STATE_INTROSPECT_IN_PROGRESS
+                        proxy._introspect_state = IN_PROGRESS
+                        proxy._Introspect()
+                        self.db_plugin_iface.get_dbus_method(name)(
+                            *args,
+                            **kwargs,
+                            timeout=const_TIMEOUT,
+                            reply_handler=reply_handler,
+                            error_handler=error_handler
+                        )
+
+                    else:
+                        raise e
+                return fut
+
+            return get_plugin_method
+
+    def bridge_connect(self):
+        loop = asyncio.get_running_loop()
+        fut = loop.create_future()
+        super().bridge_connect(
+            callback=lambda: loop.call_soon_threadsafe(fut.set_result, None),
+            errback=lambda e: loop.call_soon_threadsafe(fut.set_exception, e)
+        )
+        return fut
+
+##ASYNC_METHODS_PART##
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constructors/embedded/constructor.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.bridge.bridge_constructor import base_constructor
+
+#  from textwraps import dedent
+
+
+class EmbeddedConstructor(base_constructor.Constructor):
+    NAME = "embedded"
+    CORE_TEMPLATE = "embedded_template.py"
+    CORE_DEST = "embedded.py"
+    CORE_FORMATS = {
+        "methods": """\
+    def {name}(self, {args}{args_comma}callback=None, errback=None):
+{ret_routine}
+""",
+        "signals": """\
+    def {name}(self, {args}):
+        try:
+            cb = self._signals_cbs["{category}"]["{name}"]
+        except KeyError:
+            log.warning(u"ignoring signal {name}: no callback registered")
+        else:
+            cb({args_result})
+""",
+    }
+    FRONTEND_TEMPLATE = "embedded_frontend_template.py"
+    FRONTEND_DEST = CORE_DEST
+    FRONTEND_FORMATS = {}
+
+    def core_completion_method(self, completion, function, default, arg_doc, async_):
+        completion.update(
+            {
+                "debug": ""
+                if not self.args.debug
+                else 'log.debug ("%s")\n%s' % (completion["name"], 8 * " "),
+                "args_result": self.get_arguments(function["sig_in"], name=arg_doc),
+                "args_comma": ", " if function["sig_in"] else "",
+            }
+        )
+
+        if async_:
+            completion["cb_or_lambda"] = (
+                "callback" if function["sig_out"] else "lambda __: callback()"
+            )
+            completion[
+                "ret_routine"
+            ] = """\
+        d = self._methods_cbs["{name}"]({args_result})
+        if callback is not None:
+            d.addCallback({cb_or_lambda})
+        if errback is None:
+            d.addErrback(lambda failure_: log.error(failure_))
+        else:
+            d.addErrback(errback)
+        return d
+        """.format(
+                **completion
+            )
+        else:
+            completion["ret_or_nothing"] = "ret" if function["sig_out"] else ""
+            completion[
+                "ret_routine"
+            ] = """\
+        try:
+            ret = self._methods_cbs["{name}"]({args_result})
+        except Exception as e:
+            if errback is not None:
+                errback(e)
+            else:
+                raise e
+        else:
+            if callback is None:
+                return ret
+            else:
+                callback({ret_or_nothing})""".format(
+                **completion
+            )
+
+    def core_completion_signal(self, completion, function, default, arg_doc, async_):
+        completion.update(
+            {"args_result": self.get_arguments(function["sig_in"], name=arg_doc)}
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constructors/embedded/embedded_frontend_template.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,20 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.bridge.embedded import bridge
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constructors/embedded/embedded_template.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+
+
+class _Bridge(object):
+    def __init__(self):
+        log.debug("Init embedded bridge...")
+        self._methods_cbs = {}
+        self._signals_cbs = {"core": {}, "plugin": {}}
+
+    def bridge_connect(self, callback, errback):
+        callback()
+
+    def register_method(self, name, callback):
+        log.debug("registering embedded bridge method [{}]".format(name))
+        if name in self._methods_cbs:
+            raise exceptions.ConflictError("method {} is already regitered".format(name))
+        self._methods_cbs[name] = callback
+
+    def register_signal(self, functionName, handler, iface="core"):
+        iface_dict = self._signals_cbs[iface]
+        if functionName in iface_dict:
+            raise exceptions.ConflictError(
+                "signal {name} is already regitered for interface {iface}".format(
+                    name=functionName, iface=iface
+                )
+            )
+        iface_dict[functionName] = handler
+
+    def call_method(self, name, out_sign, async_, args, kwargs):
+        callback = kwargs.pop("callback", None)
+        errback = kwargs.pop("errback", None)
+        if async_:
+            d = self._methods_cbs[name](*args, **kwargs)
+            if callback is not None:
+                d.addCallback(callback if out_sign else lambda __: callback())
+            if errback is None:
+                d.addErrback(lambda failure_: log.error(failure_))
+            else:
+                d.addErrback(errback)
+            return d
+        else:
+            try:
+                ret = self._methods_cbs[name](*args, **kwargs)
+            except Exception as e:
+                if errback is not None:
+                    errback(e)
+                else:
+                    raise e
+            else:
+                if callback is None:
+                    return ret
+                else:
+                    if out_sign:
+                        callback(ret)
+                    else:
+                        callback()
+
+    def send_signal(self, name, args, kwargs):
+        try:
+            cb = self._signals_cbs["plugin"][name]
+        except KeyError:
+            log.debug("ignoring signal {}: no callback registered".format(name))
+        else:
+            cb(*args, **kwargs)
+
+    def add_method(self, name, int_suffix, in_sign, out_sign, method, async_=False, doc={}):
+        # FIXME: doc parameter is kept only temporary, the time to remove it from calls
+        log.debug("Adding method [{}] to embedded bridge".format(name))
+        self.register_method(name, method)
+        setattr(
+            self.__class__,
+            name,
+            lambda self_, *args, **kwargs: self.call_method(
+                name, out_sign, async_, args, kwargs
+            ),
+        )
+
+    def add_signal(self, name, int_suffix, signature, doc={}):
+        setattr(
+            self.__class__,
+            name,
+            lambda self_, *args, **kwargs: self.send_signal(name, args, kwargs),
+        )
+
+    ## signals ##
+
+
+##SIGNALS_PART##
+## methods ##
+
+##METHODS_PART##
+
+# we want the same instance for both core and frontend
+bridge = None
+
+
+def bridge():
+    global bridge
+    if bridge is None:
+        bridge = _Bridge()
+    return bridge
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constructors/mediawiki/constructor.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.bridge.bridge_constructor import base_constructor
+import sys
+from datetime import datetime
+import re
+
+
+class MediawikiConstructor(base_constructor.Constructor):
+    def __init__(self, bridge_template, options):
+        base_constructor.Constructor.__init__(self, bridge_template, options)
+        self.core_template = "mediawiki_template.tpl"
+        self.core_dest = "mediawiki.wiki"
+
+    def _add_text_decorations(self, text):
+        """Add text decorations like coloration or shortcuts"""
+
+        def anchor_link(match):
+            link = match.group(1)
+            # we add anchor_link for [method_name] syntax:
+            if link in self.bridge_template.sections():
+                return "[[#%s|%s]]" % (link, link)
+            print("WARNING: found an anchor link to an unknown method")
+            return link
+
+        return re.sub(r"\[(\w+)\]", anchor_link, text)
+
+    def _wiki_parameter(self, name, sig_in):
+        """Format parameters with the wiki syntax
+        @param name: name of the function
+        @param sig_in: signature in
+        @return: string of the formated parameters"""
+        arg_doc = self.get_arguments_doc(name)
+        arg_default = self.get_default(name)
+        args_str = self.get_arguments(sig_in)
+        args = args_str.split(", ") if args_str else []  # ugly but it works :)
+        wiki = []
+        for i in range(len(args)):
+            if i in arg_doc:
+                name, doc = arg_doc[i]
+                doc = "\n:".join(doc.rstrip("\n").split("\n"))
+                wiki.append("; %s: %s" % (name, self._add_text_decorations(doc)))
+            else:
+                wiki.append("; arg_%d: " % i)
+            if i in arg_default:
+                wiki.append(":''DEFAULT: %s''" % arg_default[i])
+        return "\n".join(wiki)
+
+    def _wiki_return(self, name):
+        """Format return doc with the wiki syntax
+        @param name: name of the function
+        """
+        arg_doc = self.get_arguments_doc(name)
+        wiki = []
+        if "return" in arg_doc:
+            wiki.append("\n|-\n! scope=row | return value\n|")
+            wiki.append(
+                "<br />\n".join(
+                    self._add_text_decorations(arg_doc["return"]).rstrip("\n").split("\n")
+                )
+            )
+        return "\n".join(wiki)
+
+    def generate_core_side(self):
+        signals_part = []
+        methods_part = []
+        sections = self.bridge_template.sections()
+        sections.sort()
+        for section in sections:
+            function = self.getValues(section)
+            print(("Adding %s %s" % (section, function["type"])))
+            async_msg = """<br />'''This method is asynchronous'''"""
+            deprecated_msg = """<br />'''<font color="#FF0000">/!\ WARNING /!\ : This method is deprecated, please don't use it !</font>'''"""
+            signature_signal = (
+                """\
+! scope=row | signature
+| %s
+|-\
+"""
+                % function["sig_in"]
+            )
+            signature_method = """\
+! scope=row | signature in
+| %s
+|-
+! scope=row | signature out
+| %s
+|-\
+""" % (
+                function["sig_in"],
+                function["sig_out"],
+            )
+            completion = {
+                "signature": signature_signal
+                if function["type"] == "signal"
+                else signature_method,
+                "sig_out": function["sig_out"] or "",
+                "category": function["category"],
+                "name": section,
+                "doc": self.get_doc(section) or "FIXME: No description available",
+                "async": async_msg if "async" in self.getFlags(section) else "",
+                "deprecated": deprecated_msg
+                if "deprecated" in self.getFlags(section)
+                else "",
+                "parameters": self._wiki_parameter(section, function["sig_in"]),
+                "return": self._wiki_return(section)
+                if function["type"] == "method"
+                else "",
+            }
+
+            dest = signals_part if function["type"] == "signal" else methods_part
+            dest.append(
+                """\
+== %(name)s ==
+''%(doc)s''
+%(deprecated)s
+%(async)s
+{| class="wikitable" style="text-align:left; width:80%%;"
+! scope=row | category
+| %(category)s
+|-
+%(signature)s
+! scope=row | parameters
+|
+%(parameters)s%(return)s
+|}
+"""
+                % completion
+            )
+
+        # at this point, signals_part, and methods_part should be filled,
+        # we just have to place them in the right part of the template
+        core_bridge = []
+        template_path = self.get_template_path(self.core_template)
+        try:
+            with open(template_path) as core_template:
+                for line in core_template:
+                    if line.startswith("##SIGNALS_PART##"):
+                        core_bridge.extend(signals_part)
+                    elif line.startswith("##METHODS_PART##"):
+                        core_bridge.extend(methods_part)
+                    elif line.startswith("##TIMESTAMP##"):
+                        core_bridge.append("Generated on %s" % datetime.now())
+                    else:
+                        core_bridge.append(line.replace("\n", ""))
+        except IOError:
+            print(("Can't open template file [%s]" % template_path))
+            sys.exit(1)
+
+        # now we write to final file
+        self.final_write(self.core_dest, core_bridge)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constructors/mediawiki/mediawiki_template.tpl	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,11 @@
+[[Catégorie:Salut à Toi]]
+[[Catégorie:documentation développeur]]
+
+= Overview =
+This is an autogenerated doc for SàT bridge's API
+= Signals =
+##SIGNALS_PART##
+= Methods =
+##METHODS_PART##
+----
+##TIMESTAMP##
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constructors/pb/constructor.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.bridge.bridge_constructor import base_constructor
+
+
+class pbConstructor(base_constructor.Constructor):
+    NAME = "pb"
+    CORE_TEMPLATE = "pb_core_template.py"
+    CORE_DEST = "pb.py"
+    CORE_FORMATS = {
+        "signals": """\
+    def {name}(self, {args}):
+        {debug}self.send_signal("{name}", {args_no_def})\n"""
+    }
+
+    FRONTEND_TEMPLATE = "pb_frontend_template.py"
+    FRONTEND_DEST = CORE_DEST
+    FRONTEND_FORMATS = {
+        "methods": """\
+    def {name}(self{args_comma}{args}, callback=None, errback=None):
+        {debug}d = self.root.callRemote("{name}"{args_comma}{args_no_def})
+        if callback is not None:
+            d.addCallback({callback})
+        if errback is None:
+            d.addErrback(self._generic_errback)
+        else:
+            d.addErrback(self._errback, ori_errback=errback)\n""",
+        "async_methods": """\
+    def {name}(self{args_comma}{args}):
+        {debug}d = self.root.callRemote("{name}"{args_comma}{args_no_def})
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())\n""",
+    }
+
+    def core_completion_signal(self, completion, function, default, arg_doc, async_):
+        completion["args_no_def"] = self.get_arguments(function["sig_in"], name=arg_doc)
+        completion["debug"] = (
+            ""
+            if not self.args.debug
+            else 'log.debug ("%s")\n%s' % (completion["name"], 8 * " ")
+        )
+
+    def frontend_completion_method(self, completion, function, default, arg_doc, async_):
+        completion.update(
+            {
+                "args_comma": ", " if function["sig_in"] else "",
+                "args_no_def": self.get_arguments(function["sig_in"], name=arg_doc),
+                "callback": "callback"
+                if function["sig_out"]
+                else "lambda __: callback()",
+                "debug": ""
+                if not self.args.debug
+                else 'log.debug ("%s")\n%s' % (completion["name"], 8 * " "),
+            }
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constructors/pb/pb_core_template.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import dataclasses
+from functools import partial
+from pathlib import Path
+from twisted.spread import jelly, pb
+from twisted.internet import reactor
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import config
+
+log = getLogger(__name__)
+
+
+## jelly hack
+# we monkey patch jelly to handle namedtuple
+ori_jelly = jelly._Jellier.jelly
+
+
+def fixed_jelly(self, obj):
+    """this method fix handling of namedtuple"""
+    if isinstance(obj, tuple) and not obj is tuple:
+        obj = tuple(obj)
+    return ori_jelly(self, obj)
+
+
+jelly._Jellier.jelly = fixed_jelly
+
+
+@dataclasses.dataclass(eq=False)
+class HandlerWrapper:
+    # we use a wrapper to keep signals handlers because RemoteReference doesn't support
+    # comparison (other than equality), making it unusable with a list
+    handler: pb.RemoteReference
+
+
+class PBRoot(pb.Root):
+    def __init__(self):
+        self.signals_handlers = []
+
+    def remote_init_bridge(self, signals_handler):
+        self.signals_handlers.append(HandlerWrapper(signals_handler))
+        log.info("registered signal handler")
+
+    def send_signal_eb(self, failure_, signal_name):
+        if not failure_.check(pb.PBConnectionLost):
+            log.error(
+                f"Error while sending signal {signal_name}: {failure_}",
+            )
+
+    def send_signal(self, name, args, kwargs):
+        to_remove = []
+        for wrapper in self.signals_handlers:
+            handler = wrapper.handler
+            try:
+                d = handler.callRemote(name, *args, **kwargs)
+            except pb.DeadReferenceError:
+                to_remove.append(wrapper)
+            else:
+                d.addErrback(self.send_signal_eb, name)
+        if to_remove:
+            for wrapper in to_remove:
+                log.debug("Removing signal handler for dead frontend")
+                self.signals_handlers.remove(wrapper)
+
+    def _bridge_deactivate_signals(self):
+        if hasattr(self, "signals_paused"):
+            log.warning("bridge signals already deactivated")
+            if self.signals_handler:
+                self.signals_paused.extend(self.signals_handler)
+        else:
+            self.signals_paused = self.signals_handlers
+        self.signals_handlers = []
+        log.debug("bridge signals have been deactivated")
+
+    def _bridge_reactivate_signals(self):
+        try:
+            self.signals_handlers = self.signals_paused
+        except AttributeError:
+            log.debug("signals were already activated")
+        else:
+            del self.signals_paused
+            log.debug("bridge signals have been reactivated")
+
+##METHODS_PART##
+
+
+class bridge(object):
+    def __init__(self):
+        log.info("Init Perspective Broker...")
+        self.root = PBRoot()
+        conf = config.parse_main_conf()
+        get_conf = partial(config.get_conf, conf, "bridge_pb", "")
+        conn_type = get_conf("connection_type", "unix_socket")
+        if conn_type == "unix_socket":
+            local_dir = Path(config.config_get(conf, "", "local_dir")).resolve()
+            socket_path = local_dir / "bridge_pb"
+            log.info(f"using UNIX Socket at {socket_path}")
+            reactor.listenUNIX(
+                str(socket_path), pb.PBServerFactory(self.root), mode=0o600
+            )
+        elif conn_type == "socket":
+            port = int(get_conf("port", 8789))
+            log.info(f"using TCP Socket at port {port}")
+            reactor.listenTCP(port, pb.PBServerFactory(self.root))
+        else:
+            raise ValueError(f"Unknown pb connection type: {conn_type!r}")
+
+    def send_signal(self, name, *args, **kwargs):
+        self.root.send_signal(name, args, kwargs)
+
+    def remote_init_bridge(self, signals_handler):
+        self.signals_handlers.append(signals_handler)
+        log.info("registered signal handler")
+
+    def register_method(self, name, callback):
+        log.debug("registering PB bridge method [%s]" % name)
+        setattr(self.root, "remote_" + name, callback)
+        #  self.root.register_method(name, callback)
+
+    def add_method(
+            self, name, int_suffix, in_sign, out_sign, method, async_=False, doc={}
+    ):
+        """Dynamically add a method to PB bridge"""
+        # FIXME: doc parameter is kept only temporary, the time to remove it from calls
+        log.debug("Adding method {name} to PB bridge".format(name=name))
+        self.register_method(name, method)
+
+    def add_signal(self, name, int_suffix, signature, doc={}):
+        log.debug("Adding signal {name} to PB bridge".format(name=name))
+        setattr(
+            self, name, lambda *args, **kwargs: self.send_signal(name, *args, **kwargs)
+        )
+
+    def bridge_deactivate_signals(self):
+        """Stop sending signals to bridge
+
+        Mainly used for mobile frontends, when the frontend is paused
+        """
+        self.root._bridge_deactivate_signals()
+
+    def bridge_reactivate_signals(self):
+        """Send again signals to bridge
+
+        Should only be used after bridge_deactivate_signals has been called
+        """
+        self.root._bridge_reactivate_signals()
+
+##SIGNALS_PART##
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/bridge_constructor/constructors/pb/pb_frontend_template.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,199 @@
+#!/usr/bin/env python3
+
+# SàT communication bridge
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import asyncio
+from logging import getLogger
+from functools import partial
+from pathlib import Path
+from twisted.spread import pb
+from twisted.internet import reactor, defer
+from twisted.internet.error import ConnectionRefusedError, ConnectError
+from libervia.backend.core import exceptions
+from libervia.backend.tools import config
+from sat_frontends.bridge.bridge_frontend import BridgeException
+
+log = getLogger(__name__)
+
+
+class SignalsHandler(pb.Referenceable):
+    def __getattr__(self, name):
+        if name.startswith("remote_"):
+            log.debug("calling an unregistered signal: {name}".format(name=name[7:]))
+            return lambda *args, **kwargs: None
+
+        else:
+            raise AttributeError(name)
+
+    def register_signal(self, name, handler, iface="core"):
+        log.debug("registering signal {name}".format(name=name))
+        method_name = "remote_" + name
+        try:
+            self.__getattribute__(method_name)
+        except AttributeError:
+            pass
+        else:
+            raise exceptions.InternalError(
+                "{name} signal handler has been registered twice".format(
+                    name=method_name
+                )
+            )
+        setattr(self, method_name, handler)
+
+
+class bridge(object):
+
+    def __init__(self):
+        self.signals_handler = SignalsHandler()
+
+    def __getattr__(self, name):
+        return partial(self.call, name)
+
+    def _generic_errback(self, err):
+        log.error(f"bridge error: {err}")
+
+    def _errback(self, failure_, ori_errback):
+        """Convert Failure to BridgeException"""
+        ori_errback(
+            BridgeException(
+                name=failure_.type.decode('utf-8'),
+                message=str(failure_.value)
+            )
+        )
+
+    def remote_callback(self, result, callback):
+        """call callback with argument or None
+
+        if result is not None not argument is used,
+        else result is used as argument
+        @param result: remote call result
+        @param callback(callable): method to call on result
+        """
+        if result is None:
+            callback()
+        else:
+            callback(result)
+
+    def call(self, name, *args, **kwargs):
+        """call a remote method
+
+        @param name(str): name of the bridge method
+        @param args(list): arguments
+            may contain callback and errback as last 2 items
+        @param kwargs(dict): keyword arguments
+            may contain callback and errback
+        """
+        callback = errback = None
+        if kwargs:
+            try:
+                callback = kwargs.pop("callback")
+            except KeyError:
+                pass
+            try:
+                errback = kwargs.pop("errback")
+            except KeyError:
+                pass
+        elif len(args) >= 2 and callable(args[-1]) and callable(args[-2]):
+            errback = args.pop()
+            callback = args.pop()
+        d = self.root.callRemote(name, *args, **kwargs)
+        if callback is not None:
+            d.addCallback(self.remote_callback, callback)
+        if errback is not None:
+            d.addErrback(errback)
+
+    def _init_bridge_eb(self, failure_):
+        log.error("Can't init bridge: {msg}".format(msg=failure_))
+        return failure_
+
+    def _set_root(self, root):
+        """set remote root object
+
+        bridge will then be initialised
+        """
+        self.root = root
+        d = root.callRemote("initBridge", self.signals_handler)
+        d.addErrback(self._init_bridge_eb)
+        return d
+
+    def get_root_object_eb(self, failure_):
+        """Call errback with appropriate bridge error"""
+        if failure_.check(ConnectionRefusedError, ConnectError):
+            raise exceptions.BridgeExceptionNoService
+        else:
+            raise failure_
+
+    def bridge_connect(self, callback, errback):
+        factory = pb.PBClientFactory()
+        conf = config.parse_main_conf()
+        get_conf = partial(config.get_conf, conf, "bridge_pb", "")
+        conn_type = get_conf("connection_type", "unix_socket")
+        if conn_type == "unix_socket":
+            local_dir = Path(config.config_get(conf, "", "local_dir")).resolve()
+            socket_path = local_dir / "bridge_pb"
+            reactor.connectUNIX(str(socket_path), factory)
+        elif conn_type == "socket":
+            host = get_conf("host", "localhost")
+            port = int(get_conf("port", 8789))
+            reactor.connectTCP(host, port, factory)
+        else:
+            raise ValueError(f"Unknown pb connection type: {conn_type!r}")
+        d = factory.getRootObject()
+        d.addCallback(self._set_root)
+        if callback is not None:
+            d.addCallback(lambda __: callback())
+        d.addErrback(self.get_root_object_eb)
+        if errback is not None:
+            d.addErrback(lambda failure_: errback(failure_.value))
+        return d
+
+    def register_signal(self, functionName, handler, iface="core"):
+        self.signals_handler.register_signal(functionName, handler, iface)
+
+
+##METHODS_PART##
+
+class AIOSignalsHandler(SignalsHandler):
+
+    def register_signal(self, name, handler, iface="core"):
+        async_handler = lambda *args, **kwargs: defer.Deferred.fromFuture(
+            asyncio.ensure_future(handler(*args, **kwargs)))
+        return super().register_signal(name, async_handler, iface)
+
+
+class AIOBridge(bridge):
+
+    def __init__(self):
+        self.signals_handler = AIOSignalsHandler()
+
+    def _errback(self, failure_):
+        """Convert Failure to BridgeException"""
+        raise BridgeException(
+            name=failure_.type.decode('utf-8'),
+            message=str(failure_.value)
+            )
+
+    def call(self, name, *args, **kwargs):
+        d = self.root.callRemote(name, *args, *kwargs)
+        d.addErrback(self._errback)
+        return d.asFuture(asyncio.get_event_loop())
+
+    async def bridge_connect(self):
+        d = super().bridge_connect(callback=None, errback=None)
+        return await d.asFuture(asyncio.get_event_loop())
+
+##ASYNC_METHODS_PART##
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/dbus_bridge.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,495 @@
+#!/usr/bin/env python3
+
+# Libervia communication bridge
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from types import MethodType
+from functools import partialmethod
+from twisted.internet import defer, reactor
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.exceptions import BridgeInitError
+from libervia.backend.tools import config
+from txdbus import client, objects, error
+from txdbus.interface import DBusInterface, Method, Signal
+
+
+log = getLogger(__name__)
+
+# Interface prefix
+const_INT_PREFIX = config.config_get(
+    config.parse_main_conf(),
+    "",
+    "bridge_dbus_int_prefix",
+    "org.libervia.Libervia")
+const_ERROR_PREFIX = const_INT_PREFIX + ".error"
+const_OBJ_PATH = "/org/libervia/Libervia/bridge"
+const_CORE_SUFFIX = ".core"
+const_PLUGIN_SUFFIX = ".plugin"
+
+
+class ParseError(Exception):
+    pass
+
+
+class DBusException(Exception):
+    pass
+
+
+class MethodNotRegistered(DBusException):
+    dbusErrorName = const_ERROR_PREFIX + ".MethodNotRegistered"
+
+
+class GenericException(DBusException):
+    def __init__(self, twisted_error):
+        """
+
+        @param twisted_error (Failure): instance of twisted Failure
+        error message is used to store a repr of message and condition in a tuple,
+        so it can be evaluated by the frontend bridge.
+        """
+        try:
+            # twisted_error.value is a class
+            class_ = twisted_error.value().__class__
+        except TypeError:
+            # twisted_error.value is an instance
+            class_ = twisted_error.value.__class__
+            data = twisted_error.getErrorMessage()
+            try:
+                data = (data, twisted_error.value.condition)
+            except AttributeError:
+                data = (data,)
+        else:
+            data = (str(twisted_error),)
+        self.dbusErrorName = ".".join(
+            (const_ERROR_PREFIX, class_.__module__, class_.__name__)
+        )
+        super(GenericException, self).__init__(repr(data))
+
+    @classmethod
+    def create_and_raise(cls, exc):
+        raise cls(exc)
+
+
+class DBusObject(objects.DBusObject):
+
+    core_iface = DBusInterface(
+        const_INT_PREFIX + const_CORE_SUFFIX,
+        Method('action_launch', arguments='sss', returns='s'),
+        Method('actions_get', arguments='s', returns='a(ssi)'),
+        Method('config_get', arguments='ss', returns='s'),
+        Method('connect', arguments='ssa{ss}', returns='b'),
+        Method('contact_add', arguments='ss', returns=''),
+        Method('contact_del', arguments='ss', returns=''),
+        Method('contact_get', arguments='ss', returns='(a{ss}as)'),
+        Method('contact_update', arguments='ssass', returns=''),
+        Method('contacts_get', arguments='s', returns='a(sa{ss}as)'),
+        Method('contacts_get_from_group', arguments='ss', returns='as'),
+        Method('devices_infos_get', arguments='ss', returns='s'),
+        Method('disco_find_by_features', arguments='asa(ss)bbbbbs', returns='(a{sa(sss)}a{sa(sss)}a{sa(sss)})'),
+        Method('disco_infos', arguments='ssbs', returns='(asa(sss)a{sa(a{ss}as)})'),
+        Method('disco_items', arguments='ssbs', returns='a(sss)'),
+        Method('disconnect', arguments='s', returns=''),
+        Method('encryption_namespace_get', arguments='s', returns='s'),
+        Method('encryption_plugins_get', arguments='', returns='s'),
+        Method('encryption_trust_ui_get', arguments='sss', returns='s'),
+        Method('entities_data_get', arguments='asass', returns='a{sa{ss}}'),
+        Method('entity_data_get', arguments='sass', returns='a{ss}'),
+        Method('features_get', arguments='s', returns='a{sa{ss}}'),
+        Method('history_get', arguments='ssiba{ss}s', returns='a(sdssa{ss}a{ss}ss)'),
+        Method('image_check', arguments='s', returns='s'),
+        Method('image_convert', arguments='ssss', returns='s'),
+        Method('image_generate_preview', arguments='ss', returns='s'),
+        Method('image_resize', arguments='sii', returns='s'),
+        Method('is_connected', arguments='s', returns='b'),
+        Method('main_resource_get', arguments='ss', returns='s'),
+        Method('menu_help_get', arguments='ss', returns='s'),
+        Method('menu_launch', arguments='sasa{ss}is', returns='a{ss}'),
+        Method('menus_get', arguments='si', returns='a(ssasasa{ss})'),
+        Method('message_encryption_get', arguments='ss', returns='s'),
+        Method('message_encryption_start', arguments='ssbs', returns=''),
+        Method('message_encryption_stop', arguments='ss', returns=''),
+        Method('message_send', arguments='sa{ss}a{ss}sss', returns=''),
+        Method('namespaces_get', arguments='', returns='a{ss}'),
+        Method('param_get_a', arguments='ssss', returns='s'),
+        Method('param_get_a_async', arguments='sssis', returns='s'),
+        Method('param_set', arguments='sssis', returns=''),
+        Method('param_ui_get', arguments='isss', returns='s'),
+        Method('params_categories_get', arguments='', returns='as'),
+        Method('params_register_app', arguments='sis', returns=''),
+        Method('params_template_load', arguments='s', returns='b'),
+        Method('params_template_save', arguments='s', returns='b'),
+        Method('params_values_from_category_get_async', arguments='sisss', returns='a{ss}'),
+        Method('presence_set', arguments='ssa{ss}s', returns=''),
+        Method('presence_statuses_get', arguments='s', returns='a{sa{s(sia{ss})}}'),
+        Method('private_data_delete', arguments='sss', returns=''),
+        Method('private_data_get', arguments='sss', returns='s'),
+        Method('private_data_set', arguments='ssss', returns=''),
+        Method('profile_create', arguments='sss', returns=''),
+        Method('profile_delete_async', arguments='s', returns=''),
+        Method('profile_is_session_started', arguments='s', returns='b'),
+        Method('profile_name_get', arguments='s', returns='s'),
+        Method('profile_set_default', arguments='s', returns=''),
+        Method('profile_start_session', arguments='ss', returns='b'),
+        Method('profiles_list_get', arguments='bb', returns='as'),
+        Method('progress_get', arguments='ss', returns='a{ss}'),
+        Method('progress_get_all', arguments='s', returns='a{sa{sa{ss}}}'),
+        Method('progress_get_all_metadata', arguments='s', returns='a{sa{sa{ss}}}'),
+        Method('ready_get', arguments='', returns=''),
+        Method('roster_resync', arguments='s', returns=''),
+        Method('session_infos_get', arguments='s', returns='a{ss}'),
+        Method('sub_waiting_get', arguments='s', returns='a{ss}'),
+        Method('subscription', arguments='sss', returns=''),
+        Method('version_get', arguments='', returns='s'),
+        Signal('_debug', 'sa{ss}s'),
+        Signal('action_new', 'ssis'),
+        Signal('connected', 'ss'),
+        Signal('contact_deleted', 'ss'),
+        Signal('contact_new', 'sa{ss}ass'),
+        Signal('disconnected', 's'),
+        Signal('entity_data_updated', 'ssss'),
+        Signal('message_encryption_started', 'sss'),
+        Signal('message_encryption_stopped', 'sa{ss}s'),
+        Signal('message_new', 'sdssa{ss}a{ss}sss'),
+        Signal('param_update', 'ssss'),
+        Signal('presence_update', 'ssia{ss}s'),
+        Signal('progress_error', 'sss'),
+        Signal('progress_finished', 'sa{ss}s'),
+        Signal('progress_started', 'sa{ss}s'),
+        Signal('subscribe', 'sss'),
+    )
+    plugin_iface = DBusInterface(
+        const_INT_PREFIX + const_PLUGIN_SUFFIX
+    )
+
+    dbusInterfaces = [core_iface, plugin_iface]
+
+    def __init__(self, path):
+        super().__init__(path)
+        log.debug("Init DBusObject...")
+        self.cb = {}
+
+    def register_method(self, name, cb):
+        self.cb[name] = cb
+
+    def _callback(self, name, *args, **kwargs):
+        """Call the callback if it exists, raise an exception else"""
+        try:
+            cb = self.cb[name]
+        except KeyError:
+            raise MethodNotRegistered
+        else:
+            d = defer.maybeDeferred(cb, *args, **kwargs)
+            d.addErrback(GenericException.create_and_raise)
+            return d
+
+    def dbus_action_launch(self, callback_id, data, profile_key="@DEFAULT@"):
+        return self._callback("action_launch", callback_id, data, profile_key)
+
+    def dbus_actions_get(self, profile_key="@DEFAULT@"):
+        return self._callback("actions_get", profile_key)
+
+    def dbus_config_get(self, section, name):
+        return self._callback("config_get", section, name)
+
+    def dbus_connect(self, profile_key="@DEFAULT@", password='', options={}):
+        return self._callback("connect", profile_key, password, options)
+
+    def dbus_contact_add(self, entity_jid, profile_key="@DEFAULT@"):
+        return self._callback("contact_add", entity_jid, profile_key)
+
+    def dbus_contact_del(self, entity_jid, profile_key="@DEFAULT@"):
+        return self._callback("contact_del", entity_jid, profile_key)
+
+    def dbus_contact_get(self, arg_0, profile_key="@DEFAULT@"):
+        return self._callback("contact_get", arg_0, profile_key)
+
+    def dbus_contact_update(self, entity_jid, name, groups, profile_key="@DEFAULT@"):
+        return self._callback("contact_update", entity_jid, name, groups, profile_key)
+
+    def dbus_contacts_get(self, profile_key="@DEFAULT@"):
+        return self._callback("contacts_get", profile_key)
+
+    def dbus_contacts_get_from_group(self, group, profile_key="@DEFAULT@"):
+        return self._callback("contacts_get_from_group", group, profile_key)
+
+    def dbus_devices_infos_get(self, bare_jid, profile_key):
+        return self._callback("devices_infos_get", bare_jid, profile_key)
+
+    def dbus_disco_find_by_features(self, namespaces, identities, bare_jid=False, service=True, roster=True, own_jid=True, local_device=False, profile_key="@DEFAULT@"):
+        return self._callback("disco_find_by_features", namespaces, identities, bare_jid, service, roster, own_jid, local_device, profile_key)
+
+    def dbus_disco_infos(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@"):
+        return self._callback("disco_infos", entity_jid, node, use_cache, profile_key)
+
+    def dbus_disco_items(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@"):
+        return self._callback("disco_items", entity_jid, node, use_cache, profile_key)
+
+    def dbus_disconnect(self, profile_key="@DEFAULT@"):
+        return self._callback("disconnect", profile_key)
+
+    def dbus_encryption_namespace_get(self, arg_0):
+        return self._callback("encryption_namespace_get", arg_0)
+
+    def dbus_encryption_plugins_get(self, ):
+        return self._callback("encryption_plugins_get", )
+
+    def dbus_encryption_trust_ui_get(self, to_jid, namespace, profile_key):
+        return self._callback("encryption_trust_ui_get", to_jid, namespace, profile_key)
+
+    def dbus_entities_data_get(self, jids, keys, profile):
+        return self._callback("entities_data_get", jids, keys, profile)
+
+    def dbus_entity_data_get(self, jid, keys, profile):
+        return self._callback("entity_data_get", jid, keys, profile)
+
+    def dbus_features_get(self, profile_key):
+        return self._callback("features_get", profile_key)
+
+    def dbus_history_get(self, from_jid, to_jid, limit, between=True, filters='', profile="@NONE@"):
+        return self._callback("history_get", from_jid, to_jid, limit, between, filters, profile)
+
+    def dbus_image_check(self, arg_0):
+        return self._callback("image_check", arg_0)
+
+    def dbus_image_convert(self, source, dest, arg_2, extra):
+        return self._callback("image_convert", source, dest, arg_2, extra)
+
+    def dbus_image_generate_preview(self, image_path, profile_key):
+        return self._callback("image_generate_preview", image_path, profile_key)
+
+    def dbus_image_resize(self, image_path, width, height):
+        return self._callback("image_resize", image_path, width, height)
+
+    def dbus_is_connected(self, profile_key="@DEFAULT@"):
+        return self._callback("is_connected", profile_key)
+
+    def dbus_main_resource_get(self, contact_jid, profile_key="@DEFAULT@"):
+        return self._callback("main_resource_get", contact_jid, profile_key)
+
+    def dbus_menu_help_get(self, menu_id, language):
+        return self._callback("menu_help_get", menu_id, language)
+
+    def dbus_menu_launch(self, menu_type, path, data, security_limit, profile_key):
+        return self._callback("menu_launch", menu_type, path, data, security_limit, profile_key)
+
+    def dbus_menus_get(self, language, security_limit):
+        return self._callback("menus_get", language, security_limit)
+
+    def dbus_message_encryption_get(self, to_jid, profile_key):
+        return self._callback("message_encryption_get", to_jid, profile_key)
+
+    def dbus_message_encryption_start(self, to_jid, namespace='', replace=False, profile_key="@NONE@"):
+        return self._callback("message_encryption_start", to_jid, namespace, replace, profile_key)
+
+    def dbus_message_encryption_stop(self, to_jid, profile_key):
+        return self._callback("message_encryption_stop", to_jid, profile_key)
+
+    def dbus_message_send(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@"):
+        return self._callback("message_send", to_jid, message, subject, mess_type, extra, profile_key)
+
+    def dbus_namespaces_get(self, ):
+        return self._callback("namespaces_get", )
+
+    def dbus_param_get_a(self, name, category, attribute="value", profile_key="@DEFAULT@"):
+        return self._callback("param_get_a", name, category, attribute, profile_key)
+
+    def dbus_param_get_a_async(self, name, category, attribute="value", security_limit=-1, profile_key="@DEFAULT@"):
+        return self._callback("param_get_a_async", name, category, attribute, security_limit, profile_key)
+
+    def dbus_param_set(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@"):
+        return self._callback("param_set", name, value, category, security_limit, profile_key)
+
+    def dbus_param_ui_get(self, security_limit=-1, app='', extra='', profile_key="@DEFAULT@"):
+        return self._callback("param_ui_get", security_limit, app, extra, profile_key)
+
+    def dbus_params_categories_get(self, ):
+        return self._callback("params_categories_get", )
+
+    def dbus_params_register_app(self, xml, security_limit=-1, app=''):
+        return self._callback("params_register_app", xml, security_limit, app)
+
+    def dbus_params_template_load(self, filename):
+        return self._callback("params_template_load", filename)
+
+    def dbus_params_template_save(self, filename):
+        return self._callback("params_template_save", filename)
+
+    def dbus_params_values_from_category_get_async(self, category, security_limit=-1, app="", extra="", profile_key="@DEFAULT@"):
+        return self._callback("params_values_from_category_get_async", category, security_limit, app, extra, profile_key)
+
+    def dbus_presence_set(self, to_jid='', show='', statuses={}, profile_key="@DEFAULT@"):
+        return self._callback("presence_set", to_jid, show, statuses, profile_key)
+
+    def dbus_presence_statuses_get(self, profile_key="@DEFAULT@"):
+        return self._callback("presence_statuses_get", profile_key)
+
+    def dbus_private_data_delete(self, namespace, key, arg_2):
+        return self._callback("private_data_delete", namespace, key, arg_2)
+
+    def dbus_private_data_get(self, namespace, key, profile_key):
+        return self._callback("private_data_get", namespace, key, profile_key)
+
+    def dbus_private_data_set(self, namespace, key, data, profile_key):
+        return self._callback("private_data_set", namespace, key, data, profile_key)
+
+    def dbus_profile_create(self, profile, password='', component=''):
+        return self._callback("profile_create", profile, password, component)
+
+    def dbus_profile_delete_async(self, profile):
+        return self._callback("profile_delete_async", profile)
+
+    def dbus_profile_is_session_started(self, profile_key="@DEFAULT@"):
+        return self._callback("profile_is_session_started", profile_key)
+
+    def dbus_profile_name_get(self, profile_key="@DEFAULT@"):
+        return self._callback("profile_name_get", profile_key)
+
+    def dbus_profile_set_default(self, profile):
+        return self._callback("profile_set_default", profile)
+
+    def dbus_profile_start_session(self, password='', profile_key="@DEFAULT@"):
+        return self._callback("profile_start_session", password, profile_key)
+
+    def dbus_profiles_list_get(self, clients=True, components=False):
+        return self._callback("profiles_list_get", clients, components)
+
+    def dbus_progress_get(self, id, profile):
+        return self._callback("progress_get", id, profile)
+
+    def dbus_progress_get_all(self, profile):
+        return self._callback("progress_get_all", profile)
+
+    def dbus_progress_get_all_metadata(self, profile):
+        return self._callback("progress_get_all_metadata", profile)
+
+    def dbus_ready_get(self, ):
+        return self._callback("ready_get", )
+
+    def dbus_roster_resync(self, profile_key="@DEFAULT@"):
+        return self._callback("roster_resync", profile_key)
+
+    def dbus_session_infos_get(self, profile_key):
+        return self._callback("session_infos_get", profile_key)
+
+    def dbus_sub_waiting_get(self, profile_key="@DEFAULT@"):
+        return self._callback("sub_waiting_get", profile_key)
+
+    def dbus_subscription(self, sub_type, entity, profile_key="@DEFAULT@"):
+        return self._callback("subscription", sub_type, entity, profile_key)
+
+    def dbus_version_get(self, ):
+        return self._callback("version_get", )
+
+
+class bridge:
+
+    def __init__(self):
+        log.info("Init DBus...")
+        self._obj = DBusObject(const_OBJ_PATH)
+
+    async def post_init(self):
+        try:
+            conn = await client.connect(reactor)
+        except error.DBusException as e:
+            if e.errName == "org.freedesktop.DBus.Error.NotSupported":
+                log.error(
+                    _(
+                        "D-Bus is not launched, please see README to see instructions on "
+                        "how to launch it"
+                    )
+                )
+            raise BridgeInitError(str(e))
+
+        conn.exportObject(self._obj)
+        await conn.requestBusName(const_INT_PREFIX)
+
+    def _debug(self, action, params, profile):
+        self._obj.emitSignal("_debug", action, params, profile)
+
+    def action_new(self, action_data, id, security_limit, profile):
+        self._obj.emitSignal("action_new", action_data, id, security_limit, profile)
+
+    def connected(self, jid_s, profile):
+        self._obj.emitSignal("connected", jid_s, profile)
+
+    def contact_deleted(self, entity_jid, profile):
+        self._obj.emitSignal("contact_deleted", entity_jid, profile)
+
+    def contact_new(self, contact_jid, attributes, groups, profile):
+        self._obj.emitSignal("contact_new", contact_jid, attributes, groups, profile)
+
+    def disconnected(self, profile):
+        self._obj.emitSignal("disconnected", profile)
+
+    def entity_data_updated(self, jid, name, value, profile):
+        self._obj.emitSignal("entity_data_updated", jid, name, value, profile)
+
+    def message_encryption_started(self, to_jid, encryption_data, profile_key):
+        self._obj.emitSignal("message_encryption_started", to_jid, encryption_data, profile_key)
+
+    def message_encryption_stopped(self, to_jid, encryption_data, profile_key):
+        self._obj.emitSignal("message_encryption_stopped", to_jid, encryption_data, profile_key)
+
+    def message_new(self, uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile):
+        self._obj.emitSignal("message_new", uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile)
+
+    def param_update(self, name, value, category, profile):
+        self._obj.emitSignal("param_update", name, value, category, profile)
+
+    def presence_update(self, entity_jid, show, priority, statuses, profile):
+        self._obj.emitSignal("presence_update", entity_jid, show, priority, statuses, profile)
+
+    def progress_error(self, id, error, profile):
+        self._obj.emitSignal("progress_error", id, error, profile)
+
+    def progress_finished(self, id, metadata, profile):
+        self._obj.emitSignal("progress_finished", id, metadata, profile)
+
+    def progress_started(self, id, metadata, profile):
+        self._obj.emitSignal("progress_started", id, metadata, profile)
+
+    def subscribe(self, sub_type, entity_jid, profile):
+        self._obj.emitSignal("subscribe", sub_type, entity_jid, profile)
+
+    def register_method(self, name, callback):
+        log.debug(f"registering DBus bridge method [{name}]")
+        self._obj.register_method(name, callback)
+
+    def emit_signal(self, name, *args):
+        self._obj.emitSignal(name, *args)
+
+    def add_method(
+            self, name, int_suffix, in_sign, out_sign, method, async_=False, doc={}
+    ):
+        """Dynamically add a method to D-Bus bridge"""
+        # FIXME: doc parameter is kept only temporary, the time to remove it from calls
+        log.debug(f"Adding method {name!r} to D-Bus bridge")
+        self._obj.plugin_iface.addMethod(
+            Method(name, arguments=in_sign, returns=out_sign)
+        )
+        # we have to create a method here instead of using partialmethod, because txdbus
+        # uses __func__ which doesn't work with partialmethod
+        def caller(self_, *args, **kwargs):
+            return self_._callback(name, *args, **kwargs)
+        setattr(self._obj, f"dbus_{name}", MethodType(caller, self._obj))
+        self.register_method(name, method)
+
+    def add_signal(self, name, int_suffix, signature, doc={}):
+        """Dynamically add a signal to D-Bus bridge"""
+        log.debug(f"Adding signal {name!r} to D-Bus bridge")
+        self._obj.plugin_iface.addSignal(Signal(name, signature))
+        setattr(bridge, name, partialmethod(bridge.emit_signal, name))
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/bridge/pb.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,212 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import dataclasses
+from functools import partial
+from pathlib import Path
+from twisted.spread import jelly, pb
+from twisted.internet import reactor
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import config
+
+log = getLogger(__name__)
+
+
+## jelly hack
+# we monkey patch jelly to handle namedtuple
+ori_jelly = jelly._Jellier.jelly
+
+
+def fixed_jelly(self, obj):
+    """this method fix handling of namedtuple"""
+    if isinstance(obj, tuple) and not obj is tuple:
+        obj = tuple(obj)
+    return ori_jelly(self, obj)
+
+
+jelly._Jellier.jelly = fixed_jelly
+
+
+@dataclasses.dataclass(eq=False)
+class HandlerWrapper:
+    # we use a wrapper to keep signals handlers because RemoteReference doesn't support
+    # comparison (other than equality), making it unusable with a list
+    handler: pb.RemoteReference
+
+
+class PBRoot(pb.Root):
+    def __init__(self):
+        self.signals_handlers = []
+
+    def remote_init_bridge(self, signals_handler):
+        self.signals_handlers.append(HandlerWrapper(signals_handler))
+        log.info("registered signal handler")
+
+    def send_signal_eb(self, failure_, signal_name):
+        if not failure_.check(pb.PBConnectionLost):
+            log.error(
+                f"Error while sending signal {signal_name}: {failure_}",
+            )
+
+    def send_signal(self, name, args, kwargs):
+        to_remove = []
+        for wrapper in self.signals_handlers:
+            handler = wrapper.handler
+            try:
+                d = handler.callRemote(name, *args, **kwargs)
+            except pb.DeadReferenceError:
+                to_remove.append(wrapper)
+            else:
+                d.addErrback(self.send_signal_eb, name)
+        if to_remove:
+            for wrapper in to_remove:
+                log.debug("Removing signal handler for dead frontend")
+                self.signals_handlers.remove(wrapper)
+
+    def _bridge_deactivate_signals(self):
+        if hasattr(self, "signals_paused"):
+            log.warning("bridge signals already deactivated")
+            if self.signals_handler:
+                self.signals_paused.extend(self.signals_handler)
+        else:
+            self.signals_paused = self.signals_handlers
+        self.signals_handlers = []
+        log.debug("bridge signals have been deactivated")
+
+    def _bridge_reactivate_signals(self):
+        try:
+            self.signals_handlers = self.signals_paused
+        except AttributeError:
+            log.debug("signals were already activated")
+        else:
+            del self.signals_paused
+            log.debug("bridge signals have been reactivated")
+
+##METHODS_PART##
+
+
+class bridge(object):
+    def __init__(self):
+        log.info("Init Perspective Broker...")
+        self.root = PBRoot()
+        conf = config.parse_main_conf()
+        get_conf = partial(config.get_conf, conf, "bridge_pb", "")
+        conn_type = get_conf("connection_type", "unix_socket")
+        if conn_type == "unix_socket":
+            local_dir = Path(config.config_get(conf, "", "local_dir")).resolve()
+            socket_path = local_dir / "bridge_pb"
+            log.info(f"using UNIX Socket at {socket_path}")
+            reactor.listenUNIX(
+                str(socket_path), pb.PBServerFactory(self.root), mode=0o600
+            )
+        elif conn_type == "socket":
+            port = int(get_conf("port", 8789))
+            log.info(f"using TCP Socket at port {port}")
+            reactor.listenTCP(port, pb.PBServerFactory(self.root))
+        else:
+            raise ValueError(f"Unknown pb connection type: {conn_type!r}")
+
+    def send_signal(self, name, *args, **kwargs):
+        self.root.send_signal(name, args, kwargs)
+
+    def remote_init_bridge(self, signals_handler):
+        self.signals_handlers.append(signals_handler)
+        log.info("registered signal handler")
+
+    def register_method(self, name, callback):
+        log.debug("registering PB bridge method [%s]" % name)
+        setattr(self.root, "remote_" + name, callback)
+        #  self.root.register_method(name, callback)
+
+    def add_method(
+            self, name, int_suffix, in_sign, out_sign, method, async_=False, doc={}
+    ):
+        """Dynamically add a method to PB bridge"""
+        # FIXME: doc parameter is kept only temporary, the time to remove it from calls
+        log.debug("Adding method {name} to PB bridge".format(name=name))
+        self.register_method(name, method)
+
+    def add_signal(self, name, int_suffix, signature, doc={}):
+        log.debug("Adding signal {name} to PB bridge".format(name=name))
+        setattr(
+            self, name, lambda *args, **kwargs: self.send_signal(name, *args, **kwargs)
+        )
+
+    def bridge_deactivate_signals(self):
+        """Stop sending signals to bridge
+
+        Mainly used for mobile frontends, when the frontend is paused
+        """
+        self.root._bridge_deactivate_signals()
+
+    def bridge_reactivate_signals(self):
+        """Send again signals to bridge
+
+        Should only be used after bridge_deactivate_signals has been called
+        """
+        self.root._bridge_reactivate_signals()
+
+    def _debug(self, action, params, profile):
+        self.send_signal("_debug", action, params, profile)
+
+    def action_new(self, action_data, id, security_limit, profile):
+        self.send_signal("action_new", action_data, id, security_limit, profile)
+
+    def connected(self, jid_s, profile):
+        self.send_signal("connected", jid_s, profile)
+
+    def contact_deleted(self, entity_jid, profile):
+        self.send_signal("contact_deleted", entity_jid, profile)
+
+    def contact_new(self, contact_jid, attributes, groups, profile):
+        self.send_signal("contact_new", contact_jid, attributes, groups, profile)
+
+    def disconnected(self, profile):
+        self.send_signal("disconnected", profile)
+
+    def entity_data_updated(self, jid, name, value, profile):
+        self.send_signal("entity_data_updated", jid, name, value, profile)
+
+    def message_encryption_started(self, to_jid, encryption_data, profile_key):
+        self.send_signal("message_encryption_started", to_jid, encryption_data, profile_key)
+
+    def message_encryption_stopped(self, to_jid, encryption_data, profile_key):
+        self.send_signal("message_encryption_stopped", to_jid, encryption_data, profile_key)
+
+    def message_new(self, uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile):
+        self.send_signal("message_new", uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile)
+
+    def param_update(self, name, value, category, profile):
+        self.send_signal("param_update", name, value, category, profile)
+
+    def presence_update(self, entity_jid, show, priority, statuses, profile):
+        self.send_signal("presence_update", entity_jid, show, priority, statuses, profile)
+
+    def progress_error(self, id, error, profile):
+        self.send_signal("progress_error", id, error, profile)
+
+    def progress_finished(self, id, metadata, profile):
+        self.send_signal("progress_finished", id, metadata, profile)
+
+    def progress_started(self, id, metadata, profile):
+        self.send_signal("progress_started", id, metadata, profile)
+
+    def subscribe(self, sub_type, entity_jid, profile):
+        self.send_signal("subscribe", sub_type, entity_jid, profile)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/core/constants.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,534 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+try:
+    from xdg import BaseDirectory
+    from os.path import expanduser, realpath
+except ImportError:
+    BaseDirectory = None
+from os.path import dirname
+from typing import Final
+from libervia import backend
+
+
+class Const(object):
+
+    ## Application ##
+    APP_NAME = "Libervia"
+    APP_COMPONENT = "backend"
+    APP_NAME_ALT = "Libervia"
+    APP_NAME_FILE = "libervia"
+    APP_NAME_FULL = f"{APP_NAME} ({APP_COMPONENT})"
+    APP_VERSION = (
+        backend.__version__
+    )  # Please add 'D' at the end of version in sat/VERSION for dev versions
+    APP_RELEASE_NAME = "La Ruche"
+    APP_URL = "https://libervia.org"
+
+    ## Runtime ##
+    PLUGIN_EXT = "py"
+    HISTORY_SKIP = "skip"
+
+    ## Main config ##
+    DEFAULT_BRIDGE = "dbus"
+
+    ## Protocol ##
+    XMPP_C2S_PORT = 5222
+    XMPP_MAX_RETRIES = None
+    # default port used on Prosody, may differ on other servers
+    XMPP_COMPONENT_PORT = 5347
+
+    ## Parameters ##
+    NO_SECURITY_LIMIT = -1  #  FIXME: to rename
+    SECURITY_LIMIT_MAX = 0
+    INDIVIDUAL = "individual"
+    GENERAL = "general"
+    # General parameters
+    HISTORY_LIMIT = "History"
+    SHOW_OFFLINE_CONTACTS = "Offline contacts"
+    SHOW_EMPTY_GROUPS = "Empty groups"
+    # Parameters related to connection
+    FORCE_SERVER_PARAM = "Force server"
+    FORCE_PORT_PARAM = "Force port"
+    # Parameters related to encryption
+    PROFILE_PASS_PATH = ("General", "Password")
+    MEMORY_CRYPTO_NAMESPACE = "crypto"  # for the private persistent binary dict
+    MEMORY_CRYPTO_KEY = "personal_key"
+    # Parameters for static blog pages
+    # FIXME: blog constants should not be in core constants
+    STATIC_BLOG_KEY = "Blog page"
+    STATIC_BLOG_PARAM_TITLE = "Title"
+    STATIC_BLOG_PARAM_BANNER = "Banner"
+    STATIC_BLOG_PARAM_KEYWORDS = "Keywords"
+    STATIC_BLOG_PARAM_DESCRIPTION = "Description"
+
+    ## Menus ##
+    MENU_GLOBAL = "GLOBAL"
+    MENU_ROOM = "ROOM"
+    MENU_SINGLE = "SINGLE"
+    MENU_JID_CONTEXT = "JID_CONTEXT"
+    MENU_ROSTER_JID_CONTEXT = "ROSTER_JID_CONTEXT"
+    MENU_ROSTER_GROUP_CONTEXT = "MENU_ROSTER_GROUP_CONTEXT"
+    MENU_ROOM_OCCUPANT_CONTEXT = "MENU_ROOM_OCCUPANT_CONTEXT"
+
+    ## Profile and entities ##
+    PROF_KEY_NONE = "@NONE@"
+    PROF_KEY_DEFAULT = "@DEFAULT@"
+    PROF_KEY_ALL = "@ALL@"
+    ENTITY_ALL = "@ALL@"
+    ENTITY_ALL_RESOURCES = "@ALL_RESOURCES@"
+    ENTITY_MAIN_RESOURCE = "@MAIN_RESOURCE@"
+    ENTITY_CAP_HASH = "CAP_HASH"
+    ENTITY_TYPE = "type"
+    ENTITY_TYPE_MUC = "MUC"
+
+    ## Roster jids selection ##
+    PUBLIC = "PUBLIC"
+    ALL = (
+        "ALL"
+    )  # ALL means all known contacts, while PUBLIC means everybody, known or not
+    GROUP = "GROUP"
+    JID = "JID"
+
+    ## Messages ##
+    MESS_TYPE_INFO = "info"
+    MESS_TYPE_CHAT = "chat"
+    MESS_TYPE_ERROR = "error"
+    MESS_TYPE_GROUPCHAT = "groupchat"
+    MESS_TYPE_HEADLINE = "headline"
+    MESS_TYPE_NORMAL = "normal"
+    MESS_TYPE_AUTO = "auto"  # magic value to let the backend guess the type
+    MESS_TYPE_STANDARD = (
+        MESS_TYPE_CHAT,
+        MESS_TYPE_ERROR,
+        MESS_TYPE_GROUPCHAT,
+        MESS_TYPE_HEADLINE,
+        MESS_TYPE_NORMAL,
+    )
+    MESS_TYPE_ALL = MESS_TYPE_STANDARD + (MESS_TYPE_INFO, MESS_TYPE_AUTO)
+
+    MESS_EXTRA_INFO = "info_type"
+    EXTRA_INFO_DECR_ERR = "DECRYPTION_ERROR"
+    EXTRA_INFO_ENCR_ERR = "ENCRYPTION_ERROR"
+
+    # encryption is a key for plugins
+    MESS_KEY_ENCRYPTION: Final = "ENCRYPTION"
+    # encrypted is a key for frontends
+    MESS_KEY_ENCRYPTED = "encrypted"
+    MESS_KEY_TRUSTED = "trusted"
+
+    # File encryption algorithms
+    ENC_AES_GCM = "AES-GCM"
+
+    ## Chat ##
+    CHAT_ONE2ONE = "one2one"
+    CHAT_GROUP = "group"
+
+    ## Presence ##
+    PRESENCE_UNAVAILABLE = "unavailable"
+    PRESENCE_SHOW_AWAY = "away"
+    PRESENCE_SHOW_CHAT = "chat"
+    PRESENCE_SHOW_DND = "dnd"
+    PRESENCE_SHOW_XA = "xa"
+    PRESENCE_SHOW = "show"
+    PRESENCE_STATUSES = "statuses"
+    PRESENCE_STATUSES_DEFAULT = "default"
+    PRESENCE_PRIORITY = "priority"
+
+    ## Common namespaces ##
+    NS_XML = "http://www.w3.org/XML/1998/namespace"
+    NS_CLIENT = "jabber:client"
+    NS_COMPONENT = "jabber:component:accept"
+    NS_STREAM = (NS_CLIENT, NS_COMPONENT)
+    NS_FORWARD = "urn:xmpp:forward:0"
+    NS_DELAY = "urn:xmpp:delay"
+    NS_XHTML = "http://www.w3.org/1999/xhtml"
+
+    ## Common XPath ##
+
+    IQ_GET = '/iq[@type="get"]'
+    IQ_SET = '/iq[@type="set"]'
+
+    ## Directories ##
+
+    # directory for components specific data
+    COMPONENTS_DIR = "components"
+    CACHE_DIR = "cache"
+    # files in file dir are stored for long term
+    # files dir is global, i.e. for all profiles
+    FILES_DIR = "files"
+    # FILES_LINKS_DIR is a directory where files owned by a specific profile
+    # are linked to the global files directory. This way the directory can be
+    #  shared per profiles while keeping global directory where identical files
+    # shared between different profiles are not duplicated.
+    FILES_LINKS_DIR = "files_links"
+    # FILES_TMP_DIR is where profile's partially transfered files are put.
+    # Once transfer is completed, they are moved to FILES_DIR
+    FILES_TMP_DIR = "files_tmp"
+
+    ## Templates ##
+    TEMPLATE_TPL_DIR = "templates"
+    TEMPLATE_THEME_DEFAULT = "default"
+    TEMPLATE_STATIC_DIR = "static"
+    # templates i18n
+    KEY_LANG = "lang"
+    KEY_THEME = "theme"
+
+    ## Plugins ##
+
+    # PLUGIN_INFO keys
+    # XXX: we use PI instead of PLUG_INFO which would normally be used
+    #      to make the header more readable
+    PI_NAME = "name"
+    PI_IMPORT_NAME = "import_name"
+    PI_MAIN = "main"
+    PI_HANDLER = "handler"
+    PI_TYPE = (
+        "type"
+    )  #  FIXME: should be types, and should handle single unicode type or tuple of types (e.g. "blog" and "import")
+    PI_MODES = "modes"
+    PI_PROTOCOLS = "protocols"
+    PI_DEPENDENCIES = "dependencies"
+    PI_RECOMMENDATIONS = "recommendations"
+    PI_DESCRIPTION = "description"
+    PI_USAGE = "usage"
+
+    # Types
+    PLUG_TYPE_XEP = "XEP"
+    PLUG_TYPE_MISC = "MISC"
+    PLUG_TYPE_EXP = "EXP"
+    PLUG_TYPE_SEC = "SEC"
+    PLUG_TYPE_SYNTAXE = "SYNTAXE"
+    PLUG_TYPE_PUBSUB = "PUBSUB"
+    PLUG_TYPE_BLOG = "BLOG"
+    PLUG_TYPE_IMPORT = "IMPORT"
+    PLUG_TYPE_ENTRY_POINT = "ENTRY_POINT"
+
+    # Modes
+    PLUG_MODE_CLIENT = "client"
+    PLUG_MODE_COMPONENT = "component"
+    PLUG_MODE_DEFAULT = (PLUG_MODE_CLIENT,)
+    PLUG_MODE_BOTH = (PLUG_MODE_CLIENT, PLUG_MODE_COMPONENT)
+
+    # names of widely used plugins
+    TEXT_CMDS = "TEXT-COMMANDS"
+
+    # PubSub event categories
+    PS_PEP = "PEP"
+    PS_MICROBLOG = "MICROBLOG"
+
+    # PubSub
+    PS_PUBLISH = "publish"
+    PS_RETRACT = "retract"  # used for items
+    PS_DELETE = "delete"  # used for nodes
+    PS_PURGE = "purge"  # used for nodes
+    PS_ITEM = "item"
+    PS_ITEMS = "items"  # Can contain publish and retract items
+    PS_EVENTS = (PS_ITEMS, PS_DELETE, PS_PURGE)
+
+    ## MESSAGE/NOTIFICATION LEVELS ##
+
+    LVL_INFO = "info"
+    LVL_WARNING = "warning"
+    LVL_ERROR = "error"
+
+    ## XMLUI ##
+    XMLUI_WINDOW = "window"
+    XMLUI_POPUP = "popup"
+    XMLUI_FORM = "form"
+    XMLUI_PARAM = "param"
+    XMLUI_DIALOG = "dialog"
+    XMLUI_DIALOG_CONFIRM = "confirm"
+    XMLUI_DIALOG_MESSAGE = "message"
+    XMLUI_DIALOG_NOTE = "note"
+    XMLUI_DIALOG_FILE = "file"
+    XMLUI_DATA_ANSWER = "answer"
+    XMLUI_DATA_CANCELLED = "cancelled"
+    XMLUI_DATA_TYPE = "type"
+    XMLUI_DATA_MESS = "message"
+    XMLUI_DATA_LVL = "level"
+    XMLUI_DATA_LVL_INFO = LVL_INFO
+    XMLUI_DATA_LVL_WARNING = LVL_WARNING
+    XMLUI_DATA_LVL_ERROR = LVL_ERROR
+    XMLUI_DATA_LVL_DEFAULT = XMLUI_DATA_LVL_INFO
+    XMLUI_DATA_LVLS = (XMLUI_DATA_LVL_INFO, XMLUI_DATA_LVL_WARNING, XMLUI_DATA_LVL_ERROR)
+    XMLUI_DATA_BTNS_SET = "buttons_set"
+    XMLUI_DATA_BTNS_SET_OKCANCEL = "ok/cancel"
+    XMLUI_DATA_BTNS_SET_YESNO = "yes/no"
+    XMLUI_DATA_BTNS_SET_DEFAULT = XMLUI_DATA_BTNS_SET_OKCANCEL
+    XMLUI_DATA_FILETYPE = "filetype"
+    XMLUI_DATA_FILETYPE_FILE = "file"
+    XMLUI_DATA_FILETYPE_DIR = "dir"
+    XMLUI_DATA_FILETYPE_DEFAULT = XMLUI_DATA_FILETYPE_FILE
+
+    ## Logging ##
+    LOG_LVL_DEBUG = "DEBUG"
+    LOG_LVL_INFO = "INFO"
+    LOG_LVL_WARNING = "WARNING"
+    LOG_LVL_ERROR = "ERROR"
+    LOG_LVL_CRITICAL = "CRITICAL"
+    LOG_LEVELS = (
+        LOG_LVL_DEBUG,
+        LOG_LVL_INFO,
+        LOG_LVL_WARNING,
+        LOG_LVL_ERROR,
+        LOG_LVL_CRITICAL,
+    )
+    LOG_BACKEND_STANDARD = "standard"
+    LOG_BACKEND_TWISTED = "twisted"
+    LOG_BACKEND_BASIC = "basic"
+    LOG_BACKEND_CUSTOM = "custom"
+    LOG_BASE_LOGGER = "root"
+    LOG_TWISTED_LOGGER = "twisted"
+    LOG_OPT_SECTION = "DEFAULT"  # section of sat.conf where log options should be
+    LOG_OPT_PREFIX = "log_"
+    # (option_name, default_value) tuples
+    LOG_OPT_COLORS = (
+        "colors",
+        "true",
+    )  # true for auto colors, force to have colors even if stdout is not a tty, false for no color
+    LOG_OPT_TAINTS_DICT = (
+        "levels_taints_dict",
+        {
+            LOG_LVL_DEBUG: ("cyan",),
+            LOG_LVL_INFO: (),
+            LOG_LVL_WARNING: ("yellow",),
+            LOG_LVL_ERROR: ("red", "blink", r"/!\ ", "blink_off"),
+            LOG_LVL_CRITICAL: ("bold", "red", "Guru Meditation ", "normal_weight"),
+        },
+    )
+    LOG_OPT_LEVEL = ("level", "info")
+    LOG_OPT_FORMAT = ("fmt", "%(message)s")  # similar to logging format.
+    LOG_OPT_LOGGER = ("logger", "")  # regex to filter logger name
+    LOG_OPT_OUTPUT_SEP = "//"
+    LOG_OPT_OUTPUT_DEFAULT = "default"
+    LOG_OPT_OUTPUT_MEMORY = "memory"
+    LOG_OPT_OUTPUT_MEMORY_LIMIT = 300
+    LOG_OPT_OUTPUT_FILE = "file"  # file is implicit if only output
+    LOG_OPT_OUTPUT = (
+        "output",
+        LOG_OPT_OUTPUT_SEP + LOG_OPT_OUTPUT_DEFAULT,
+    )  # //default = normal output (stderr or a file with twistd), path/to/file for a file (must be the first if used), //memory for memory (options can be put in parenthesis, e.g.: //memory(500) for a 500 lines memory)
+
+    ## action constants ##
+    META_TYPE_FILE = "file"
+    META_TYPE_CALL = "call"
+    META_TYPE_OVERWRITE = "overwrite"
+    META_TYPE_NOT_IN_ROSTER_LEAK = "not_in_roster_leak"
+    META_SUBTYPE_CALL_AUDIO = "audio"
+    META_SUBTYPE_CALL_VIDEO = "video"
+
+    ## HARD-CODED ACTIONS IDS (generated with uuid.uuid4) ##
+    AUTHENTICATE_PROFILE_ID = "b03bbfa8-a4ae-4734-a248-06ce6c7cf562"
+    CHANGE_XMPP_PASSWD_ID = "878b9387-de2b-413b-950f-e424a147bcd0"
+
+    ## Text values ##
+    BOOL_TRUE = "true"
+    BOOL_FALSE = "false"
+
+    ## Special values used in bridge methods calls ##
+    HISTORY_LIMIT_DEFAULT = -1
+    HISTORY_LIMIT_NONE = -2
+
+    ## Progress error special values ##
+    PROGRESS_ERROR_DECLINED = "declined"  #  session has been declined by peer user
+    PROGRESS_ERROR_FAILED = "failed"  #  something went wrong with the session
+
+    ## Files ##
+    FILE_TYPE_DIRECTORY = "directory"
+    FILE_TYPE_FILE = "file"
+    # when filename can't be found automatically, this one will be used
+    FILE_DEFAULT_NAME = "unnamed"
+
+    ## Permissions management ##
+    ACCESS_PERM_READ = "read"
+    ACCESS_PERM_WRITE = "write"
+    ACCESS_PERMS = {ACCESS_PERM_READ, ACCESS_PERM_WRITE}
+    ACCESS_TYPE_PUBLIC = "public"
+    ACCESS_TYPE_WHITELIST = "whitelist"
+    ACCESS_TYPES = (ACCESS_TYPE_PUBLIC, ACCESS_TYPE_WHITELIST)
+
+    ## Common data keys ##
+    KEY_THUMBNAILS = "thumbnails"
+    KEY_PROGRESS_ID = "progress_id"
+    KEY_ATTACHMENTS = "attachments"
+    KEY_ATTACHMENTS_MEDIA_TYPE = "media_type"
+    KEY_ATTACHMENTS_PREVIEW = "preview"
+    KEY_ATTACHMENTS_RESIZE = "resize"
+
+
+    ## Common extra keys/values ##
+    KEY_ORDER_BY = "order_by"
+    KEY_USE_CACHE = "use_cache"
+    KEY_DECRYPT = "decrypt"
+
+    ORDER_BY_CREATION = 'creation'
+    ORDER_BY_MODIFICATION = 'modification'
+
+    # internationalisation
+    DEFAULT_LOCALE = "en_GB"
+
+    ## Command Line ##
+
+    # Exit codes used by CLI applications
+    EXIT_OK = 0
+    EXIT_ERROR = 1  # generic error, when nothing else match
+    EXIT_BAD_ARG = 2  # arguments given by user are bad
+    EXIT_BRIDGE_ERROR = 3  # can't connect to bridge
+    EXIT_BRIDGE_ERRBACK = 4  # something went wrong when calling a bridge method
+    EXIT_BACKEND_NOT_FOUND = 5  # can't find backend with this bride
+    EXIT_NOT_FOUND = 16  # an item required by a command was not found
+    EXIT_DATA_ERROR = 17  # data needed for a command is invalid
+    EXIT_MISSING_FEATURE = 18  # a needed plugin or feature is not available
+    EXIT_CONFLICT = 19  # an item already exists
+    EXIT_USER_CANCELLED = 20  # user cancelled action
+    EXIT_INTERNAL_ERROR = 111  # unexpected error
+    EXIT_FILE_NOT_EXE = (
+        126
+    )  # a file to be executed was found, but it was not an executable utility (cf. man 1 exit)
+    EXIT_CMD_NOT_FOUND = 127  # a utility to be executed was not found (cf. man 1 exit)
+    EXIT_CMD_ERROR = 127  # a utility to be executed returned an error exit code
+    EXIT_SIGNAL_INT = 128  # a command was interrupted by a signal (cf. man 1 exit)
+
+    ## Misc ##
+    SAVEFILE_DATABASE = APP_NAME_FILE + ".db"
+    IQ_SET = '/iq[@type="set"]'
+    ENV_PREFIX = "SAT_"  # Prefix used for environment variables
+    IGNORE = "ignore"
+    NO_LIMIT = -1  # used in bridge when a integer value is expected
+    DEFAULT_MAX_AGE = 1209600  # default max age of cached files, in seconds
+    STANZA_NAMES = ("iq", "message", "presence")
+
+    # Stream Hooks
+    STREAM_HOOK_SEND = "send"
+    STREAM_HOOK_RECEIVE = "receive"
+
+    @classmethod
+    def LOG_OPTIONS(cls):
+        """Return options checked for logs"""
+        # XXX: we use a classmethod so we can use Const inheritance to change default options
+        return (
+            cls.LOG_OPT_COLORS,
+            cls.LOG_OPT_TAINTS_DICT,
+            cls.LOG_OPT_LEVEL,
+            cls.LOG_OPT_FORMAT,
+            cls.LOG_OPT_LOGGER,
+            cls.LOG_OPT_OUTPUT,
+        )
+
+    @classmethod
+    def bool(cls, value: str) -> bool:
+        """@return (bool): bool value for associated constant"""
+        assert isinstance(value, str)
+        return value.lower() in (cls.BOOL_TRUE, "1", "yes", "on")
+
+    @classmethod
+    def bool_const(cls, value: bool) -> str:
+        """@return (str): constant associated to bool value"""
+        assert isinstance(value, bool)
+        return cls.BOOL_TRUE if value else cls.BOOL_FALSE
+
+
+
+## Configuration ##
+if (
+    BaseDirectory
+):  # skipped when xdg module is not available (should not happen in backend)
+    if "org.libervia.cagou" in BaseDirectory.__file__:
+        # FIXME: hack to make config read from the right location on Android
+        # TODO: fix it in a more proper way
+
+        # we need to use Android API to get downloads directory
+        import os.path
+        from jnius import autoclass
+
+        # we don't want the very verbose jnius log when we are in DEBUG level
+        import logging
+        logging.getLogger('jnius').setLevel(logging.WARNING)
+        logging.getLogger('jnius.reflect').setLevel(logging.WARNING)
+
+        Environment = autoclass("android.os.Environment")
+
+        BaseDirectory = None
+        Const.DEFAULT_CONFIG = {
+            "local_dir": "/data/data/org.libervia.cagou/app",
+            "media_dir": "/data/data/org.libervia.cagou/files/app/media",
+            # FIXME: temporary location for downloads, need to call API properly
+            "downloads_dir": os.path.join(
+                Environment.getExternalStoragePublicDirectory(
+                    Environment.DIRECTORY_DOWNLOADS
+                ).getAbsolutePath(),
+                Const.APP_NAME_FILE,
+            ),
+            "pid_dir": "%(local_dir)s",
+            "log_dir": "%(local_dir)s",
+        }
+        Const.CONFIG_FILES = [
+            "/data/data/org.libervia.cagou/files/app/android/"
+            + Const.APP_NAME_FILE
+            + ".conf"
+        ]
+    else:
+        import os
+        # we use parent of "sat" module dir as last config path, this is useful for
+        # per instance configurations (e.g. a dev instance and a main instance)
+        root_dir = dirname(dirname(backend.__file__)) + '/'
+        Const.CONFIG_PATHS = (
+            # /etc/_sat.conf is used for system-related settings (e.g. when media_dir
+            # is set by the distribution and has not reason to change, or in a Docker
+            # image)
+            ["/etc/_", "/etc/", "~/", "~/."]
+            + [
+                "{}/".format(path)
+                for path in list(BaseDirectory.load_config_paths(Const.APP_NAME_FILE))
+            ]
+            # this is to handle legacy sat.conf
+            + [
+                "{}/".format(path)
+                for path in list(BaseDirectory.load_config_paths("sat"))
+            ]
+            + [root_dir]
+        )
+
+        # on recent versions of Flatpak, FLATPAK_ID is set at run time
+        # it seems that this is not the case on older versions,
+        # but FLATPAK_SANDBOX_DIR seems set then
+        if os.getenv('FLATPAK_ID') or os.getenv('FLATPAK_SANDBOX_DIR'):
+            # for Flatpak, the conf can't be set in /etc or $HOME, so we have
+            # to add /app
+            Const.CONFIG_PATHS.append('/app/')
+
+        ## Configuration ##
+        Const.DEFAULT_CONFIG = {
+            "media_dir": "/usr/share/" + Const.APP_NAME_FILE + "/media",
+            "local_dir": BaseDirectory.save_data_path(Const.APP_NAME_FILE),
+            "downloads_dir": "~/Downloads/" + Const.APP_NAME_FILE,
+            "pid_dir": "%(local_dir)s",
+            "log_dir": "%(local_dir)s",
+        }
+
+        # List of the configuration filenames sorted by ascending priority
+        Const.CONFIG_FILES = [
+            realpath(expanduser(path) + Const.APP_NAME_FILE + ".conf")
+            for path in Const.CONFIG_PATHS
+        ] + [
+            # legacy sat.conf
+            realpath(expanduser(path) + "sat.conf")
+            for path in Const.CONFIG_PATHS
+        ]
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/core/core_types.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+
+# Libervia types
+# Copyright (C) 2011  Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from collections import namedtuple
+from typing import Dict, Callable, Optional
+from typing_extensions import TypedDict
+
+from twisted.words.protocols.jabber import jid as t_jid
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.xish import domish
+
+
+class SatXMPPEntity:
+
+    profile: str
+    jid: t_jid.JID
+    is_component: bool
+    server_jid: t_jid.JID
+    IQ: Callable[[Optional[str], Optional[int]], xmlstream.IQ]
+
+EncryptionPlugin = namedtuple("EncryptionPlugin", ("instance",
+                                                   "name",
+                                                   "namespace",
+                                                   "priority",
+                                                   "directed"))
+
+
+class EncryptionSession(TypedDict):
+    plugin: EncryptionPlugin
+
+
+# Incomplete types built through observation rather than code inspection.
+MessageDataExtra = TypedDict(
+    "MessageDataExtra",
+    { "encrypted": bool, "origin_id": str },
+    total=False
+)
+
+
+MessageData = TypedDict("MessageData", {
+    "from": t_jid.JID,
+    "to": t_jid.JID,
+    "uid": str,
+    "message": Dict[str, str],
+    "subject": Dict[str, str],
+    "type": str,
+    "timestamp": float,
+    "extra": MessageDataExtra,
+    "ENCRYPTION": EncryptionSession,
+    "xml": domish.Element
+}, total=False)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/core/exceptions.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+
+
+# SàT Exceptions
+# Copyright (C) 2011  Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+class ProfileUnknownError(Exception):
+    pass
+
+
+class ProfileNotInCacheError(Exception):
+    pass
+
+
+class ProfileNotSetError(Exception):
+    """This error raises when no profile has been set (value @NONE@ is found, but it should have been replaced)"""
+
+
+class ProfileConnected(Exception):
+    """This error is raised when trying to delete a connected profile."""
+
+
+class ProfileNotConnected(Exception):
+    pass
+
+
+class ProfileKeyUnknown(Exception):
+    pass
+
+
+class ClientTypeError(Exception):
+    """This code is not allowed for this type of client (i.e. component or not)"""
+
+
+class UnknownEntityError(Exception):
+    pass
+
+
+class UnknownGroupError(Exception):
+    pass
+
+
+class MissingModule(Exception):
+    # Used to indicate when a plugin dependence is not found
+    # it's nice to indicate when to find the dependence in argument string
+    pass
+
+
+class MissingPlugin(Exception):
+    """A SàT plugin needed for a feature/method is missing"""
+    pass
+
+
+class NotFound(Exception):
+    pass
+
+
+class ConfigError(Exception):
+    pass
+
+
+class DataError(Exception):
+    pass
+
+
+class ExternalRequestError(Exception):
+    """Request to third party server failed"""
+
+
+class ConflictError(Exception):
+    pass
+
+
+class TimeOutError(Exception):
+    pass
+
+
+class CancelError(Exception):
+    pass
+
+
+class InternalError(Exception):
+    pass
+
+
+class FeatureNotFound(
+    Exception
+):  # a disco feature/identity which is needed is not present
+    pass
+
+
+class BridgeInitError(Exception):
+    pass
+
+
+class BridgeExceptionNoService(Exception):
+    pass
+
+
+class DatabaseError(Exception):
+    pass
+
+
+class PasswordError(Exception):
+    pass
+
+
+class PermissionError(Exception):
+    pass
+
+
+class ParsingError(ValueError):
+    pass
+
+
+class EncryptionError(Exception):
+    """Invalid encryption"""
+    pass
+
+
+# Something which need to be done is not available yet
+class NotReady(Exception):
+    pass
+
+
+class NetworkError(Exception):
+    """Something is wrong with a request (e.g. HTTP(S))"""
+
+
+class InvalidCertificate(Exception):
+    """A TLS certificate is not valid"""
+    pass
+
+
+class CommandException(RuntimeError):
+    """An external command failed
+
+    stdout and stderr will be attached to the Exception
+    """
+
+    def __init__(self, msg, stdout, stderr):
+        super(CommandException, self).__init__(msg)
+        self.stdout = stdout
+        self.stderr = stderr
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/core/i18n.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,50 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Callable, cast
+
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+try:
+
+    import gettext
+
+    _ = gettext.translation("libervia_backend", "i18n", fallback=True).gettext
+    _translators = {None: gettext.NullTranslations()}
+
+    def language_switch(lang=None):
+        if not lang in _translators:
+            _translators[lang] = gettext.translation(
+                "libervia_backendt", languages=[lang], fallback=True
+            )
+        _translators[lang].install()
+
+
+except ImportError:
+
+    log.warning("gettext support disabled")
+    _ = cast(Callable[[str], str], lambda msg: msg)  # Libervia doesn't support gettext
+
+    def language_switch(lang=None):
+        pass
+
+
+D_ = cast(Callable[[str], str], lambda msg: msg)  # used for deferred translations
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/core/launcher.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,247 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Script launching SàT backend"""
+
+import sys
+import os
+import argparse
+from pathlib import Path
+from configparser import ConfigParser
+from twisted.application import app
+from twisted.python import usage
+from libervia.backend.core.constants import Const as C
+
+
+class LiberviaLogger(app.AppLogger):
+
+    def start(self, application):
+        # logging is initialised by sat.core.log_config via the Twisted plugin, nothing
+        # to do here
+        self._initialLog()
+
+    def stop(self):
+        pass
+
+
+class Launcher:
+    APP_NAME=C.APP_NAME
+    APP_NAME_FILE=C.APP_NAME_FILE
+
+    @property
+    def NOT_RUNNING_MSG(self):
+        return f"{self.APP_NAME} is *NOT* running"
+
+    def cmd_no_subparser(self, args):
+        """Command launched by default"""
+        args.extra_args = []
+        self.cmd_background(args)
+
+    def cmd_background(self, args):
+        self.run_twistd(args)
+
+    def cmd_foreground(self, args):
+        self.run_twistd(args, twistd_opts=['--nodaemon'])
+
+    def cmd_debug(self, args):
+        self.run_twistd(args, twistd_opts=['--debug'])
+
+    def cmd_stop(self, args):
+        import signal
+        import time
+        config = self.get_config()
+        pid_file = self.get_pid_file(config)
+        if not pid_file.is_file():
+            print(self.NOT_RUNNING_MSG)
+            sys.exit(0)
+        try:
+            pid = int(pid_file.read_text())
+        except Exception as e:
+            print(f"Can't read PID file at {pid_file}: {e}")
+            # we use the same exit code as DATA_ERROR in jp
+            sys.exit(17)
+        print(f"Terminating {self.APP_NAME}…")
+        os.kill(pid, signal.SIGTERM)
+        kill_started = time.time()
+        state = "init"
+        import errno
+        while True:
+            try:
+                os.kill(pid, 0)
+            except OSError as e:
+                if e.errno == errno.ESRCH:
+                    break
+                elif e.errno == errno.EPERM:
+                    print(f"Can't kill {self.APP_NAME}, the process is owned by an other user", file=sys.stderr)
+                    sys.exit(18)
+                else:
+                    raise e
+            time.sleep(0.2)
+            now = time.time()
+            if state == 'init' and now - kill_started > 5:
+                if state == 'init':
+                    state = 'waiting'
+                    print(f"Still waiting for {self.APP_NAME} to be terminated…")
+            elif state == 'waiting' and now - kill_started > 10:
+                state == 'killing'
+                print("Waiting for too long, we kill the process")
+                os.kill(pid, signal.SIGKILL)
+                sys.exit(1)
+
+        sys.exit(0)
+
+    def cmd_status(self, args):
+        config = self.get_config()
+        pid_file = self.get_pid_file(config)
+        if pid_file.is_file():
+            import errno
+            try:
+                pid = int(pid_file.read_text())
+            except Exception as e:
+                print(f"Can't read PID file at {pid_file}: {e}")
+                # we use the same exit code as DATA_ERROR in jp
+                sys.exit(17)
+            # we check if there is a process
+            # inspired by https://stackoverflow.com/a/568285 and https://stackoverflow.com/a/6940314
+            try:
+                os.kill(pid, 0)
+            except OSError as e:
+                if e.errno == errno.ESRCH:
+                    running = False
+                elif e.errno == errno.EPERM:
+                    print("Process {pid} is run by an other user")
+                    running = True
+            else:
+                running = True
+
+            if running:
+                print(f"{self.APP_NAME} is running (pid: {pid})")
+                sys.exit(0)
+            else:
+                print(f"{self.NOT_RUNNING_MSG}, but a pid file is present (bad exit ?): {pid_file}")
+                sys.exit(2)
+        else:
+            print(self.NOT_RUNNING_MSG)
+            sys.exit(1)
+
+    def parse_args(self):
+        parser = argparse.ArgumentParser(description=f"Launch {self.APP_NAME} backend")
+        parser.set_defaults(cmd=self.cmd_no_subparser)
+        subparsers = parser.add_subparsers()
+        extra_help = f"arguments to pass to {self.APP_NAME} service"
+
+        bg_parser = subparsers.add_parser(
+            'background',
+            aliases=['bg'],
+            help=f"run {self.APP_NAME} backend in background (as a daemon)")
+        bg_parser.add_argument('extra_args', nargs=argparse.REMAINDER, help=extra_help)
+        bg_parser.set_defaults(cmd=self.cmd_background)
+
+        fg_parser = subparsers.add_parser(
+            'foreground',
+            aliases=['fg'],
+            help=f"run {self.APP_NAME} backend in foreground")
+        fg_parser.add_argument('extra_args', nargs=argparse.REMAINDER, help=extra_help)
+        fg_parser.set_defaults(cmd=self.cmd_foreground)
+
+        dbg_parser = subparsers.add_parser(
+            'debug',
+            aliases=['dbg'],
+            help=f"run {self.APP_NAME} backend in debug mode")
+        dbg_parser.add_argument('extra_args', nargs=argparse.REMAINDER, help=extra_help)
+        dbg_parser.set_defaults(cmd=self.cmd_debug)
+
+        stop_parser = subparsers.add_parser(
+            'stop',
+            help=f"stop running {self.APP_NAME} backend")
+        stop_parser.set_defaults(cmd=self.cmd_stop)
+
+        status_parser = subparsers.add_parser(
+            'status',
+            help=f"indicate if {self.APP_NAME} backend is running")
+        status_parser.set_defaults(cmd=self.cmd_status)
+
+        return parser.parse_args()
+
+    def get_config(self):
+        config = ConfigParser(defaults=C.DEFAULT_CONFIG)
+        try:
+            config.read(C.CONFIG_FILES)
+        except Exception as e:
+            print (rf"/!\ Can't read main config! {e}")
+            sys.exit(1)
+        return config
+
+    def get_pid_file(self, config):
+        pid_dir = Path(config.get('DEFAULT', 'pid_dir')).expanduser()
+        return pid_dir / f"{self.APP_NAME_FILE}.pid"
+
+    def run_twistd(self, args, twistd_opts=None):
+        """Run twistd settings options with args"""
+        from twisted.python.runtime import platformType
+        if platformType == "win32":
+            from twisted.scripts._twistw import (ServerOptions,
+                                                 WindowsApplicationRunner as app_runner)
+        else:
+            from twisted.scripts._twistd_unix import (ServerOptions,
+                                                      UnixApplicationRunner as app_runner)
+
+        app_runner.loggerFactory = LiberviaLogger
+        server_options = ServerOptions()
+        config = self.get_config()
+        pid_file = self.get_pid_file(config)
+        log_dir = Path(config.get('DEFAULT', 'log_dir')).expanduser()
+        log_file = log_dir / f"{self.APP_NAME_FILE}.log"
+        server_opts = [
+            '--no_save',
+            '--pidfile', str(pid_file),
+            '--logfile', str(log_file),
+            ]
+        if twistd_opts is not None:
+            server_opts.extend(twistd_opts)
+        server_opts.append(self.APP_NAME_FILE)
+        if args.extra_args:
+            try:
+                args.extra_args.remove('--')
+            except ValueError:
+                pass
+            server_opts.extend(args.extra_args)
+        try:
+            server_options.parseOptions(server_opts)
+        except usage.error as ue:
+            print(server_options)
+            print("%s: %s" % (sys.argv[0], ue))
+            sys.exit(1)
+        else:
+            runner = app_runner(server_options)
+            runner.run()
+            if runner._exitSignal is not None:
+                app._exitWithSignal(runner._exitSignal)
+            try:
+                sys.exit(app._exitCode)
+            except AttributeError:
+                pass
+
+    @classmethod
+    def run(cls):
+        args = cls().parse_args()
+        args.cmd(args)
+
+
+if __name__ == '__main__':
+    Launcher.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/core/log.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,426 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""High level logging functions"""
+# XXX: this module use standard logging module when possible, but as SàT can work in different cases where logging is not the best choice (twisted, pyjamas, etc), it is necessary to have a dedicated module. Additional feature like environment variables and colors are also managed.
+# TODO: change formatting from "%s" style to "{}" when moved to Python 3
+
+from typing import TYPE_CHECKING, Any, Optional, Dict
+
+if TYPE_CHECKING:
+    from logging import _ExcInfoType
+else:
+    _ExcInfoType = Any
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.core import exceptions
+import traceback
+
+backend = None
+_loggers: Dict[str, "Logger"] = {}
+handlers = {}
+COLOR_START = '%(color_start)s'
+COLOR_END = '%(color_end)s'
+
+
+class Filtered(Exception):
+    pass
+
+
+class Logger:
+    """High level logging class"""
+    fmt = None # format option as given by user (e.g. SAT_LOG_LOGGER)
+    filter_name = None # filter to call
+    post_treat = None
+
+    def __init__(self, name):
+        if isinstance(name, Logger):
+            self.copy(name)
+        else:
+            self._name = name
+
+    def copy(self, other):
+        """Copy values from other Logger"""
+        self.fmt = other.fmt
+        self.Filter_name = other.fmt
+        self.post_treat = other.post_treat
+        self._name = other._name
+
+    def add_traceback(self, message):
+        tb = traceback.format_exc()
+        return message + "\n==== traceback ====\n" + tb
+
+    def out(
+        self,
+        message: object,
+        level: Optional[str] = None,
+        exc_info: _ExcInfoType = False,
+        **kwargs
+    ) -> None:
+        """Actually log the message
+
+        @param message: formatted message
+        """
+        if exc_info:
+            message = self.add_traceback(message)
+        print(message)
+
+    def log(
+        self,
+        level: str,
+        message: object,
+        exc_info: _ExcInfoType = False,
+        **kwargs
+    ) -> None:
+        """Print message
+
+        @param level: one of C.LOG_LEVELS
+        @param message: message to format and print
+        """
+        if exc_info:
+            message = self.add_traceback(message)
+        try:
+            formatted = self.format(level, message)
+            if self.post_treat is None:
+                self.out(formatted, level, **kwargs)
+            else:
+                self.out(self.post_treat(level, formatted), level, **kwargs)
+        except Filtered:
+            pass
+
+    def format(self, level: str, message: object) -> object:
+        """Format message according to Logger.fmt
+
+        @param level: one of C.LOG_LEVELS
+        @param message: message to format
+        @return: formatted message
+
+        @raise: Filtered when the message must not be logged
+        """
+        if self.fmt is None and self.filter_name is None:
+            return message
+        record = {'name': self._name,
+                  'message': message,
+                  'levelname': level,
+                 }
+        try:
+            if not self.filter_name.dict_filter(record):
+                raise Filtered
+        except (AttributeError, TypeError): # XXX: TypeError is here because of a pyjamas bug which need to be fixed (TypeError is raised instead of AttributeError)
+            if self.filter_name is not None:
+                raise ValueError("Bad filter: filters must have a .filter method")
+        try:
+            return self.fmt % record
+        except TypeError:
+            return message
+        except KeyError as e:
+            if e.args[0] == 'profile':
+                # XXX: %(profile)s use some magic with introspection, for debugging purpose only *DO NOT* use in production
+                record['profile'] = configure_cls[backend].get_profile()
+                return self.fmt % record
+            else:
+                raise e
+
+    def debug(self, msg: object, **kwargs) -> None:
+        self.log(C.LOG_LVL_DEBUG, msg, **kwargs)
+
+    def info(self, msg: object, **kwargs) -> None:
+        self.log(C.LOG_LVL_INFO, msg, **kwargs)
+
+    def warning(self, msg: object, **kwargs) -> None:
+        self.log(C.LOG_LVL_WARNING, msg, **kwargs)
+
+    def error(self, msg: object, **kwargs) -> None:
+        self.log(C.LOG_LVL_ERROR, msg, **kwargs)
+
+    def critical(self, msg: object, **kwargs) -> None:
+        self.log(C.LOG_LVL_CRITICAL, msg, **kwargs)
+
+    def exception(self, msg: object, exc_info=True, **kwargs) -> None:
+        self.log(C.LOG_LVL_ERROR, msg, exc_info=exc_info, **kwargs)
+
+
+class FilterName(object):
+    """Filter on logger name according to a regex"""
+
+    def __init__(self, name_re):
+        """Initialise name filter
+
+        @param name_re: regular expression used to filter names (using search and not match)
+        """
+        assert name_re
+        import re
+        self.name_re = re.compile(name_re)
+
+    def filter(self, record):
+        if self.name_re.search(record.name) is not None:
+            return 1
+        return 0
+
+    def dict_filter(self, dict_record):
+        """Filter using a dictionary record
+
+        @param dict_record: dictionary with at list a key "name" with logger name
+        @return: True if message should be logged
+        """
+        class LogRecord(object):
+            pass
+        log_record = LogRecord()
+        log_record.name = dict_record['name']
+        return self.filter(log_record) == 1
+
+
+class ConfigureBase:
+    LOGGER_CLASS = Logger
+    # True if color location is specified in fmt (with COLOR_START)
+    _color_location = False
+
+    def __init__(self, level=None, fmt=None, output=None, logger=None, colors=False,
+                 levels_taints_dict=None, force_colors=False, backend_data=None):
+        """Configure a backend
+
+        @param level: one of C.LOG_LEVELS
+        @param fmt: format string, pretty much as in std logging.
+            Accept the following keywords (maybe more depending on backend):
+            - "message"
+            - "levelname"
+            - "name" (logger name)
+        @param logger: if set, use it as a regular expression to filter on logger name.
+            Use search to match expression, so ^ or $ can be necessary.
+        @param colors: if True use ANSI colors to show log levels
+        @param force_colors: if True ANSI colors are used even if stdout is not a tty
+        """
+        self.backend_data = backend_data
+        self.pre_treatment()
+        self.configure_level(level)
+        self.configure_format(fmt)
+        self.configure_output(output)
+        self.configure_logger(logger)
+        self.configure_colors(colors, force_colors, levels_taints_dict)
+        self.post_treatment()
+        self.update_current_logger()
+
+    def update_current_logger(self):
+        """update existing logger to the class needed for this backend"""
+        if self.LOGGER_CLASS is None:
+            return
+        for name, logger in list(_loggers.items()):
+            _loggers[name] = self.LOGGER_CLASS(logger)
+
+    def pre_treatment(self):
+        pass
+
+    def configure_level(self, level):
+        if level is not None:
+            # we deactivate methods below level
+            level_idx = C.LOG_LEVELS.index(level)
+            def dev_null(self, msg):
+                pass
+            for _level in C.LOG_LEVELS[:level_idx]:
+                setattr(Logger, _level.lower(), dev_null)
+
+    def configure_format(self, fmt):
+        if fmt is not None:
+            if fmt != '%(message)s': # %(message)s is the same as None
+                Logger.fmt = fmt
+            if COLOR_START in fmt:
+                ConfigureBase._color_location = True
+                if fmt.find(COLOR_END,fmt.rfind(COLOR_START))<0:
+                   # color_start not followed by an end, we add it
+                    Logger.fmt += COLOR_END
+
+    def configure_output(self, output):
+        if output is not None:
+            if output != C.LOG_OPT_OUTPUT_SEP + C.LOG_OPT_OUTPUT_DEFAULT:
+                # TODO: manage other outputs
+                raise NotImplementedError("Basic backend only manage default output yet")
+
+    def configure_logger(self, logger):
+        if logger:
+            Logger.filter_name = FilterName(logger)
+
+    def configure_colors(self, colors, force_colors, levels_taints_dict):
+        if colors:
+            # if color are used, we need to handle levels_taints_dict
+            for level in list(levels_taints_dict.keys()):
+                # we wants levels in uppercase to correspond to contstants
+                levels_taints_dict[level.upper()] = levels_taints_dict[level]
+            taints = self.__class__.taints = {}
+            for level in C.LOG_LEVELS:
+                # we want use values and use constant value as default
+                taint_list = levels_taints_dict.get(level, C.LOG_OPT_TAINTS_DICT[1][level])
+                ansi_list = []
+                for elt in taint_list:
+                    elt = elt.upper()
+                    try:
+                        ansi = getattr(A, 'FG_{}'.format(elt))
+                    except AttributeError:
+                        try:
+                            ansi = getattr(A, elt)
+                        except AttributeError:
+                            # we use raw string if element is unknown
+                            ansi = elt
+                    ansi_list.append(ansi)
+                taints[level] = ''.join(ansi_list)
+
+    def post_treatment(self):
+        pass
+
+    def manage_outputs(self, outputs_raw):
+        """ Parse output option in a backend agnostic way, and fill handlers consequently
+
+        @param outputs_raw: output option as enterred in environment variable or in configuration
+        """
+        if not outputs_raw:
+            return
+        outputs = outputs_raw.split(C.LOG_OPT_OUTPUT_SEP)
+        global handlers
+        if len(outputs) == 1:
+            handlers[C.LOG_OPT_OUTPUT_FILE] = [outputs.pop()]
+
+        for output in outputs:
+            if not output:
+                continue
+            if output[-1] == ')':
+                # we have options
+                opt_begin = output.rfind('(')
+                options = output[opt_begin+1:-1]
+                output = output[:opt_begin]
+            else:
+                options = None
+
+            if output not in (C.LOG_OPT_OUTPUT_DEFAULT, C.LOG_OPT_OUTPUT_FILE, C.LOG_OPT_OUTPUT_MEMORY):
+                raise ValueError("Invalid output [%s]" % output)
+
+            if output == C.LOG_OPT_OUTPUT_DEFAULT:
+                # no option for defaut handler
+                handlers[output] = None
+            elif output == C.LOG_OPT_OUTPUT_FILE:
+                if not options:
+                    ValueError("{handler} output need a path as option" .format(handle=output))
+                handlers.setdefault(output, []).append(options)
+                options = None # option are parsed, we can empty them
+            elif output == C.LOG_OPT_OUTPUT_MEMORY:
+                # we have memory handler, option can be the len limit or None
+                try:
+                    limit = int(options)
+                    options = None # option are parsed, we can empty them
+                except (TypeError, ValueError):
+                    limit = C.LOG_OPT_OUTPUT_MEMORY_LIMIT
+                handlers[output] = limit
+
+            if options: # we should not have unparsed options
+                raise ValueError("options [{options}] are not supported for {handler} output".format(options=options, handler=output))
+
+    @staticmethod
+    def memory_get(size=None):
+        """Return buffered logs
+
+        @param size: number of logs to return
+        """
+        raise NotImplementedError
+
+    @classmethod
+    def ansi_colors(cls, level, message):
+        """Colorise message depending on level for terminals
+
+        @param level: one of C.LOG_LEVELS
+        @param message: formatted message to log
+        @return: message with ANSI escape codes for coloration
+        """
+
+        try:
+            start = cls.taints[level]
+        except KeyError:
+            start = ''
+
+        if cls._color_location:
+            return message % {'color_start': start,
+                              'color_end': A.RESET}
+        else:
+            return '%s%s%s' % (start, message, A.RESET)
+
+    @staticmethod
+    def get_profile():
+        """Try to find profile value using introspection"""
+        raise NotImplementedError
+
+
+class ConfigureCustom(ConfigureBase):
+    LOGGER_CLASS = None
+
+    def __init__(self, logger_class, *args, **kwargs):
+        ConfigureCustom.LOGGER_CLASS = logger_class
+
+
+configure_cls = { None: ConfigureBase,
+                   C.LOG_BACKEND_CUSTOM: ConfigureCustom
+                 }  # XXX: (key: backend, value: Configure subclass) must be filled when new backend are added
+
+
+def configure(backend_, **options):
+    """Configure logging behaviour
+    @param backend: can be:
+        C.LOG_BACKEND_BASIC: use a basic print based logging
+        C.LOG_BACKEND_CUSTOM: use a given Logger subclass
+    """
+    global backend
+    if backend is not None:
+        raise exceptions.InternalError("Logging can only be configured once")
+    backend = backend_
+
+    try:
+        configure_class = configure_cls[backend]
+    except KeyError:
+        raise ValueError("unknown backend [{}]".format(backend))
+    if backend == C.LOG_BACKEND_CUSTOM:
+        logger_class = options.pop('logger_class')
+        configure_class(logger_class, **options)
+    else:
+        configure_class(**options)
+
+def memory_get(size=None):
+    if not C.LOG_OPT_OUTPUT_MEMORY in handlers:
+        raise ValueError('memory output is not used')
+    return configure_cls[backend].memory_get(size)
+
+def getLogger(name=C.LOG_BASE_LOGGER) -> Logger:
+    try:
+        logger_class = configure_cls[backend].LOGGER_CLASS
+    except KeyError:
+        raise ValueError("This method should not be called with backend [{}]".format(backend))
+    return _loggers.setdefault(name, logger_class(name))
+
+_root_logger = getLogger()
+
+def debug(msg, **kwargs):
+    _root_logger.debug(msg, **kwargs)
+
+def info(msg, **kwargs):
+    _root_logger.info(msg, **kwargs)
+
+def warning(msg, **kwargs):
+    _root_logger.warning(msg, **kwargs)
+
+def error(msg, **kwargs):
+    _root_logger.error(msg, **kwargs)
+
+def critical(msg, **kwargs):
+    _root_logger.critical(msg, **kwargs)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/core/log_config.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,411 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""High level logging functions"""
+# XXX: this module use standard logging module when possible, but as SàT can work in different cases where logging is not the best choice (twisted, pyjamas, etc), it is necessary to have a dedicated module. Additional feature like environment variables and colors are also managed.
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import log
+
+
+class TwistedLogger(log.Logger):
+    colors = True
+    force_colors = False
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        from twisted.logger import Logger
+        self.twisted_log = Logger()
+
+    def out(self, message, level=None, **kwargs):
+        """Actually log the message
+
+        @param message: formatted message
+        """
+        self.twisted_log.emit(
+            level=self.level_map[level],
+            format=message,
+            sat_logged=True,
+            **kwargs,
+        )
+
+
+class ConfigureBasic(log.ConfigureBase):
+    def configure_colors(self, colors, force_colors, levels_taints_dict):
+        super(ConfigureBasic, self).configure_colors(
+            colors, force_colors, levels_taints_dict
+        )
+        if colors:
+            import sys
+
+            try:
+                isatty = sys.stdout.isatty()
+            except AttributeError:
+                isatty = False
+            # FIXME: isatty should be tested on each handler, not globaly
+            if (force_colors or isatty):
+                # we need colors
+                log.Logger.post_treat = lambda logger, level, message: self.ansi_colors(
+                    level, message
+                )
+        elif force_colors:
+            raise ValueError("force_colors can't be used if colors is False")
+
+    @staticmethod
+    def get_profile():
+        """Try to find profile value using introspection"""
+        import inspect
+
+        stack = inspect.stack()
+        current_path = stack[0][1]
+        for frame_data in stack[:-1]:
+            if frame_data[1] != current_path:
+                if (
+                    log.backend == C.LOG_BACKEND_STANDARD
+                    and "/logging/__init__.py" in frame_data[1]
+                ):
+                    continue
+                break
+
+        frame = frame_data[0]
+        args = inspect.getargvalues(frame)
+        try:
+            profile = args.locals.get("profile") or args.locals["profile_key"]
+        except (TypeError, KeyError):
+            try:
+                try:
+                    profile = args.locals["self"].profile
+                except AttributeError:
+                    try:
+                        profile = args.locals["self"].parent.profile
+                    except AttributeError:
+                        profile = args.locals[
+                            "self"
+                        ].host.profile  # used in quick_frontend for single profile configuration
+            except Exception:
+                # we can't find profile, we return an empty value
+                profile = ""
+        return profile
+
+
+class ConfigureTwisted(ConfigureBasic):
+    LOGGER_CLASS = TwistedLogger
+
+    def pre_treatment(self):
+        from twisted import logger
+        global logger
+        self.level_map = {
+            C.LOG_LVL_DEBUG: logger.LogLevel.debug,
+            C.LOG_LVL_INFO: logger.LogLevel.info,
+            C.LOG_LVL_WARNING: logger.LogLevel.warn,
+            C.LOG_LVL_ERROR: logger.LogLevel.error,
+            C.LOG_LVL_CRITICAL: logger.LogLevel.critical,
+        }
+        self.LOGGER_CLASS.level_map = self.level_map
+
+    def configure_level(self, level):
+        self.level = self.level_map[level]
+
+    def configure_output(self, output):
+        import sys
+        from twisted.python import logfile
+        self.log_publisher = logger.LogPublisher()
+
+        if output is None:
+            output = C.LOG_OPT_OUTPUT_SEP + C.LOG_OPT_OUTPUT_DEFAULT
+        self.manage_outputs(output)
+
+        if C.LOG_OPT_OUTPUT_DEFAULT in log.handlers:
+            if self.backend_data is None:
+                raise ValueError(
+                    "You must pass options as backend_data with Twisted backend"
+                )
+            options = self.backend_data
+            log_file = logfile.LogFile.fromFullPath(options['logfile'])
+            self.log_publisher.addObserver(
+                logger.FileLogObserver(log_file, self.text_formatter))
+            # we also want output to stdout if we are in debug or nodaemon mode
+            if options.get("nodaemon", False) or options.get("debug", False):
+                self.log_publisher.addObserver(
+                    logger.FileLogObserver(sys.stdout, self.text_formatter))
+
+        if C.LOG_OPT_OUTPUT_FILE in log.handlers:
+
+            for path in log.handlers[C.LOG_OPT_OUTPUT_FILE]:
+                log_file = (
+                    sys.stdout if path == "-" else logfile.LogFile.fromFullPath(path)
+                )
+                self.log_publisher.addObserver(
+                    logger.FileLogObserver(log_file, self.text_formatter))
+
+        if C.LOG_OPT_OUTPUT_MEMORY in log.handlers:
+            raise NotImplementedError(
+                "Memory observer is not implemented in Twisted backend"
+            )
+
+    def configure_colors(self, colors, force_colors, levels_taints_dict):
+        super(ConfigureTwisted, self).configure_colors(
+            colors, force_colors, levels_taints_dict
+        )
+        self.LOGGER_CLASS.colors = colors
+        self.LOGGER_CLASS.force_colors = force_colors
+        if force_colors and not colors:
+            raise ValueError("colors must be True if force_colors is True")
+
+    def post_treatment(self):
+        """Install twistedObserver which manage non SàT logs"""
+        # from twisted import logger
+        import sys
+        filtering_obs = logger.FilteringLogObserver(
+            observer=self.log_publisher,
+            predicates=[
+                logger.LogLevelFilterPredicate(self.level),
+                ]
+        )
+        logger.globalLogBeginner.beginLoggingTo([filtering_obs])
+
+    def text_formatter(self, event):
+        if event.get('sat_logged', False):
+            timestamp = ''.join([logger.formatTime(event.get("log_time", None)), " "])
+            return f"{timestamp}{event.get('log_format', '')}\n"
+        else:
+            eventText = logger.eventAsText(
+                event, includeSystem=True)
+            if not eventText:
+                return None
+            return eventText.replace("\n", "\n\t") + "\n"
+
+
+class ConfigureStandard(ConfigureBasic):
+    def __init__(
+        self,
+        level=None,
+        fmt=None,
+        output=None,
+        logger=None,
+        colors=False,
+        levels_taints_dict=None,
+        force_colors=False,
+        backend_data=None,
+    ):
+        if fmt is None:
+            fmt = C.LOG_OPT_FORMAT[1]
+        if output is None:
+            output = C.LOG_OPT_OUTPUT[1]
+        super(ConfigureStandard, self).__init__(
+            level,
+            fmt,
+            output,
+            logger,
+            colors,
+            levels_taints_dict,
+            force_colors,
+            backend_data,
+        )
+
+    def pre_treatment(self):
+        """We use logging methods directly, instead of using Logger"""
+        import logging
+
+        log.getLogger = logging.getLogger
+        log.debug = logging.debug
+        log.info = logging.info
+        log.warning = logging.warning
+        log.error = logging.error
+        log.critical = logging.critical
+
+    def configure_level(self, level):
+        if level is None:
+            level = C.LOG_LVL_DEBUG
+        self.level = level
+
+    def configure_format(self, fmt):
+        super(ConfigureStandard, self).configure_format(fmt)
+        import logging
+
+        class SatFormatter(logging.Formatter):
+            """Formatter which manage SàT specificities"""
+            _format = fmt
+            _with_profile = "%(profile)s" in fmt
+
+            def __init__(self, can_colors=False):
+                super(SatFormatter, self).__init__(self._format)
+                self.can_colors = can_colors
+
+            def format(self, record):
+                if self._with_profile:
+                    record.profile = ConfigureStandard.get_profile()
+                do_color = self.with_colors and (self.can_colors or self.force_colors)
+                if ConfigureStandard._color_location:
+                    # we copy raw formatting strings for color_*
+                    # as formatting is handled in ansi_colors in this case
+                    if do_color:
+                        record.color_start = log.COLOR_START
+                        record.color_end = log.COLOR_END
+                    else:
+                        record.color_start = record.color_end = ""
+                s = super(SatFormatter, self).format(record)
+                if do_color:
+                    s = ConfigureStandard.ansi_colors(record.levelname, s)
+                return s
+
+        self.formatterClass = SatFormatter
+
+    def configure_output(self, output):
+        self.manage_outputs(output)
+
+    def configure_logger(self, logger):
+        self.name_filter = log.FilterName(logger) if logger else None
+
+    def configure_colors(self, colors, force_colors, levels_taints_dict):
+        super(ConfigureStandard, self).configure_colors(
+            colors, force_colors, levels_taints_dict
+        )
+        self.formatterClass.with_colors = colors
+        self.formatterClass.force_colors = force_colors
+        if not colors and force_colors:
+            raise ValueError("force_colors can't be used if colors is False")
+
+    def _add_handler(self, root_logger, hdlr, can_colors=False):
+        hdlr.setFormatter(self.formatterClass(can_colors))
+        root_logger.addHandler(hdlr)
+        root_logger.setLevel(self.level)
+        if self.name_filter is not None:
+            hdlr.addFilter(self.name_filter)
+
+    def post_treatment(self):
+        import logging
+
+        root_logger = logging.getLogger()
+        if len(root_logger.handlers) == 0:
+            for handler, options in list(log.handlers.items()):
+                if handler == C.LOG_OPT_OUTPUT_DEFAULT:
+                    hdlr = logging.StreamHandler()
+                    try:
+                        can_colors = hdlr.stream.isatty()
+                    except AttributeError:
+                        can_colors = False
+                    self._add_handler(root_logger, hdlr, can_colors=can_colors)
+                elif handler == C.LOG_OPT_OUTPUT_MEMORY:
+                    from logging.handlers import BufferingHandler
+
+                    class SatMemoryHandler(BufferingHandler):
+                        def emit(self, record):
+                            super(SatMemoryHandler, self).emit(self.format(record))
+
+                    hdlr = SatMemoryHandler(options)
+                    log.handlers[
+                        handler
+                    ] = (
+                        hdlr
+                    )  # we keep a reference to the handler to read the buffer later
+                    self._add_handler(root_logger, hdlr, can_colors=False)
+                elif handler == C.LOG_OPT_OUTPUT_FILE:
+                    import os.path
+
+                    for path in options:
+                        hdlr = logging.FileHandler(os.path.expanduser(path))
+                        self._add_handler(root_logger, hdlr, can_colors=False)
+                else:
+                    raise ValueError("Unknown handler type")
+        else:
+            root_logger.warning("Handlers already set on root logger")
+
+    @staticmethod
+    def memory_get(size=None):
+        """Return buffered logs
+
+        @param size: number of logs to return
+        """
+        mem_handler = log.handlers[C.LOG_OPT_OUTPUT_MEMORY]
+        return (
+            log_msg for log_msg in mem_handler.buffer[size if size is None else -size :]
+        )
+
+
+log.configure_cls[C.LOG_BACKEND_BASIC] = ConfigureBasic
+log.configure_cls[C.LOG_BACKEND_TWISTED] = ConfigureTwisted
+log.configure_cls[C.LOG_BACKEND_STANDARD] = ConfigureStandard
+
+
+def configure(backend, **options):
+    """Configure logging behaviour
+    @param backend: can be:
+        C.LOG_BACKEND_STANDARD: use standard logging module
+        C.LOG_BACKEND_TWISTED: use twisted logging module (with standard logging observer)
+        C.LOG_BACKEND_BASIC: use a basic print based logging
+        C.LOG_BACKEND_CUSTOM: use a given Logger subclass
+    """
+    return log.configure(backend, **options)
+
+
+def _parse_options(options):
+    """Parse string options as given in conf or environment variable, and return expected python value
+
+    @param options (dict): options with (key: name, value: string value)
+    """
+    COLORS = C.LOG_OPT_COLORS[0]
+    LEVEL = C.LOG_OPT_LEVEL[0]
+
+    if COLORS in options:
+        if options[COLORS].lower() in ("1", "true"):
+            options[COLORS] = True
+        elif options[COLORS] == "force":
+            options[COLORS] = True
+            options["force_colors"] = True
+        else:
+            options[COLORS] = False
+    if LEVEL in options:
+        level = options[LEVEL].upper()
+        if level not in C.LOG_LEVELS:
+            level = C.LOG_LVL_INFO
+        options[LEVEL] = level
+
+
+def sat_configure(backend=C.LOG_BACKEND_STANDARD, const=None, backend_data=None):
+    """Configure logging system for SàT, can be used by frontends
+
+    logs conf is read in SàT conf, then in environment variables. It must be done before Memory init
+    @param backend: backend to use, it can be:
+        - C.LOG_BACKEND_BASIC: print based backend
+        - C.LOG_BACKEND_TWISTED: Twisted logging backend
+        - C.LOG_BACKEND_STANDARD: standard logging backend
+    @param const: Const class to use instead of sat.core.constants.Const (mainly used to change default values)
+    """
+    if const is not None:
+        global C
+        C = const
+        log.C = const
+    from libervia.backend.tools import config
+    import os
+
+    log_conf = {}
+    sat_conf = config.parse_main_conf()
+    for opt_name, opt_default in C.LOG_OPTIONS():
+        try:
+            log_conf[opt_name] = os.environ[
+                "".join((C.ENV_PREFIX, C.LOG_OPT_PREFIX.upper(), opt_name.upper()))
+            ]
+        except KeyError:
+            log_conf[opt_name] = config.config_get(
+                sat_conf, C.LOG_OPT_SECTION, C.LOG_OPT_PREFIX + opt_name, opt_default
+            )
+
+    _parse_options(log_conf)
+    configure(backend, backend_data=backend_data, **log_conf)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/core/patches.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,156 @@
+import copy
+from twisted.words.protocols.jabber import xmlstream, sasl, client as tclient, jid
+from wokkel import client
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+"""This module applies monkey patches to Twisted and Wokkel
+   First part handle certificate validation during XMPP connectionand are temporary
+   (until merged upstream).
+   Second part add a trigger point to send and onElement method of XmlStream
+   """
+
+
+## certificate validation patches
+
+
+class XMPPClient(client.XMPPClient):
+
+    def __init__(self, jid, password, host=None, port=5222,
+                 tls_required=True, configurationForTLS=None):
+        self.jid = jid
+        self.domain = jid.host.encode('idna')
+        self.host = host
+        self.port = port
+
+        factory = HybridClientFactory(
+            jid, password, tls_required=tls_required,
+            configurationForTLS=configurationForTLS)
+
+        client.StreamManager.__init__(self, factory)
+
+
+def HybridClientFactory(jid, password, tls_required=True, configurationForTLS=None):
+    a = HybridAuthenticator(jid, password, tls_required, configurationForTLS)
+
+    return xmlstream.XmlStreamFactory(a)
+
+
+class HybridAuthenticator(client.HybridAuthenticator):
+    res_binding = True
+
+    def __init__(self, jid, password, tls_required=True, configurationForTLS=None):
+        xmlstream.ConnectAuthenticator.__init__(self, jid.host)
+        self.jid = jid
+        self.password = password
+        self.tls_required = tls_required
+        self.configurationForTLS = configurationForTLS
+
+    def associateWithStream(self, xs):
+        xmlstream.ConnectAuthenticator.associateWithStream(self, xs)
+
+        tlsInit = xmlstream.TLSInitiatingInitializer(
+            xs, required=self.tls_required, configurationForTLS=self.configurationForTLS)
+        xs.initializers = [client.client.CheckVersionInitializer(xs),
+                           tlsInit,
+                           CheckAuthInitializer(xs, self.res_binding)]
+
+
+# XmlStream triggers
+
+
+class XmlStream(xmlstream.XmlStream):
+    """XmlStream which allows to add hooks"""
+
+    def __init__(self, authenticator):
+        xmlstream.XmlStream.__init__(self, authenticator)
+        # hooks at this level should not modify content
+        # so it's not needed to handle priority as with triggers
+        self._onElementHooks = []
+        self._sendHooks = []
+
+    def add_hook(self, hook_type, callback):
+        """Add a send or receive hook"""
+        conflict_msg = f"Hook conflict: can't add {hook_type} hook {callback}"
+        if hook_type == C.STREAM_HOOK_RECEIVE:
+            if callback not in self._onElementHooks:
+                self._onElementHooks.append(callback)
+            else:
+                log.warning(conflict_msg)
+        elif hook_type == C.STREAM_HOOK_SEND:
+            if callback not in self._sendHooks:
+                self._sendHooks.append(callback)
+            else:
+                log.warning(conflict_msg)
+        else:
+            raise ValueError(f"Invalid hook type: {hook_type}")
+
+    def onElement(self, element):
+        for hook in self._onElementHooks:
+            hook(element)
+        xmlstream.XmlStream.onElement(self, element)
+
+    def send(self, obj):
+        for hook in self._sendHooks:
+            hook(obj)
+        xmlstream.XmlStream.send(self, obj)
+
+
+# Binding activation (needed for stream management, XEP-0198)
+
+
+class CheckAuthInitializer(client.CheckAuthInitializer):
+
+    def __init__(self, xs, res_binding):
+        super(CheckAuthInitializer, self).__init__(xs)
+        self.res_binding = res_binding
+
+    def initialize(self):
+        # XXX: modification of client.CheckAuthInitializer which has optional
+        #      resource binding, and which doesn't do deprecated
+        #      SessionInitializer
+        if (sasl.NS_XMPP_SASL, 'mechanisms') in self.xmlstream.features:
+            inits = [(sasl.SASLInitiatingInitializer, True)]
+            if self.res_binding:
+                inits.append((tclient.BindInitializer, True)),
+
+            for initClass, required in inits:
+                init = initClass(self.xmlstream)
+                init.required = required
+                self.xmlstream.initializers.append(init)
+        elif (tclient.NS_IQ_AUTH_FEATURE, 'auth') in self.xmlstream.features:
+            self.xmlstream.initializers.append(
+                    tclient.IQAuthInitializer(self.xmlstream))
+        else:
+            raise Exception("No available authentication method found")
+
+
+# jid fix
+
+def internJID(jidstring):
+    """
+    Return interned JID.
+
+    @rtype: L{JID}
+    """
+    # XXX: this interJID return a copy of the cached jid
+    #      this avoid modification of cached jid as JID is mutable
+    # TODO: propose this upstream
+
+    if jidstring in jid.__internJIDs:
+        return copy.copy(jid.__internJIDs[jidstring])
+    else:
+        j = jid.JID(jidstring)
+        jid.__internJIDs[jidstring] = j
+        return copy.copy(j)
+
+
+def apply():
+    # certificate validation
+    client.XMPPClient = XMPPClient
+    # XmlStream triggers
+    xmlstream.XmlStreamFactory.protocol = XmlStream
+    # jid fix
+    jid.internJID = internJID
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/core/sat_main.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1666 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import os.path
+import uuid
+import hashlib
+import copy
+from pathlib import Path
+from typing import Optional, List, Tuple, Dict
+
+from wokkel.data_form import Option
+from libervia import backend
+from libervia.backend.core.i18n import _, D_, language_switch
+from libervia.backend.core import patches
+patches.apply()
+from twisted.application import service
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.internet import reactor
+from wokkel.xmppim import RosterItem
+from libervia.backend.core import xmpp
+from libervia.backend.core import exceptions
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.log import getLogger
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.memory import memory
+from libervia.backend.memory import cache
+from libervia.backend.memory import encryption
+from libervia.backend.tools import async_trigger as trigger
+from libervia.backend.tools import utils
+from libervia.backend.tools import image
+from libervia.backend.tools.common import dynamic_import
+from libervia.backend.tools.common import regex
+from libervia.backend.tools.common import data_format
+from libervia.backend.stdui import ui_contact_list, ui_profile_manager
+import libervia.backend.plugins
+
+
+log = getLogger(__name__)
+
+class SAT(service.Service):
+
+    def _init(self):
+        # we don't use __init__ to avoid doule initialisation with twistd
+        # this _init is called in startService
+        log.info(f"{C.APP_NAME} {self.full_version}")
+        self._cb_map = {}  # map from callback_id to callbacks
+        # dynamic menus. key: callback_id, value: menu data (dictionnary)
+        self._menus = {}
+        self._menus_paths = {}  # path to id. key: (menu_type, lower case tuple of path),
+                                # value: menu id
+        self.initialised = defer.Deferred()
+        self.profiles = {}
+        self.plugins = {}
+        # map for short name to whole namespace,
+        # extended by plugins with register_namespace
+        self.ns_map = {
+            "x-data": xmpp.NS_X_DATA,
+            "disco#info": xmpp.NS_DISCO_INFO,
+        }
+
+        self.memory = memory.Memory(self)
+
+        # trigger are used to change Libervia behaviour
+        self.trigger = (
+            trigger.TriggerManager()
+        )
+
+        bridge_name = (
+            os.getenv("LIBERVIA_BRIDGE_NAME")
+            or self.memory.config_get("", "bridge", "dbus")
+        )
+
+        bridge_module = dynamic_import.bridge(bridge_name)
+        if bridge_module is None:
+            log.error(f"Can't find bridge module of name {bridge_name}")
+            sys.exit(1)
+        log.info(f"using {bridge_name} bridge")
+        try:
+            self.bridge = bridge_module.bridge()
+        except exceptions.BridgeInitError:
+            log.exception("bridge can't be initialised, can't start Libervia Backend")
+            sys.exit(1)
+
+        defer.ensureDeferred(self._post_init())
+
+    @property
+    def version(self):
+        """Return the short version of Libervia"""
+        return C.APP_VERSION
+
+    @property
+    def full_version(self):
+        """Return the full version of Libervia
+
+        In developement mode, release name and extra data are returned too
+        """
+        version = self.version
+        if version[-1] == "D":
+            # we are in debug version, we add extra data
+            try:
+                return self._version_cache
+            except AttributeError:
+                self._version_cache = "{} « {} » ({})".format(
+                    version, C.APP_RELEASE_NAME, utils.get_repository_data(backend)
+                )
+                return self._version_cache
+        else:
+            return version
+
+    @property
+    def bridge_name(self):
+        return os.path.splitext(os.path.basename(self.bridge.__file__))[0]
+
+    async def _post_init(self):
+        try:
+            bridge_pi = self.bridge.post_init
+        except AttributeError:
+            pass
+        else:
+            try:
+                await bridge_pi()
+            except Exception:
+                log.exception("Could not initialize bridge")
+                # because init is not complete at this stage, we use callLater
+                reactor.callLater(0, self.stop)
+                return
+
+        self.bridge.register_method("ready_get", lambda: self.initialised)
+        self.bridge.register_method("version_get", lambda: self.full_version)
+        self.bridge.register_method("features_get", self.features_get)
+        self.bridge.register_method("profile_name_get", self.memory.get_profile_name)
+        self.bridge.register_method("profiles_list_get", self.memory.get_profiles_list)
+        self.bridge.register_method("entity_data_get", self.memory._get_entity_data)
+        self.bridge.register_method("entities_data_get", self.memory._get_entities_data)
+        self.bridge.register_method("profile_create", self.memory.create_profile)
+        self.bridge.register_method("profile_delete_async", self.memory.profile_delete_async)
+        self.bridge.register_method("profile_start_session", self.memory.start_session)
+        self.bridge.register_method(
+            "profile_is_session_started", self.memory._is_session_started
+        )
+        self.bridge.register_method("profile_set_default", self.memory.profile_set_default)
+        self.bridge.register_method("connect", self._connect)
+        self.bridge.register_method("disconnect", self.disconnect)
+        self.bridge.register_method("contact_get", self._contact_get)
+        self.bridge.register_method("contacts_get", self.contacts_get)
+        self.bridge.register_method("contacts_get_from_group", self.contacts_get_from_group)
+        self.bridge.register_method("main_resource_get", self.memory._get_main_resource)
+        self.bridge.register_method(
+            "presence_statuses_get", self.memory._get_presence_statuses
+        )
+        self.bridge.register_method("sub_waiting_get", self.memory.sub_waiting_get)
+        self.bridge.register_method("message_send", self._message_send)
+        self.bridge.register_method("message_encryption_start",
+                                    self._message_encryption_start)
+        self.bridge.register_method("message_encryption_stop",
+                                    self._message_encryption_stop)
+        self.bridge.register_method("message_encryption_get",
+                                    self._message_encryption_get)
+        self.bridge.register_method("encryption_namespace_get",
+                                    self._encryption_namespace_get)
+        self.bridge.register_method("encryption_plugins_get", self._encryption_plugins_get)
+        self.bridge.register_method("encryption_trust_ui_get", self._encryption_trust_ui_get)
+        self.bridge.register_method("config_get", self._get_config)
+        self.bridge.register_method("param_set", self.param_set)
+        self.bridge.register_method("param_get_a", self.memory.get_string_param_a)
+        self.bridge.register_method("private_data_get", self.memory._private_data_get)
+        self.bridge.register_method("private_data_set", self.memory._private_data_set)
+        self.bridge.register_method("private_data_delete", self.memory._private_data_delete)
+        self.bridge.register_method("param_get_a_async", self.memory.async_get_string_param_a)
+        self.bridge.register_method(
+            "params_values_from_category_get_async",
+            self.memory._get_params_values_from_category,
+        )
+        self.bridge.register_method("param_ui_get", self.memory._get_params_ui)
+        self.bridge.register_method(
+            "params_categories_get", self.memory.params_categories_get
+        )
+        self.bridge.register_method("params_register_app", self.memory.params_register_app)
+        self.bridge.register_method("history_get", self.memory._history_get)
+        self.bridge.register_method("presence_set", self._set_presence)
+        self.bridge.register_method("subscription", self.subscription)
+        self.bridge.register_method("contact_add", self._add_contact)
+        self.bridge.register_method("contact_update", self._update_contact)
+        self.bridge.register_method("contact_del", self._del_contact)
+        self.bridge.register_method("roster_resync", self._roster_resync)
+        self.bridge.register_method("is_connected", self.is_connected)
+        self.bridge.register_method("action_launch", self._action_launch)
+        self.bridge.register_method("actions_get", self.actions_get)
+        self.bridge.register_method("progress_get", self._progress_get)
+        self.bridge.register_method("progress_get_all", self._progress_get_all)
+        self.bridge.register_method("menus_get", self.get_menus)
+        self.bridge.register_method("menu_help_get", self.get_menu_help)
+        self.bridge.register_method("menu_launch", self._launch_menu)
+        self.bridge.register_method("disco_infos", self.memory.disco._disco_infos)
+        self.bridge.register_method("disco_items", self.memory.disco._disco_items)
+        self.bridge.register_method("disco_find_by_features", self._find_by_features)
+        self.bridge.register_method("params_template_save", self.memory.save_xml)
+        self.bridge.register_method("params_template_load", self.memory.load_xml)
+        self.bridge.register_method("session_infos_get", self.get_session_infos)
+        self.bridge.register_method("devices_infos_get", self._get_devices_infos)
+        self.bridge.register_method("namespaces_get", self.get_namespaces)
+        self.bridge.register_method("image_check", self._image_check)
+        self.bridge.register_method("image_resize", self._image_resize)
+        self.bridge.register_method("image_generate_preview", self._image_generate_preview)
+        self.bridge.register_method("image_convert", self._image_convert)
+
+
+        await self.memory.initialise()
+        self.common_cache = cache.Cache(self, None)
+        log.info(_("Memory initialised"))
+        try:
+            self._import_plugins()
+            ui_contact_list.ContactList(self)
+            ui_profile_manager.ProfileManager(self)
+        except Exception as e:
+            log.error(f"Could not initialize backend: {e}")
+            sys.exit(1)
+        self._add_base_menus()
+
+        self.initialised.callback(None)
+        log.info(_("Backend is ready"))
+
+        # profile autoconnection must be done after self.initialised is called because
+        # start_session waits for it.
+        autoconnect_dict = await self.memory.storage.get_ind_param_values(
+            category='Connection', name='autoconnect_backend',
+        )
+        profiles_autoconnect = [p for p, v in autoconnect_dict.items() if C.bool(v)]
+        if not self.trigger.point("profilesAutoconnect", profiles_autoconnect):
+            return
+        if profiles_autoconnect:
+            log.info(D_(
+                "Following profiles will be connected automatically: {profiles}"
+                ).format(profiles= ', '.join(profiles_autoconnect)))
+        connect_d_list = []
+        for profile in profiles_autoconnect:
+            connect_d_list.append(defer.ensureDeferred(self.connect(profile)))
+
+        if connect_d_list:
+            results = await defer.DeferredList(connect_d_list)
+            for idx, (success, result) in enumerate(results):
+                if not success:
+                    profile = profiles_autoconnect[0]
+                    log.warning(
+                        _("Can't autoconnect profile {profile}: {reason}").format(
+                            profile = profile,
+                            reason = result)
+                    )
+
+    def _add_base_menus(self):
+        """Add base menus"""
+        encryption.EncryptionHandler._import_menus(self)
+
+    def _unimport_plugin(self, plugin_path):
+        """remove a plugin from sys.modules if it is there"""
+        try:
+            del sys.modules[plugin_path]
+        except KeyError:
+            pass
+
+    def _import_plugins(self):
+        """import all plugins found in plugins directory"""
+        # FIXME: module imported but cancelled should be deleted
+        # TODO: make this more generic and reusable in tools.common
+        # FIXME: should use imp
+        # TODO: do not import all plugins if no needed: component plugins are not needed
+        #       if we just use a client, and plugin blacklisting should be possible in
+        #       libervia.conf
+        plugins_path = Path(libervia.backend.plugins.__file__).parent
+        plugins_to_import = {}  # plugins we still have to import
+        for plug_path in plugins_path.glob("plugin_*"):
+            if plug_path.is_dir():
+                init_path = plug_path / f"__init__.{C.PLUGIN_EXT}"
+                if not init_path.exists():
+                    log.warning(
+                        f"{plug_path} doesn't appear to be a package, can't load it")
+                    continue
+                plug_name = plug_path.name
+            elif plug_path.is_file():
+                if plug_path.suffix != f".{C.PLUGIN_EXT}":
+                    continue
+                plug_name = plug_path.stem
+            else:
+                log.warning(
+                    f"{plug_path} is not a file or a dir, ignoring it")
+                continue
+            if not plug_name.isidentifier():
+                log.warning(
+                    f"{plug_name!r} is not a valid name for a plugin, ignoring it")
+                continue
+            plugin_path = f"libervia.backend.plugins.{plug_name}"
+            try:
+                __import__(plugin_path)
+            except exceptions.MissingModule as e:
+                self._unimport_plugin(plugin_path)
+                log.warning(
+                    "Can't import plugin [{path}] because of an unavailale third party "
+                    "module:\n{msg}".format(
+                        path=plugin_path, msg=e
+                    )
+                )
+                continue
+            except exceptions.CancelError as e:
+                log.info(
+                    "Plugin [{path}] cancelled its own import: {msg}".format(
+                        path=plugin_path, msg=e
+                    )
+                )
+                self._unimport_plugin(plugin_path)
+                continue
+            except Exception:
+                import traceback
+
+                log.error(
+                    _("Can't import plugin [{path}]:\n{error}").format(
+                        path=plugin_path, error=traceback.format_exc()
+                    )
+                )
+                self._unimport_plugin(plugin_path)
+                continue
+            mod = sys.modules[plugin_path]
+            plugin_info = mod.PLUGIN_INFO
+            import_name = plugin_info["import_name"]
+
+            plugin_modes = plugin_info["modes"] = set(
+                plugin_info.setdefault("modes", C.PLUG_MODE_DEFAULT)
+            )
+            if not plugin_modes.intersection(C.PLUG_MODE_BOTH):
+                log.error(
+                    f"Can't import plugin at {plugin_path}, invalid {C.PI_MODES!r} "
+                    f"value: {plugin_modes!r}"
+                )
+                continue
+
+            # if the plugin is an entry point, it must work in component mode
+            if plugin_info["type"] == C.PLUG_TYPE_ENTRY_POINT:
+                # if plugin is an entrypoint, we cache it
+                if C.PLUG_MODE_COMPONENT not in plugin_modes:
+                    log.error(
+                        _(
+                            "{type} type must be used with {mode} mode, ignoring plugin"
+                        ).format(type=C.PLUG_TYPE_ENTRY_POINT, mode=C.PLUG_MODE_COMPONENT)
+                    )
+                    self._unimport_plugin(plugin_path)
+                    continue
+
+            if import_name in plugins_to_import:
+                log.error(
+                    _(
+                        "Name conflict for import name [{import_name}], can't import "
+                        "plugin [{name}]"
+                    ).format(**plugin_info)
+                )
+                continue
+            plugins_to_import[import_name] = (plugin_path, mod, plugin_info)
+        while True:
+            try:
+                self._import_plugins_from_dict(plugins_to_import)
+            except ImportError:
+                pass
+            if not plugins_to_import:
+                break
+
+    def _import_plugins_from_dict(
+        self, plugins_to_import, import_name=None, optional=False
+    ):
+        """Recursively import and their dependencies in the right order
+
+        @param plugins_to_import(dict): key=import_name and values=(plugin_path, module,
+                                        plugin_info)
+        @param import_name(unicode, None): name of the plugin to import as found in
+                                           PLUGIN_INFO['import_name']
+        @param optional(bool): if False and plugin is not found, an ImportError exception
+                               is raised
+        """
+        if import_name in self.plugins:
+            log.debug("Plugin {} already imported, passing".format(import_name))
+            return
+        if not import_name:
+            import_name, (plugin_path, mod, plugin_info) = plugins_to_import.popitem()
+        else:
+            if not import_name in plugins_to_import:
+                if optional:
+                    log.warning(
+                        _("Recommended plugin not found: {}").format(import_name)
+                    )
+                    return
+                msg = "Dependency not found: {}".format(import_name)
+                log.error(msg)
+                raise ImportError(msg)
+            plugin_path, mod, plugin_info = plugins_to_import.pop(import_name)
+        dependencies = plugin_info.setdefault("dependencies", [])
+        recommendations = plugin_info.setdefault("recommendations", [])
+        for to_import in dependencies + recommendations:
+            if to_import not in self.plugins:
+                log.debug(
+                    "Recursively import dependency of [%s]: [%s]"
+                    % (import_name, to_import)
+                )
+                try:
+                    self._import_plugins_from_dict(
+                        plugins_to_import, to_import, to_import not in dependencies
+                    )
+                except ImportError as e:
+                    log.warning(
+                        _("Can't import plugin {name}: {error}").format(
+                            name=plugin_info["name"], error=e
+                        )
+                    )
+                    if optional:
+                        return
+                    raise e
+        log.info("importing plugin: {}".format(plugin_info["name"]))
+        # we instanciate the plugin here
+        try:
+            self.plugins[import_name] = getattr(mod, plugin_info["main"])(self)
+        except Exception as e:
+            log.exception(
+                f"Can't load plugin \"{plugin_info['name']}\", ignoring it: {e}"
+            )
+            if optional:
+                return
+            raise ImportError("Error during initiation")
+        if C.bool(plugin_info.get(C.PI_HANDLER, C.BOOL_FALSE)):
+            self.plugins[import_name].is_handler = True
+        else:
+            self.plugins[import_name].is_handler = False
+        # we keep metadata as a Class attribute
+        self.plugins[import_name]._info = plugin_info
+        # TODO: test xmppclient presence and register handler parent
+
+    def plugins_unload(self):
+        """Call unload method on every loaded plugin, if exists
+
+        @return (D): A deferred which return None when all method have been called
+        """
+        # TODO: in the futur, it should be possible to hot unload a plugin
+        #       pluging depending on the unloaded one should be unloaded too
+        #       for now, just a basic call on plugin.unload is done
+        defers_list = []
+        for plugin in self.plugins.values():
+            try:
+                unload = plugin.unload
+            except AttributeError:
+                continue
+            else:
+                defers_list.append(utils.as_deferred(unload))
+        return defers_list
+
+    def _connect(self, profile_key, password="", options=None):
+        profile = self.memory.get_profile_name(profile_key)
+        return defer.ensureDeferred(self.connect(profile, password, options))
+
+    async def connect(
+        self, profile, password="", options=None, max_retries=C.XMPP_MAX_RETRIES):
+        """Connect a profile (i.e. connect client.component to XMPP server)
+
+        Retrieve the individual parameters, authenticate the profile
+        and initiate the connection to the associated XMPP server.
+        @param profile: %(doc_profile)s
+        @param password (string): the Libervia profile password
+        @param options (dict): connection options. Key can be:
+            -
+        @param max_retries (int): max number of connection retries
+        @return (D(bool)):
+            - True if the XMPP connection was already established
+            - False if the XMPP connection has been initiated (it may still fail)
+        @raise exceptions.PasswordError: Profile password is wrong
+        """
+        if options is None:
+            options = {}
+
+        await self.memory.start_session(password, profile)
+
+        if self.is_connected(profile):
+            log.info(_("already connected !"))
+            return True
+
+        if self.memory.is_component(profile):
+            await xmpp.SatXMPPComponent.start_connection(self, profile, max_retries)
+        else:
+            await xmpp.SatXMPPClient.start_connection(self, profile, max_retries)
+
+        return False
+
+    def disconnect(self, profile_key):
+        """disconnect from jabber server"""
+        # FIXME: client should not be deleted if only disconnected
+        #        it shoud be deleted only when session is finished
+        if not self.is_connected(profile_key):
+            # is_connected is checked here and not on client
+            # because client is deleted when session is ended
+            log.info(_("not connected !"))
+            return defer.succeed(None)
+        client = self.get_client(profile_key)
+        return client.entity_disconnect()
+
+    def features_get(self, profile_key=C.PROF_KEY_NONE):
+        """Get available features
+
+        Return list of activated plugins and plugin specific data
+        @param profile_key: %(doc_profile_key)s
+            C.PROF_KEY_NONE can be used to have general plugins data (i.e. not profile
+            dependent)
+        @return (dict)[Deferred]: features data where:
+            - key is plugin import name, present only for activated plugins
+            - value is a an other dict, when meaning is specific to each plugin.
+                this dict is return by plugin's getFeature method.
+                If this method doesn't exists, an empty dict is returned.
+        """
+        try:
+            # FIXME: there is no method yet to check profile session
+            #        as soon as one is implemented, it should be used here
+            self.get_client(profile_key)
+        except KeyError:
+            log.warning("Requesting features for a profile outside a session")
+            profile_key = C.PROF_KEY_NONE
+        except exceptions.ProfileNotSetError:
+            pass
+
+        features = []
+        for import_name, plugin in self.plugins.items():
+            try:
+                features_d = utils.as_deferred(plugin.features_get, profile_key)
+            except AttributeError:
+                features_d = defer.succeed({})
+            features.append(features_d)
+
+        d_list = defer.DeferredList(features)
+
+        def build_features(result, import_names):
+            assert len(result) == len(import_names)
+            ret = {}
+            for name, (success, data) in zip(import_names, result):
+                if success:
+                    ret[name] = data
+                else:
+                    log.warning(
+                        "Error while getting features for {name}: {failure}".format(
+                            name=name, failure=data
+                        )
+                    )
+                    ret[name] = {}
+            return ret
+
+        d_list.addCallback(build_features, list(self.plugins.keys()))
+        return d_list
+
+    def _contact_get(self, entity_jid_s, profile_key):
+        client = self.get_client(profile_key)
+        entity_jid = jid.JID(entity_jid_s)
+        return defer.ensureDeferred(self.get_contact(client, entity_jid))
+
+    async def get_contact(self, client, entity_jid):
+        # we want to be sure that roster has been received
+        await client.roster.got_roster
+        item = client.roster.get_item(entity_jid)
+        if item is None:
+            raise exceptions.NotFound(f"{entity_jid} is not in roster!")
+        return (client.roster.get_attributes(item), list(item.groups))
+
+    def contacts_get(self, profile_key):
+        client = self.get_client(profile_key)
+
+        def got_roster(__):
+            ret = []
+            for item in client.roster.get_items():  # we get all items for client's roster
+                # and convert them to expected format
+                attr = client.roster.get_attributes(item)
+                # we use full() and not userhost() because jid with resources are allowed
+                # in roster, even if it's not common.
+                ret.append([item.entity.full(), attr, list(item.groups)])
+            return ret
+
+        return client.roster.got_roster.addCallback(got_roster)
+
+    def contacts_get_from_group(self, group, profile_key):
+        client = self.get_client(profile_key)
+        return [jid_.full() for jid_ in client.roster.get_jids_from_group(group)]
+
+    def purge_entity(self, profile):
+        """Remove reference to a profile client/component and purge cache
+
+        the garbage collector can then free the memory
+        """
+        try:
+            del self.profiles[profile]
+        except KeyError:
+            log.error(_("Trying to remove reference to a client not referenced"))
+        else:
+            self.memory.purge_profile_session(profile)
+
+    def startService(self):
+        self._init()
+        log.info("Salut à toi ô mon frère !")
+
+    def stopService(self):
+        log.info("Salut aussi à Rantanplan")
+        return self.plugins_unload()
+
+    def run(self):
+        log.debug(_("running app"))
+        reactor.run()
+
+    def stop(self):
+        log.debug(_("stopping app"))
+        reactor.stop()
+
+    ## Misc methods ##
+
+    def get_jid_n_stream(self, profile_key):
+        """Convenient method to get jid and stream from profile key
+        @return: tuple (jid, xmlstream) from profile, can be None"""
+        # TODO: deprecate this method (get_client is enough)
+        profile = self.memory.get_profile_name(profile_key)
+        if not profile or not self.profiles[profile].is_connected():
+            return (None, None)
+        return (self.profiles[profile].jid, self.profiles[profile].xmlstream)
+
+    def get_client(self, profile_key: str) -> xmpp.SatXMPPClient:
+        """Convenient method to get client from profile key
+
+        @return: the client
+        @raise exceptions.ProfileKeyUnknown: the profile or profile key doesn't exist
+        @raise exceptions.NotFound: client is not available
+            This happen if profile has not been used yet
+        """
+        profile = self.memory.get_profile_name(profile_key)
+        if not profile:
+            raise exceptions.ProfileKeyUnknown
+        try:
+            return self.profiles[profile]
+        except KeyError:
+            raise exceptions.NotFound(profile_key)
+
+    def get_clients(self, profile_key):
+        """Convenient method to get list of clients from profile key
+
+        Manage list through profile_key like C.PROF_KEY_ALL
+        @param profile_key: %(doc_profile_key)s
+        @return: list of clients
+        """
+        if not profile_key:
+            raise exceptions.DataError(_("profile_key must not be empty"))
+        try:
+            profile = self.memory.get_profile_name(profile_key, True)
+        except exceptions.ProfileUnknownError:
+            return []
+        if profile == C.PROF_KEY_ALL:
+            return list(self.profiles.values())
+        elif profile[0] == "@":  #  only profile keys can start with "@"
+            raise exceptions.ProfileKeyUnknown
+        return [self.profiles[profile]]
+
+    def _get_config(self, section, name):
+        """Get the main configuration option
+
+        @param section: section of the config file (None or '' for DEFAULT)
+        @param name: name of the option
+        @return: unicode representation of the option
+        """
+        return str(self.memory.config_get(section, name, ""))
+
+    def log_errback(self, failure_, msg=_("Unexpected error: {failure_}")):
+        """Generic errback logging
+
+        @param msg(unicode): error message ("failure_" key will be use for format)
+        can be used as last errback to show unexpected error
+        """
+        log.error(msg.format(failure_=failure_))
+        return failure_
+
+    #  namespaces
+
+    def register_namespace(self, short_name, namespace):
+        """associate a namespace to a short name"""
+        if short_name in self.ns_map:
+            raise exceptions.ConflictError("this short name is already used")
+        log.debug(f"registering namespace {short_name} => {namespace}")
+        self.ns_map[short_name] = namespace
+
+    def get_namespaces(self):
+        return self.ns_map
+
+    def get_namespace(self, short_name):
+        try:
+            return self.ns_map[short_name]
+        except KeyError:
+            raise exceptions.NotFound("namespace {short_name} is not registered"
+                                      .format(short_name=short_name))
+
+    def get_session_infos(self, profile_key):
+        """compile interesting data on current profile session"""
+        client = self.get_client(profile_key)
+        data = {
+            "jid": client.jid.full(),
+            "started": str(int(client.started))
+            }
+        return defer.succeed(data)
+
+    def _get_devices_infos(self, bare_jid, profile_key):
+        client = self.get_client(profile_key)
+        if not bare_jid:
+            bare_jid = None
+        d = defer.ensureDeferred(self.get_devices_infos(client, bare_jid))
+        d.addCallback(lambda data: data_format.serialise(data))
+        return d
+
+    async def get_devices_infos(self, client, bare_jid=None):
+        """compile data on an entity devices
+
+        @param bare_jid(jid.JID, None): bare jid of entity to check
+            None to use client own jid
+        @return (list[dict]): list of data, one item per resource.
+            Following keys can be set:
+                - resource(str): resource name
+        """
+        own_jid = client.jid.userhostJID()
+        if bare_jid is None:
+            bare_jid = own_jid
+        else:
+            bare_jid = jid.JID(bare_jid)
+        resources = self.memory.get_all_resources(client, bare_jid)
+        if bare_jid == own_jid:
+            # our own jid is not stored in memory's cache
+            resources.add(client.jid.resource)
+        ret_data = []
+        for resource in resources:
+            res_jid = copy.copy(bare_jid)
+            res_jid.resource = resource
+            cache_data = self.memory.entity_data_get(client, res_jid)
+            res_data = {
+                "resource": resource,
+            }
+            try:
+                presence = cache_data['presence']
+            except KeyError:
+                pass
+            else:
+                res_data['presence'] = {
+                    "show": presence.show,
+                    "priority": presence.priority,
+                    "statuses": presence.statuses,
+                }
+
+            disco = await self.get_disco_infos(client, res_jid)
+
+            for (category, type_), name in disco.identities.items():
+                identities = res_data.setdefault('identities', [])
+                identities.append({
+                    "name": name,
+                    "category": category,
+                    "type": type_,
+                })
+
+            ret_data.append(res_data)
+
+        return ret_data
+
+    # images
+
+    def _image_check(self, path):
+        report = image.check(self, path)
+        return data_format.serialise(report)
+
+    def _image_resize(self, path, width, height):
+        d = image.resize(path, (width, height))
+        d.addCallback(lambda new_image_path: str(new_image_path))
+        return d
+
+    def _image_generate_preview(self, path, profile_key):
+        client = self.get_client(profile_key)
+        d = defer.ensureDeferred(self.image_generate_preview(client, Path(path)))
+        d.addCallback(lambda preview_path: str(preview_path))
+        return d
+
+    async def image_generate_preview(self, client, path):
+        """Helper method to generate in cache a preview of an image
+
+        @param path(Path): path to the image
+        @return (Path): path to the generated preview
+        """
+        report = image.check(self, path, max_size=(300, 300))
+
+        if not report['too_large']:
+            # in the unlikely case that image is already smaller than a preview
+            preview_path = path
+        else:
+            # we use hash as id, to re-use potentially existing preview
+            path_hash = hashlib.sha256(str(path).encode()).hexdigest()
+            uid = f"{path.stem}_{path_hash}_preview"
+            filename = f"{uid}{path.suffix.lower()}"
+            metadata = client.cache.get_metadata(uid=uid)
+            if metadata is not None:
+                preview_path = metadata['path']
+            else:
+                with client.cache.cache_data(
+                    source='HOST_PREVIEW',
+                    uid=uid,
+                    filename=filename) as cache_f:
+
+                    preview_path = await image.resize(
+                        path,
+                        new_size=report['recommended_size'],
+                        dest=cache_f
+                    )
+
+        return preview_path
+
+    def _image_convert(self, source, dest, extra, profile_key):
+        client = self.get_client(profile_key) if profile_key else None
+        source = Path(source)
+        dest = None if not dest else Path(dest)
+        extra = data_format.deserialise(extra)
+        d = defer.ensureDeferred(self.image_convert(client, source, dest, extra))
+        d.addCallback(lambda dest_path: str(dest_path))
+        return d
+
+    async def image_convert(self, client, source, dest=None, extra=None):
+        """Helper method to convert an image from one format to an other
+
+        @param client(SatClient, None): client to use for caching
+            this parameter is only used if dest is None
+            if client is None, common cache will be used insted of profile cache
+        @param source(Path): path to the image to convert
+        @param dest(None, Path, file): where to save the converted file
+            - None: use a cache file (uid generated from hash of source)
+                file will be converted to PNG
+            - Path: path to the file to create/overwrite
+            - file: a file object which must be opened for writing in binary mode
+        @param extra(dict, None): conversion options
+            see [image.convert] for details
+        @return (Path): path to the converted image
+        @raise ValueError: an issue happened with source of dest
+        """
+        if not source.is_file:
+            raise ValueError(f"Source file {source} doesn't exist!")
+        if dest is None:
+            # we use hash as id, to re-use potentially existing conversion
+            path_hash = hashlib.sha256(str(source).encode()).hexdigest()
+            uid = f"{source.stem}_{path_hash}_convert_png"
+            filename = f"{uid}.png"
+            if client is None:
+                cache = self.common_cache
+            else:
+                cache = client.cache
+            metadata = cache.get_metadata(uid=uid)
+            if metadata is not None:
+                # there is already a conversion for this image in cache
+                return metadata['path']
+            else:
+                with cache.cache_data(
+                    source='HOST_IMAGE_CONVERT',
+                    uid=uid,
+                    filename=filename) as cache_f:
+
+                    converted_path = await image.convert(
+                        source,
+                        dest=cache_f,
+                        extra=extra
+                    )
+                return converted_path
+        else:
+            return await image.convert(source, dest, extra)
+
+
+    # local dirs
+
+    def get_local_path(
+        self,
+        client: Optional[SatXMPPEntity],
+        dir_name: str,
+        *extra_path: str,
+        component: bool = False,
+    ) -> Path:
+        """Retrieve path for local data
+
+        if path doesn't exist, it will be created
+        @param client: client instance
+            if not none, client.profile will be used as last path element
+        @param dir_name: name of the main path directory
+        @param *extra_path: extra path element(s) to use
+        @param component: if True, path will be prefixed with C.COMPONENTS_DIR
+        @return: path
+        """
+        local_dir = self.memory.config_get("", "local_dir")
+        if not local_dir:
+            raise exceptions.InternalError("local_dir must be set")
+        path_elts = []
+        if component:
+            path_elts.append(C.COMPONENTS_DIR)
+        path_elts.append(regex.path_escape(dir_name))
+        if extra_path:
+            path_elts.extend([regex.path_escape(p) for p in extra_path])
+        if client is not None:
+            path_elts.append(regex.path_escape(client.profile))
+        local_path = Path(*path_elts)
+        local_path.mkdir(0o700, parents=True, exist_ok=True)
+        return local_path
+
+    ## Client management ##
+
+    def param_set(self, name, value, category, security_limit, profile_key):
+        """set wanted paramater and notice observers"""
+        self.memory.param_set(name, value, category, security_limit, profile_key)
+
+    def is_connected(self, profile_key):
+        """Return connection status of profile
+
+        @param profile_key: key_word or profile name to determine profile name
+        @return: True if connected
+        """
+        profile = self.memory.get_profile_name(profile_key)
+        if not profile:
+            log.error(_("asking connection status for a non-existant profile"))
+            raise exceptions.ProfileUnknownError(profile_key)
+        if profile not in self.profiles:
+            return False
+        return self.profiles[profile].is_connected()
+
+    ## Encryption ##
+
+    def register_encryption_plugin(self, *args, **kwargs):
+        return encryption.EncryptionHandler.register_plugin(*args, **kwargs)
+
+    def _message_encryption_start(self, to_jid_s, namespace, replace=False,
+                                profile_key=C.PROF_KEY_NONE):
+        client = self.get_client(profile_key)
+        to_jid = jid.JID(to_jid_s)
+        return defer.ensureDeferred(
+            client.encryption.start(to_jid, namespace or None, replace))
+
+    def _message_encryption_stop(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
+        client = self.get_client(profile_key)
+        to_jid = jid.JID(to_jid_s)
+        return defer.ensureDeferred(
+            client.encryption.stop(to_jid))
+
+    def _message_encryption_get(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
+        client = self.get_client(profile_key)
+        to_jid = jid.JID(to_jid_s)
+        session_data = client.encryption.getSession(to_jid)
+        return client.encryption.get_bridge_data(session_data)
+
+    def _encryption_namespace_get(self, name):
+        return encryption.EncryptionHandler.get_ns_from_name(name)
+
+    def _encryption_plugins_get(self):
+        plugins = encryption.EncryptionHandler.getPlugins()
+        ret = []
+        for p in plugins:
+            ret.append({
+                "name": p.name,
+                "namespace": p.namespace,
+                "priority": p.priority,
+                "directed": p.directed,
+                })
+        return data_format.serialise(ret)
+
+    def _encryption_trust_ui_get(self, to_jid_s, namespace, profile_key):
+        client = self.get_client(profile_key)
+        to_jid = jid.JID(to_jid_s)
+        d = defer.ensureDeferred(
+            client.encryption.get_trust_ui(to_jid, namespace=namespace or None))
+        d.addCallback(lambda xmlui: xmlui.toXml())
+        return d
+
+    ## XMPP methods ##
+
+    def _message_send(
+            self, to_jid_s, message, subject=None, mess_type="auto", extra_s="",
+            profile_key=C.PROF_KEY_NONE):
+        client = self.get_client(profile_key)
+        to_jid = jid.JID(to_jid_s)
+        return client.sendMessage(
+            to_jid,
+            message,
+            subject,
+            mess_type,
+            data_format.deserialise(extra_s)
+        )
+
+    def _set_presence(self, to="", show="", statuses=None, profile_key=C.PROF_KEY_NONE):
+        return self.presence_set(jid.JID(to) if to else None, show, statuses, profile_key)
+
+    def presence_set(self, to_jid=None, show="", statuses=None,
+                    profile_key=C.PROF_KEY_NONE):
+        """Send our presence information"""
+        if statuses is None:
+            statuses = {}
+        profile = self.memory.get_profile_name(profile_key)
+        assert profile
+        priority = int(
+            self.memory.param_get_a("Priority", "Connection", profile_key=profile)
+        )
+        self.profiles[profile].presence.available(to_jid, show, statuses, priority)
+        # XXX: FIXME: temporary fix to work around openfire 3.7.0 bug (presence is not
+        #             broadcasted to generating resource)
+        if "" in statuses:
+            statuses[C.PRESENCE_STATUSES_DEFAULT] = statuses.pop("")
+        self.bridge.presence_update(
+            self.profiles[profile].jid.full(), show, int(priority), statuses, profile
+        )
+
+    def subscription(self, subs_type, raw_jid, profile_key):
+        """Called to manage subscription
+        @param subs_type: subsciption type (cf RFC 3921)
+        @param raw_jid: unicode entity's jid
+        @param profile_key: profile"""
+        profile = self.memory.get_profile_name(profile_key)
+        assert profile
+        to_jid = jid.JID(raw_jid)
+        log.debug(
+            _("subsciption request [%(subs_type)s] for %(jid)s")
+            % {"subs_type": subs_type, "jid": to_jid.full()}
+        )
+        if subs_type == "subscribe":
+            self.profiles[profile].presence.subscribe(to_jid)
+        elif subs_type == "subscribed":
+            self.profiles[profile].presence.subscribed(to_jid)
+        elif subs_type == "unsubscribe":
+            self.profiles[profile].presence.unsubscribe(to_jid)
+        elif subs_type == "unsubscribed":
+            self.profiles[profile].presence.unsubscribed(to_jid)
+
+    def _add_contact(self, to_jid_s, profile_key):
+        return self.contact_add(jid.JID(to_jid_s), profile_key)
+
+    def contact_add(self, to_jid, profile_key):
+        """Add a contact in roster list"""
+        profile = self.memory.get_profile_name(profile_key)
+        assert profile
+        # presence is sufficient, as a roster push will be sent according to
+        # RFC 6121 §3.1.2
+        self.profiles[profile].presence.subscribe(to_jid)
+
+    def _update_contact(self, to_jid_s, name, groups, profile_key):
+        client = self.get_client(profile_key)
+        return self.contact_update(client, jid.JID(to_jid_s), name, groups)
+
+    def contact_update(self, client, to_jid, name, groups):
+        """update a contact in roster list"""
+        roster_item = RosterItem(to_jid)
+        roster_item.name = name or u''
+        roster_item.groups = set(groups)
+        if not self.trigger.point("roster_update", client, roster_item):
+            return
+        return client.roster.setItem(roster_item)
+
+    def _del_contact(self, to_jid_s, profile_key):
+        return self.contact_del(jid.JID(to_jid_s), profile_key)
+
+    def contact_del(self, to_jid, profile_key):
+        """Remove contact from roster list"""
+        profile = self.memory.get_profile_name(profile_key)
+        assert profile
+        self.profiles[profile].presence.unsubscribe(to_jid)  # is not asynchronous
+        return self.profiles[profile].roster.removeItem(to_jid)
+
+    def _roster_resync(self, profile_key):
+        client = self.get_client(profile_key)
+        return client.roster.resync()
+
+    ## Discovery ##
+    # discovery methods are shortcuts to self.memory.disco
+    # the main difference with client.disco is that self.memory.disco manage cache
+
+    def hasFeature(self, *args, **kwargs):
+        return self.memory.disco.hasFeature(*args, **kwargs)
+
+    def check_feature(self, *args, **kwargs):
+        return self.memory.disco.check_feature(*args, **kwargs)
+
+    def check_features(self, *args, **kwargs):
+        return self.memory.disco.check_features(*args, **kwargs)
+
+    def has_identity(self, *args, **kwargs):
+        return self.memory.disco.has_identity(*args, **kwargs)
+
+    def get_disco_infos(self, *args, **kwargs):
+        return self.memory.disco.get_infos(*args, **kwargs)
+
+    def getDiscoItems(self, *args, **kwargs):
+        return self.memory.disco.get_items(*args, **kwargs)
+
+    def find_service_entity(self, *args, **kwargs):
+        return self.memory.disco.find_service_entity(*args, **kwargs)
+
+    def find_service_entities(self, *args, **kwargs):
+        return self.memory.disco.find_service_entities(*args, **kwargs)
+
+    def find_features_set(self, *args, **kwargs):
+        return self.memory.disco.find_features_set(*args, **kwargs)
+
+    def _find_by_features(self, namespaces, identities, bare_jids, service, roster, own_jid,
+                        local_device, profile_key):
+        client = self.get_client(profile_key)
+        identities = [tuple(i) for i in identities] if identities else None
+        return defer.ensureDeferred(self.find_by_features(
+            client, namespaces, identities, bare_jids, service, roster, own_jid,
+            local_device))
+
+    async def find_by_features(
+        self,
+        client: SatXMPPEntity,
+        namespaces: List[str],
+        identities: Optional[List[Tuple[str, str]]]=None,
+        bare_jids: bool=False,
+        service: bool=True,
+        roster: bool=True,
+        own_jid: bool=True,
+        local_device: bool=False
+    ) -> Tuple[
+        Dict[jid.JID, Tuple[str, str, str]],
+        Dict[jid.JID, Tuple[str, str, str]],
+        Dict[jid.JID, Tuple[str, str, str]]
+    ]:
+        """Retrieve all services or contacts managing a set a features
+
+        @param namespaces: features which must be handled
+        @param identities: if not None or empty,
+            only keep those identities
+            tuple must be (category, type)
+        @param bare_jids: retrieve only bare_jids if True
+            if False, retrieve full jid of connected devices
+        @param service: if True return service from our server
+        @param roster: if True, return entities in roster
+            full jid of all matching resources available will be returned
+        @param own_jid: if True, return profile's jid resources
+        @param local_device: if True, return profile's jid local resource
+            (i.e. client.jid)
+        @return: found entities in a tuple with:
+            - service entities
+            - own entities
+            - roster entities
+            Each element is a dict mapping from jid to a tuple with category, type and
+            name of the entity
+        """
+        assert isinstance(namespaces, list)
+        if not identities:
+            identities = None
+        if not namespaces and not identities:
+            raise exceptions.DataError(
+                "at least one namespace or one identity must be set"
+            )
+        found_service = {}
+        found_own = {}
+        found_roster = {}
+        if service:
+            services_jids = await self.find_features_set(client, namespaces)
+            services_jids = list(services_jids)  # we need a list to map results below
+            services_infos  = await defer.DeferredList(
+                [self.get_disco_infos(client, service_jid) for service_jid in services_jids]
+            )
+
+            for idx, (success, infos) in enumerate(services_infos):
+                service_jid = services_jids[idx]
+                if not success:
+                    log.warning(
+                        _("Can't find features for service {service_jid}, ignoring")
+                        .format(service_jid=service_jid.full()))
+                    continue
+                if (identities is not None
+                    and not set(infos.identities.keys()).issuperset(identities)):
+                    continue
+                found_identities = [
+                    (cat, type_, name or "")
+                    for (cat, type_), name in infos.identities.items()
+                ]
+                found_service[service_jid.full()] = found_identities
+
+        to_find = []
+        if own_jid:
+            to_find.append((found_own, [client.jid.userhostJID()]))
+        if roster:
+            to_find.append((found_roster, client.roster.get_jids()))
+
+        for found, jids in to_find:
+            full_jids = []
+            disco_defers = []
+
+            for jid_ in jids:
+                if jid_.resource:
+                    if bare_jids:
+                        continue
+                    resources = [jid_.resource]
+                else:
+                    if bare_jids:
+                        resources = [None]
+                    else:
+                        try:
+                            resources = self.memory.get_available_resources(client, jid_)
+                        except exceptions.UnknownEntityError:
+                            continue
+                        if not resources and jid_ == client.jid.userhostJID() and own_jid:
+                            # small hack to avoid missing our own resource when this
+                            # method is called at the very beginning of the session
+                            # and our presence has not been received yet
+                            resources = [client.jid.resource]
+                for resource in resources:
+                    full_jid = jid.JID(tuple=(jid_.user, jid_.host, resource))
+                    if full_jid == client.jid and not local_device:
+                        continue
+                    full_jids.append(full_jid)
+
+                    disco_defers.append(self.get_disco_infos(client, full_jid))
+
+            d_list = defer.DeferredList(disco_defers)
+            # XXX: 10 seconds may be too low for slow connections (e.g. mobiles)
+            #      but for discovery, that's also the time the user will wait the first time
+            #      before seing the page, if something goes wrong.
+            d_list.addTimeout(10, reactor)
+            infos_data = await d_list
+
+            for idx, (success, infos) in enumerate(infos_data):
+                full_jid = full_jids[idx]
+                if not success:
+                    log.warning(
+                        _("Can't retrieve {full_jid} infos, ignoring")
+                        .format(full_jid=full_jid.full()))
+                    continue
+                if infos.features.issuperset(namespaces):
+                    if identities is not None and not set(
+                        infos.identities.keys()
+                    ).issuperset(identities):
+                        continue
+                    found_identities = [
+                        (cat, type_, name or "")
+                        for (cat, type_), name in infos.identities.items()
+                    ]
+                    found[full_jid.full()] = found_identities
+
+        return (found_service, found_own, found_roster)
+
+    ## Generic HMI ##
+
+    def _kill_action(self, keep_id, client):
+        log.debug("Killing action {} for timeout".format(keep_id))
+        client.actions[keep_id]
+
+    def action_new(
+        self,
+        action_data,
+        security_limit=C.NO_SECURITY_LIMIT,
+        keep_id=None,
+        profile=C.PROF_KEY_NONE,
+    ):
+        """Shortcut to bridge.action_new which generate an id and keep for retrieval
+
+        @param action_data(dict): action data (see bridge documentation)
+        @param security_limit: %(doc_security_limit)s
+        @param keep_id(None, unicode): if not None, used to keep action for differed
+            retrieval. The value will be used as callback_id, be sure to use an unique
+            value.
+            Action will be deleted after 30 min.
+        @param profile: %(doc_profile)s
+        """
+        if keep_id is not None:
+            id_ = keep_id
+            client = self.get_client(profile)
+            action_timer = reactor.callLater(60 * 30, self._kill_action, keep_id, client)
+            client.actions[keep_id] = (action_data, id_, security_limit, action_timer)
+        else:
+            id_ = str(uuid.uuid4())
+
+        self.bridge.action_new(
+            data_format.serialise(action_data), id_, security_limit, profile
+        )
+
+    def actions_get(self, profile):
+        """Return current non answered actions
+
+        @param profile: %(doc_profile)s
+        """
+        client = self.get_client(profile)
+        return [
+            (data_format.serialise(action_tuple[0]), *action_tuple[1:-1])
+            for action_tuple in client.actions.values()
+        ]
+
+    def register_progress_cb(
+        self, progress_id, callback, metadata=None, profile=C.PROF_KEY_NONE
+    ):
+        """Register a callback called when progress is requested for id"""
+        if metadata is None:
+            metadata = {}
+        client = self.get_client(profile)
+        if progress_id in client._progress_cb:
+            raise exceptions.ConflictError("Progress ID is not unique !")
+        client._progress_cb[progress_id] = (callback, metadata)
+
+    def remove_progress_cb(self, progress_id, profile):
+        """Remove a progress callback"""
+        client = self.get_client(profile)
+        try:
+            del client._progress_cb[progress_id]
+        except KeyError:
+            log.error(_("Trying to remove an unknow progress callback"))
+
+    def _progress_get(self, progress_id, profile):
+        data = self.progress_get(progress_id, profile)
+        return {k: str(v) for k, v in data.items()}
+
+    def progress_get(self, progress_id, profile):
+        """Return a dict with progress information
+
+        @param progress_id(unicode): unique id of the progressing element
+        @param profile: %(doc_profile)s
+        @return (dict): data with the following keys:
+            'position' (int): current possition
+            'size' (int): end_position
+            if id doesn't exists (may be a finished progression), and empty dict is
+            returned
+        """
+        client = self.get_client(profile)
+        try:
+            data = client._progress_cb[progress_id][0](progress_id, profile)
+        except KeyError:
+            data = {}
+        return data
+
+    def _progress_get_all(self, profile_key):
+        progress_all = self.progress_get_all(profile_key)
+        for profile, progress_dict in progress_all.items():
+            for progress_id, data in progress_dict.items():
+                for key, value in data.items():
+                    data[key] = str(value)
+        return progress_all
+
+    def progress_get_all_metadata(self, profile_key):
+        """Return all progress metadata at once
+
+        @param profile_key: %(doc_profile)s
+            if C.PROF_KEY_ALL is used, all progress metadata from all profiles are
+            returned
+        @return (dict[dict[dict]]): a dict which map profile to progress_dict
+            progress_dict map progress_id to progress_data
+            progress_metadata is the same dict as sent by [progress_started]
+        """
+        clients = self.get_clients(profile_key)
+        progress_all = {}
+        for client in clients:
+            profile = client.profile
+            progress_dict = {}
+            progress_all[profile] = progress_dict
+            for (
+                progress_id,
+                (__, progress_metadata),
+            ) in client._progress_cb.items():
+                progress_dict[progress_id] = progress_metadata
+        return progress_all
+
+    def progress_get_all(self, profile_key):
+        """Return all progress status at once
+
+        @param profile_key: %(doc_profile)s
+            if C.PROF_KEY_ALL is used, all progress status from all profiles are returned
+        @return (dict[dict[dict]]): a dict which map profile to progress_dict
+            progress_dict map progress_id to progress_data
+            progress_data is the same dict as returned by [progress_get]
+        """
+        clients = self.get_clients(profile_key)
+        progress_all = {}
+        for client in clients:
+            profile = client.profile
+            progress_dict = {}
+            progress_all[profile] = progress_dict
+            for progress_id, (progress_cb, __) in client._progress_cb.items():
+                progress_dict[progress_id] = progress_cb(progress_id, profile)
+        return progress_all
+
+    def register_callback(self, callback, *args, **kwargs):
+        """Register a callback.
+
+        @param callback(callable): method to call
+        @param kwargs: can contain:
+            with_data(bool): True if the callback use the optional data dict
+            force_id(unicode): id to avoid generated id. Can lead to name conflict, avoid
+                               if possible
+            one_shot(bool): True to delete callback once it has been called
+        @return: id of the registered callback
+        """
+        callback_id = kwargs.pop("force_id", None)
+        if callback_id is None:
+            callback_id = str(uuid.uuid4())
+        else:
+            if callback_id in self._cb_map:
+                raise exceptions.ConflictError(_("id already registered"))
+        self._cb_map[callback_id] = (callback, args, kwargs)
+
+        if "one_shot" in kwargs:  # One Shot callback are removed after 30 min
+
+            def purge_callback():
+                try:
+                    self.remove_callback(callback_id)
+                except KeyError:
+                    pass
+
+            reactor.callLater(1800, purge_callback)
+
+        return callback_id
+
+    def remove_callback(self, callback_id):
+        """ Remove a previously registered callback
+        @param callback_id: id returned by [register_callback] """
+        log.debug("Removing callback [%s]" % callback_id)
+        del self._cb_map[callback_id]
+
+    def _action_launch(
+        self,
+        callback_id: str,
+        data_s: str,
+        profile_key: str
+    ) -> defer.Deferred:
+        d = self.launch_callback(
+            callback_id,
+            data_format.deserialise(data_s),
+            profile_key
+        )
+        d.addCallback(data_format.serialise)
+        return d
+
+    def launch_callback(
+        self,
+        callback_id: str,
+        data: Optional[dict] = None,
+        profile_key: str = C.PROF_KEY_NONE
+    ) -> defer.Deferred:
+        """Launch a specific callback
+
+        @param callback_id: id of the action (callback) to launch
+        @param data: optional data
+        @profile_key: %(doc_profile_key)s
+        @return: a deferred which fire a dict where key can be:
+            - xmlui: a XMLUI need to be displayed
+            - validated: if present, can be used to launch a callback, it can have the
+                values
+                - C.BOOL_TRUE
+                - C.BOOL_FALSE
+        """
+        # FIXME: is it possible to use this method without profile connected? If not,
+        #     client must be used instead of profile_key
+        # FIXME: security limit need to be checked here
+        try:
+            client = self.get_client(profile_key)
+        except exceptions.NotFound:
+            # client is not available yet
+            profile = self.memory.get_profile_name(profile_key)
+            if not profile:
+                raise exceptions.ProfileUnknownError(
+                    _("trying to launch action with a non-existant profile")
+                )
+        else:
+            profile = client.profile
+            # we check if the action is kept, and remove it
+            try:
+                action_tuple = client.actions[callback_id]
+            except KeyError:
+                pass
+            else:
+                action_tuple[-1].cancel()  # the last item is the action timer
+                del client.actions[callback_id]
+
+        try:
+            callback, args, kwargs = self._cb_map[callback_id]
+        except KeyError:
+            raise exceptions.DataError("Unknown callback id {}".format(callback_id))
+
+        if kwargs.get("with_data", False):
+            if data is None:
+                raise exceptions.DataError("Required data for this callback is missing")
+            args, kwargs = (
+                list(args)[:],
+                kwargs.copy(),
+            )  # we don't want to modify the original (kw)args
+            args.insert(0, data)
+            kwargs["profile"] = profile
+            del kwargs["with_data"]
+
+        if kwargs.pop("one_shot", False):
+            self.remove_callback(callback_id)
+
+        return utils.as_deferred(callback, *args, **kwargs)
+
+    # Menus management
+
+    def _get_menu_canonical_path(self, path):
+        """give canonical form of path
+
+        canonical form is a tuple of the path were every element is stripped and lowercase
+        @param path(iterable[unicode]): untranslated path to menu
+        @return (tuple[unicode]): canonical form of path
+        """
+        return tuple((p.lower().strip() for p in path))
+
+    def import_menu(self, path, callback, security_limit=C.NO_SECURITY_LIMIT,
+                   help_string="", type_=C.MENU_GLOBAL):
+        r"""register a new menu for frontends
+
+        @param path(iterable[unicode]): path to go to the menu
+            (category/subcategory/.../item) (e.g.: ("File", "Open"))
+            /!\ use D_() instead of _() for translations (e.g. (D_("File"), D_("Open")))
+            untranslated/lower case path can be used to identity a menu, for this reason
+            it must be unique independently of case.
+        @param callback(callable): method to be called when menuitem is selected, callable
+            or a callback id (string) as returned by [register_callback]
+        @param security_limit(int): %(doc_security_limit)s
+            /!\ security_limit MUST be added to data in launch_callback if used #TODO
+        @param help_string(unicode): string used to indicate what the menu do (can be
+            show as a tooltip).
+            /!\ use D_() instead of _() for translations
+        @param type(unicode): one of:
+            - C.MENU_GLOBAL: classical menu, can be shown in a menubar on top (e.g.
+                something like File/Open)
+            - C.MENU_ROOM: like a global menu, but only shown in multi-user chat
+                menu_data must contain a "room_jid" data
+            - C.MENU_SINGLE: like a global menu, but only shown in one2one chat
+                menu_data must contain a "jid" data
+            - C.MENU_JID_CONTEXT: contextual menu, used with any jid (e.g.: ad hoc
+                commands, jid is already filled)
+                menu_data must contain a "jid" data
+            - C.MENU_ROSTER_JID_CONTEXT: like JID_CONTEXT, but restricted to jids in
+                roster.
+                menu_data must contain a "room_jid" data
+            - C.MENU_ROSTER_GROUP_CONTEXT: contextual menu, used with group (e.g.: publish
+                microblog, group is already filled)
+                menu_data must contain a "group" data
+        @return (unicode): menu_id (same as callback_id)
+        """
+
+        if callable(callback):
+            callback_id = self.register_callback(callback, with_data=True)
+        elif isinstance(callback, str):
+            # The callback is already registered
+            callback_id = callback
+            try:
+                callback, args, kwargs = self._cb_map[callback_id]
+            except KeyError:
+                raise exceptions.DataError("Unknown callback id")
+            kwargs["with_data"] = True  # we have to be sure that we use extra data
+        else:
+            raise exceptions.DataError("Unknown callback type")
+
+        for menu_data in self._menus.values():
+            if menu_data["path"] == path and menu_data["type"] == type_:
+                raise exceptions.ConflictError(
+                    _("A menu with the same path and type already exists")
+                )
+
+        path_canonical = self._get_menu_canonical_path(path)
+        menu_key = (type_, path_canonical)
+
+        if menu_key in self._menus_paths:
+            raise exceptions.ConflictError(
+                "this menu path is already used: {path} ({menu_key})".format(
+                    path=path_canonical, menu_key=menu_key
+                )
+            )
+
+        menu_data = {
+            "path": tuple(path),
+            "path_canonical": path_canonical,
+            "security_limit": security_limit,
+            "help_string": help_string,
+            "type": type_,
+        }
+
+        self._menus[callback_id] = menu_data
+        self._menus_paths[menu_key] = callback_id
+
+        return callback_id
+
+    def get_menus(self, language="", security_limit=C.NO_SECURITY_LIMIT):
+        """Return all menus registered
+
+        @param language: language used for translation, or empty string for default
+        @param security_limit: %(doc_security_limit)s
+        @return: array of tuple with:
+            - menu id (same as callback_id)
+            - menu type
+            - raw menu path (array of strings)
+            - translated menu path
+            - extra (dict(unicode, unicode)): extra data where key can be:
+                - icon: name of the icon to use (TODO)
+                - help_url: link to a page with more complete documentation (TODO)
+        """
+        ret = []
+        for menu_id, menu_data in self._menus.items():
+            type_ = menu_data["type"]
+            path = menu_data["path"]
+            menu_security_limit = menu_data["security_limit"]
+            if security_limit != C.NO_SECURITY_LIMIT and (
+                menu_security_limit == C.NO_SECURITY_LIMIT
+                or menu_security_limit > security_limit
+            ):
+                continue
+            language_switch(language)
+            path_i18n = [_(elt) for elt in path]
+            language_switch()
+            extra = {}  # TODO: manage extra data like icon
+            ret.append((menu_id, type_, path, path_i18n, extra))
+
+        return ret
+
+    def _launch_menu(self, menu_type, path, data=None, security_limit=C.NO_SECURITY_LIMIT,
+                    profile_key=C.PROF_KEY_NONE):
+        client = self.get_client(profile_key)
+        return self.launch_menu(client, menu_type, path, data, security_limit)
+
+    def launch_menu(self, client, menu_type, path, data=None,
+        security_limit=C.NO_SECURITY_LIMIT):
+        """launch action a menu action
+
+        @param menu_type(unicode): type of menu to launch
+        @param path(iterable[unicode]): canonical path of the menu
+        @params data(dict): menu data
+        @raise NotFound: this path is not known
+        """
+        # FIXME: manage security_limit here
+        #        defaut security limit should be high instead of C.NO_SECURITY_LIMIT
+        canonical_path = self._get_menu_canonical_path(path)
+        menu_key = (menu_type, canonical_path)
+        try:
+            callback_id = self._menus_paths[menu_key]
+        except KeyError:
+            raise exceptions.NotFound(
+                "Can't find menu {path} ({menu_type})".format(
+                    path=canonical_path, menu_type=menu_type
+                )
+            )
+        return self.launch_callback(callback_id, data, client.profile)
+
+    def get_menu_help(self, menu_id, language=""):
+        """return the help string of the menu
+
+        @param menu_id: id of the menu (same as callback_id)
+        @param language: language used for translation, or empty string for default
+        @param return: translated help
+
+        """
+        try:
+            menu_data = self._menus[menu_id]
+        except KeyError:
+            raise exceptions.DataError("Trying to access an unknown menu")
+        language_switch(language)
+        help_string = _(menu_data["help_string"])
+        language_switch()
+        return help_string
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/core/xmpp.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1953 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import calendar
+import copy
+from functools import partial
+import mimetypes
+from pathlib import Path
+import sys
+import time
+from typing import Callable, Dict, Tuple, Optional
+from urllib.parse import unquote, urlparse
+import uuid
+
+import shortuuid
+from twisted.internet import defer, error as internet_error
+from twisted.internet import ssl
+from twisted.python import failure
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.protocols.jabber import error
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from wokkel import client as wokkel_client, disco, generic, iwokkel, xmppim
+from wokkel import component
+from wokkel import delay
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core import core_types
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.memory import cache
+from libervia.backend.memory import encryption
+from libervia.backend.memory import persistent
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools import utils
+from libervia.backend.tools.common import data_format
+
+log = getLogger(__name__)
+
+
+NS_X_DATA = "jabber:x:data"
+NS_DISCO_INFO = "http://jabber.org/protocol/disco#info"
+NS_XML_ELEMENT = "urn:xmpp:xml-element"
+NS_ROSTER_VER = "urn:xmpp:features:rosterver"
+# we use 2 "@" which is illegal in a jid, to be sure we are not mixing keys
+# with roster jids
+ROSTER_VER_KEY = "@version@"
+
+
+class ClientPluginWrapper:
+    """Use a plugin with default value if plugin is missing"""
+
+    def __init__(self, client, plugin_name, missing):
+        self.client = client
+        self.plugin = client.host_app.plugins.get(plugin_name)
+        if self.plugin is None:
+            self.plugin_name = plugin_name
+        self.missing = missing
+
+    def __getattr__(self, attr):
+        if self.plugin is None:
+            missing = self.missing
+            if isinstance(missing, type) and issubclass(missing, Exception):
+                raise missing(f"plugin {self.plugin_name!r} is not available")
+            elif isinstance(missing, Exception):
+                raise missing
+            else:
+                return lambda *args, **kwargs: missing
+        return partial(getattr(self.plugin, attr), self.client)
+
+
+class SatXMPPEntity(core_types.SatXMPPEntity):
+    """Common code for Client and Component"""
+    # profile is added there when start_connection begins and removed when it is finished
+    profiles_connecting = set()
+
+    def __init__(self, host_app, profile, max_retries):
+        factory = self.factory
+
+        # we monkey patch clientConnectionLost to handle network_enabled/network_disabled
+        # and to allow plugins to tune reconnection mechanism
+        clientConnectionFailed_ori = factory.clientConnectionFailed
+        clientConnectionLost_ori = factory.clientConnectionLost
+        factory.clientConnectionFailed = partial(
+            self.connection_terminated, term_type="failed", cb=clientConnectionFailed_ori)
+        factory.clientConnectionLost = partial(
+            self.connection_terminated, term_type="lost", cb=clientConnectionLost_ori)
+
+        factory.maxRetries = max_retries
+        factory.maxDelay = 30
+        # when self._connected_d is None, we are not connected
+        # else, it's a deferred which fire on disconnection
+        self._connected_d = None
+        self.profile = profile
+        self.host_app = host_app
+        self.cache = cache.Cache(host_app, profile)
+        self.mess_id2uid = {}  # map from message id to uid used in history.
+                               # Key: (full_jid, message_id) Value: uid
+        # this Deferred fire when entity is connected
+        self.conn_deferred = defer.Deferred()
+        self._progress_cb = {}  # callback called when a progress is requested
+                                # (key = progress id)
+        self.actions = {}  # used to keep track of actions for retrieval (key = action_id)
+        self.encryption = encryption.EncryptionHandler(self)
+
+    def __str__(self):
+        return f"Client for profile {self.profile}"
+
+    def __repr__(self):
+        return f"{super().__repr__()} - profile: {self.profile!r}"
+
+    ## initialisation ##
+
+    async def _call_connection_triggers(self, connection_timer):
+        """Call conneting trigger prepare connected trigger
+
+        @param plugins(iterable): plugins to use
+        @return (list[object, callable]): plugin to trigger tuples with:
+            - plugin instance
+            - profile_connected* triggers (to call after connection)
+        """
+        plugin_conn_cb = []
+        for plugin in self._get_plugins_list():
+            # we check if plugin handle client mode
+            if plugin.is_handler:
+                plugin.get_handler(self).setHandlerParent(self)
+
+            # profile_connecting/profile_connected methods handling
+
+            timer = connection_timer[plugin] = {
+                "total": 0
+            }
+            # profile connecting is called right now (before actually starting client)
+            connecting_cb = getattr(plugin, "profile_connecting", None)
+            if connecting_cb is not None:
+                connecting_start = time.time()
+                await utils.as_deferred(connecting_cb, self)
+                timer["connecting"] = time.time() - connecting_start
+                timer["total"] += timer["connecting"]
+
+            # profile connected is called after client is ready and roster is got
+            connected_cb = getattr(plugin, "profile_connected", None)
+            if connected_cb is not None:
+                plugin_conn_cb.append((plugin, connected_cb))
+
+        return plugin_conn_cb
+
+    def _get_plugins_list(self):
+        """Return list of plugin to use
+
+        need to be implemented by subclasses
+        this list is used to call profileConnect* triggers
+        @return(iterable[object]): plugins to use
+        """
+        raise NotImplementedError
+
+    def _create_sub_protocols(self):
+        return
+
+    def entity_connected(self):
+        """Called once connection is done
+
+        may return a Deferred, to perform initialisation tasks
+        """
+        return
+
+    @staticmethod
+    async def _run_profile_connected(
+        callback: Callable,
+        entity: "SatXMPPEntity",
+        timer: Dict[str, float]
+    ) -> None:
+        connected_start = time.time()
+        await utils.as_deferred(callback, entity)
+        timer["connected"] = time.time() - connected_start
+        timer["total"] += timer["connected"]
+
+    @classmethod
+    async def start_connection(cls, host, profile, max_retries):
+        """instantiate the entity and start the connection"""
+        # FIXME: reconnection doesn't seems to be handled correclty
+        #        (client is deleted then recreated from scratch)
+        #        most of methods called here should be called once on first connection
+        #        (e.g. adding subprotocols)
+        #        but client should not be deleted except if session is finished
+        #        (independently of connection/deconnection)
+        if profile in cls.profiles_connecting:
+            raise exceptions.CancelError(f"{profile} is already being connected")
+        cls.profiles_connecting.add(profile)
+        try:
+            try:
+                port = int(
+                    host.memory.param_get_a(
+                        C.FORCE_PORT_PARAM, "Connection", profile_key=profile
+                    )
+                )
+            except ValueError:
+                log.debug(_("Can't parse port value, using default value"))
+                port = (
+                    None
+                )  # will use default value 5222 or be retrieved from a DNS SRV record
+
+            password = await host.memory.param_get_a_async(
+                "Password", "Connection", profile_key=profile
+            )
+
+            entity_jid_s = await host.memory.param_get_a_async(
+                "JabberID", "Connection", profile_key=profile)
+            entity_jid = jid.JID(entity_jid_s)
+
+            if not entity_jid.resource and not cls.is_component and entity_jid.user:
+                # if no resource is specified, we create our own instead of using
+                # server returned one, as it will then stay stable in case of
+                # reconnection. we only do that for client and if there is a user part, to
+                # let server decide for anonymous login
+                resource_dict = await host.memory.storage.get_privates(
+                    "core:xmpp", ["resource"] , profile=profile)
+                try:
+                    resource = resource_dict["resource"]
+                except KeyError:
+                    resource = f"{C.APP_NAME_FILE}.{shortuuid.uuid()}"
+                    await host.memory.storage.set_private_value(
+                        "core:xmpp", "resource", resource, profile=profile)
+
+                log.info(_("We'll use the stable resource {resource}").format(
+                    resource=resource))
+                entity_jid.resource = resource
+
+            if profile in host.profiles:
+                if host.profiles[profile].is_connected():
+                    raise exceptions.InternalError(
+                        f"There is already a connected profile of name {profile!r} in "
+                        f"host")
+                log.debug(
+                    "removing unconnected profile {profile!r}")
+                del host.profiles[profile]
+            entity = host.profiles[profile] = cls(
+                host, profile, entity_jid, password,
+                host.memory.param_get_a(C.FORCE_SERVER_PARAM, "Connection",
+                                      profile_key=profile) or None,
+                port, max_retries,
+                )
+
+            await entity.encryption.load_sessions()
+
+            entity._create_sub_protocols()
+
+            entity.fallBack = SatFallbackHandler(host)
+            entity.fallBack.setHandlerParent(entity)
+
+            entity.versionHandler = SatVersionHandler(C.APP_NAME, host.full_version)
+            entity.versionHandler.setHandlerParent(entity)
+
+            entity.identityHandler = SatIdentityHandler()
+            entity.identityHandler.setHandlerParent(entity)
+
+            log.debug(_("setting plugins parents"))
+
+            connection_timer: Dict[str, Dict[str, float]] = {}
+            plugin_conn_cb = await entity._call_connection_triggers(connection_timer)
+
+            entity.startService()
+
+            await entity.conn_deferred
+
+            await defer.maybeDeferred(entity.entity_connected)
+
+            # Call profile_connected callback for all plugins,
+            # and print error message if any of them fails
+            conn_cb_list = []
+            for plugin, callback in plugin_conn_cb:
+                conn_cb_list.append(
+                    defer.ensureDeferred(
+                        cls._run_profile_connected(
+                            callback, entity, connection_timer[plugin]
+                        )
+                    )
+                )
+            list_d = defer.DeferredList(conn_cb_list)
+
+            def log_plugin_results(results):
+                if not results:
+                    log.info("no plugin loaded")
+                    return
+                all_succeed = all([success for success, result in results])
+                if not all_succeed:
+                    log.error(_("Plugins initialisation error"))
+                    for idx, (success, result) in enumerate(results):
+                        if not success:
+                            plugin_name = plugin_conn_cb[idx][0]._info["import_name"]
+                            log.error(f"error (plugin {plugin_name}): {result}")
+
+                log.debug(f"Plugin loading time for {profile!r} (longer to shorter):\n")
+                plugins_by_timer = sorted(
+                    connection_timer,
+                    key=lambda p: connection_timer[p]["total"],
+                    reverse=True
+                )
+                # total is the addition of all connecting and connected, doesn't really
+                # reflect the real loading time as connected are launched in a
+                # DeferredList
+                total_plugins = 0
+                # total real sum all connecting (which happen sequentially) and the
+                # longuest connected (connected happen in parallel, thus the longuest is
+                # roughly the total time for connected)
+                total_real = 0
+                total_real = max(t.get("connected", 0) for t in connection_timer.values())
+
+                for plugin in plugins_by_timer:
+                    name = plugin._info["import_name"]
+                    timer = connection_timer[plugin]
+                    total_plugins += timer["total"]
+                    try:
+                        connecting = f"{timer['connecting']:.2f}s"
+                    except KeyError:
+                        connecting = "n/a"
+                    else:
+                        total_real += timer["connecting"]
+                    try:
+                        connected = f"{timer['connected']:.2f}s"
+                    except KeyError:
+                        connected = "n/a"
+                    log.debug(
+                        f"  - {name}: total={timer['total']:.2f}s "
+                        f"connecting={connecting} connected={connected}"
+                    )
+                log.debug(
+                    f"  Plugins total={total_plugins:.2f}s real={total_real:.2f}s\n"
+                )
+
+            await list_d.addCallback(
+                log_plugin_results
+            )  # FIXME: we should have a timeout here, and a way to know if a plugin freeze
+            # TODO: mesure launch time of each plugin
+        finally:
+            cls.profiles_connecting.remove(profile)
+
+    def _disconnection_cb(self, __):
+        self._connected_d = None
+
+    def _disconnection_eb(self, failure_):
+        log.error(_("Error while disconnecting: {}".format(failure_)))
+
+    def _authd(self, xmlstream):
+        super(SatXMPPEntity, self)._authd(xmlstream)
+        log.debug(_("{profile} identified").format(profile=self.profile))
+        self.stream_initialized()
+
+    def _finish_connection(self, __):
+        if self.conn_deferred.called:
+            # can happen in case of forced disconnection by server
+            log.debug(f"{self} has already been connected")
+        else:
+            self.conn_deferred.callback(None)
+
+    def stream_initialized(self):
+        """Called after _authd"""
+        log.debug(_("XML stream is initialized"))
+        if not self.host_app.trigger.point("xml_init", self):
+            return
+        self.post_stream_init()
+
+    def post_stream_init(self):
+        """Workflow after stream initalisation."""
+        log.info(
+            _("********** [{profile}] CONNECTED **********").format(profile=self.profile)
+        )
+
+        # the following Deferred is used to know when we are connected
+        # so we need to be set it to None when connection is lost
+        self._connected_d = defer.Deferred()
+        self._connected_d.addCallback(self._clean_connection)
+        self._connected_d.addCallback(self._disconnection_cb)
+        self._connected_d.addErrback(self._disconnection_eb)
+
+        # we send the signal to the clients
+        self.host_app.bridge.connected(self.jid.full(), self.profile)
+
+        self.disco = SatDiscoProtocol(self)
+        self.disco.setHandlerParent(self)
+        self.discoHandler = disco.DiscoHandler()
+        self.discoHandler.setHandlerParent(self)
+        disco_d = defer.succeed(None)
+
+        if not self.host_app.trigger.point("Disco handled", disco_d, self.profile):
+            return
+
+        disco_d.addCallback(self._finish_connection)
+
+    def initializationFailed(self, reason):
+        log.error(
+            _(
+                "ERROR: XMPP connection failed for profile '%(profile)s': %(reason)s"
+                % {"profile": self.profile, "reason": reason}
+            )
+        )
+        self.conn_deferred.errback(reason.value)
+        try:
+            super(SatXMPPEntity, self).initializationFailed(reason)
+        except:
+            # we already chained an errback, no need to raise an exception
+            pass
+
+    ## connection ##
+
+    def connection_terminated(self, connector, reason, term_type, cb):
+        """Display disconnection reason, and call factory method
+
+        This method is monkey patched to factory, allowing plugins to handle finely
+        reconnection with the triggers.
+        @param connector(twisted.internet.base.BaseConnector): current connector
+        @param reason(failure.Failure): why connection has been terminated
+        @param term_type(unicode): on of 'failed' or 'lost'
+        @param cb(callable): original factory method
+
+        @trigger connection_failed(connector, reason): connection can't be established
+        @trigger connection_lost(connector, reason): connection was available but it not
+            anymore
+        """
+        # we save connector because it may be deleted when connection will be dropped
+        # if reconnection is disabled
+        self._saved_connector = connector
+        if reason is not None and not isinstance(reason.value,
+                                                 internet_error.ConnectionDone):
+            try:
+                reason_str = str(reason.value)
+            except Exception:
+                # FIXME: workaround for Android were p4a strips docstrings
+                #        while Twisted use docstring in __str__
+                # TODO: create a ticket upstream, Twisted should work when optimization
+                #       is used
+                reason_str = str(reason.value.__class__)
+            log.warning(f"[{self.profile}] Connection {term_type}: {reason_str}")
+        if not self.host_app.trigger.point("connection_" + term_type, connector, reason):
+            return
+        return cb(connector, reason)
+
+    def network_disabled(self):
+        """Indicate that network has been completely disabled
+
+        In other words, internet is not available anymore and transport must be stopped.
+        Retrying is disabled too, as it makes no sense to try without network, and it may
+        use resources (notably battery on mobiles).
+        """
+        log.info(_("stopping connection because of network disabled"))
+        self.factory.continueTrying = 0
+        self._network_disabled = True
+        if self.xmlstream is not None:
+            self.xmlstream.transport.abortConnection()
+
+    def network_enabled(self):
+        """Indicate that network has been (re)enabled
+
+        This happens when e.g. user activate WIFI connection.
+        """
+        try:
+            connector = self._saved_connector
+            network_disabled = self._network_disabled
+        except AttributeError:
+            # connection has not been stopped by network_disabled
+            # we don't have to restart it
+            log.debug(f"no connection to restart [{self.profile}]")
+            return
+        else:
+            del self._network_disabled
+            if not network_disabled:
+                raise exceptions.InternalError("network_disabled should be True")
+        log.info(_("network is available, trying to connect"))
+        # we want to be sure to start fresh
+        self.factory.resetDelay()
+        # we have a saved connector, meaning the connection has been stopped previously
+        # we can now try to reconnect
+        connector.connect()
+
+    def _connected(self, xs):
+        send_hooks = []
+        receive_hooks = []
+        self.host_app.trigger.point(
+            "stream_hooks", self, receive_hooks, send_hooks)
+        for hook in receive_hooks:
+            xs.add_hook(C.STREAM_HOOK_RECEIVE, hook)
+        for hook in send_hooks:
+            xs.add_hook(C.STREAM_HOOK_SEND, hook)
+        super(SatXMPPEntity, self)._connected(xs)
+
+    def disconnect_profile(self, reason):
+        if self._connected_d is not None:
+            self.host_app.bridge.disconnected(
+                self.profile
+            )  # we send the signal to the clients
+            log.info(
+                _("********** [{profile}] DISCONNECTED **********").format(
+                    profile=self.profile
+                )
+            )
+            # we purge only if no new connection attempt is expected
+            if not self.factory.continueTrying:
+                log.debug("continueTrying not set, purging entity")
+                self._connected_d.callback(None)
+                # and we remove references to this client
+                self.host_app.purge_entity(self.profile)
+
+        if not self.conn_deferred.called:
+            if reason is None:
+                err = error.StreamError("Server unexpectedly closed the connection")
+            else:
+                err = reason
+                try:
+                    if err.value.args[0][0][2] == "certificate verify failed":
+                        err = exceptions.InvalidCertificate(
+                            _("Your server certificate is not valid "
+                              "(its identity can't be checked).\n\n"
+                              "This should never happen and may indicate that "
+                              "somebody is trying to spy on you.\n"
+                              "Please contact your server administrator."))
+                        self.factory.stopTrying()
+                        try:
+                            # with invalid certificate, we should not retry to connect
+                            # so we delete saved connector to avoid reconnection if
+                            # network_enabled is called.
+                            del self._saved_connector
+                        except AttributeError:
+                            pass
+                except (IndexError, TypeError):
+                    pass
+            self.conn_deferred.errback(err)
+
+    def _disconnected(self, reason):
+        super(SatXMPPEntity, self)._disconnected(reason)
+        if not self.host_app.trigger.point("disconnected", self, reason):
+            return
+        self.disconnect_profile(reason)
+
+    @defer.inlineCallbacks
+    def _clean_connection(self, __):
+        """method called on disconnection
+
+        used to call profile_disconnected* triggers
+        """
+        trigger_name = "profile_disconnected"
+        for plugin in self._get_plugins_list():
+            disconnected_cb = getattr(plugin, trigger_name, None)
+            if disconnected_cb is not None:
+                yield disconnected_cb(self)
+
+    def is_connected(self):
+        """Return True is client is fully connected
+
+        client is considered fully connected if transport is started and all plugins
+        are initialised
+        """
+        try:
+            transport_connected = bool(self.xmlstream.transport.connected)
+        except AttributeError:
+            return False
+
+        return self._connected_d is not None and transport_connected
+
+    def entity_disconnect(self):
+        if not self.host_app.trigger.point("disconnecting", self):
+            return
+        log.info(_("Disconnecting..."))
+        self.stopService()
+        if self._connected_d is not None:
+            return self._connected_d
+        else:
+            return defer.succeed(None)
+
+    ## sending ##
+
+    def IQ(self, type_="set", timeout=60):
+        """shortcut to create an IQ element managing deferred
+
+        @param type_(unicode): IQ type ('set' or 'get')
+        @param timeout(None, int): timeout in seconds
+        @return((D)domish.Element: result stanza
+            errback is called if an error stanza is returned
+        """
+        iq_elt = xmlstream.IQ(self.xmlstream, type_)
+        iq_elt.timeout = timeout
+        return iq_elt
+
+    def sendError(self, iq_elt, condition, text=None, appCondition=None):
+        """Send error stanza build from iq_elt
+
+        @param iq_elt(domish.Element): initial IQ element
+        @param condition(unicode): error condition
+        """
+        iq_error_elt = error.StanzaError(
+            condition, text=text, appCondition=appCondition
+        ).toResponse(iq_elt)
+        self.xmlstream.send(iq_error_elt)
+
+    def generate_message_xml(
+        self,
+        data: core_types.MessageData,
+        post_xml_treatments: Optional[defer.Deferred] = None
+    ) -> core_types.MessageData:
+        """Generate <message/> stanza from message data
+
+        @param data: message data
+            domish element will be put in data['xml']
+            following keys are needed:
+                - from
+                - to
+                - uid: can be set to '' if uid attribute is not wanted
+                - message
+                - type
+                - subject
+                - extra
+        @param post_xml_treatments: a Deferred which will be called with data once XML is
+            generated
+        @return: message data
+        """
+        data["xml"] = message_elt = domish.Element((None, "message"))
+        message_elt["to"] = data["to"].full()
+        message_elt["from"] = data["from"].full()
+        message_elt["type"] = data["type"]
+        if data["uid"]:  # key must be present but can be set to ''
+            # by a plugin to avoid id on purpose
+            message_elt["id"] = data["uid"]
+        for lang, subject in data["subject"].items():
+            subject_elt = message_elt.addElement("subject", content=subject)
+            if lang:
+                subject_elt[(C.NS_XML, "lang")] = lang
+        for lang, message in data["message"].items():
+            body_elt = message_elt.addElement("body", content=message)
+            if lang:
+                body_elt[(C.NS_XML, "lang")] = lang
+        try:
+            thread = data["extra"]["thread"]
+        except KeyError:
+            if "thread_parent" in data["extra"]:
+                raise exceptions.InternalError(
+                    "thread_parent found while there is not associated thread"
+                )
+        else:
+            thread_elt = message_elt.addElement("thread", content=thread)
+            try:
+                thread_elt["parent"] = data["extra"]["thread_parent"]
+            except KeyError:
+                pass
+
+        if post_xml_treatments is not None:
+            post_xml_treatments.callback(data)
+        return data
+
+    @property
+    def is_admin(self) -> bool:
+        """True if a client is an administrator with extra privileges"""
+        return self.host_app.memory.is_admin(self.profile)
+
+    def add_post_xml_callbacks(self, post_xml_treatments):
+        """Used to add class level callbacks at the end of the workflow
+
+        @param post_xml_treatments(D): the same Deferred as in sendMessage trigger
+        """
+        raise NotImplementedError
+
+    async def a_send(self, obj):
+        # original send method accept string
+        # but we restrict to domish.Element to make trigger treatments easier
+        assert isinstance(obj, domish.Element)
+        # XXX: this trigger is the last one before sending stanza on wire
+        #      it is intended for things like end 2 end encryption.
+        #      *DO NOT* cancel (i.e. return False) without very good reason
+        #      (out of band transmission for instance).
+        #      e2e should have a priority of 0 here, and out of band transmission
+        #      a lower priority
+        if not (await self.host_app.trigger.async_point("send", self, obj)):
+            return
+        super().send(obj)
+
+    def send(self, obj):
+        defer.ensureDeferred(self.a_send(obj))
+
+    async def send_message_data(self, mess_data):
+        """Convenient method to send message data to stream
+
+        This method will send mess_data[u'xml'] to stream, but a trigger is there
+        The trigger can't be cancelled, it's a good place for e2e encryption which
+        don't handle full stanza encryption
+        This trigger can return a Deferred (it's an async_point)
+        @param mess_data(dict): message data as constructed by onMessage workflow
+        @return (dict): mess_data (so it can be used in a deferred chain)
+        """
+        # XXX: This is the last trigger before u"send" (last but one globally)
+        #      for sending message.
+        #      This is intented for e2e encryption which doesn't do full stanza
+        #      encryption (e.g. OTR)
+        #      This trigger point can't cancel the method
+        await self.host_app.trigger.async_point("send_message_data", self, mess_data,
+            triggers_no_cancel=True)
+        await self.a_send(mess_data["xml"])
+        return mess_data
+
+    def sendMessage(
+            self, to_jid, message, subject=None, mess_type="auto", extra=None, uid=None,
+            no_trigger=False):
+        r"""Send a message to an entity
+
+        @param to_jid(jid.JID): destinee of the message
+        @param message(dict): message body, key is the language (use '' when unknown)
+        @param subject(dict): message subject, key is the language (use '' when unknown)
+        @param mess_type(str): one of standard message type (cf RFC 6121 §5.2.2) or:
+            - auto: for automatic type detection
+            - info: for information ("info_type" can be specified in extra)
+        @param extra(dict, None): extra data. Key can be:
+            - info_type: information type, can be
+                TODO
+        @param uid(unicode, None): unique id:
+            should be unique at least in this XMPP session
+            if None, an uuid will be generated
+        @param no_trigger (bool): if True, sendMessage[suffix] trigger will no be used
+            useful when a message need to be sent without any modification
+            /!\ this will also skip encryption methods!
+        """
+        if subject is None:
+            subject = {}
+        if extra is None:
+            extra = {}
+
+        assert mess_type in C.MESS_TYPE_ALL
+
+        data = {  # dict is similar to the one used in client.onMessage
+            "from": self.jid,
+            "to": to_jid,
+            "uid": uid or str(uuid.uuid4()),
+            "message": message,
+            "subject": subject,
+            "type": mess_type,
+            "extra": extra,
+            "timestamp": time.time(),
+        }
+        # XXX: plugin can add their pre XML treatments to this deferred
+        pre_xml_treatments = defer.Deferred()
+        # XXX: plugin can add their post XML treatments to this deferred
+        post_xml_treatments = defer.Deferred()
+
+        if data["type"] == C.MESS_TYPE_AUTO:
+            # we try to guess the type
+            if data["subject"]:
+                data["type"] = C.MESS_TYPE_NORMAL
+            elif not data["to"].resource:
+                # we may have a groupchat message, we check if the we know this jid
+                try:
+                    entity_type = self.host_app.memory.get_entity_datum(
+                        self, data["to"], C.ENTITY_TYPE
+                    )
+                    # FIXME: should entity_type manage resources ?
+                except (exceptions.UnknownEntityError, KeyError):
+                    entity_type = "contact"
+
+                if entity_type == C.ENTITY_TYPE_MUC:
+                    data["type"] = C.MESS_TYPE_GROUPCHAT
+                else:
+                    data["type"] = C.MESS_TYPE_CHAT
+            else:
+                data["type"] = C.MESS_TYPE_CHAT
+
+        # FIXME: send_only is used by libervia's OTR plugin to avoid
+        #        the triggers from frontend, and no_trigger do the same
+        #        thing internally, this could be unified
+        send_only = data["extra"].get("send_only", False)
+
+        if not no_trigger and not send_only:
+            # is the session encrypted? If so we indicate it in data
+            self.encryption.set_encryption_flag(data)
+
+            if not self.host_app.trigger.point(
+                "sendMessage" + self.trigger_suffix,
+                self,
+                data,
+                pre_xml_treatments,
+                post_xml_treatments,
+            ):
+                return defer.succeed(None)
+
+        log.debug(_("Sending message (type {type}, to {to})")
+                    .format(type=data["type"], to=to_jid.full()))
+
+        pre_xml_treatments.addCallback(lambda __: self.generate_message_xml(data, post_xml_treatments))
+        pre_xml_treatments.addCallback(lambda __: post_xml_treatments)
+        pre_xml_treatments.addErrback(self._cancel_error_trap)
+        post_xml_treatments.addCallback(
+            lambda __: defer.ensureDeferred(self.send_message_data(data))
+        )
+        if send_only:
+            log.debug(_("Triggers, storage and echo have been inhibited by the "
+                        "'send_only' parameter"))
+        else:
+            self.add_post_xml_callbacks(post_xml_treatments)
+            post_xml_treatments.addErrback(self._cancel_error_trap)
+            post_xml_treatments.addErrback(self.host_app.log_errback)
+        pre_xml_treatments.callback(data)
+        return pre_xml_treatments
+
+    def _cancel_error_trap(self, failure):
+        """A message sending can be cancelled by a plugin treatment"""
+        failure.trap(exceptions.CancelError)
+
+    def is_message_printable(self, mess_data):
+        """Return True if a message contain payload to show in frontends"""
+        return (
+            mess_data["message"] or mess_data["subject"]
+            or mess_data["extra"].get(C.KEY_ATTACHMENTS)
+            or mess_data["type"] == C.MESS_TYPE_INFO
+        )
+
+    async def message_add_to_history(self, data):
+        """Store message into database (for local history)
+
+        @param data: message data dictionnary
+        @param client: profile's client
+        """
+        if data["type"] != C.MESS_TYPE_GROUPCHAT:
+            # we don't add groupchat message to history, as we get them back
+            # and they will be added then
+
+            # we need a message to store
+            if self.is_message_printable(data):
+                await self.host_app.memory.add_to_history(self, data)
+            else:
+                log.warning(
+                    "No message found"
+                )  # empty body should be managed by plugins before this point
+        return data
+
+    def message_get_bridge_args(self, data):
+        """Generate args to use with bridge from data dict"""
+        return (data["uid"], data["timestamp"], data["from"].full(),
+                data["to"].full(), data["message"], data["subject"],
+                data["type"], data_format.serialise(data["extra"]))
+
+
+    def message_send_to_bridge(self, data):
+        """Send message to bridge, so frontends can display it
+
+        @param data: message data dictionnary
+        @param client: profile's client
+        """
+        if data["type"] != C.MESS_TYPE_GROUPCHAT:
+            # we don't send groupchat message to bridge, as we get them back
+            # and they will be added the
+
+            # we need a message to send something
+            if self.is_message_printable(data):
+
+                # We send back the message, so all frontends are aware of it
+                self.host_app.bridge.message_new(
+                    *self.message_get_bridge_args(data),
+                    profile=self.profile
+                )
+            else:
+                log.warning(_("No message found"))
+        return data
+
+    ## helper methods ##
+
+    def p(self, plugin_name, missing=exceptions.MissingModule):
+        """Get a plugin if available
+
+        @param plugin_name(str): name of the plugin
+        @param missing(object): value to return if plugin is missing
+            if it is a subclass of Exception, it will be raised with a helping str as
+            argument.
+        @return (object): requested plugin wrapper, or default value
+            The plugin wrapper will return the method with client set as first
+            positional argument
+        """
+        return ClientPluginWrapper(self, plugin_name, missing)
+
+
+ExtraDict = dict  # TODO
+
+
+@implementer(iwokkel.IDisco)
+class SatXMPPClient(SatXMPPEntity, wokkel_client.XMPPClient):
+    trigger_suffix = ""
+    is_component = False
+
+    def __init__(self, host_app, profile, user_jid, password, host=None,
+                 port=C.XMPP_C2S_PORT, max_retries=C.XMPP_MAX_RETRIES):
+        # XXX: DNS SRV records are checked when the host is not specified.
+        # If no SRV record is found, the host is directly extracted from the JID.
+        self.started = time.time()
+
+        # Currently, we use "client/pc/Salut à Toi", but as
+        # SàT is multi-frontends and can be used on mobile devices, as a bot,
+        # with a web frontend,
+        # etc., we should implement a way to dynamically update identities through the
+        # bridge
+        self.identities = [disco.DiscoIdentity("client", "pc", C.APP_NAME)]
+        if sys.platform == "android":
+            # for now we consider Android devices to be always phones
+            self.identities = [disco.DiscoIdentity("client", "phone", C.APP_NAME)]
+
+        hosts_map = host_app.memory.config_get(None, "hosts_dict", {})
+        if host is None and user_jid.host in hosts_map:
+            host_data = hosts_map[user_jid.host]
+            if isinstance(host_data, str):
+                host = host_data
+            elif isinstance(host_data, dict):
+                if "host" in host_data:
+                    host = host_data["host"]
+                if "port" in host_data:
+                    port = host_data["port"]
+            else:
+                log.warning(
+                    _("invalid data used for host: {data}").format(data=host_data)
+                )
+                host_data = None
+            if host_data is not None:
+                log.info(
+                    "using {host}:{port} for host {host_ori} as requested in config"
+                    .format(host_ori=user_jid.host, host=host, port=port)
+                )
+
+        self.check_certificate = host_app.memory.param_get_a(
+            "check_certificate", "Connection", profile_key=profile)
+
+        if self.check_certificate:
+            tls_required, configurationForTLS = True, None
+        else:
+            tls_required = False
+            configurationForTLS = ssl.CertificateOptions(trustRoot=None)
+
+        wokkel_client.XMPPClient.__init__(
+            self, user_jid, password, host or None, port or C.XMPP_C2S_PORT,
+            tls_required=tls_required, configurationForTLS=configurationForTLS
+        )
+        SatXMPPEntity.__init__(self, host_app, profile, max_retries)
+
+        if not self.check_certificate:
+            msg = (_("Certificate validation is deactivated, this is unsecure and "
+                "somebody may be spying on you. If you have no good reason to disable "
+                "certificate validation, please activate \"Check certificate\" in your "
+                "settings in \"Connection\" tab."))
+            xml_tools.quick_note(host_app, self, msg, _("Security notice"),
+                level = C.XMLUI_DATA_LVL_WARNING)
+
+    @property
+    def server_jid(self):
+        return jid.JID(self.jid.host)
+
+    def _get_plugins_list(self):
+        for p in self.host_app.plugins.values():
+            if C.PLUG_MODE_CLIENT in p._info["modes"]:
+                yield p
+
+    def _create_sub_protocols(self):
+        self.messageProt = SatMessageProtocol(self.host_app)
+        self.messageProt.setHandlerParent(self)
+
+        self.roster = SatRosterProtocol(self.host_app)
+        self.roster.setHandlerParent(self)
+
+        self.presence = SatPresenceProtocol(self.host_app)
+        self.presence.setHandlerParent(self)
+
+    @classmethod
+    async def start_connection(cls, host, profile, max_retries):
+        try:
+            await super(SatXMPPClient, cls).start_connection(host, profile, max_retries)
+        except exceptions.CancelError as e:
+            log.warning(f"start_connection cancelled: {e}")
+            return
+        entity = host.profiles[profile]
+        # we finally send our presence
+        entity.presence.available()
+
+    def entity_connected(self):
+        # we want to be sure that we got the roster
+        return self.roster.got_roster
+
+    def add_post_xml_callbacks(self, post_xml_treatments):
+        post_xml_treatments.addCallback(self.messageProt.complete_attachments)
+        post_xml_treatments.addCallback(
+            lambda ret: defer.ensureDeferred(self.message_add_to_history(ret))
+        )
+        post_xml_treatments.addCallback(self.message_send_to_bridge)
+
+    def feedback(
+        self,
+        to_jid: jid.JID,
+        message: str,
+        extra: Optional[ExtraDict] = None
+    ) -> None:
+        """Send message to frontends
+
+        This message will be an info message, not recorded in history.
+        It can be used to give feedback of a command
+        @param to_jid: destinee jid
+        @param message: message to send to frontends
+        @param extra: extra data to use in particular, info subtype can be specified with
+            MESS_EXTRA_INFO
+        """
+        if extra is None:
+            extra = {}
+        self.host_app.bridge.message_new(
+            uid=str(uuid.uuid4()),
+            timestamp=time.time(),
+            from_jid=self.jid.full(),
+            to_jid=to_jid.full(),
+            message={"": message},
+            subject={},
+            mess_type=C.MESS_TYPE_INFO,
+            extra=data_format.serialise(extra),
+            profile=self.profile,
+        )
+
+    def _finish_connection(self, __):
+        d = self.roster.request_roster()
+        d.addCallback(lambda __: super(SatXMPPClient, self)._finish_connection(__))
+
+
+@implementer(iwokkel.IDisco)
+class SatXMPPComponent(SatXMPPEntity, component.Component):
+    """XMPP component
+
+    This component are similar but not identical to clients.
+    An entry point plugin is launched after component is connected.
+    Component need to instantiate MessageProtocol itself
+    """
+
+    trigger_suffix = (
+        "Component"
+    )  # used for to distinguish some trigger points set in SatXMPPEntity
+    is_component = True
+    # XXX: set to True from entry plugin to keep messages in history for sent messages
+    sendHistory = False
+    # XXX: same as sendHistory but for received messaged
+    receiveHistory = False
+
+    def __init__(self, host_app, profile, component_jid, password, host=None, port=None,
+                 max_retries=C.XMPP_MAX_RETRIES):
+        self.started = time.time()
+        if port is None:
+            port = C.XMPP_COMPONENT_PORT
+
+        ## entry point ##
+        entry_point = host_app.memory.get_entry_point(profile)
+        try:
+            self.entry_plugin = host_app.plugins[entry_point]
+        except KeyError:
+            raise exceptions.NotFound(
+                _("The requested entry point ({entry_point}) is not available").format(
+                    entry_point=entry_point
+                )
+            )
+
+        self.enabled_features = set()
+        self.identities = [disco.DiscoIdentity("component", "generic", C.APP_NAME)]
+        # jid is set automatically on bind by Twisted for Client, but not for Component
+        self.jid = component_jid
+        if host is None:
+            try:
+                host = component_jid.host.split(".", 1)[1]
+            except IndexError:
+                raise ValueError("Can't guess host from jid, please specify a host")
+        # XXX: component.Component expect unicode jid, while Client expect jid.JID.
+        #      this is not consistent, so we use jid.JID for SatXMPP*
+        component.Component.__init__(self, host, port, component_jid.full(), password)
+        SatXMPPEntity.__init__(self, host_app, profile, max_retries)
+
+    @property
+    def server_jid(self):
+        # FIXME: not the best way to get server jid, maybe use config option?
+        return jid.JID(self.jid.host.split(".", 1)[-1])
+
+    @property
+    def is_admin(self) -> bool:
+        return False
+
+    def _create_sub_protocols(self):
+        self.messageProt = SatMessageProtocol(self.host_app)
+        self.messageProt.setHandlerParent(self)
+
+    def _build_dependencies(self, current, plugins, required=True):
+        """build recursively dependencies needed for a plugin
+
+        this method build list of plugin needed for a component and raises
+        errors if they are not available or not allowed for components
+        @param current(object): parent plugin to check
+            use entry_point for first call
+        @param plugins(list): list of validated plugins, will be filled by the method
+            give an empty list for first call
+        @param required(bool): True if plugin is mandatory
+            for recursive calls only, should not be modified by inital caller
+        @raise InternalError: one of the plugin is not handling components
+        @raise KeyError: one plugin should be present in self.host_app.plugins but it
+                         is not
+        """
+        if C.PLUG_MODE_COMPONENT not in current._info["modes"]:
+            if not required:
+                return
+            else:
+                log.error(
+                    _(
+                        "Plugin {current_name} is needed for {entry_name}, "
+                        "but it doesn't handle component mode"
+                    ).format(
+                        current_name=current._info["import_name"],
+                        entry_name=self.entry_plugin._info["import_name"],
+                    )
+                )
+                raise exceptions.InternalError(_("invalid plugin mode"))
+
+        for import_name in current._info.get(C.PI_DEPENDENCIES, []):
+            # plugins are already loaded as dependencies
+            # so we know they are in self.host_app.plugins
+            dep = self.host_app.plugins[import_name]
+            self._build_dependencies(dep, plugins)
+
+        for import_name in current._info.get(C.PI_RECOMMENDATIONS, []):
+            # here plugins are only recommendations,
+            # so they may not exist in self.host_app.plugins
+            try:
+                dep = self.host_app.plugins[import_name]
+            except KeyError:
+                continue
+            self._build_dependencies(dep, plugins, required=False)
+
+        if current not in plugins:
+            # current can be required for several plugins and so
+            # it can already be present in the list
+            plugins.append(current)
+
+    def _get_plugins_list(self):
+        # XXX: for component we don't launch all plugins triggers
+        #      but only the ones from which there is a dependency
+        plugins = []
+        self._build_dependencies(self.entry_plugin, plugins)
+        return plugins
+
+    def entity_connected(self):
+        # we can now launch entry point
+        try:
+            start_cb = self.entry_plugin.componentStart
+        except AttributeError:
+            return
+        else:
+            return start_cb(self)
+
+    def add_post_xml_callbacks(self, post_xml_treatments):
+        if self.sendHistory:
+            post_xml_treatments.addCallback(
+                lambda ret: defer.ensureDeferred(self.message_add_to_history(ret))
+            )
+
+    def get_owner_from_jid(self, to_jid: jid.JID) -> jid.JID:
+        """Retrieve "owner" of a component resource from the destination jid of the request
+
+        This method needs plugin XEP-0106 for unescaping, if you use it you must add the
+        plugin to your dependencies.
+        A "user" part must be present in "to_jid" (otherwise, the component itself is addressed)
+        @param to_jid: destination JID of the request
+        """
+        try:
+            unescape = self.host_app.plugins['XEP-0106'].unescape
+        except KeyError:
+            raise exceptions.MissingPlugin("Plugin XEP-0106 is needed to retrieve owner")
+        else:
+            user = unescape(to_jid.user)
+        if '@' in user:
+            # a full jid is specified
+            return jid.JID(user)
+        else:
+            # only user part is specified, we use our own host to build the full jid
+            return jid.JID(None, (user, self.host, None))
+
+    def get_owner_and_peer(self, iq_elt: domish.Element) -> Tuple[jid.JID, jid.JID]:
+        """Retrieve owner of a component jid, and the jid of the requesting peer
+
+        "owner" is found by either unescaping full jid from node, or by combining node
+        with our host.
+        Peer jid is the requesting jid from the IQ element
+        @param iq_elt: IQ stanza sent from the requested
+        @return: owner and peer JIDs
+        """
+        to_jid = jid.JID(iq_elt['to'])
+        if to_jid.user:
+            owner = self.get_owner_from_jid(to_jid)
+        else:
+            owner = jid.JID(iq_elt["from"]).userhostJID()
+
+        peer_jid = jid.JID(iq_elt["from"])
+        return peer_jid, owner
+
+    def get_virtual_client(self, jid_: jid.JID) -> SatXMPPEntity:
+        """Get client for this component with a specified jid
+
+        This is needed to perform operations with a virtual JID corresponding to a virtual
+        entity (e.g. identified of a legacy network account) instead of the JID of the
+        gateway itself.
+        @param jid_: virtual JID to use
+        @return: virtual client
+        """
+        client = copy.copy(self)
+        client.jid = jid_
+        return client
+
+
+class SatMessageProtocol(xmppim.MessageProtocol):
+
+    def __init__(self, host):
+        xmppim.MessageProtocol.__init__(self)
+        self.host = host
+
+    @property
+    def client(self):
+        return self.parent
+
+    def normalize_ns(self, elt: domish.Element, namespace: Optional[str]) -> None:
+        if elt.uri == namespace:
+            elt.defaultUri = elt.uri = C.NS_CLIENT
+        for child in elt.elements():
+            self.normalize_ns(child, namespace)
+
+    def parse_message(self, message_elt):
+        """Parse a message XML and return message_data
+
+        @param message_elt(domish.Element): raw <message> xml
+        @param client(SatXMPPClient, None): client to map message id to uid
+            if None, mapping will not be done
+        @return(dict): message data
+        """
+        if message_elt.name != "message":
+            log.warning(_(
+                "parse_message used with a non <message/> stanza, ignoring: {xml}"
+                .format(xml=message_elt.toXml())))
+            return {}
+
+        if message_elt.uri == None:
+            # xmlns may be None when wokkel element parsing strip out root namespace
+            self.normalize_ns(message_elt, None)
+        elif message_elt.uri != C.NS_CLIENT:
+            log.warning(_(
+                "received <message> with a wrong namespace: {xml}"
+                .format(xml=message_elt.toXml())))
+
+        client = self.parent
+
+        if not message_elt.hasAttribute('to'):
+            message_elt['to'] = client.jid.full()
+
+        message = {}
+        subject = {}
+        extra = {}
+        data = {
+            "from": jid.JID(message_elt["from"]),
+            "to": jid.JID(message_elt["to"]),
+            "uid": message_elt.getAttribute(
+                "uid", str(uuid.uuid4())
+            ),  # XXX: uid is not a standard attribute but may be added by plugins
+            "message": message,
+            "subject": subject,
+            "type": message_elt.getAttribute("type", "normal"),
+            "extra": extra,
+        }
+
+        try:
+            message_id = data["extra"]["message_id"] = message_elt["id"]
+        except KeyError:
+            pass
+        else:
+            client.mess_id2uid[(data["from"], message_id)] = data["uid"]
+
+        # message
+        for e in message_elt.elements(C.NS_CLIENT, "body"):
+            message[e.getAttribute((C.NS_XML, "lang"), "")] = str(e)
+
+        # subject
+        for e in message_elt.elements(C.NS_CLIENT, "subject"):
+            subject[e.getAttribute((C.NS_XML, "lang"), "")] = str(e)
+
+        # delay and timestamp
+        try:
+            received_timestamp = message_elt._received_timestamp
+        except AttributeError:
+            # message_elt._received_timestamp should have been set in onMessage
+            # but if parse_message is called directly, it can be missing
+            log.debug("missing received timestamp for {message_elt}".format(
+                message_elt=message_elt))
+            received_timestamp = time.time()
+
+        try:
+            delay_elt = next(message_elt.elements(delay.NS_DELAY, "delay"))
+        except StopIteration:
+            data["timestamp"] = received_timestamp
+        else:
+            parsed_delay = delay.Delay.fromElement(delay_elt)
+            data["timestamp"] = calendar.timegm(parsed_delay.stamp.utctimetuple())
+            data["received_timestamp"] = received_timestamp
+            if parsed_delay.sender:
+                data["delay_sender"] = parsed_delay.sender.full()
+
+        self.host.trigger.point("message_parse", client,  message_elt, data)
+        return data
+
+    def _on_message_start_workflow(self, cont, client, message_elt, post_treat):
+        """Parse message and do post treatments
+
+        It is the first callback called after message_received trigger
+        @param cont(bool): workflow will continue only if this is True
+        @param message_elt(domish.Element): message stanza
+            may have be modified by triggers
+        @param post_treat(defer.Deferred): post parsing treatments
+        """
+        if not cont:
+            return
+        data = self.parse_message(message_elt)
+        post_treat.addCallback(self.complete_attachments)
+        post_treat.addCallback(self.skip_empty_message)
+        if not client.is_component or client.receiveHistory:
+            post_treat.addCallback(
+                lambda ret: defer.ensureDeferred(self.add_to_history(ret))
+            )
+        if not client.is_component:
+            post_treat.addCallback(self.bridge_signal, data)
+        post_treat.addErrback(self.cancel_error_trap)
+        post_treat.callback(data)
+
+    def onMessage(self, message_elt):
+        # TODO: handle threads
+        message_elt._received_timestamp = time.time()
+        client = self.parent
+        if not "from" in message_elt.attributes:
+            message_elt["from"] = client.jid.host
+        log.debug(_("got message from: {from_}").format(from_=message_elt["from"]))
+        if self.client.is_component and message_elt.uri == component.NS_COMPONENT_ACCEPT:
+            # we use client namespace all the time to simplify parsing
+            self.normalize_ns(message_elt, component.NS_COMPONENT_ACCEPT)
+
+        # plugin can add their treatments to this deferred
+        post_treat = defer.Deferred()
+
+        d = self.host.trigger.async_point(
+            "message_received", client, message_elt, post_treat
+        )
+
+        d.addCallback(self._on_message_start_workflow, client, message_elt, post_treat)
+
+    def complete_attachments(self, data):
+        """Complete missing metadata of attachments"""
+        for attachment in data['extra'].get(C.KEY_ATTACHMENTS, []):
+            if "name" not in attachment and "url" in attachment:
+                name = (Path(unquote(urlparse(attachment['url']).path)).name
+                        or C.FILE_DEFAULT_NAME)
+                attachment["name"] = name
+            if ((C.KEY_ATTACHMENTS_MEDIA_TYPE not in attachment
+                 and "name" in attachment)):
+                media_type = mimetypes.guess_type(attachment['name'], strict=False)[0]
+                if media_type:
+                    attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE] = media_type
+
+        return data
+
+    def skip_empty_message(self, data):
+        if not data["message"] and not data["extra"] and not data["subject"]:
+            raise failure.Failure(exceptions.CancelError("Cancelled empty message"))
+        return data
+
+    async def add_to_history(self, data):
+        if data.pop("history", None) == C.HISTORY_SKIP:
+            log.debug("history is skipped as requested")
+            data["extra"]["history"] = C.HISTORY_SKIP
+        else:
+            # we need a message to store
+            if self.parent.is_message_printable(data):
+                return await self.host.memory.add_to_history(self.parent, data)
+            else:
+                log.debug("not storing empty message to history: {data}"
+                    .format(data=data))
+
+    def bridge_signal(self, __, data):
+        try:
+            data["extra"]["received_timestamp"] = str(data["received_timestamp"])
+            data["extra"]["delay_sender"] = data["delay_sender"]
+        except KeyError:
+            pass
+        if self.client.encryption.isEncrypted(data):
+            data["extra"]["encrypted"] = True
+        if data is not None:
+            if self.parent.is_message_printable(data):
+                self.host.bridge.message_new(
+                    data["uid"],
+                    data["timestamp"],
+                    data["from"].full(),
+                    data["to"].full(),
+                    data["message"],
+                    data["subject"],
+                    data["type"],
+                    data_format.serialise(data["extra"]),
+                    profile=self.parent.profile,
+                )
+            else:
+                log.debug("Discarding bridge signal for empty message: {data}".format(
+                    data=data))
+        return data
+
+    def cancel_error_trap(self, failure_):
+        """A message sending can be cancelled by a plugin treatment"""
+        failure_.trap(exceptions.CancelError)
+
+
+class SatRosterProtocol(xmppim.RosterClientProtocol):
+
+    def __init__(self, host):
+        xmppim.RosterClientProtocol.__init__(self)
+        self.host = host
+        self.got_roster = defer.Deferred()  # called when roster is received and ready
+        # XXX: the two following dicts keep a local copy of the roster
+        self._jids = {}  # map from jids to RosterItem: key=jid value=RosterItem
+        self._groups = {}  # map from groups to jids: key=group value=set of jids
+
+    def __contains__(self, entity_jid):
+        return self.is_jid_in_roster(entity_jid)
+
+    @property
+    def versioning(self):
+        """True if server support roster versioning"""
+        return (NS_ROSTER_VER, 'ver') in self.parent.xmlstream.features
+
+    @property
+    def roster_cache(self):
+        """Cache of roster from storage
+
+        This property return a new PersistentDict on each call, it must be loaded
+        manually if necessary
+        """
+        return persistent.PersistentDict(NS_ROSTER_VER, self.parent.profile)
+
+    def _register_item(self, item):
+        """Register item in local cache
+
+        item must be already registered in self._jids before this method is called
+        @param item (RosterIem): item added
+        """
+        log.debug("registering item: {}".format(item.entity.full()))
+        if item.entity.resource:
+            log.warning(
+                "Received a roster item with a resource, this is not common but not "
+                "restricted by RFC 6121, this case may be not well tested."
+            )
+        if not item.subscriptionTo:
+            if not item.subscriptionFrom:
+                log.info(
+                    _("There's no subscription between you and [{}]!").format(
+                        item.entity.full()
+                    )
+                )
+            else:
+                log.info(_("You are not subscribed to [{}]!").format(item.entity.full()))
+        if not item.subscriptionFrom:
+            log.info(_("[{}] is not subscribed to you!").format(item.entity.full()))
+
+        for group in item.groups:
+            self._groups.setdefault(group, set()).add(item.entity)
+
+    @defer.inlineCallbacks
+    def _cache_roster(self, version):
+        """Serialise local roster and save it to storage
+
+        @param version(unicode): version of roster in local cache
+        """
+        roster_cache = self.roster_cache
+        yield roster_cache.clear()
+        roster_cache[ROSTER_VER_KEY] = version
+        for roster_jid, roster_item in self._jids.items():
+            roster_jid_s = roster_jid.full()
+            roster_item_elt = roster_item.toElement().toXml()
+            roster_cache[roster_jid_s] = roster_item_elt
+
+    @defer.inlineCallbacks
+    def resync(self):
+        """Ask full roster to resync database
+
+        this should not be necessary, but may be used if user suspsect roster
+        to be somehow corrupted
+        """
+        roster_cache = self.roster_cache
+        yield roster_cache.clear()
+        self._jids.clear()
+        self._groups.clear()
+        yield self.request_roster()
+
+    @defer.inlineCallbacks
+    def request_roster(self):
+        """Ask the server for Roster list """
+        if self.versioning:
+            log.info(_("our server support roster versioning, we use it"))
+            roster_cache = self.roster_cache
+            yield roster_cache.load()
+            try:
+                version = roster_cache[ROSTER_VER_KEY]
+            except KeyError:
+                log.info(_("no roster in cache, we start fresh"))
+                # u"" means we use versioning without valid roster in cache
+                version = ""
+            else:
+                log.info(_("We have roster v{version} in cache").format(version=version))
+                # we deserialise cached roster to our local cache
+                for roster_jid_s, roster_item_elt_s in roster_cache.items():
+                    if roster_jid_s == ROSTER_VER_KEY:
+                        continue
+                    roster_jid = jid.JID(roster_jid_s)
+                    roster_item_elt = generic.parseXml(roster_item_elt_s.encode('utf-8'))
+                    roster_item = xmppim.RosterItem.fromElement(roster_item_elt)
+                    self._jids[roster_jid] = roster_item
+                    self._register_item(roster_item)
+        else:
+            log.warning(_("our server doesn't support roster versioning"))
+            version = None
+
+        log.debug("requesting roster")
+        roster = yield self.getRoster(version=version)
+        if roster is None:
+            log.debug("empty roster result received, we'll get roster item with roster "
+                      "pushes")
+        else:
+            # a full roster is received
+            self._groups.clear()
+            self._jids = roster
+            for item in roster.values():
+                if not item.subscriptionTo and not item.subscriptionFrom and not item.ask:
+                    # XXX: current behaviour: we don't want contact in our roster list
+                    # if there is no presence subscription
+                    # may change in the future
+                    log.info(
+                        "Removing contact {} from roster because there is no presence "
+                        "subscription".format(
+                            item.jid
+                        )
+                    )
+                    self.removeItem(item.entity)  # FIXME: to be checked
+                else:
+                    self._register_item(item)
+            yield self._cache_roster(roster.version)
+
+        if not self.got_roster.called:
+            # got_roster may already be called if we use resync()
+            self.got_roster.callback(None)
+
+    def removeItem(self, to_jid):
+        """Remove a contact from roster list
+        @param to_jid: a JID instance
+        @return: Deferred
+        """
+        return xmppim.RosterClientProtocol.removeItem(self, to_jid)
+
+    def get_attributes(self, item):
+        """Return dictionary of attributes as used in bridge from a RosterItem
+
+        @param item: RosterItem
+        @return: dictionary of attributes
+        """
+        item_attr = {
+            "to": str(item.subscriptionTo),
+            "from": str(item.subscriptionFrom),
+            "ask": str(item.ask),
+        }
+        if item.name:
+            item_attr["name"] = item.name
+        return item_attr
+
+    def setReceived(self, request):
+        item = request.item
+        entity = item.entity
+        log.info(_("adding {entity} to roster").format(entity=entity.full()))
+        if request.version is not None:
+            # we update the cache in storage
+            roster_cache = self.roster_cache
+            roster_cache[entity.full()] = item.toElement().toXml()
+            roster_cache[ROSTER_VER_KEY] = request.version
+
+        try:  # update the cache for the groups the contact has been removed from
+            left_groups = set(self._jids[entity].groups).difference(item.groups)
+            for group in left_groups:
+                jids_set = self._groups[group]
+                jids_set.remove(entity)
+                if not jids_set:
+                    del self._groups[group]
+        except KeyError:
+            pass  # no previous item registration (or it's been cleared)
+        self._jids[entity] = item
+        self._register_item(item)
+        self.host.bridge.contact_new(
+            entity.full(), self.get_attributes(item), list(item.groups),
+            self.parent.profile
+        )
+
+    def removeReceived(self, request):
+        entity = request.item.entity
+        log.info(_("removing {entity} from roster").format(entity=entity.full()))
+        if request.version is not None:
+            # we update the cache in storage
+            roster_cache = self.roster_cache
+            try:
+                del roster_cache[request.item.entity.full()]
+            except KeyError:
+                # because we don't use load(), cache won't have the key, but it
+                # will be deleted from storage anyway
+                pass
+            roster_cache[ROSTER_VER_KEY] = request.version
+
+        # we first remove item from local cache (self._groups and self._jids)
+        try:
+            item = self._jids.pop(entity)
+        except KeyError:
+            log.error(
+                "Received a roster remove event for an item not in cache ({})".format(
+                    entity
+                )
+            )
+            return
+        for group in item.groups:
+            try:
+                jids_set = self._groups[group]
+                jids_set.remove(entity)
+                if not jids_set:
+                    del self._groups[group]
+            except KeyError:
+                log.warning(
+                    f"there is no cache for the group [{group}] of the removed roster "
+                    f"item [{entity}]"
+                )
+
+        # then we send the bridge signal
+        self.host.bridge.contact_deleted(entity.full(), self.parent.profile)
+
+    def get_groups(self):
+        """Return a list of groups"""
+        return list(self._groups.keys())
+
+    def get_item(self, entity_jid):
+        """Return RosterItem for a given jid
+
+        @param entity_jid(jid.JID): jid of the contact
+        @return(RosterItem, None): RosterItem instance
+            None if contact is not in cache
+        """
+        return self._jids.get(entity_jid, None)
+
+    def get_jids(self):
+        """Return all jids of the roster"""
+        return list(self._jids.keys())
+
+    def is_jid_in_roster(self, entity_jid):
+        """Return True if jid is in roster"""
+        if not isinstance(entity_jid, jid.JID):
+            raise exceptions.InternalError(
+                f"a JID is expected, not {type(entity_jid)}: {entity_jid!r}")
+        return entity_jid in self._jids
+
+    def is_subscribed_from(self, entity_jid: jid.JID) -> bool:
+        """Return True if entity is authorised to see our presence"""
+        try:
+            item = self._jids[entity_jid.userhostJID()]
+        except KeyError:
+            return False
+        return item.subscriptionFrom
+
+    def is_subscribed_to(self, entity_jid: jid.JID) -> bool:
+        """Return True if we are subscribed to entity"""
+        try:
+            item = self._jids[entity_jid.userhostJID()]
+        except KeyError:
+            return False
+        return item.subscriptionTo
+
+    def get_items(self):
+        """Return all items of the roster"""
+        return list(self._jids.values())
+
+    def get_jids_from_group(self, group):
+        try:
+            return self._groups[group]
+        except KeyError:
+            raise exceptions.UnknownGroupError(group)
+
+    def get_jids_set(self, type_, groups=None):
+        """Helper method to get a set of jids
+
+        @param type_(unicode): one of:
+            C.ALL: get all jids from roster
+            C.GROUP: get jids from groups (listed in "groups")
+        @groups(list[unicode]): list of groups used if type_==C.GROUP
+        @return (set(jid.JID)): set of selected jids
+        """
+        if type_ == C.ALL and groups is not None:
+            raise ValueError("groups must not be set for {} type".format(C.ALL))
+
+        if type_ == C.ALL:
+            return set(self.get_jids())
+        elif type_ == C.GROUP:
+            jids = set()
+            for group in groups:
+                jids.update(self.get_jids_from_group(group))
+            return jids
+        else:
+            raise ValueError("Unexpected type_ {}".format(type_))
+
+    def get_nick(self, entity_jid):
+        """Return a nick name for an entity
+
+        return nick choosed by user if available
+        else return user part of entity_jid
+        """
+        item = self.get_item(entity_jid)
+        if item is None:
+            return entity_jid.user
+        else:
+            return item.name or entity_jid.user
+
+
+class SatPresenceProtocol(xmppim.PresenceClientProtocol):
+
+    def __init__(self, host):
+        xmppim.PresenceClientProtocol.__init__(self)
+        self.host = host
+
+    @property
+    def client(self):
+        return self.parent
+
+    def send(self, obj):
+        presence_d = defer.succeed(None)
+        if not self.host.trigger.point("Presence send", self.parent, obj, presence_d):
+            return
+        presence_d.addCallback(lambda __: super(SatPresenceProtocol, self).send(obj))
+        return presence_d
+
+    def availableReceived(self, entity, show=None, statuses=None, priority=0):
+        if not statuses:
+            statuses = {}
+
+        if None in statuses:  # we only want string keys
+            statuses[C.PRESENCE_STATUSES_DEFAULT] = statuses.pop(None)
+
+        if not self.host.trigger.point(
+            "presence_received", self.parent, entity, show, priority, statuses
+        ):
+            return
+
+        self.host.memory.set_presence_status(
+            entity, show or "", int(priority), statuses, self.parent.profile
+        )
+
+        # now it's time to notify frontends
+        self.host.bridge.presence_update(
+            entity.full(), show or "", int(priority), statuses, self.parent.profile
+        )
+
+    def unavailableReceived(self, entity, statuses=None):
+        log.debug(
+            _("presence update for [%(entity)s] (unavailable, statuses=%(statuses)s)")
+            % {"entity": entity, C.PRESENCE_STATUSES: statuses}
+        )
+
+        if not statuses:
+            statuses = {}
+
+        if None in statuses:  # we only want string keys
+            statuses[C.PRESENCE_STATUSES_DEFAULT] = statuses.pop(None)
+
+        if not self.host.trigger.point(
+            "presence_received", self.parent, entity, C.PRESENCE_UNAVAILABLE, 0, statuses,
+        ):
+            return
+
+        # now it's time to notify frontends
+        # if the entity is not known yet in this session or is already unavailable,
+        # there is no need to send an unavailable signal
+        try:
+            presence = self.host.memory.get_entity_datum(
+                self.client, entity, "presence"
+            )
+        except (KeyError, exceptions.UnknownEntityError):
+            # the entity has not been seen yet in this session
+            pass
+        else:
+            if presence.show != C.PRESENCE_UNAVAILABLE:
+                self.host.bridge.presence_update(
+                    entity.full(),
+                    C.PRESENCE_UNAVAILABLE,
+                    0,
+                    statuses,
+                    self.parent.profile,
+                )
+
+        self.host.memory.set_presence_status(
+            entity, C.PRESENCE_UNAVAILABLE, 0, statuses, self.parent.profile
+        )
+
+    def available(self, entity=None, show=None, statuses=None, priority=None):
+        """Set a presence and statuses.
+
+        @param entity (jid.JID): entity
+        @param show (unicode): value in ('unavailable', '', 'away', 'xa', 'chat', 'dnd')
+        @param statuses (dict{unicode: unicode}): multilingual statuses with
+            the entry key beeing a language code on 2 characters or "default".
+        """
+        if priority is None:
+            try:
+                priority = int(
+                    self.host.memory.param_get_a(
+                        "Priority", "Connection", profile_key=self.parent.profile
+                    )
+                )
+            except ValueError:
+                priority = 0
+
+        if statuses is None:
+            statuses = {}
+
+        # default for us is None for wokkel
+        # so we must temporarily switch to wokkel's convention...
+        if C.PRESENCE_STATUSES_DEFAULT in statuses:
+            statuses[None] = statuses.pop(C.PRESENCE_STATUSES_DEFAULT)
+
+        presence_elt = xmppim.AvailablePresence(entity, show, statuses, priority)
+
+        # ... before switching back
+        if None in statuses:
+            statuses["default"] = statuses.pop(None)
+
+        if not self.host.trigger.point("presence_available", presence_elt, self.parent):
+            return
+        return self.send(presence_elt)
+
+    @defer.inlineCallbacks
+    def subscribed(self, entity):
+        yield self.parent.roster.got_roster
+        xmppim.PresenceClientProtocol.subscribed(self, entity)
+        self.host.memory.del_waiting_sub(entity.userhost(), self.parent.profile)
+        item = self.parent.roster.get_item(entity)
+        if (
+            not item or not item.subscriptionTo
+        ):  # we automatically subscribe to 'to' presence
+            log.debug(_('sending automatic "from" subscription request'))
+            self.subscribe(entity)
+
+    def unsubscribed(self, entity):
+        xmppim.PresenceClientProtocol.unsubscribed(self, entity)
+        self.host.memory.del_waiting_sub(entity.userhost(), self.parent.profile)
+
+    def subscribedReceived(self, entity):
+        log.debug(_("subscription approved for [%s]") % entity.userhost())
+        self.host.bridge.subscribe("subscribed", entity.userhost(), self.parent.profile)
+
+    def unsubscribedReceived(self, entity):
+        log.debug(_("unsubscription confirmed for [%s]") % entity.userhost())
+        self.host.bridge.subscribe("unsubscribed", entity.userhost(), self.parent.profile)
+
+    @defer.inlineCallbacks
+    def subscribeReceived(self, entity):
+        log.debug(_("subscription request from [%s]") % entity.userhost())
+        yield self.parent.roster.got_roster
+        item = self.parent.roster.get_item(entity)
+        if item and item.subscriptionTo:
+            # We automatically accept subscription if we are already subscribed to
+            # contact presence
+            log.debug(_("sending automatic subscription acceptance"))
+            self.subscribed(entity)
+        else:
+            self.host.memory.add_waiting_sub(
+                "subscribe", entity.userhost(), self.parent.profile
+            )
+            self.host.bridge.subscribe(
+                "subscribe", entity.userhost(), self.parent.profile
+            )
+
+    @defer.inlineCallbacks
+    def unsubscribeReceived(self, entity):
+        log.debug(_("unsubscription asked for [%s]") % entity.userhost())
+        yield self.parent.roster.got_roster
+        item = self.parent.roster.get_item(entity)
+        if item and item.subscriptionFrom:  # we automatically remove contact
+            log.debug(_("automatic contact deletion"))
+            self.host.contact_del(entity, self.parent.profile)
+        self.host.bridge.subscribe("unsubscribe", entity.userhost(), self.parent.profile)
+
+
+@implementer(iwokkel.IDisco)
+class SatDiscoProtocol(disco.DiscoClientProtocol):
+
+    def __init__(self, host):
+        disco.DiscoClientProtocol.__init__(self)
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        # those features are implemented in Wokkel (or sat_tmp.wokkel)
+        # and thus are always available
+        return [disco.DiscoFeature(NS_X_DATA),
+                disco.DiscoFeature(NS_XML_ELEMENT),
+                disco.DiscoFeature(NS_DISCO_INFO)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
+
+
+class SatFallbackHandler(generic.FallbackHandler):
+    def __init__(self, host):
+        generic.FallbackHandler.__init__(self)
+
+    def iqFallback(self, iq):
+        if iq.handled is True:
+            return
+        log.debug("iqFallback: xml = [%s]" % (iq.toXml()))
+        generic.FallbackHandler.iqFallback(self, iq)
+
+
+class SatVersionHandler(generic.VersionHandler):
+
+    def getDiscoInfo(self, requestor, target, node):
+        # XXX: We need to work around wokkel's behaviour (namespace not added if there
+        #      is a node) as it cause issues with XEP-0115 & PEP (XEP-0163): there is a
+        #      node when server ask for disco info, and not when we generate the key, so
+        #      the hash is used with different disco features, and when the server (seen
+        #      on ejabberd) generate its own hash for security check it reject our
+        #      features (resulting in e.g. no notification on PEP)
+        return generic.VersionHandler.getDiscoInfo(self, requestor, target, None)
+
+
+@implementer(iwokkel.IDisco)
+class SatIdentityHandler(XMPPHandler):
+    """Manage disco Identity of SàT."""
+    # TODO: dynamic identity update (see docstring). Note that a XMPP entity can have
+    #       several identities
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return self.parent.identities
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/cache.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,281 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from io import BufferedIOBase
+import mimetypes
+from pathlib import Path
+import pickle as pickle
+import time
+from typing import Any, Dict, Optional
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import regex
+
+
+log = getLogger(__name__)
+
+DEFAULT_EXT = ".raw"
+
+
+class Cache(object):
+    """generic file caching"""
+
+    def __init__(self, host, profile):
+        """
+        @param profile(unicode, None): name of the profile to set the cache for
+            if None, the cache will be common for all profiles
+        """
+        self.profile = profile
+        path_elts = [host.memory.config_get("", "local_dir"), C.CACHE_DIR]
+        if profile:
+            path_elts.extend(["profiles", regex.path_escape(profile)])
+        else:
+            path_elts.append("common")
+        self.cache_dir = Path(*path_elts)
+
+        self.cache_dir.mkdir(0o700, parents=True, exist_ok=True)
+        self.purge()
+
+    def purge(self):
+        # remove expired files from cache
+        # TODO: this should not be called only on startup, but at regular interval
+        #   (e.g. once a day)
+        purged = set()
+        # we sort files to have metadata files first
+        for cache_file in sorted(self.cache_dir.iterdir()):
+            if cache_file in purged:
+                continue
+            try:
+                with cache_file.open('rb') as f:
+                    cache_data = pickle.load(f)
+            except IOError:
+                log.warning(
+                    _("Can't read metadata file at {path}")
+                    .format(path=cache_file))
+                continue
+            except (pickle.UnpicklingError, EOFError):
+                log.debug(f"File at {cache_file} is not a metadata file")
+                continue
+            try:
+                eol = cache_data['eol']
+                filename = cache_data['filename']
+            except KeyError:
+                log.warning(
+                    _("Invalid cache metadata at {path}")
+                    .format(path=cache_file))
+                continue
+
+            filepath = self.getPath(filename)
+
+            if not filepath.exists():
+                log.warning(_(
+                    "cache {cache_file!r} references an inexisting file: {filepath!r}"
+                ).format(cache_file=str(cache_file), filepath=str(filepath)))
+                log.debug("purging cache with missing file")
+                cache_file.unlink()
+            elif eol < time.time():
+                log.debug(
+                    "purging expired cache {filepath!r} (expired for {time}s)"
+                    .format(filepath=str(filepath), time=int(time.time() - eol))
+                )
+                cache_file.unlink()
+                try:
+                    filepath.unlink()
+                except FileNotFoundError:
+                    log.warning(
+                        _("following file is missing while purging cache: {path}")
+                        .format(path=filepath)
+                    )
+                purged.add(cache_file)
+                purged.add(filepath)
+
+    def getPath(self, filename: str) -> Path:
+        """return cached file URL
+
+        @param filename: cached file name (cache data or actual file)
+        @return: path to the cached file
+        """
+        if not filename or "/" in filename:
+            log.error(
+                "invalid char found in file name, hack attempt? name:{}".format(filename)
+            )
+            raise exceptions.DataError("Invalid char found")
+        return self.cache_dir / filename
+
+    def get_metadata(self, uid: str, update_eol: bool = True) -> Optional[Dict[str, Any]]:
+        """Retrieve metadata for cached data
+
+        @param uid(unicode): unique identifier of file
+        @param update_eol(bool): True if eol must extended
+            if True, max_age will be added to eol (only if it is not already expired)
+        @return (dict, None): metadata with following keys:
+            see [cache_data] for data details, an additional "path" key is the full path to
+            cached file.
+            None if file is not in cache (or cache is invalid)
+        """
+
+        uid = uid.strip()
+        if not uid:
+            raise exceptions.InternalError("uid must not be empty")
+        cache_url = self.getPath(uid)
+        if not cache_url.exists():
+            return None
+
+        try:
+            with cache_url.open("rb") as f:
+                cache_data = pickle.load(f)
+        except (IOError, EOFError) as e:
+            log.warning(f"can't read cache at {cache_url}: {e}")
+            return None
+        except pickle.UnpicklingError:
+            log.warning(f"invalid cache found at {cache_url}")
+            return None
+
+        try:
+            eol = cache_data["eol"]
+        except KeyError:
+            log.warning("no End Of Life found for cached file {}".format(uid))
+            eol = 0
+        if eol < time.time():
+            log.debug(
+                "removing expired cache (expired for {}s)".format(time.time() - eol)
+            )
+            return None
+
+        if update_eol:
+            try:
+                max_age = cache_data["max_age"]
+            except KeyError:
+                log.warning(f"no max_age found for cache at {cache_url}, using default")
+                max_age = cache_data["max_age"] = C.DEFAULT_MAX_AGE
+            now = int(time.time())
+            cache_data["last_access"] = now
+            cache_data["eol"] = now + max_age
+            with cache_url.open("wb") as f:
+                pickle.dump(cache_data, f, protocol=2)
+
+        cache_data["path"] = self.getPath(cache_data["filename"])
+        return cache_data
+
+    def get_file_path(self, uid: str) -> Path:
+        """Retrieve absolute path to file
+
+        @param uid(unicode): unique identifier of file
+        @return (unicode, None): absolute path to cached file
+            None if file is not in cache (or cache is invalid)
+        """
+        metadata = self.get_metadata(uid)
+        if metadata is not None:
+            return metadata["path"]
+
+    def remove_from_cache(self, uid, metadata=None):
+        """Remove data from cache
+
+        @param uid(unicode): unique identifier cache file
+        """
+        cache_data = self.get_metadata(uid, update_eol=False)
+        if cache_data is None:
+            log.debug(f"cache with uid {uid!r} has already expired or been removed")
+            return
+
+        try:
+            filename = cache_data['filename']
+        except KeyError:
+            log.warning(_("missing filename for cache {uid!r}") .format(uid=uid))
+        else:
+            filepath = self.getPath(filename)
+            try:
+                filepath.unlink()
+            except FileNotFoundError:
+                log.warning(
+                    _("missing file referenced in cache {uid!r}: {filename}")
+                    .format(uid=uid, filename=filename)
+                )
+
+        cache_file = self.getPath(uid)
+        cache_file.unlink()
+        log.debug(f"cache with uid {uid!r} has been removed")
+
+    def cache_data(
+        self,
+        source: str,
+        uid: str,
+        mime_type: Optional[str] = None,
+        max_age: Optional[int] = None,
+        original_filename: Optional[str] = None
+    ) -> BufferedIOBase:
+        """create cache metadata and file object to use for actual data
+
+        @param source: source of the cache (should be plugin's import_name)
+        @param uid: an identifier of the file which must be unique
+        @param mime_type: MIME type of the file to cache
+            it will be used notably to guess file extension
+            It may be autogenerated if filename is specified
+        @param max_age: maximum age in seconds
+            the cache metadata will have an "eol" (end of life)
+            None to use default value
+            0 to ignore cache (file will be re-downloaded on each access)
+        @param original_filename: if not None, will be used to retrieve file extension and
+            guess
+            mime type, and stored in "original_filename"
+        @return: file object opened in write mode
+            you have to close it yourself (hint: use ``with`` statement)
+        """
+        if max_age is None:
+            max_age = C.DEFAULT_MAX_AGE
+        cache_data = {
+            "source": source,
+            # we also store max_age for updating eol
+            "max_age": max_age,
+        }
+        cache_url = self.getPath(uid)
+        if original_filename is not None:
+            cache_data["original_filename"] = original_filename
+            if mime_type is None:
+                # we have original_filename but not MIME type, we try to guess the later
+                mime_type = mimetypes.guess_type(original_filename, strict=False)[0]
+        if mime_type:
+            ext = mimetypes.guess_extension(mime_type, strict=False)
+            if ext is None:
+                log.warning(
+                    "can't find extension for MIME type {}".format(mime_type)
+                )
+                ext = DEFAULT_EXT
+            elif ext == ".jpe":
+                ext = ".jpg"
+        else:
+            ext = DEFAULT_EXT
+            mime_type = None
+        filename = uid + ext
+        now = int(time.time())
+        cache_data.update({
+            "filename": filename,
+            "creation": now,
+            "eol": now + max_age,
+            "mime_type": mime_type,
+        })
+        file_path = self.getPath(filename)
+
+        with open(cache_url, "wb") as f:
+            pickle.dump(cache_data, f, protocol=2)
+
+        return file_path.open("wb")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/crypto.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,170 @@
+#!/usr/bin/env python3
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from os import urandom
+from base64 import b64encode, b64decode
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+from cryptography.hazmat.backends import default_backend
+
+
+crypto_backend = default_backend()
+
+
+class BlockCipher:
+
+    BLOCK_SIZE = 16
+    MAX_KEY_SIZE = 32
+    IV_SIZE = BLOCK_SIZE  # initialization vector size, 16 bits
+
+    @staticmethod
+    def encrypt(key, text, leave_empty=True):
+        """Encrypt a message.
+
+        Based on http://stackoverflow.com/a/12525165
+
+        @param key (unicode): the encryption key
+        @param text (unicode): the text to encrypt
+        @param leave_empty (bool): if True, empty text will be returned "as is"
+        @return (D(str)): base-64 encoded encrypted message
+        """
+        if leave_empty and text == "":
+            return ""
+        iv = BlockCipher.get_random_key()
+        key = key.encode()
+        key = (
+            key[: BlockCipher.MAX_KEY_SIZE]
+            if len(key) >= BlockCipher.MAX_KEY_SIZE
+            else BlockCipher.pad(key)
+        )
+
+        cipher = Cipher(algorithms.AES(key), modes.CFB8(iv), backend=crypto_backend)
+        encryptor = cipher.encryptor()
+        encrypted = encryptor.update(BlockCipher.pad(text.encode())) + encryptor.finalize()
+        return b64encode(iv + encrypted).decode()
+
+    @staticmethod
+    def decrypt(key, ciphertext, leave_empty=True):
+        """Decrypt a message.
+
+        Based on http://stackoverflow.com/a/12525165
+
+        @param key (unicode): the decryption key
+        @param ciphertext (base-64 encoded str): the text to decrypt
+        @param leave_empty (bool): if True, empty ciphertext will be returned "as is"
+        @return: Deferred: str or None if the password could not be decrypted
+        """
+        if leave_empty and ciphertext == "":
+            return ""
+        ciphertext = b64decode(ciphertext)
+        iv, ciphertext = (
+            ciphertext[: BlockCipher.IV_SIZE],
+            ciphertext[BlockCipher.IV_SIZE :],
+        )
+        key = key.encode()
+        key = (
+            key[: BlockCipher.MAX_KEY_SIZE]
+            if len(key) >= BlockCipher.MAX_KEY_SIZE
+            else BlockCipher.pad(key)
+        )
+
+        cipher = Cipher(algorithms.AES(key), modes.CFB8(iv), backend=crypto_backend)
+        decryptor = cipher.decryptor()
+        decrypted = decryptor.update(ciphertext) + decryptor.finalize()
+        return BlockCipher.unpad(decrypted)
+
+    @staticmethod
+    def get_random_key(size=None, base64=False):
+        """Return a random key suitable for block cipher encryption.
+
+        Note: a good value for the key length is to make it as long as the block size.
+
+        @param size: key length in bytes, positive or null (default: BlockCipher.IV_SIZE)
+        @param base64: if True, encode the result to base-64
+        @return: str (eventually base-64 encoded)
+        """
+        if size is None or size < 0:
+            size = BlockCipher.IV_SIZE
+        key = urandom(size)
+        return b64encode(key) if base64 else key
+
+    @staticmethod
+    def pad(s):
+        """Method from http://stackoverflow.com/a/12525165"""
+        bs = BlockCipher.BLOCK_SIZE
+        return s + (bs - len(s) % bs) * (chr(bs - len(s) % bs)).encode()
+
+    @staticmethod
+    def unpad(s):
+        """Method from http://stackoverflow.com/a/12525165"""
+        s = s.decode()
+        return s[0 : -ord(s[-1])]
+
+
+class PasswordHasher:
+
+    SALT_LEN = 16  # 128 bits
+
+    @staticmethod
+    def hash(password, salt=None, leave_empty=True):
+        """Hash a password.
+
+        @param password (str): the password to hash
+        @param salt (base-64 encoded str): if not None, use the given salt instead of a random value
+        @param leave_empty (bool): if True, empty password will be returned "as is"
+        @return: Deferred: base-64 encoded str
+        """
+        if leave_empty and password == "":
+            return ""
+        salt = (
+            b64decode(salt)[: PasswordHasher.SALT_LEN]
+            if salt
+            else urandom(PasswordHasher.SALT_LEN)
+        )
+
+        # we use PyCrypto's PBKDF2 arguments while porting to crytography, to stay
+        # compatible with existing installations. But this is temporary and we need
+        # to update them to more secure values.
+        kdf = PBKDF2HMAC(
+            # FIXME: SHA1() is not secure, it is used here for historical reasons
+            #   and must be changed as soon as possible
+            algorithm=hashes.SHA1(),
+            length=16,
+            salt=salt,
+            iterations=1000,
+            backend=crypto_backend
+        )
+        key = kdf.derive(password.encode())
+        return b64encode(salt + key).decode()
+
+    @staticmethod
+    def verify(attempt, pwd_hash):
+        """Verify a password attempt.
+
+        @param attempt (str): the attempt to check
+        @param pwd_hash (str): the hash of the password
+        @return: Deferred: boolean
+        """
+        assert isinstance(attempt, str)
+        assert isinstance(pwd_hash, str)
+        leave_empty = pwd_hash == ""
+        attempt_hash = PasswordHasher.hash(attempt, pwd_hash, leave_empty)
+        assert isinstance(attempt_hash, str)
+        return attempt_hash == pwd_hash
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/disco.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,499 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.core_types import SatXMPPEntity
+
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber.error import StanzaError
+from twisted.internet import defer
+from twisted.internet import reactor
+from twisted.python import failure
+from libervia.backend.core.constants import Const as C
+from libervia.backend.tools import xml_tools
+from libervia.backend.memory import persistent
+from wokkel import disco
+from base64 import b64encode
+from hashlib import sha1
+
+
+log = getLogger(__name__)
+
+
+TIMEOUT = 15
+CAP_HASH_ERROR = "ERROR"
+
+
+class HashGenerationError(Exception):
+    pass
+
+
+class ByteIdentity(object):
+    """This class manage identity as bytes (needed for i;octet sort), it is used for the hash generation"""
+
+    def __init__(self, identity, lang=None):
+        assert isinstance(identity, disco.DiscoIdentity)
+        self.category = identity.category.encode("utf-8")
+        self.idType = identity.type.encode("utf-8")
+        self.name = identity.name.encode("utf-8") if identity.name else b""
+        self.lang = lang.encode("utf-8") if lang is not None else b""
+
+    def __bytes__(self):
+        return b"%s/%s/%s/%s" % (self.category, self.idType, self.lang, self.name)
+
+
+class HashManager(object):
+    """map object which manage hashes
+
+    persistent storage is update when a new hash is added
+    """
+
+    def __init__(self, persistent):
+        self.hashes = {
+            CAP_HASH_ERROR: disco.DiscoInfo()  # used when we can't get disco infos
+        }
+        self.persistent = persistent
+
+    def __getitem__(self, key):
+        return self.hashes[key]
+
+    def __setitem__(self, hash_, disco_info):
+        if hash_ in self.hashes:
+            log.debug("ignoring hash set: it is already known")
+            return
+        self.hashes[hash_] = disco_info
+        self.persistent[hash_] = disco_info.toElement().toXml()
+
+    def __contains__(self, hash_):
+        return self.hashes.__contains__(hash_)
+
+    def load(self):
+        def fill_hashes(hashes):
+            for hash_, xml in hashes.items():
+                element = xml_tools.ElementParser()(xml)
+                disco_info = disco.DiscoInfo.fromElement(element)
+                for ext_form in disco_info.extensions.values():
+                    # wokkel doesn't call typeCheck on reception, so we do it here
+                    ext_form.typeCheck()
+                if not disco_info.features and not disco_info.identities:
+                    log.warning(
+                        _(
+                            "no feature/identity found in disco element (hash: {cap_hash}), ignoring: {xml}"
+                        ).format(cap_hash=hash_, xml=xml)
+                    )
+                else:
+                    self.hashes[hash_] = disco_info
+
+            log.info("Disco hashes loaded")
+
+        d = self.persistent.load()
+        d.addCallback(fill_hashes)
+        return d
+
+
+class Discovery(object):
+    """ Manage capabilities of entities """
+
+    def __init__(self, host):
+        self.host = host
+        # TODO: remove legacy hashes
+
+    def load(self):
+        """Load persistent hashes"""
+        self.hashes = HashManager(persistent.PersistentDict("disco"))
+        return self.hashes.load()
+
+    @defer.inlineCallbacks
+    def hasFeature(self, client, feature, jid_=None, node=""):
+        """Tell if an entity has the required feature
+
+        @param feature: feature namespace
+        @param jid_: jid of the target, or None for profile's server
+        @param node(unicode): optional node to use for disco request
+        @return: a Deferred which fire a boolean (True if feature is available)
+        """
+        disco_infos = yield self.get_infos(client, jid_, node)
+        defer.returnValue(feature in disco_infos.features)
+
+    @defer.inlineCallbacks
+    def check_feature(self, client, feature, jid_=None, node=""):
+        """Like hasFeature, but raise an exception is feature is not Found
+
+        @param feature: feature namespace
+        @param jid_: jid of the target, or None for profile's server
+        @param node(unicode): optional node to use for disco request
+
+        @raise: exceptions.FeatureNotFound
+        """
+        disco_infos = yield self.get_infos(client, jid_, node)
+        if not feature in disco_infos.features:
+            raise failure.Failure(exceptions.FeatureNotFound())
+
+    @defer.inlineCallbacks
+    def check_features(self, client, features, jid_=None, identity=None, node=""):
+        """Like check_feature, but check several features at once, and check also identity
+
+        @param features(iterable[unicode]): features to check
+        @param jid_(jid.JID): jid of the target, or None for profile's server
+        @param node(unicode): optional node to use for disco request
+        @param identity(None, tuple(unicode, unicode): if not None, the entity must have an identity with this (category, type) tuple
+
+        @raise: exceptions.FeatureNotFound
+        """
+        disco_infos = yield self.get_infos(client, jid_, node)
+        if not set(features).issubset(disco_infos.features):
+            raise failure.Failure(exceptions.FeatureNotFound())
+
+        if identity is not None and identity not in disco_infos.identities:
+            raise failure.Failure(exceptions.FeatureNotFound())
+
+    async def has_identity(
+        self,
+        client: SatXMPPEntity,
+        category: str,
+        type_: str,
+        jid_: Optional[jid.JID] = None,
+        node: str = ""
+    ) -> bool:
+        """Tell if an entity has the requested identity
+
+        @param category: identity category
+        @param type_: identity type
+        @param jid_: jid of the target, or None for profile's server
+        @param node(unicode): optional node to use for disco request
+        @return: True if the entity has the given identity
+        """
+        disco_infos = await self.get_infos(client, jid_, node)
+        return (category, type_) in disco_infos.identities
+
+    def get_infos(self, client, jid_=None, node="", use_cache=True):
+        """get disco infos from jid_, filling capability hash if needed
+
+        @param jid_: jid of the target, or None for profile's server
+        @param node(unicode): optional node to use for disco request
+        @param use_cache(bool): if True, use cached data if available
+        @return: a Deferred which fire disco.DiscoInfo
+        """
+        if jid_ is None:
+            jid_ = jid.JID(client.jid.host)
+        try:
+            if not use_cache:
+                # we ignore cache, so we pretend we haven't found it
+                raise KeyError
+            cap_hash = self.host.memory.entity_data_get(
+                client, jid_, [C.ENTITY_CAP_HASH]
+            )[C.ENTITY_CAP_HASH]
+        except (KeyError, exceptions.UnknownEntityError):
+            # capability hash is not available, we'll compute one
+            def infos_cb(disco_infos):
+                cap_hash = self.generate_hash(disco_infos)
+                for ext_form in disco_infos.extensions.values():
+                    # wokkel doesn't call typeCheck on reception, so we do it here
+                    # to avoid ending up with incorrect types. We have to do it after
+                    # the hash has been generated (str value is needed to compute the
+                    # hash)
+                    ext_form.typeCheck()
+                self.hashes[cap_hash] = disco_infos
+                self.host.memory.update_entity_data(
+                    client, jid_, C.ENTITY_CAP_HASH, cap_hash
+                )
+                return disco_infos
+
+            def infos_eb(fail):
+                if fail.check(defer.CancelledError):
+                    reason = "request time-out"
+                    fail = failure.Failure(exceptions.TimeOutError(str(fail.value)))
+                else:
+                    try:
+                        reason = str(fail.value)
+                    except AttributeError:
+                        reason = str(fail)
+
+                log.warning(
+                    "can't request disco infos from {jid}: {reason}".format(
+                        jid=jid_.full(), reason=reason
+                    )
+                )
+
+                # XXX we set empty disco in cache, to avoid getting an error or waiting
+                # for a timeout again the next time
+                self.host.memory.update_entity_data(
+                    client, jid_, C.ENTITY_CAP_HASH, CAP_HASH_ERROR
+                )
+                raise fail
+
+            d = client.disco.requestInfo(jid_, nodeIdentifier=node)
+            d.addCallback(infos_cb)
+            d.addErrback(infos_eb)
+            return d
+        else:
+            disco_infos = self.hashes[cap_hash]
+            return defer.succeed(disco_infos)
+
+    @defer.inlineCallbacks
+    def get_items(self, client, jid_=None, node="", use_cache=True):
+        """get disco items from jid_, cache them for our own server
+
+        @param jid_(jid.JID): jid of the target, or None for profile's server
+        @param node(unicode): optional node to use for disco request
+        @param use_cache(bool): if True, use cached data if available
+        @return: a Deferred which fire disco.DiscoItems
+        """
+        if jid_ is None:
+            jid_ = client.server_jid
+
+        if jid_ == client.server_jid and not node:
+            # we cache items only for our own server and if node is not set
+            try:
+                items = self.host.memory.entity_data_get(
+                    client, jid_, ["DISCO_ITEMS"]
+                )["DISCO_ITEMS"]
+                log.debug("[%s] disco items are in cache" % jid_.full())
+                if not use_cache:
+                    # we ignore cache, so we pretend we haven't found it
+                    raise KeyError
+            except (KeyError, exceptions.UnknownEntityError):
+                log.debug("Caching [%s] disco items" % jid_.full())
+                items = yield client.disco.requestItems(jid_, nodeIdentifier=node)
+                self.host.memory.update_entity_data(
+                    client, jid_, "DISCO_ITEMS", items
+                )
+        else:
+            try:
+                items = yield client.disco.requestItems(jid_, nodeIdentifier=node)
+            except StanzaError as e:
+                log.warning(
+                    "Error while requesting items for {jid}: {reason}".format(
+                        jid=jid_.full(), reason=e.condition
+                    )
+                )
+                items = disco.DiscoItems()
+
+        defer.returnValue(items)
+
+    def _infos_eb(self, failure_, entity_jid):
+        failure_.trap(StanzaError)
+        log.warning(
+            _("Error while requesting [%(jid)s]: %(error)s")
+            % {"jid": entity_jid.full(), "error": failure_.getErrorMessage()}
+        )
+
+    def find_service_entity(self, client, category, type_, jid_=None):
+        """Helper method to find first available entity from find_service_entities
+
+        args are the same as for [find_service_entities]
+        @return (jid.JID, None): found entity
+        """
+        d = self.host.find_service_entities(client, category, type_)
+        d.addCallback(lambda entities: entities.pop() if entities else None)
+        return d
+
+    def find_service_entities(self, client, category, type_, jid_=None):
+        """Return all available items of an entity which correspond to (category, type_)
+
+        @param category: identity's category
+        @param type_: identitiy's type
+        @param jid_: the jid of the target server (None for profile's server)
+        @return: a set of found entities
+        @raise defer.CancelledError: the request timed out
+        """
+        found_entities = set()
+
+        def infos_cb(infos, entity_jid):
+            if (category, type_) in infos.identities:
+                found_entities.add(entity_jid)
+
+        def got_items(items):
+            defers_list = []
+            for item in items:
+                info_d = self.get_infos(client, item.entity)
+                info_d.addCallbacks(
+                    infos_cb, self._infos_eb, [item.entity], None, [item.entity]
+                )
+                defers_list.append(info_d)
+            return defer.DeferredList(defers_list)
+
+        d = self.get_items(client, jid_)
+        d.addCallback(got_items)
+        d.addCallback(lambda __: found_entities)
+        reactor.callLater(
+            TIMEOUT, d.cancel
+        )  # FIXME: one bad service make a general timeout
+        return d
+
+    def find_features_set(self, client, features, identity=None, jid_=None):
+        """Return entities (including jid_ and its items) offering features
+
+        @param features: iterable of features which must be present
+        @param identity(None, tuple(unicode, unicode)): if not None, accept only this
+            (category/type) identity
+        @param jid_: the jid of the target server (None for profile's server)
+        @param profile: %(doc_profile)s
+        @return: a set of found entities
+        """
+        if jid_ is None:
+            jid_ = jid.JID(client.jid.host)
+        features = set(features)
+        found_entities = set()
+
+        def infos_cb(infos, entity):
+            if entity is None:
+                log.warning(_("received an item without jid"))
+                return
+            if identity is not None and identity not in infos.identities:
+                return
+            if features.issubset(infos.features):
+                found_entities.add(entity)
+
+        def got_items(items):
+            defer_list = []
+            for entity in [jid_] + [item.entity for item in items]:
+                infos_d = self.get_infos(client, entity)
+                infos_d.addCallbacks(infos_cb, self._infos_eb, [entity], None, [entity])
+                defer_list.append(infos_d)
+            return defer.DeferredList(defer_list)
+
+        d = self.get_items(client, jid_)
+        d.addCallback(got_items)
+        d.addCallback(lambda __: found_entities)
+        reactor.callLater(
+            TIMEOUT, d.cancel
+        )  # FIXME: one bad service make a general timeout
+        return d
+
+    def generate_hash(self, services):
+        """ Generate a unique hash for given service
+
+        hash algorithm is the one described in XEP-0115
+        @param services: iterable of disco.DiscoIdentity/disco.DiscoFeature, as returned by discoHandler.info
+
+        """
+        s = []
+        # identities
+        byte_identities = [
+            ByteIdentity(service)
+            for service in services
+            if isinstance(service, disco.DiscoIdentity)
+        ]  # FIXME: lang must be managed here
+        byte_identities.sort(key=lambda i: i.lang)
+        byte_identities.sort(key=lambda i: i.idType)
+        byte_identities.sort(key=lambda i: i.category)
+        for identity in byte_identities:
+            s.append(bytes(identity))
+            s.append(b"<")
+        # features
+        byte_features = [
+            service.encode("utf-8")
+            for service in services
+            if isinstance(service, disco.DiscoFeature)
+        ]
+        byte_features.sort()  # XXX: the default sort has the same behaviour as the requested RFC 4790 i;octet sort
+        for feature in byte_features:
+            s.append(feature)
+            s.append(b"<")
+
+        # extensions
+        ext = list(services.extensions.values())
+        ext.sort(key=lambda f: f.formNamespace.encode('utf-8'))
+        for extension in ext:
+            s.append(extension.formNamespace.encode('utf-8'))
+            s.append(b"<")
+            fields = extension.fieldList
+            fields.sort(key=lambda f: f.var.encode('utf-8'))
+            for field in fields:
+                s.append(field.var.encode('utf-8'))
+                s.append(b"<")
+                values = [v.encode('utf-8') for v in field.values]
+                values.sort()
+                for value in values:
+                    s.append(value)
+                    s.append(b"<")
+
+        cap_hash = b64encode(sha1(b"".join(s)).digest()).decode('utf-8')
+        log.debug(_("Capability hash generated: [{cap_hash}]").format(cap_hash=cap_hash))
+        return cap_hash
+
+    @defer.inlineCallbacks
+    def _disco_infos(
+        self, entity_jid_s, node="", use_cache=True, profile_key=C.PROF_KEY_NONE
+    ):
+        """Discovery method for the bridge
+        @param entity_jid_s: entity we want to discover
+        @param use_cache(bool): if True, use cached data if available
+        @param node(unicode): optional node to use
+
+        @return: list of tuples
+        """
+        client = self.host.get_client(profile_key)
+        entity = jid.JID(entity_jid_s)
+        disco_infos = yield self.get_infos(client, entity, node, use_cache)
+        extensions = {}
+        # FIXME: should extensions be serialised using tools.common.data_format?
+        for form_type, form in list(disco_infos.extensions.items()):
+            fields = []
+            for field in form.fieldList:
+                data = {"type": field.fieldType}
+                for attr in ("var", "label", "desc"):
+                    value = getattr(field, attr)
+                    if value is not None:
+                        data[attr] = value
+
+                values = [field.value] if field.value is not None else field.values
+                if field.fieldType == "boolean":
+                    values = [C.bool_const(v) for v in values]
+                fields.append((data, values))
+
+            extensions[form_type or ""] = fields
+
+        defer.returnValue((
+            [str(f) for f in disco_infos.features],
+            [(cat, type_, name or "")
+             for (cat, type_), name in list(disco_infos.identities.items())],
+            extensions))
+
+    def items2tuples(self, disco_items):
+        """convert disco items to tuple of strings
+
+        @param disco_items(iterable[disco.DiscoItem]): items
+        @return G(tuple[unicode,unicode,unicode]): serialised items
+        """
+        for item in disco_items:
+            if not item.entity:
+                log.warning(_("invalid item (no jid)"))
+                continue
+            yield (item.entity.full(), item.nodeIdentifier or "", item.name or "")
+
+    @defer.inlineCallbacks
+    def _disco_items(
+        self, entity_jid_s, node="", use_cache=True, profile_key=C.PROF_KEY_NONE
+    ):
+        """ Discovery method for the bridge
+
+        @param entity_jid_s: entity we want to discover
+        @param node(unicode): optional node to use
+        @param use_cache(bool): if True, use cached data if available
+        @return: list of tuples"""
+        client = self.host.get_client(profile_key)
+        entity = jid.JID(entity_jid_s)
+        disco_items = yield self.get_items(client, entity, node, use_cache)
+        ret = list(self.items2tuples(disco_items))
+        defer.returnValue(ret)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/encryption.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,534 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import copy
+from functools import partial
+from typing import Optional
+from twisted.words.protocols.jabber import jid
+from twisted.internet import defer
+from twisted.python import failure
+from libervia.backend.core.core_types import EncryptionPlugin, EncryptionSession, MessageData
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools import utils
+from libervia.backend.memory import persistent
+
+
+log = getLogger(__name__)
+
+
+class EncryptionHandler:
+    """Class to handle encryption sessions for a client"""
+    plugins = []  # plugin able to encrypt messages
+
+    def __init__(self, client):
+        self.client = client
+        self._sessions = {}  # bare_jid ==> encryption_data
+        self._stored_session = persistent.PersistentDict(
+            "core:encryption", profile=client.profile)
+
+    @property
+    def host(self):
+        return self.client.host_app
+
+    async def load_sessions(self):
+        """Load persistent sessions"""
+        await self._stored_session.load()
+        start_d_list = []
+        for entity_jid_s, namespace in self._stored_session.items():
+            entity = jid.JID(entity_jid_s)
+            start_d_list.append(defer.ensureDeferred(self.start(entity, namespace)))
+
+        if start_d_list:
+            result = await defer.DeferredList(start_d_list)
+            for idx, (success, err) in enumerate(result):
+                if not success:
+                    entity_jid_s, namespace = list(self._stored_session.items())[idx]
+                    log.warning(_(
+                        "Could not restart {namespace!r} encryption with {entity}: {err}"
+                        ).format(namespace=namespace, entity=entity_jid_s, err=err))
+            log.info(_("encryption sessions restored"))
+
+    @classmethod
+    def register_plugin(cls, plg_instance, name, namespace, priority=0, directed=False):
+        """Register a plugin handling an encryption algorithm
+
+        @param plg_instance(object): instance of the plugin
+            it must have the following methods:
+                - get_trust_ui(entity): return a XMLUI for trust management
+                    entity(jid.JID): entity to manage
+                    The returned XMLUI must be a form
+            if may have the following methods:
+                - start_encryption(entity): start encrypted session
+                    entity(jid.JID): entity to start encrypted session with
+                - stop_encryption(entity): start encrypted session
+                    entity(jid.JID): entity to stop encrypted session with
+            if they don't exists, those 2 methods will be ignored.
+
+        @param name(unicode): human readable name of the encryption algorithm
+        @param namespace(unicode): namespace of the encryption algorithm
+        @param priority(int): priority of this plugin to encrypt an message when not
+            selected manually
+        @param directed(bool): True if this plugin is directed (if it works with one
+                               device only at a time)
+        """
+        existing_ns = set()
+        existing_names = set()
+        for p in cls.plugins:
+            existing_ns.add(p.namespace.lower())
+            existing_names.add(p.name.lower())
+        if namespace.lower() in existing_ns:
+            raise exceptions.ConflictError("A plugin with this namespace already exists!")
+        if name.lower() in existing_names:
+            raise exceptions.ConflictError("A plugin with this name already exists!")
+        plugin = EncryptionPlugin(
+            instance=plg_instance,
+            name=name,
+            namespace=namespace,
+            priority=priority,
+            directed=directed)
+        cls.plugins.append(plugin)
+        cls.plugins.sort(key=lambda p: p.priority)
+        log.info(_("Encryption plugin registered: {name}").format(name=name))
+
+    @classmethod
+    def getPlugins(cls):
+        return cls.plugins
+
+    @classmethod
+    def get_plugin(cls, namespace):
+        try:
+            return next(p for p in cls.plugins if p.namespace == namespace)
+        except StopIteration:
+            raise exceptions.NotFound(_(
+                "Can't find requested encryption plugin: {namespace}").format(
+                    namespace=namespace))
+
+    @classmethod
+    def get_namespaces(cls):
+        """Get available plugin namespaces"""
+        return {p.namespace for p in cls.getPlugins()}
+
+    @classmethod
+    def get_ns_from_name(cls, name):
+        """Retrieve plugin namespace from its name
+
+        @param name(unicode): name of the plugin (case insensitive)
+        @return (unicode): namespace of the plugin
+        @raise exceptions.NotFound: there is not encryption plugin of this name
+        """
+        for p in cls.plugins:
+            if p.name.lower() == name.lower():
+                return p.namespace
+        raise exceptions.NotFound(_(
+            "Can't find a plugin with the name \"{name}\".".format(
+                name=name)))
+
+    def get_bridge_data(self, session):
+        """Retrieve session data serialized for bridge.
+
+        @param session(dict): encryption session
+        @return (unicode): serialized data for bridge
+        """
+        if session is None:
+            return ''
+        plugin = session['plugin']
+        bridge_data = {'name': plugin.name,
+                       'namespace': plugin.namespace}
+        if 'directed_devices' in session:
+            bridge_data['directed_devices'] = session['directed_devices']
+
+        return data_format.serialise(bridge_data)
+
+    async def _start_encryption(self, plugin, entity):
+        """Start encryption with a plugin
+
+        This method must be called just before adding a plugin session.
+        StartEncryptionn method of plugin will be called if it exists.
+        """
+        if not plugin.directed:
+            await self._stored_session.aset(entity.userhost(), plugin.namespace)
+        try:
+            start_encryption = plugin.instance.start_encryption
+        except AttributeError:
+            log.debug(f"No start_encryption method found for {plugin.namespace}")
+        else:
+            # we copy entity to avoid having the resource changed by stop_encryption
+            await utils.as_deferred(start_encryption, self.client, copy.copy(entity))
+
+    async def _stop_encryption(self, plugin, entity):
+        """Stop encryption with a plugin
+
+        This method must be called just before removing a plugin session.
+        StopEncryptionn method of plugin will be called if it exists.
+        """
+        try:
+            await self._stored_session.adel(entity.userhost())
+        except KeyError:
+            pass
+        try:
+            stop_encryption = plugin.instance.stop_encryption
+        except AttributeError:
+            log.debug(f"No stop_encryption method found for {plugin.namespace}")
+        else:
+            # we copy entity to avoid having the resource changed by stop_encryption
+            return utils.as_deferred(stop_encryption, self.client, copy.copy(entity))
+
+    async def start(self, entity, namespace=None, replace=False):
+        """Start an encryption session with an entity
+
+        @param entity(jid.JID): entity to start an encryption session with
+            must be bare jid is the algorithm encrypt for all devices
+        @param namespace(unicode, None): namespace of the encryption algorithm
+            to use.
+            None to select automatically an algorithm
+        @param replace(bool): if True and an encrypted session already exists,
+            it will be replaced by the new one
+        """
+        if not self.plugins:
+            raise exceptions.NotFound(_("No encryption plugin is registered, "
+                                        "an encryption session can't be started"))
+
+        if namespace is None:
+            plugin = self.plugins[0]
+        else:
+            plugin = self.get_plugin(namespace)
+
+        bare_jid = entity.userhostJID()
+        if bare_jid in self._sessions:
+            # we have already an encryption session with this contact
+            former_plugin = self._sessions[bare_jid]["plugin"]
+            if former_plugin.namespace == namespace:
+                log.info(_("Session with {bare_jid} is already encrypted with {name}. "
+                           "Nothing to do.").format(
+                               bare_jid=bare_jid, name=former_plugin.name))
+                return
+
+            if replace:
+                # there is a conflict, but replacement is requested
+                # so we stop previous encryption to use new one
+                del self._sessions[bare_jid]
+                await self._stop_encryption(former_plugin, entity)
+            else:
+                msg = (_("Session with {bare_jid} is already encrypted with {name}. "
+                         "Please stop encryption session before changing algorithm.")
+                       .format(bare_jid=bare_jid, name=plugin.name))
+                log.warning(msg)
+                raise exceptions.ConflictError(msg)
+
+        data = {"plugin": plugin}
+        if plugin.directed:
+            if not entity.resource:
+                entity.resource = self.host.memory.main_resource_get(self.client, entity)
+                if not entity.resource:
+                    raise exceptions.NotFound(
+                        _("No resource found for {destinee}, can't encrypt with {name}")
+                        .format(destinee=entity.full(), name=plugin.name))
+                log.info(_("No resource specified to encrypt with {name}, using "
+                           "{destinee}.").format(destinee=entity.full(),
+                                                  name=plugin.name))
+            # indicate that we encrypt only for some devices
+            directed_devices = data['directed_devices'] = [entity.resource]
+        elif entity.resource:
+            raise ValueError(_("{name} encryption must be used with bare jids."))
+
+        await self._start_encryption(plugin, entity)
+        self._sessions[entity.userhostJID()] = data
+        log.info(_("Encryption session has been set for {entity_jid} with "
+                   "{encryption_name}").format(
+                   entity_jid=entity.full(), encryption_name=plugin.name))
+        self.host.bridge.message_encryption_started(
+            entity.full(),
+            self.get_bridge_data(data),
+            self.client.profile)
+        msg = D_("Encryption session started: your messages with {destinee} are "
+                 "now end to end encrypted using {name} algorithm.").format(
+                 destinee=entity.full(), name=plugin.name)
+        directed_devices = data.get('directed_devices')
+        if directed_devices:
+            msg += "\n" + D_("Message are encrypted only for {nb_devices} device(s): "
+                              "{devices_list}.").format(
+                              nb_devices=len(directed_devices),
+                              devices_list = ', '.join(directed_devices))
+
+        self.client.feedback(bare_jid, msg)
+
+    async def stop(self, entity, namespace=None):
+        """Stop an encryption session with an entity
+
+        @param entity(jid.JID): entity with who the encryption session must be stopped
+            must be bare jid if the algorithm encrypt for all devices
+        @param namespace(unicode): namespace of the session to stop
+            when specified, used to check that we stop the right encryption session
+        """
+        session = self.getSession(entity.userhostJID())
+        if not session:
+            raise failure.Failure(
+                exceptions.NotFound(_("There is no encryption session with this "
+                                      "entity.")))
+        plugin = session['plugin']
+        if namespace is not None and plugin.namespace != namespace:
+            raise exceptions.InternalError(_(
+                "The encryption session is not run with the expected plugin: encrypted "
+                "with {current_name} and was expecting {expected_name}").format(
+                current_name=session['plugin'].namespace,
+                expected_name=namespace))
+        if entity.resource:
+            try:
+                directed_devices = session['directed_devices']
+            except KeyError:
+                raise exceptions.NotFound(_(
+                    "There is a session for the whole entity (i.e. all devices of the "
+                    "entity), not a directed one. Please use bare jid if you want to "
+                    "stop the whole encryption with this entity."))
+
+            try:
+                directed_devices.remove(entity.resource)
+            except ValueError:
+                raise exceptions.NotFound(_("There is no directed session with this "
+                                            "entity."))
+            else:
+                if not directed_devices:
+                    # if we have no more directed device sessions,
+                    # we stop the whole session
+                    # see comment below for deleting session before stopping encryption
+                    del self._sessions[entity.userhostJID()]
+                    await self._stop_encryption(plugin, entity)
+        else:
+            # plugin's stop_encryption may call stop again (that's the case with OTR)
+            # so we need to remove plugin from session before calling self._stop_encryption
+            del self._sessions[entity.userhostJID()]
+            await self._stop_encryption(plugin, entity)
+
+        log.info(_("encryption session stopped with entity {entity}").format(
+            entity=entity.full()))
+        self.host.bridge.message_encryption_stopped(
+            entity.full(),
+            {'name': plugin.name,
+             'namespace': plugin.namespace,
+            },
+            self.client.profile)
+        msg = D_("Encryption session finished: your messages with {destinee} are "
+                 "NOT end to end encrypted anymore.\nYour server administrators or "
+                 "{destinee} server administrators will be able to read them.").format(
+                 destinee=entity.full())
+
+        self.client.feedback(entity, msg)
+
+    def getSession(self, entity: jid.JID) -> Optional[EncryptionSession]:
+        """Get encryption session for this contact
+
+        @param entity(jid.JID): get the session for this entity
+            must be a bare jid
+        @return (dict, None): encryption session data
+            None if there is not encryption for this session with this jid
+        """
+        if entity.resource:
+            raise ValueError("Full jid given when expecting bare jid")
+        return self._sessions.get(entity)
+
+    def get_namespace(self, entity: jid.JID) -> Optional[str]:
+        """Helper method to get the current encryption namespace used
+
+        @param entity: get the namespace for this entity must be a bare jid
+        @return: the algorithm namespace currently used in this session, or None if no
+            e2ee is currently used.
+        """
+        session = self.getSession(entity)
+        if session is None:
+            return None
+        return session["plugin"].namespace
+
+    def get_trust_ui(self, entity_jid, namespace=None):
+        """Retrieve encryption UI
+
+        @param entity_jid(jid.JID): get the UI for this entity
+            must be a bare jid
+        @param namespace(unicode): namespace of the algorithm to manage
+            if None use current algorithm
+        @return D(xmlui): XMLUI for trust management
+            the xmlui is a form
+            None if there is not encryption for this session with this jid
+        @raise exceptions.NotFound: no algorithm/plugin found
+        @raise NotImplementedError: plugin doesn't handle UI management
+        """
+        if namespace is None:
+            session = self.getSession(entity_jid)
+            if not session:
+                raise exceptions.NotFound(
+                    "No encryption session currently active for {entity_jid}"
+                    .format(entity_jid=entity_jid.full()))
+            plugin = session['plugin']
+        else:
+            plugin = self.get_plugin(namespace)
+        try:
+            get_trust_ui = plugin.instance.get_trust_ui
+        except AttributeError:
+            raise NotImplementedError(
+                "Encryption plugin doesn't handle trust management UI")
+        else:
+            return utils.as_deferred(get_trust_ui, self.client, entity_jid)
+
+    ## Menus ##
+
+    @classmethod
+    def _import_menus(cls, host):
+        host.import_menu(
+             (D_("Encryption"), D_("unencrypted (plain text)")),
+             partial(cls._on_menu_unencrypted, host=host),
+             security_limit=0,
+             help_string=D_("End encrypted session"),
+             type_=C.MENU_SINGLE,
+        )
+        for plg in cls.getPlugins():
+            host.import_menu(
+                 (D_("Encryption"), plg.name),
+                 partial(cls._on_menu_name, host=host, plg=plg),
+                 security_limit=0,
+                 help_string=D_("Start {name} session").format(name=plg.name),
+                 type_=C.MENU_SINGLE,
+            )
+            host.import_menu(
+                 (D_("Encryption"), D_("⛨ {name} trust").format(name=plg.name)),
+                 partial(cls._on_menu_trust, host=host, plg=plg),
+                 security_limit=0,
+                 help_string=D_("Manage {name} trust").format(name=plg.name),
+                 type_=C.MENU_SINGLE,
+            )
+
+    @classmethod
+    def _on_menu_unencrypted(cls, data, host, profile):
+        client = host.get_client(profile)
+        peer_jid = jid.JID(data['jid']).userhostJID()
+        d = defer.ensureDeferred(client.encryption.stop(peer_jid))
+        d.addCallback(lambda __: {})
+        return d
+
+    @classmethod
+    def _on_menu_name(cls, data, host, plg, profile):
+        client = host.get_client(profile)
+        peer_jid = jid.JID(data['jid'])
+        if not plg.directed:
+            peer_jid = peer_jid.userhostJID()
+        d = defer.ensureDeferred(
+            client.encryption.start(peer_jid, plg.namespace, replace=True))
+        d.addCallback(lambda __: {})
+        return d
+
+    @classmethod
+    @defer.inlineCallbacks
+    def _on_menu_trust(cls, data, host, plg, profile):
+        client = host.get_client(profile)
+        peer_jid = jid.JID(data['jid']).userhostJID()
+        ui = yield client.encryption.get_trust_ui(peer_jid, plg.namespace)
+        defer.returnValue({'xmlui': ui.toXml()})
+
+    ## Triggers ##
+
+    def set_encryption_flag(self, mess_data):
+        """Set "encryption" key in mess_data if session with destinee is encrypted"""
+        to_jid = mess_data['to']
+        encryption = self._sessions.get(to_jid.userhostJID())
+        if encryption is not None:
+            plugin = encryption['plugin']
+            if mess_data["type"] == "groupchat" and plugin.directed:
+                raise exceptions.InternalError(
+                f"encryption flag must not be set for groupchat if encryption algorithm "
+                f"({encryption['plugin'].name}) is directed!")
+            mess_data[C.MESS_KEY_ENCRYPTION] = encryption
+            self.mark_as_encrypted(mess_data, plugin.namespace)
+
+    ## Misc ##
+
+    def mark_as_encrypted(self, mess_data, namespace):
+        """Helper method to mark a message as having been e2e encrypted.
+
+        This should be used in the post_treat workflow of message_received trigger of
+        the plugin
+        @param mess_data(dict): message data as used in post treat workflow
+        @param namespace(str): namespace of the algorithm used for encrypting the message
+        """
+        mess_data['extra'][C.MESS_KEY_ENCRYPTED] = True
+        from_bare_jid = mess_data['from'].userhostJID()
+        if from_bare_jid != self.client.jid.userhostJID():
+            session = self.getSession(from_bare_jid)
+            if session is None:
+                # if we are currently unencrypted, we start a session automatically
+                # to avoid sending unencrypted messages in an encrypted context
+                log.info(_(
+                    "Starting e2e session with {peer_jid} as we receive encrypted "
+                    "messages")
+                    .format(peer_jid=from_bare_jid)
+                )
+                defer.ensureDeferred(self.start(from_bare_jid, namespace))
+
+        return mess_data
+
+    def is_encryption_requested(
+        self,
+        mess_data: MessageData,
+        namespace: Optional[str] = None
+    ) -> bool:
+        """Helper method to check if encryption is requested in an outgoind message
+
+        @param mess_data: message data for outgoing message
+        @param namespace: if set, check if encryption is requested for the algorithm
+            specified
+        @return: True if the encryption flag is present
+        """
+        encryption = mess_data.get(C.MESS_KEY_ENCRYPTION)
+        if encryption is None:
+            return False
+        # we get plugin even if namespace is None to be sure that the key exists
+        plugin = encryption['plugin']
+        if namespace is None:
+            return True
+        return plugin.namespace == namespace
+
+    def isEncrypted(self, mess_data):
+        """Helper method to check if a message has the e2e encrypted flag
+
+        @param mess_data(dict): message data
+        @return (bool): True if the encrypted flag is present
+        """
+        return mess_data['extra'].get(C.MESS_KEY_ENCRYPTED, False)
+
+
+    def mark_as_trusted(self, mess_data):
+        """Helper methor to mark a message as sent from a trusted entity.
+
+        This should be used in the post_treat workflow of message_received trigger of
+        the plugin
+        @param mess_data(dict): message data as used in post treat workflow
+        """
+        mess_data[C.MESS_KEY_TRUSTED] = True
+        return mess_data
+
+    def mark_as_untrusted(self, mess_data):
+        """Helper methor to mark a message as sent from an untrusted entity.
+
+        This should be used in the post_treat workflow of message_received trigger of
+        the plugin
+        @param mess_data(dict): message data as used in post treat workflow
+        """
+        mess_data['trusted'] = False
+        return mess_data
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/memory.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1881 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import os.path
+import copy
+import shortuuid
+import mimetypes
+import time
+from functools import partial
+from typing import Optional, Tuple, Dict
+from pathlib import Path
+from uuid import uuid4
+from collections import namedtuple
+from twisted.python import failure
+from twisted.internet import defer, reactor, error
+from twisted.words.protocols.jabber import jid
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.memory.sqla import Storage
+from libervia.backend.memory.persistent import PersistentDict
+from libervia.backend.memory.params import Params
+from libervia.backend.memory.disco import Discovery
+from libervia.backend.memory.crypto import BlockCipher
+from libervia.backend.memory.crypto import PasswordHasher
+from libervia.backend.tools import config as tools_config
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import regex
+
+
+log = getLogger(__name__)
+
+
+PresenceTuple = namedtuple("PresenceTuple", ("show", "priority", "statuses"))
+MSG_NO_SESSION = "Session id doesn't exist or is finished"
+
+
+class Sessions(object):
+    """Sessions are data associated to key used for a temporary moment, with optional profile checking."""
+
+    DEFAULT_TIMEOUT = 600
+
+    def __init__(self, timeout=None, resettable_timeout=True):
+        """
+        @param timeout (int): nb of seconds before session destruction
+        @param resettable_timeout (bool): if True, the timeout is reset on each access
+        """
+        self._sessions = dict()
+        self.timeout = timeout or Sessions.DEFAULT_TIMEOUT
+        self.resettable_timeout = resettable_timeout
+
+    def new_session(self, session_data=None, session_id=None, profile=None):
+        """Create a new session
+
+        @param session_data: mutable data to use, default to a dict
+        @param session_id (str): force the session_id to the given string
+        @param profile: if set, the session is owned by the profile,
+                        and profile_get must be used instead of __getitem__
+        @return: session_id, session_data
+        """
+        if session_id is None:
+            session_id = str(uuid4())
+        elif session_id in self._sessions:
+            raise exceptions.ConflictError(
+                "Session id {} is already used".format(session_id)
+            )
+        timer = reactor.callLater(self.timeout, self._purge_session, session_id)
+        if session_data is None:
+            session_data = {}
+        self._sessions[session_id] = (
+            (timer, session_data) if profile is None else (timer, session_data, profile)
+        )
+        return session_id, session_data
+
+    def _purge_session(self, session_id):
+        try:
+            timer, session_data, profile = self._sessions[session_id]
+        except ValueError:
+            timer, session_data = self._sessions[session_id]
+            profile = None
+        try:
+            timer.cancel()
+        except error.AlreadyCalled:
+            # if the session is time-outed, the timer has been called
+            pass
+        del self._sessions[session_id]
+        log.debug(
+            "Session {} purged{}".format(
+                session_id,
+                " (profile {})".format(profile) if profile is not None else "",
+            )
+        )
+
+    def __len__(self):
+        return len(self._sessions)
+
+    def __contains__(self, session_id):
+        return session_id in self._sessions
+
+    def profile_get(self, session_id, profile):
+        try:
+            timer, session_data, profile_set = self._sessions[session_id]
+        except ValueError:
+            raise exceptions.InternalError(
+                "You need to use __getitem__ when profile is not set"
+            )
+        except KeyError:
+            raise failure.Failure(KeyError(MSG_NO_SESSION))
+        if profile_set != profile:
+            raise exceptions.InternalError("current profile differ from set profile !")
+        if self.resettable_timeout:
+            timer.reset(self.timeout)
+        return session_data
+
+    def __getitem__(self, session_id):
+        try:
+            timer, session_data = self._sessions[session_id]
+        except ValueError:
+            raise exceptions.InternalError(
+                "You need to use profile_get instead of __getitem__ when profile is set"
+            )
+        except KeyError:
+            raise failure.Failure(KeyError(MSG_NO_SESSION))
+        if self.resettable_timeout:
+            timer.reset(self.timeout)
+        return session_data
+
+    def __setitem__(self, key, value):
+        raise NotImplementedError("You need do use new_session to create a session")
+
+    def __delitem__(self, session_id):
+        """ delete the session data """
+        self._purge_session(session_id)
+
+    def keys(self):
+        return list(self._sessions.keys())
+
+    def iterkeys(self):
+        return iter(self._sessions.keys())
+
+
+class ProfileSessions(Sessions):
+    """ProfileSessions extends the Sessions class, but here the profile can be
+    used as the key to retrieve data or delete a session (instead of session id).
+    """
+
+    def _profile_get_all_ids(self, profile):
+        """Return a list of the sessions ids that are associated to the given profile.
+
+        @param profile: %(doc_profile)s
+        @return: a list containing the sessions ids
+        """
+        ret = []
+        for session_id in self._sessions.keys():
+            try:
+                timer, session_data, profile_set = self._sessions[session_id]
+            except ValueError:
+                continue
+            if profile == profile_set:
+                ret.append(session_id)
+        return ret
+
+    def profile_get_unique(self, profile):
+        """Return the data of the unique session that is associated to the given profile.
+
+        @param profile: %(doc_profile)s
+        @return:
+            - mutable data (default: dict) of the unique session
+            - None if no session is associated to the profile
+            - raise an error if more than one session are found
+        """
+        ids = self._profile_get_all_ids(profile)
+        if len(ids) > 1:
+            raise exceptions.InternalError(
+                "profile_get_unique has been used but more than one session has been found!"
+            )
+        return (
+            self.profile_get(ids[0], profile) if len(ids) == 1 else None
+        )  # XXX: timeout might be reset
+
+    def profile_del_unique(self, profile):
+        """Delete the unique session that is associated to the given profile.
+
+        @param profile: %(doc_profile)s
+        @return: None, but raise an error if more than one session are found
+        """
+        ids = self._profile_get_all_ids(profile)
+        if len(ids) > 1:
+            raise exceptions.InternalError(
+                "profile_del_unique has been used but more than one session has been found!"
+            )
+        if len(ids) == 1:
+            del self._sessions[ids[0]]
+
+
+class PasswordSessions(ProfileSessions):
+
+    # FIXME: temporary hack for the user personal key not to be lost. The session
+    # must actually be purged and later, when the personal key is needed, the
+    # profile password should be asked again in order to decrypt it.
+    def __init__(self, timeout=None):
+        ProfileSessions.__init__(self, timeout, resettable_timeout=False)
+
+    def _purge_session(self, session_id):
+        log.debug(
+            "FIXME: PasswordSessions should ask for the profile password after the session expired"
+        )
+
+
+class Memory:
+    """This class manage all the persistent information"""
+
+    def __init__(self, host):
+        log.info(_("Memory manager init"))
+        self.host = host
+        self._entities_cache = {}  # XXX: keep presence/last resource/other data in cache
+        #     /!\ an entity is not necessarily in roster
+        #     main key is bare jid, value is a dict
+        #     where main key is resource, or None for bare jid
+        self._key_signals = set()  # key which need a signal to frontends when updated
+        self.subscriptions = {}
+        self.auth_sessions = PasswordSessions()  # remember the authenticated profiles
+        self.disco = Discovery(host)
+        self.config = tools_config.parse_main_conf(log_filenames=True)
+        self._cache_path = Path(self.config_get("", "local_dir"), C.CACHE_DIR)
+        self.admins = self.config_get("", "admins_list", [])
+        self.admin_jids = set()
+
+
+    async def initialise(self):
+        self.storage = Storage()
+        await self.storage.initialise()
+        PersistentDict.storage = self.storage
+        self.params = Params(self.host, self.storage)
+        log.info(_("Loading default params template"))
+        self.params.load_default_params()
+        await self.load()
+        self.memory_data = PersistentDict("memory")
+        await self.memory_data.load()
+        await self.disco.load()
+        for admin in self.admins:
+            try:
+                admin_jid_s = await self.param_get_a_async(
+                    "JabberID", "Connection", profile_key=admin
+                )
+            except Exception as e:
+                log.warning(f"Can't retrieve jid of admin {admin!r}: {e}")
+            else:
+                if admin_jid_s is not None:
+                    try:
+                        admin_jid = jid.JID(admin_jid_s).userhostJID()
+                    except RuntimeError:
+                        log.warning(f"Invalid JID for admin {admin}: {admin_jid_s}")
+                    else:
+                        self.admin_jids.add(admin_jid)
+
+
+    ## Configuration ##
+
+    def config_get(self, section, name, default=None):
+        """Get the main configuration option
+
+        @param section: section of the config file (None or '' for DEFAULT)
+        @param name: name of the option
+        @param default: value to use if not found
+        @return: str, list or dict
+        """
+        return tools_config.config_get(self.config, section, name, default)
+
+    def load_xml(self, filename):
+        """Load parameters template from xml file
+
+        @param filename (str): input file
+        @return: bool: True in case of success
+        """
+        if not filename:
+            return False
+        filename = os.path.expanduser(filename)
+        if os.path.exists(filename):
+            try:
+                self.params.load_xml(filename)
+                log.debug(_("Parameters loaded from file: %s") % filename)
+                return True
+            except Exception as e:
+                log.error(_("Can't load parameters from file: %s") % e)
+        return False
+
+    def save_xml(self, filename):
+        """Save parameters template to xml file
+
+        @param filename (str): output file
+        @return: bool: True in case of success
+        """
+        if not filename:
+            return False
+        # TODO: need to encrypt files (at least passwords !) and set permissions
+        filename = os.path.expanduser(filename)
+        try:
+            self.params.save_xml(filename)
+            log.debug(_("Parameters saved to file: %s") % filename)
+            return True
+        except Exception as e:
+            log.error(_("Can't save parameters to file: %s") % e)
+        return False
+
+    def load(self):
+        """Load parameters and all memory things from db"""
+        # parameters data
+        return self.params.load_gen_params()
+
+    def load_individual_params(self, profile):
+        """Load individual parameters for a profile
+        @param profile: %(doc_profile)s"""
+        return self.params.load_ind_params(profile)
+
+    ## Profiles/Sessions management ##
+
+    def start_session(self, password, profile):
+        """"Iniatialise session for a profile
+
+        @param password(unicode): profile session password
+            or empty string is no password is set
+        @param profile: %(doc_profile)s
+        @raise exceptions.ProfileUnknownError if profile doesn't exists
+        @raise exceptions.PasswordError: the password does not match
+        """
+        profile = self.get_profile_name(profile)
+
+        def create_session(__):
+            """Called once params are loaded."""
+            self._entities_cache[profile] = {}
+            log.info("[{}] Profile session started".format(profile))
+            return False
+
+        def backend_initialised(__):
+            def do_start_session(__=None):
+                if self.is_session_started(profile):
+                    log.info("Session already started!")
+                    return True
+                try:
+                    # if there is a value at this point in self._entities_cache,
+                    # it is the load_individual_params Deferred, the session is starting
+                    session_d = self._entities_cache[profile]
+                except KeyError:
+                    # else we do request the params
+                    session_d = self._entities_cache[profile] = self.load_individual_params(
+                        profile
+                    )
+                    session_d.addCallback(create_session)
+                finally:
+                    return session_d
+
+            auth_d = defer.ensureDeferred(self.profile_authenticate(password, profile))
+            auth_d.addCallback(do_start_session)
+            return auth_d
+
+        if self.host.initialised.called:
+            return defer.succeed(None).addCallback(backend_initialised)
+        else:
+            return self.host.initialised.addCallback(backend_initialised)
+
+    def stop_session(self, profile):
+        """Delete a profile session
+
+        @param profile: %(doc_profile)s
+        """
+        if self.host.is_connected(profile):
+            log.debug("Disconnecting profile because of session stop")
+            self.host.disconnect(profile)
+        self.auth_sessions.profile_del_unique(profile)
+        try:
+            self._entities_cache[profile]
+        except KeyError:
+            log.warning("Profile was not in cache")
+
+    def _is_session_started(self, profile_key):
+        return self.is_session_started(self.get_profile_name(profile_key))
+
+    def is_session_started(self, profile):
+        try:
+            # XXX: if the value in self._entities_cache is a Deferred,
+            #      the session is starting but not started yet
+            return not isinstance(self._entities_cache[profile], defer.Deferred)
+        except KeyError:
+            return False
+
+    async def profile_authenticate(self, password, profile):
+        """Authenticate the profile.
+
+        @param password (unicode): the SàT profile password
+        @return: None in case of success (an exception is raised otherwise)
+        @raise exceptions.PasswordError: the password does not match
+        """
+        if not password and self.auth_sessions.profile_get_unique(profile):
+            # XXX: this allows any frontend to connect with the empty password as soon as
+            # the profile has been authenticated at least once before. It is OK as long as
+            # submitting a form with empty passwords is restricted to local frontends.
+            return
+
+        sat_cipher = await self.param_get_a_async(
+            C.PROFILE_PASS_PATH[1], C.PROFILE_PASS_PATH[0], profile_key=profile
+        )
+        valid = PasswordHasher.verify(password, sat_cipher)
+        if not valid:
+            log.warning(_("Authentication failure of profile {profile}").format(
+                profile=profile))
+            raise exceptions.PasswordError("The provided profile password doesn't match.")
+        return await self.new_auth_session(password, profile)
+
+    async def new_auth_session(self, key, profile):
+        """Start a new session for the authenticated profile.
+
+        If there is already an existing session, no new one is created
+        The personal key is loaded encrypted from a PersistentDict before being decrypted.
+
+        @param key: the key to decrypt the personal key
+        @param profile: %(doc_profile)s
+        """
+        data = await PersistentDict(C.MEMORY_CRYPTO_NAMESPACE, profile).load()
+        personal_key = BlockCipher.decrypt(key, data[C.MEMORY_CRYPTO_KEY])
+        # Create the session for this profile and store the personal key
+        session_data = self.auth_sessions.profile_get_unique(profile)
+        if not session_data:
+            self.auth_sessions.new_session(
+                {C.MEMORY_CRYPTO_KEY: personal_key}, profile=profile
+            )
+            log.debug("auth session created for profile %s" % profile)
+
+    def purge_profile_session(self, profile):
+        """Delete cache of data of profile
+        @param profile: %(doc_profile)s"""
+        log.info(_("[%s] Profile session purge" % profile))
+        self.params.purge_profile(profile)
+        try:
+            del self._entities_cache[profile]
+        except KeyError:
+            log.error(
+                _(
+                    "Trying to purge roster status cache for a profile not in memory: [%s]"
+                )
+                % profile
+            )
+
+    def get_profiles_list(self, clients=True, components=False):
+        """retrieve profiles list
+
+        @param clients(bool): if True return clients profiles
+        @param components(bool): if True return components profiles
+        @return (list[unicode]): selected profiles
+        """
+        if not clients and not components:
+            log.warning(_("requesting no profiles at all"))
+            return []
+        profiles = self.storage.get_profiles_list()
+        if clients and components:
+            return sorted(profiles)
+        is_component = self.storage.profile_is_component
+        if clients:
+            p_filter = lambda p: not is_component(p)
+        else:
+            p_filter = lambda p: is_component(p)
+
+        return sorted(p for p in profiles if p_filter(p))
+
+    def get_profile_name(self, profile_key, return_profile_keys=False):
+        """Return name of profile from keyword
+
+        @param profile_key: can be the profile name or a keyword (like @DEFAULT@)
+        @param return_profile_keys: if True, return unmanaged profile keys (like "@ALL@"). This keys must be managed by the caller
+        @return: requested profile name
+        @raise exceptions.ProfileUnknownError if profile doesn't exists
+        """
+        return self.params.get_profile_name(profile_key, return_profile_keys)
+
+    def profile_set_default(self, profile):
+        """Set default profile
+
+        @param profile: %(doc_profile)s
+        """
+        # we want to be sure that the profile exists
+        profile = self.get_profile_name(profile)
+
+        self.memory_data["Profile_default"] = profile
+
+    def create_profile(self, name, password, component=None):
+        """Create a new profile
+
+        @param name(unicode): profile name
+        @param password(unicode): profile password
+            Can be empty to disable password
+        @param component(None, unicode): set to entry point if this is a component
+        @return: Deferred
+        @raise exceptions.NotFound: component is not a known plugin import name
+        """
+        if not name:
+            raise ValueError("Empty profile name")
+        if name[0] == "@":
+            raise ValueError("A profile name can't start with a '@'")
+        if "\n" in name:
+            raise ValueError("A profile name can't contain line feed ('\\n')")
+
+        if name in self._entities_cache:
+            raise exceptions.ConflictError("A session for this profile exists")
+
+        if component:
+            if not component in self.host.plugins:
+                raise exceptions.NotFound(
+                    _(
+                        "Can't find component {component} entry point".format(
+                            component=component
+                        )
+                    )
+                )
+            # FIXME: PLUGIN_INFO is not currently accessible after import, but type shoul be tested here
+            #  if self.host.plugins[component].PLUGIN_INFO[u"type"] != C.PLUG_TYPE_ENTRY_POINT:
+            #      raise ValueError(_(u"Plugin {component} is not an entry point !".format(
+            #          component = component)))
+
+        d = self.params.create_profile(name, component)
+
+        def init_personal_key(__):
+            # be sure to call this after checking that the profile doesn't exist yet
+
+            # generated once for all and saved in a PersistentDict
+            personal_key = BlockCipher.get_random_key(
+                base64=True
+            ).decode('utf-8')
+            self.auth_sessions.new_session(
+                {C.MEMORY_CRYPTO_KEY: personal_key}, profile=name
+            )  # will be encrypted by param_set
+
+        def start_fake_session(__):
+            # avoid ProfileNotConnected exception in param_set
+            self._entities_cache[name] = None
+            self.params.load_ind_params(name)
+
+        def stop_fake_session(__):
+            del self._entities_cache[name]
+            self.params.purge_profile(name)
+
+        d.addCallback(init_personal_key)
+        d.addCallback(start_fake_session)
+        d.addCallback(
+            lambda __: self.param_set(
+                C.PROFILE_PASS_PATH[1], password, C.PROFILE_PASS_PATH[0], profile_key=name
+            )
+        )
+        d.addCallback(stop_fake_session)
+        d.addCallback(lambda __: self.auth_sessions.profile_del_unique(name))
+        return d
+
+    def profile_delete_async(self, name, force=False):
+        """Delete an existing profile
+
+        @param name: Name of the profile
+        @param force: force the deletion even if the profile is connected.
+        To be used for direct calls only (not through the bridge).
+        @return: a Deferred instance
+        """
+
+        def clean_memory(__):
+            self.auth_sessions.profile_del_unique(name)
+            try:
+                del self._entities_cache[name]
+            except KeyError:
+                pass
+
+        d = self.params.profile_delete_async(name, force)
+        d.addCallback(clean_memory)
+        return d
+
+    def is_component(self, profile_name):
+        """Tell if a profile is a component
+
+        @param profile_name(unicode): name of the profile
+        @return (bool): True if profile is a component
+        @raise exceptions.NotFound: profile doesn't exist
+        """
+        return self.storage.profile_is_component(profile_name)
+
+    def get_entry_point(self, profile_name):
+        """Get a component entry point
+
+        @param profile_name(unicode): name of the profile
+        @return (bool): True if profile is a component
+        @raise exceptions.NotFound: profile doesn't exist
+        """
+        return self.storage.get_entry_point(profile_name)
+
+    ## History ##
+
+    def add_to_history(self, client, data):
+        return self.storage.add_to_history(data, client.profile)
+
+    def _history_get_serialise(self, history_data):
+        return [
+            (uid, timestamp, from_jid, to_jid, message, subject, mess_type,
+             data_format.serialise(extra)) for uid, timestamp, from_jid, to_jid, message,
+            subject, mess_type, extra in history_data
+        ]
+
+    def _history_get(self, from_jid_s, to_jid_s, limit=C.HISTORY_LIMIT_NONE, between=True,
+                    filters=None, profile=C.PROF_KEY_NONE):
+        d = self.history_get(jid.JID(from_jid_s), jid.JID(to_jid_s), limit, between,
+                               filters, profile)
+        d.addCallback(self._history_get_serialise)
+        return d
+
+    def history_get(self, from_jid, to_jid, limit=C.HISTORY_LIMIT_NONE, between=True,
+                   filters=None, profile=C.PROF_KEY_NONE):
+        """Retrieve messages in history
+
+        @param from_jid (JID): source JID (full, or bare for catchall)
+        @param to_jid (JID): dest JID (full, or bare for catchall)
+        @param limit (int): maximum number of messages to get:
+            - 0 for no message (returns the empty list)
+            - C.HISTORY_LIMIT_NONE or None for unlimited
+            - C.HISTORY_LIMIT_DEFAULT to use the HISTORY_LIMIT parameter value
+        @param between (bool): confound source and dest (ignore the direction)
+        @param filters (dict[unicode, unicode]): pattern to filter the history results
+            (see bridge API for details)
+        @param profile (str): %(doc_profile)s
+        @return (D(list)): list of message data as in [message_new]
+        """
+        assert profile != C.PROF_KEY_NONE
+        if limit == C.HISTORY_LIMIT_DEFAULT:
+            limit = int(self.param_get_a(C.HISTORY_LIMIT, "General", profile_key=profile))
+        elif limit == C.HISTORY_LIMIT_NONE:
+            limit = None
+        if limit == 0:
+            return defer.succeed([])
+        return self.storage.history_get(from_jid, to_jid, limit, between, filters, profile)
+
+    ## Statuses ##
+
+    def _get_presence_statuses(self, profile_key):
+        ret = self.presence_statuses_get(profile_key)
+        return {entity.full(): data for entity, data in ret.items()}
+
+    def presence_statuses_get(self, profile_key):
+        """Get all the presence statuses of a profile
+
+        @param profile_key: %(doc_profile_key)s
+        @return: presence data: key=entity JID, value=presence data for this entity
+        """
+        client = self.host.get_client(profile_key)
+        profile_cache = self._get_profile_cache(client)
+        entities_presence = {}
+
+        for entity_jid, entity_data in profile_cache.items():
+            for resource, resource_data in entity_data.items():
+                full_jid = copy.copy(entity_jid)
+                full_jid.resource = resource
+                try:
+                    presence_data = self.get_entity_datum(client, full_jid, "presence")
+                except KeyError:
+                    continue
+                entities_presence.setdefault(entity_jid, {})[
+                    resource or ""
+                ] = presence_data
+
+        return entities_presence
+
+    def set_presence_status(self, entity_jid, show, priority, statuses, profile_key):
+        """Change the presence status of an entity
+
+        @param entity_jid: jid.JID of the entity
+        @param show: show status
+        @param priority: priority
+        @param statuses: dictionary of statuses
+        @param profile_key: %(doc_profile_key)s
+        """
+        client = self.host.get_client(profile_key)
+        presence_data = PresenceTuple(show, priority, statuses)
+        self.update_entity_data(
+            client, entity_jid, "presence", presence_data
+        )
+        if entity_jid.resource and show != C.PRESENCE_UNAVAILABLE:
+            # If a resource is available, bare jid should not have presence information
+            try:
+                self.del_entity_datum(client, entity_jid.userhostJID(), "presence")
+            except (KeyError, exceptions.UnknownEntityError):
+                pass
+
+    ## Resources ##
+
+    def _get_all_resource(self, jid_s, profile_key):
+        client = self.host.get_client(profile_key)
+        jid_ = jid.JID(jid_s)
+        return self.get_all_resources(client, jid_)
+
+    def get_all_resources(self, client, entity_jid):
+        """Return all resource from jid for which we have had data in this session
+
+        @param entity_jid: bare jid of the entity
+        return (set[unicode]): set of resources
+
+        @raise exceptions.UnknownEntityError: if entity is not in cache
+        @raise ValueError: entity_jid has a resource
+        """
+        # FIXME: is there a need to keep cache data for resources which are not connected anymore?
+        if entity_jid.resource:
+            raise ValueError(
+                "get_all_resources must be used with a bare jid (got {})".format(entity_jid)
+            )
+        profile_cache = self._get_profile_cache(client)
+        try:
+            entity_data = profile_cache[entity_jid.userhostJID()]
+        except KeyError:
+            raise exceptions.UnknownEntityError(
+                "Entity {} not in cache".format(entity_jid)
+            )
+        resources = set(entity_data.keys())
+        resources.discard(None)
+        return resources
+
+    def get_available_resources(self, client, entity_jid):
+        """Return available resource for entity_jid
+
+        This method differs from get_all_resources by returning only available resources
+        @param entity_jid: bare jid of the entit
+        return (list[unicode]): list of available resources
+
+        @raise exceptions.UnknownEntityError: if entity is not in cache
+        """
+        available = []
+        for resource in self.get_all_resources(client, entity_jid):
+            full_jid = copy.copy(entity_jid)
+            full_jid.resource = resource
+            try:
+                presence_data = self.get_entity_datum(client, full_jid, "presence")
+            except KeyError:
+                log.debug("Can't get presence data for {}".format(full_jid))
+            else:
+                if presence_data.show != C.PRESENCE_UNAVAILABLE:
+                    available.append(resource)
+        return available
+
+    def _get_main_resource(self, jid_s, profile_key):
+        client = self.host.get_client(profile_key)
+        jid_ = jid.JID(jid_s)
+        return self.main_resource_get(client, jid_) or ""
+
+    def main_resource_get(self, client, entity_jid):
+        """Return the main resource used by an entity
+
+        @param entity_jid: bare entity jid
+        @return (unicode): main resource or None
+        """
+        if entity_jid.resource:
+            raise ValueError(
+                "main_resource_get must be used with a bare jid (got {})".format(entity_jid)
+            )
+        try:
+            if self.host.plugins["XEP-0045"].is_joined_room(client, entity_jid):
+                return None  # MUC rooms have no main resource
+        except KeyError:  # plugin not found
+            pass
+        try:
+            resources = self.get_all_resources(client, entity_jid)
+        except exceptions.UnknownEntityError:
+            log.warning("Entity is not in cache, we can't find any resource")
+            return None
+        priority_resources = []
+        for resource in resources:
+            full_jid = copy.copy(entity_jid)
+            full_jid.resource = resource
+            try:
+                presence_data = self.get_entity_datum(client, full_jid, "presence")
+            except KeyError:
+                log.debug("No presence information for {}".format(full_jid))
+                continue
+            priority_resources.append((resource, presence_data.priority))
+        try:
+            return max(priority_resources, key=lambda res_tuple: res_tuple[1])[0]
+        except ValueError:
+            log.warning("No resource found at all for {}".format(entity_jid))
+            return None
+
+    ## Entities data ##
+
+    def _get_profile_cache(self, client):
+        """Check profile validity and return its cache
+
+        @param client: SatXMPPClient
+        @return (dict): profile cache
+        """
+        return self._entities_cache[client.profile]
+
+    def set_signal_on_update(self, key, signal=True):
+        """Set a signal flag on the key
+
+        When the key will be updated, a signal will be sent to frontends
+        @param key: key to signal
+        @param signal(boolean): if True, do the signal
+        """
+        if signal:
+            self._key_signals.add(key)
+        else:
+            self._key_signals.discard(key)
+
+    def get_all_entities_iter(self, client, with_bare=False):
+        """Return an iterator of full jids of all entities in cache
+
+        @param with_bare: if True, include bare jids
+        @return (list[unicode]): list of jids
+        """
+        profile_cache = self._get_profile_cache(client)
+        # we construct a list of all known full jids (bare jid of entities x resources)
+        for bare_jid, entity_data in profile_cache.items():
+            for resource in entity_data.keys():
+                if resource is None:
+                    continue
+                full_jid = copy.copy(bare_jid)
+                full_jid.resource = resource
+                yield full_jid
+
+    def update_entity_data(
+        self, client, entity_jid, key, value, silent=False
+    ):
+        """Set a misc data for an entity
+
+        If key was registered with set_signal_on_update, a signal will be sent to frontends
+        @param entity_jid: JID of the entity, C.ENTITY_ALL_RESOURCES for all resources of
+            all entities, C.ENTITY_ALL for all entities (all resources + bare jids)
+        @param key: key to set (eg: C.ENTITY_TYPE)
+        @param value: value for this key (eg: C.ENTITY_TYPE_MUC)
+        @param silent(bool): if True, doesn't send signal to frontend, even if there is a
+            signal flag (see set_signal_on_update)
+        """
+        profile_cache = self._get_profile_cache(client)
+        if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL):
+            entities = self.get_all_entities_iter(client, entity_jid == C.ENTITY_ALL)
+        else:
+            entities = (entity_jid,)
+
+        for jid_ in entities:
+            entity_data = profile_cache.setdefault(jid_.userhostJID(), {}).setdefault(
+                jid_.resource, {}
+            )
+
+            entity_data[key] = value
+            if key in self._key_signals and not silent:
+                self.host.bridge.entity_data_updated(
+                    jid_.full(),
+                    key,
+                    data_format.serialise(value),
+                    client.profile
+                )
+
+    def del_entity_datum(self, client, entity_jid, key):
+        """Delete a data for an entity
+
+        @param entity_jid: JID of the entity, C.ENTITY_ALL_RESOURCES for all resources of all entities,
+                           C.ENTITY_ALL for all entities (all resources + bare jids)
+        @param key: key to delete (eg: C.ENTITY_TYPE)
+
+        @raise exceptions.UnknownEntityError: if entity is not in cache
+        @raise KeyError: key is not in cache
+        """
+        profile_cache = self._get_profile_cache(client)
+        if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL):
+            entities = self.get_all_entities_iter(client, entity_jid == C.ENTITY_ALL)
+        else:
+            entities = (entity_jid,)
+
+        for jid_ in entities:
+            try:
+                entity_data = profile_cache[jid_.userhostJID()][jid_.resource]
+            except KeyError:
+                raise exceptions.UnknownEntityError(
+                    "Entity {} not in cache".format(jid_)
+                )
+            try:
+                del entity_data[key]
+            except KeyError as e:
+                if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL):
+                    continue  # we ignore KeyError when deleting keys from several entities
+                else:
+                    raise e
+
+    def _get_entities_data(self, entities_jids, keys_list, profile_key):
+        client = self.host.get_client(profile_key)
+        ret = self.entities_data_get(
+            client, [jid.JID(jid_) for jid_ in entities_jids], keys_list
+        )
+        return {
+            jid_.full(): {k: data_format.serialise(v) for k,v in data.items()}
+            for jid_, data in ret.items()
+        }
+
+    def entities_data_get(self, client, entities_jids, keys_list=None):
+        """Get a list of cached values for several entities at once
+
+        @param entities_jids: jids of the entities, or empty list for all entities in cache
+        @param keys_list (iterable,None): list of keys to get, None for everything
+        @param profile_key: %(doc_profile_key)s
+        @return: dict withs values for each key in keys_list.
+                 if there is no value of a given key, resulting dict will
+                 have nothing with that key nether
+                 if an entity doesn't exist in cache, it will not appear
+                 in resulting dict
+
+        @raise exceptions.UnknownEntityError: if entity is not in cache
+        """
+
+        def fill_entity_data(entity_cache_data):
+            entity_data = {}
+            if keys_list is None:
+                entity_data = entity_cache_data
+            else:
+                for key in keys_list:
+                    try:
+                        entity_data[key] = entity_cache_data[key]
+                    except KeyError:
+                        continue
+            return entity_data
+
+        profile_cache = self._get_profile_cache(client)
+        ret_data = {}
+        if entities_jids:
+            for entity in entities_jids:
+                try:
+                    entity_cache_data = profile_cache[entity.userhostJID()][
+                        entity.resource
+                    ]
+                except KeyError:
+                    continue
+                ret_data[entity.full()] = fill_entity_data(entity_cache_data, keys_list)
+        else:
+            for bare_jid, data in profile_cache.items():
+                for resource, entity_cache_data in data.items():
+                    full_jid = copy.copy(bare_jid)
+                    full_jid.resource = resource
+                    ret_data[full_jid] = fill_entity_data(entity_cache_data)
+
+        return ret_data
+
+    def _get_entity_data(self, entity_jid_s, keys_list=None, profile=C.PROF_KEY_NONE):
+        return self.entity_data_get(
+            self.host.get_client(profile), jid.JID(entity_jid_s), keys_list)
+
+    def entity_data_get(self, client, entity_jid, keys_list=None):
+        """Get a list of cached values for entity
+
+        @param entity_jid: JID of the entity
+        @param keys_list (iterable,None): list of keys to get, None for everything
+        @param profile_key: %(doc_profile_key)s
+        @return: dict withs values for each key in keys_list.
+                 if there is no value of a given key, resulting dict will
+                 have nothing with that key nether
+
+        @raise exceptions.UnknownEntityError: if entity is not in cache
+        """
+        profile_cache = self._get_profile_cache(client)
+        try:
+            entity_data = profile_cache[entity_jid.userhostJID()][entity_jid.resource]
+        except KeyError:
+            raise exceptions.UnknownEntityError(
+                "Entity {} not in cache (was requesting {})".format(
+                    entity_jid, keys_list
+                )
+            )
+        if keys_list is None:
+            return entity_data
+
+        return {key: entity_data[key] for key in keys_list if key in entity_data}
+
+    def get_entity_datum(self, client, entity_jid, key):
+        """Get a datum from entity
+
+        @param entity_jid: JID of the entity
+        @param key: key to get
+        @return: requested value
+
+        @raise exceptions.UnknownEntityError: if entity is not in cache
+        @raise KeyError: if there is no value for this key and this entity
+        """
+        return self.entity_data_get(client, entity_jid, (key,))[key]
+
+    def del_entity_cache(
+        self, entity_jid, delete_all_resources=True, profile_key=C.PROF_KEY_NONE
+    ):
+        """Remove all cached data for entity
+
+        @param entity_jid: JID of the entity to delete
+        @param delete_all_resources: if True also delete all known resources from cache (a bare jid must be given in this case)
+        @param profile_key: %(doc_profile_key)s
+
+        @raise exceptions.UnknownEntityError: if entity is not in cache
+        """
+        client = self.host.get_client(profile_key)
+        profile_cache = self._get_profile_cache(client)
+
+        if delete_all_resources:
+            if entity_jid.resource:
+                raise ValueError(_("Need a bare jid to delete all resources"))
+            try:
+                del profile_cache[entity_jid]
+            except KeyError:
+                raise exceptions.UnknownEntityError(
+                    "Entity {} not in cache".format(entity_jid)
+                )
+        else:
+            try:
+                del profile_cache[entity_jid.userhostJID()][entity_jid.resource]
+            except KeyError:
+                raise exceptions.UnknownEntityError(
+                    "Entity {} not in cache".format(entity_jid)
+                )
+
+    ## Encryption ##
+
+    def encrypt_value(self, value, profile):
+        """Encrypt a value for the given profile. The personal key must be loaded
+        already in the profile session, that should be the case if the profile is
+        already authenticated.
+
+        @param value (str): the value to encrypt
+        @param profile (str): %(doc_profile)s
+        @return: the deferred encrypted value
+        """
+        try:
+            personal_key = self.auth_sessions.profile_get_unique(profile)[
+                C.MEMORY_CRYPTO_KEY
+            ]
+        except TypeError:
+            raise exceptions.InternalError(
+                _("Trying to encrypt a value for %s while the personal key is undefined!")
+                % profile
+            )
+        return BlockCipher.encrypt(personal_key, value)
+
+    def decrypt_value(self, value, profile):
+        """Decrypt a value for the given profile. The personal key must be loaded
+        already in the profile session, that should be the case if the profile is
+        already authenticated.
+
+        @param value (str): the value to decrypt
+        @param profile (str): %(doc_profile)s
+        @return: the deferred decrypted value
+        """
+        try:
+            personal_key = self.auth_sessions.profile_get_unique(profile)[
+                C.MEMORY_CRYPTO_KEY
+            ]
+        except TypeError:
+            raise exceptions.InternalError(
+                _("Trying to decrypt a value for %s while the personal key is undefined!")
+                % profile
+            )
+        return BlockCipher.decrypt(personal_key, value)
+
+    def encrypt_personal_data(self, data_key, data_value, crypto_key, profile):
+        """Re-encrypt a personal data (saved to a PersistentDict).
+
+        @param data_key: key for the individual PersistentDict instance
+        @param data_value: the value to be encrypted
+        @param crypto_key: the key to encrypt the value
+        @param profile: %(profile_doc)s
+        @return: a deferred None value
+        """
+
+        def got_ind_memory(data):
+            data[data_key] = BlockCipher.encrypt(crypto_key, data_value)
+            return data.force(data_key)
+
+        def done(__):
+            log.debug(
+                _("Personal data (%(ns)s, %(key)s) has been successfuly encrypted")
+                % {"ns": C.MEMORY_CRYPTO_NAMESPACE, "key": data_key}
+            )
+
+        d = PersistentDict(C.MEMORY_CRYPTO_NAMESPACE, profile).load()
+        return d.addCallback(got_ind_memory).addCallback(done)
+
+    ## Subscription requests ##
+
+    def add_waiting_sub(self, type_, entity_jid, profile_key):
+        """Called when a subcription request is received"""
+        profile = self.get_profile_name(profile_key)
+        assert profile
+        if profile not in self.subscriptions:
+            self.subscriptions[profile] = {}
+        self.subscriptions[profile][entity_jid] = type_
+
+    def del_waiting_sub(self, entity_jid, profile_key):
+        """Called when a subcription request is finished"""
+        profile = self.get_profile_name(profile_key)
+        assert profile
+        if profile in self.subscriptions and entity_jid in self.subscriptions[profile]:
+            del self.subscriptions[profile][entity_jid]
+
+    def sub_waiting_get(self, profile_key):
+        """Called to get a list of currently waiting subscription requests"""
+        profile = self.get_profile_name(profile_key)
+        if not profile:
+            log.error(_("Asking waiting subscriptions for a non-existant profile"))
+            return {}
+        if profile not in self.subscriptions:
+            return {}
+
+        return self.subscriptions[profile]
+
+    ## Parameters ##
+
+    def get_string_param_a(self, name, category, attr="value", profile_key=C.PROF_KEY_NONE):
+        return self.params.get_string_param_a(name, category, attr, profile_key)
+
+    def param_get_a(self, name, category, attr="value", profile_key=C.PROF_KEY_NONE):
+        return self.params.param_get_a(name, category, attr, profile_key=profile_key)
+
+    def param_get_a_async(
+        self,
+        name,
+        category,
+        attr="value",
+        security_limit=C.NO_SECURITY_LIMIT,
+        profile_key=C.PROF_KEY_NONE,
+    ):
+        return self.params.param_get_a_async(
+            name, category, attr, security_limit, profile_key
+        )
+
+    def _get_params_values_from_category(
+        self, category, security_limit, app, extra_s, profile_key
+    ):
+        return self.params._get_params_values_from_category(
+            category, security_limit, app, extra_s, profile_key
+        )
+
+    def async_get_string_param_a(
+        self, name, category, attribute="value", security_limit=C.NO_SECURITY_LIMIT,
+        profile_key=C.PROF_KEY_NONE):
+
+        profile = self.get_profile_name(profile_key)
+        return defer.ensureDeferred(self.params.async_get_string_param_a(
+            name, category, attribute, security_limit, profile
+        ))
+
+    def _get_params_ui(self, security_limit, app, extra_s, profile_key):
+        return self.params._get_params_ui(security_limit, app, extra_s, profile_key)
+
+    def params_categories_get(self):
+        return self.params.params_categories_get()
+
+    def param_set(
+        self,
+        name,
+        value,
+        category,
+        security_limit=C.NO_SECURITY_LIMIT,
+        profile_key=C.PROF_KEY_NONE,
+    ):
+        return self.params.param_set(name, value, category, security_limit, profile_key)
+
+    def update_params(self, xml):
+        return self.params.update_params(xml)
+
+    def params_register_app(self, xml, security_limit=C.NO_SECURITY_LIMIT, app=""):
+        return self.params.params_register_app(xml, security_limit, app)
+
+    def set_default(self, name, category, callback, errback=None):
+        return self.params.set_default(name, category, callback, errback)
+
+    ## Private Data ##
+
+    def _private_data_set(self, namespace, key, data_s, profile_key):
+        client = self.host.get_client(profile_key)
+        # we accept any type
+        data = data_format.deserialise(data_s, type_check=None)
+        return defer.ensureDeferred(self.storage.set_private_value(
+            namespace, key, data, binary=True, profile=client.profile))
+
+    def _private_data_get(self, namespace, key, profile_key):
+        client = self.host.get_client(profile_key)
+        d = defer.ensureDeferred(
+            self.storage.get_privates(
+                namespace, [key], binary=True, profile=client.profile)
+        )
+        d.addCallback(lambda data_dict: data_format.serialise(data_dict.get(key)))
+        return d
+
+    def _private_data_delete(self, namespace, key, profile_key):
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(self.storage.del_private_value(
+            namespace, key, binary=True, profile=client.profile))
+
+    ## Files ##
+
+    def check_file_permission(
+            self,
+            file_data: dict,
+            peer_jid: Optional[jid.JID],
+            perms_to_check: Optional[Tuple[str]],
+            set_affiliation: bool = False
+    ) -> None:
+        """Check that an entity has the right permission on a file
+
+        @param file_data: data of one file, as returned by get_files
+        @param peer_jid: entity trying to access the file
+        @param perms_to_check: permissions to check
+            tuple of C.ACCESS_PERM_*
+        @param check_parents: if True, also check all parents until root node
+        @parma set_affiliation: if True, "affiliation" metadata will be set
+        @raise exceptions.PermissionError: peer_jid doesn't have all permission
+            in perms_to_check for file_data
+        @raise exceptions.InternalError: perms_to_check is invalid
+        """
+        # TODO: knowing if user is owner is not enough, we need to check permission
+        #   to see if user can modify/delete files, and set corresponding affiliation (publisher, member)
+        if peer_jid is None and perms_to_check is None:
+            return
+        peer_jid = peer_jid.userhostJID()
+        if peer_jid == file_data["owner"]:
+            if set_affiliation:
+                file_data['affiliation'] = 'owner'
+            # the owner has all rights, nothing to check
+            return
+        if not C.ACCESS_PERMS.issuperset(perms_to_check):
+            raise exceptions.InternalError(_("invalid permission"))
+
+        for perm in perms_to_check:
+            # we check each perm and raise PermissionError as soon as one condition is not valid
+            # we must never return here, we only return after the loop if nothing was blocking the access
+            try:
+                perm_data = file_data["access"][perm]
+                perm_type = perm_data["type"]
+            except KeyError:
+                # No permission is set.
+                # If we are in a root file/directory, we deny access
+                # otherwise, we use public permission, as the parent directory will
+                # block anyway, this avoid to have to recursively change permissions for
+                # all sub directories/files when modifying a permission
+                if not file_data.get('parent'):
+                    raise exceptions.PermissionError()
+                else:
+                    perm_type = C.ACCESS_TYPE_PUBLIC
+            if perm_type == C.ACCESS_TYPE_PUBLIC:
+                continue
+            elif perm_type == C.ACCESS_TYPE_WHITELIST:
+                try:
+                    jids = perm_data["jids"]
+                except KeyError:
+                    raise exceptions.PermissionError()
+                if peer_jid.full() in jids:
+                    continue
+                else:
+                    raise exceptions.PermissionError()
+            else:
+                raise exceptions.InternalError(
+                    _("unknown access type: {type}").format(type=perm_type)
+                )
+
+    async def check_permission_to_root(self, client, file_data, peer_jid, perms_to_check):
+        """do check_file_permission on file_data and all its parents until root"""
+        current = file_data
+        while True:
+            self.check_file_permission(current, peer_jid, perms_to_check)
+            parent = current["parent"]
+            if not parent:
+                break
+            files_data = await self.get_files(
+                client, peer_jid=None, file_id=parent, perms_to_check=None
+            )
+            try:
+                current = files_data[0]
+            except IndexError:
+                raise exceptions.DataError("Missing parent")
+
+    async def _get_parent_dir(
+        self, client, path, parent, namespace, owner, peer_jid, perms_to_check
+    ):
+        """Retrieve parent node from a path, or last existing directory
+
+        each directory of the path will be retrieved, until the last existing one
+        @return (tuple[unicode, list[unicode])): parent, remaining path elements:
+            - parent is the id of the last retrieved directory (or u'' for root)
+            - remaining path elements are the directories which have not been retrieved
+              (i.e. which don't exist)
+        """
+        # if path is set, we have to retrieve parent directory of the file(s) from it
+        if parent is not None:
+            raise exceptions.ConflictError(
+                _("You can't use path and parent at the same time")
+            )
+        path_elts = [_f for _f in path.split("/") if _f]
+        if {"..", "."}.intersection(path_elts):
+            raise ValueError(_('".." or "." can\'t be used in path'))
+
+        # we retrieve all directories from path until we get the parent container
+        # non existing directories will be created
+        parent = ""
+        for idx, path_elt in enumerate(path_elts):
+            directories = await self.storage.get_files(
+                client,
+                parent=parent,
+                type_=C.FILE_TYPE_DIRECTORY,
+                name=path_elt,
+                namespace=namespace,
+                owner=owner,
+            )
+            if not directories:
+                return (parent, path_elts[idx:])
+                # from this point, directories don't exist anymore, we have to create them
+            elif len(directories) > 1:
+                raise exceptions.InternalError(
+                    _("Several directories found, this should not happen")
+                )
+            else:
+                directory = directories[0]
+                self.check_file_permission(directory, peer_jid, perms_to_check)
+                parent = directory["id"]
+        return (parent, [])
+
+    def get_file_affiliations(self, file_data: dict) -> Dict[jid.JID, str]:
+        """Convert file access to pubsub like affiliations"""
+        affiliations = {}
+        access_data = file_data['access']
+
+        read_data = access_data.get(C.ACCESS_PERM_READ, {})
+        if read_data.get('type') == C.ACCESS_TYPE_WHITELIST:
+            for entity_jid_s in read_data['jids']:
+                entity_jid = jid.JID(entity_jid_s)
+                affiliations[entity_jid] = 'member'
+
+        write_data = access_data.get(C.ACCESS_PERM_WRITE, {})
+        if write_data.get('type') == C.ACCESS_TYPE_WHITELIST:
+            for entity_jid_s in write_data['jids']:
+                entity_jid = jid.JID(entity_jid_s)
+                affiliations[entity_jid] = 'publisher'
+
+        owner = file_data.get('owner')
+        if owner:
+            affiliations[owner] = 'owner'
+
+        return affiliations
+
+    def _set_file_affiliations_update(
+        self,
+        access: dict,
+        file_data: dict,
+        affiliations: Dict[jid.JID, str]
+    ) -> None:
+        read_data = access.setdefault(C.ACCESS_PERM_READ, {})
+        if read_data.get('type') != C.ACCESS_TYPE_WHITELIST:
+            read_data['type'] = C.ACCESS_TYPE_WHITELIST
+            if 'jids' not in read_data:
+                read_data['jids'] = []
+        read_whitelist = read_data['jids']
+        write_data = access.setdefault(C.ACCESS_PERM_WRITE, {})
+        if write_data.get('type') != C.ACCESS_TYPE_WHITELIST:
+            write_data['type'] = C.ACCESS_TYPE_WHITELIST
+            if 'jids' not in write_data:
+                write_data['jids'] = []
+        write_whitelist = write_data['jids']
+        for entity_jid, affiliation in affiliations.items():
+            entity_jid_s = entity_jid.full()
+            if affiliation == "none":
+                try:
+                    read_whitelist.remove(entity_jid_s)
+                except ValueError:
+                    log.warning(
+                        "removing affiliation from an entity without read permission: "
+                        f"{entity_jid}"
+                    )
+                try:
+                    write_whitelist.remove(entity_jid_s)
+                except ValueError:
+                    pass
+            elif affiliation == "publisher":
+                if entity_jid_s not in read_whitelist:
+                    read_whitelist.append(entity_jid_s)
+                if entity_jid_s not in write_whitelist:
+                    write_whitelist.append(entity_jid_s)
+            elif affiliation == "member":
+                if entity_jid_s not in read_whitelist:
+                    read_whitelist.append(entity_jid_s)
+                try:
+                    write_whitelist.remove(entity_jid_s)
+                except ValueError:
+                    pass
+            elif affiliation == "owner":
+                raise NotImplementedError('"owner" affiliation can\'t be set')
+            else:
+                raise ValueError(f"unknown affiliation: {affiliation!r}")
+
+    async def set_file_affiliations(
+        self,
+        client,
+        file_data: dict,
+        affiliations: Dict[jid.JID, str]
+    ) -> None:
+        """Apply pubsub like affiliation to file_data
+
+        Affiliations are converted to access types, then set in a whitelist.
+        Affiliation are mapped as follow:
+            - "owner" can't be set (for now)
+            - "publisher" gives read and write permissions
+            - "member" gives read permission only
+            - "none" removes both read and write permissions
+        """
+        file_id = file_data['id']
+        await self.file_update(
+            file_id,
+            'access',
+            update_cb=partial(
+                self._set_file_affiliations_update,
+                file_data=file_data,
+                affiliations=affiliations
+            ),
+        )
+
+    def _set_file_access_model_update(
+        self,
+        access: dict,
+        file_data: dict,
+        access_model: str
+    ) -> None:
+        read_data = access.setdefault(C.ACCESS_PERM_READ, {})
+        if access_model == "open":
+            requested_type = C.ACCESS_TYPE_PUBLIC
+        elif access_model == "whitelist":
+            requested_type = C.ACCESS_TYPE_WHITELIST
+        else:
+            raise ValueError(f"unknown access model: {access_model}")
+
+        read_data['type'] = requested_type
+        if requested_type == C.ACCESS_TYPE_WHITELIST and 'jids' not in read_data:
+            read_data['jids'] = []
+
+    async def set_file_access_model(
+        self,
+        client,
+        file_data: dict,
+        access_model: str,
+    ) -> None:
+        """Apply pubsub like access_model to file_data
+
+        Only 2 access models are supported so far:
+            - "open": set public access to file/dir
+            - "whitelist": set whitelist to file/dir
+        """
+        file_id = file_data['id']
+        await self.file_update(
+            file_id,
+            'access',
+            update_cb=partial(
+                self._set_file_access_model_update,
+                file_data=file_data,
+                access_model=access_model
+            ),
+        )
+
+    def get_files_owner(
+            self,
+            client,
+            owner: Optional[jid.JID],
+            peer_jid: Optional[jid.JID],
+            file_id: Optional[str] = None,
+            parent: Optional[str] = None
+    ) -> jid.JID:
+        """Get owner to use for a file operation
+
+        if owner is not explicitely set, a suitable one will be used (client.jid for
+        clients, peer_jid for components).
+        @raise exception.InternalError: we are one a component, and neither owner nor
+            peer_jid are set
+        """
+        if owner is not None:
+            return owner.userhostJID()
+        if client is None:
+            # client may be None when looking for file with public_id
+            return None
+        if file_id or parent:
+            # owner has already been filtered on parent file
+            return None
+        if not client.is_component:
+            return client.jid.userhostJID()
+        if peer_jid is None:
+            raise exceptions.InternalError(
+                "Owner must be set for component if peer_jid is None"
+            )
+        return peer_jid.userhostJID()
+
+    async def get_files(
+        self, client, peer_jid, file_id=None, version=None, parent=None, path=None,
+        type_=None, file_hash=None, hash_algo=None, name=None, namespace=None,
+        mime_type=None, public_id=None, owner=None, access=None, projection=None,
+        unique=False, perms_to_check=(C.ACCESS_PERM_READ,)):
+        """Retrieve files with with given filters
+
+        @param peer_jid(jid.JID, None): jid trying to access the file
+            needed to check permission.
+            Use None to ignore permission (perms_to_check must be None too)
+        @param file_id(unicode, None): id of the file
+            None to ignore
+        @param version(unicode, None): version of the file
+            None to ignore
+            empty string to look for current version
+        @param parent(unicode, None): id of the directory containing the files
+            None to ignore
+            empty string to look for root files/directories
+        @param path(Path, unicode, None): path to the directory containing the files
+        @param type_(unicode, None): type of file filter, can be one of C.FILE_TYPE_*
+        @param file_hash(unicode, None): hash of the file to retrieve
+        @param hash_algo(unicode, None): algorithm use for file_hash
+        @param name(unicode, None): name of the file to retrieve
+        @param namespace(unicode, None): namespace of the files to retrieve
+        @param mime_type(unicode, None): filter on this mime type
+        @param public_id(unicode, None): filter on this public id
+        @param owner(jid.JID, None): if not None, only get files from this owner
+        @param access(dict, None): get file with given access (see [set_file])
+        @param projection(list[unicode], None): name of columns to retrieve
+            None to retrieve all
+        @param unique(bool): if True will remove duplicates
+        @param perms_to_check(tuple[unicode],None): permission to check
+            must be a tuple of C.ACCESS_PERM_* or None
+            if None, permission will no be checked (peer_jid must be None too in this
+            case)
+        other params are the same as for [set_file]
+        @return (list[dict]): files corresponding to filters
+        @raise exceptions.NotFound: parent directory not found (when path is specified)
+        @raise exceptions.PermissionError: peer_jid can't use perms_to_check for one of
+                                           the file
+            on the path
+        """
+        if peer_jid is None and perms_to_check or perms_to_check is None and peer_jid:
+            raise exceptions.InternalError(
+                "if you want to disable permission check, both peer_jid and "
+                "perms_to_check must be None"
+            )
+        owner = self.get_files_owner(client, owner, peer_jid, file_id, parent)
+        if path is not None:
+            path = str(path)
+            # permission are checked by _get_parent_dir
+            parent, remaining_path_elts = await self._get_parent_dir(
+                client, path, parent, namespace, owner, peer_jid, perms_to_check
+            )
+            if remaining_path_elts:
+                # if we have remaining path elements,
+                # the parent directory is not found
+                raise failure.Failure(exceptions.NotFound())
+        if parent and peer_jid:
+            # if parent is given directly and permission check is requested,
+            # we need to check all the parents
+            parent_data = await self.storage.get_files(client, file_id=parent)
+            try:
+                parent_data = parent_data[0]
+            except IndexError:
+                raise exceptions.DataError("mising parent")
+            await self.check_permission_to_root(
+                client, parent_data, peer_jid, perms_to_check
+            )
+
+        files = await self.storage.get_files(
+            client,
+            file_id=file_id,
+            version=version,
+            parent=parent,
+            type_=type_,
+            file_hash=file_hash,
+            hash_algo=hash_algo,
+            name=name,
+            namespace=namespace,
+            mime_type=mime_type,
+            public_id=public_id,
+            owner=owner,
+            access=access,
+            projection=projection,
+            unique=unique,
+        )
+
+        if peer_jid:
+            # if permission are checked, we must remove all file that user can't access
+            to_remove = []
+            for file_data in files:
+                try:
+                    self.check_file_permission(
+                        file_data, peer_jid, perms_to_check, set_affiliation=True
+                    )
+                except exceptions.PermissionError:
+                    to_remove.append(file_data)
+            for file_data in to_remove:
+                files.remove(file_data)
+        return files
+
+    async def set_file(
+        self, client, name, file_id=None, version="", parent=None, path=None,
+        type_=C.FILE_TYPE_FILE, file_hash=None, hash_algo=None, size=None,
+        namespace=None, mime_type=None, public_id=None, created=None, modified=None,
+        owner=None, access=None, extra=None, peer_jid=None,
+        perms_to_check=(C.ACCESS_PERM_WRITE,)
+    ):
+        """Set a file metadata
+
+        @param name(unicode): basename of the file
+        @param file_id(unicode): unique id of the file
+        @param version(unicode): version of this file
+            empty string for current version or when there is no versioning
+        @param parent(unicode, None): id of the directory containing the files
+        @param path(unicode, None): virtual path of the file in the namespace
+            if set, parent must be None. All intermediate directories will be created
+            if needed, using current access.
+        @param type_(str, None): type of file filter, can be one of C.FILE_TYPE_*
+        @param file_hash(unicode): unique hash of the payload
+        @param hash_algo(unicode): algorithm used for hashing the file (usually sha-256)
+        @param size(int): size in bytes
+        @param namespace(unicode, None): identifier (human readable is better) to group
+                                         files
+            For instance, namespace could be used to group files in a specific photo album
+        @param mime_type(unicode): MIME type of the file, or None if not known/guessed
+        @param public_id(unicode): id used to share publicly the file via HTTP
+        @param created(int): UNIX time of creation
+        @param modified(int,None): UNIX time of last modification, or None to use
+                                   created date
+        @param owner(jid.JID, None): jid of the owner of the file (mainly useful for
+                                     component)
+            will be used to check permission (only bare jid is used, don't use with MUC).
+            Use None to ignore permission (perms_to_check must be None too)
+        @param access(dict, None): serialisable dictionary with access rules.
+            None (or empty dict) to use private access, i.e. allow only profile's jid to
+            access the file
+            key can be on on C.ACCESS_PERM_*,
+            then a sub dictionary with a type key is used (one of C.ACCESS_TYPE_*).
+            According to type, extra keys can be used:
+                - C.ACCESS_TYPE_PUBLIC: the permission is granted for everybody
+                - C.ACCESS_TYPE_WHITELIST: the permission is granted for jids (as unicode)
+                  in the 'jids' key
+            will be encoded to json in database
+        @param extra(dict, None): serialisable dictionary of any extra data
+            will be encoded to json in database
+        @param perms_to_check(tuple[unicode],None): permission to check
+            must be a tuple of C.ACCESS_PERM_* or None
+            if None, permission will not be checked (peer_jid must be None too in this
+            case)
+        @param profile(unicode): profile owning the file
+        """
+        if "/" in name:
+            raise ValueError('name must not contain a slash ("/")')
+        if file_id is None:
+            file_id = shortuuid.uuid()
+        if (
+            file_hash is not None
+            and hash_algo is None
+            or hash_algo is not None
+            and file_hash is None
+        ):
+            raise ValueError("file_hash and hash_algo must be set at the same time")
+        if mime_type is None:
+            mime_type, __ = mimetypes.guess_type(name)
+        else:
+            mime_type = mime_type.lower()
+        if public_id is not None:
+            assert len(public_id)>0
+        if created is None:
+            created = time.time()
+        if namespace is not None:
+            namespace = namespace.strip() or None
+        if type_ == C.FILE_TYPE_DIRECTORY:
+            if any((version, file_hash, size, mime_type)):
+                raise ValueError(
+                    "version, file_hash, size and mime_type can't be set for a directory"
+                )
+        owner = self.get_files_owner(client, owner, peer_jid, file_id, parent)
+
+        if path is not None:
+            path = str(path)
+            # _get_parent_dir will check permissions if peer_jid is set, so we use owner
+            parent, remaining_path_elts = await self._get_parent_dir(
+                client, path, parent, namespace, owner, owner, perms_to_check
+            )
+            # if remaining directories don't exist, we have to create them
+            for new_dir in remaining_path_elts:
+                new_dir_id = shortuuid.uuid()
+                await self.storage.set_file(
+                    client,
+                    name=new_dir,
+                    file_id=new_dir_id,
+                    version="",
+                    parent=parent,
+                    type_=C.FILE_TYPE_DIRECTORY,
+                    namespace=namespace,
+                    created=time.time(),
+                    owner=owner,
+                    access=access,
+                    extra={},
+                )
+                parent = new_dir_id
+        elif parent is None:
+            parent = ""
+
+        await self.storage.set_file(
+            client,
+            file_id=file_id,
+            version=version,
+            parent=parent,
+            type_=type_,
+            file_hash=file_hash,
+            hash_algo=hash_algo,
+            name=name,
+            size=size,
+            namespace=namespace,
+            mime_type=mime_type,
+            public_id=public_id,
+            created=created,
+            modified=modified,
+            owner=owner,
+            access=access,
+            extra=extra,
+        )
+
+    async def file_get_used_space(
+        self,
+        client,
+        peer_jid: jid.JID,
+        owner: Optional[jid.JID] = None
+    ) -> int:
+        """Get space taken by all files owned by an entity
+
+        @param peer_jid: entity requesting the size
+        @param owner: entity owning the file to check. If None, will be determined by
+            get_files_owner
+        @return: size of total space used by files of this owner
+        """
+        owner = self.get_files_owner(client, owner, peer_jid)
+        if peer_jid.userhostJID() != owner and client.profile not in self.admins:
+            raise exceptions.PermissionError("You are not allowed to check this size")
+        return await self.storage.file_get_used_space(client, owner)
+
+    def file_update(self, file_id, column, update_cb):
+        """Update a file column taking care of race condition
+
+        access is NOT checked in this method, it must be checked beforehand
+        @param file_id(unicode): id of the file to update
+        @param column(unicode): one of "access" or "extra"
+        @param update_cb(callable): method to update the value of the colum
+            the method will take older value as argument, and must update it in place
+            Note that the callable must be thread-safe
+        """
+        return self.storage.file_update(file_id, column, update_cb)
+
+    @defer.inlineCallbacks
+    def _delete_file(
+        self,
+        client,
+        peer_jid: jid.JID,
+        recursive: bool,
+        files_path: Path,
+        file_data: dict
+    ):
+        """Internal method to delete files/directories recursively
+
+        @param peer_jid(jid.JID): entity requesting the deletion (must be owner of files
+            to delete)
+        @param recursive(boolean): True if recursive deletion is needed
+        @param files_path(unicode): path of the directory containing the actual files
+        @param file_data(dict): data of the file to delete
+        """
+        if file_data['owner'] != peer_jid:
+            raise exceptions.PermissionError(
+                "file {file_name} can't be deleted, {peer_jid} is not the owner"
+                .format(file_name=file_data['name'], peer_jid=peer_jid.full()))
+        if file_data['type'] == C.FILE_TYPE_DIRECTORY:
+            sub_files = yield self.get_files(client, peer_jid, parent=file_data['id'])
+            if sub_files and not recursive:
+                raise exceptions.DataError(_("Can't delete directory, it is not empty"))
+            # we first delete the sub-files
+            for sub_file_data in sub_files:
+                if sub_file_data['type'] == C.FILE_TYPE_DIRECTORY:
+                    sub_file_path = files_path / sub_file_data['name']
+                else:
+                    sub_file_path = files_path
+                yield self._delete_file(
+                    client, peer_jid, recursive, sub_file_path, sub_file_data)
+            # then the directory itself
+            yield self.storage.file_delete(file_data['id'])
+        elif file_data['type'] == C.FILE_TYPE_FILE:
+            log.info(_("deleting file {name} with hash {file_hash}").format(
+                name=file_data['name'], file_hash=file_data['file_hash']))
+            yield self.storage.file_delete(file_data['id'])
+            references = yield self.get_files(
+                client, peer_jid, file_hash=file_data['file_hash'])
+            if references:
+                log.debug("there are still references to the file, we keep it")
+            else:
+                file_path = os.path.join(files_path, file_data['file_hash'])
+                log.info(_("no reference left to {file_path}, deleting").format(
+                    file_path=file_path))
+                try:
+                    os.unlink(file_path)
+                except FileNotFoundError:
+                    log.error(f"file at {file_path!r} doesn't exist but it was referenced in files database")
+        else:
+            raise exceptions.InternalError('Unexpected file type: {file_type}'
+                .format(file_type=file_data['type']))
+
+    async def file_delete(self, client, peer_jid, file_id, recursive=False):
+        """Delete a single file or a directory and all its sub-files
+
+        @param file_id(unicode): id of the file to delete
+        @param peer_jid(jid.JID): entity requesting the deletion,
+            must be owner of all files to delete
+        @param recursive(boolean): must be True to delete a directory and all sub-files
+        """
+        # FIXME: we only allow owner of file to delete files for now, but WRITE access
+        #        should be checked too
+        files_data = await self.get_files(client, peer_jid, file_id)
+        if not files_data:
+            raise exceptions.NotFound("Can't find the file with id {file_id}".format(
+                file_id=file_id))
+        file_data = files_data[0]
+        if file_data["type"] != C.FILE_TYPE_DIRECTORY and recursive:
+            raise ValueError("recursive can only be set for directories")
+        files_path = self.host.get_local_path(None, C.FILES_DIR)
+        await self._delete_file(client, peer_jid, recursive, files_path, file_data)
+
+    ## Cache ##
+
+    def get_cache_path(self, namespace: str, *args: str) -> Path:
+        """Get path to use to get a common path for a namespace
+
+        This can be used by plugins to manage permanent data. It's the responsability
+        of plugins to clean this directory from unused data.
+        @param namespace: unique namespace to use
+        @param args: extra identifier which will be added to the path
+        """
+        namespace = namespace.strip().lower()
+        return Path(
+            self._cache_path,
+            regex.path_escape(namespace),
+            *(regex.path_escape(a) for a in args)
+        )
+
+    ## Misc ##
+
+    def is_entity_available(self, client, entity_jid):
+        """Tell from the presence information if the given entity is available.
+
+        @param entity_jid (JID): the entity to check (if bare jid is used, all resources are tested)
+        @return (bool): True if entity is available
+        """
+        if not entity_jid.resource:
+            return bool(
+                self.get_available_resources(client, entity_jid)
+            )  # is any resource is available, entity is available
+        try:
+            presence_data = self.get_entity_datum(client, entity_jid, "presence")
+        except KeyError:
+            log.debug("No presence information for {}".format(entity_jid))
+            return False
+        return presence_data.show != C.PRESENCE_UNAVAILABLE
+
+    def is_admin(self, profile: str) -> bool:
+        """Tell if given profile has administrator privileges"""
+        return profile in self.admins
+
+    def is_admin_jid(self, entity: jid.JID) -> bool:
+        """Tells if an entity jid correspond to an admin one
+
+        It is sometime not possible to use the profile alone to check if an entity is an
+        admin (e.g. a request managed by a component). In this case we check if the JID
+        correspond to an admin profile
+        """
+        return entity.userhostJID() in self.admin_jids
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/migration/README	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,3 @@
+This directory and subdirectories contains Alembic migration scripts.
+
+Please check Libervia documentation for details.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/migration/alembic.ini	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,89 @@
+# A generic, single database configuration.
+
+[alembic]
+# path to migration scripts
+script_location = %(here)s
+
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# sys.path path, will be prepended to sys.path if present.
+# defaults to the current working directory.
+# prepend_sys_path = .
+
+# timezone to use when rendering the date
+# within the migration file as well as the filename.
+# string value is passed to dateutil.tz.gettz()
+# leave blank for localtime
+# timezone =
+
+# max length of characters to apply to the
+# "slug" field
+# truncate_slug_length = 40
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+# set to 'true' to allow .pyc and .pyo files without
+# a source .py file to be detected as revisions in the
+# versions/ directory
+# sourceless = false
+
+# version location specification; this defaults
+# to migration/versions.  When using multiple version
+# directories, initial revisions must be specified with --version-path
+# version_locations = %(here)s/bar %(here)s/bat migration/versions
+
+# the output encoding used when revision files
+# are written from script.py.mako
+# output_encoding = utf-8
+
+# sqlalchemy.url = driver://user:pass@localhost/dbname
+
+
+[post_write_hooks]
+# post_write_hooks defines scripts or Python functions that are run
+# on newly generated revision scripts.  See the documentation for further
+# detail and examples
+
+# format using "black" - use the console_scripts runner, against the "black" entrypoint
+# hooks = black
+# black.type = console_scripts
+# black.entrypoint = black
+# black.options = -l 79 REVISION_SCRIPT_FILENAME
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/migration/env.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,93 @@
+import asyncio
+from logging.config import fileConfig
+from sqlalchemy import pool
+from sqlalchemy.ext.asyncio import create_async_engine
+from alembic import context
+from libervia.backend.memory import sqla_config
+from libervia.backend.memory.sqla_mapping import Base
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+target_metadata = Base.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline():
+    """Run migrations in 'offline' mode.
+
+    This configures the context with just a URL
+    and not an Engine, though an Engine is acceptable
+    here as well.  By skipping the Engine creation
+    we don't even need a DBAPI to be available.
+
+    Calls to context.execute() here emit the given string to the
+    script output.
+
+    """
+    db_config = sqla_config.get_db_config()
+    context.configure(
+        url=db_config["url"],
+        target_metadata=target_metadata,
+        literal_binds=True,
+        dialect_opts={"paramstyle": "named"},
+    )
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def include_name(name, type_, parent_names):
+    if type_ == "table":
+        if name.startswith("pubsub_items_fts"):
+            return False
+    return True
+
+
+def do_run_migrations(connection):
+    context.configure(
+        connection=connection,
+        target_metadata=target_metadata,
+        render_as_batch=True,
+        include_name=include_name
+    )
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+async def run_migrations_online():
+    """Run migrations in 'online' mode.
+
+    In this scenario we need to create an Engine
+    and associate a connection with the context.
+
+    """
+    db_config = sqla_config.get_db_config()
+    engine = create_async_engine(
+        db_config["url"],
+        poolclass=pool.NullPool,
+        future=True,
+    )
+
+    async with engine.connect() as connection:
+        await connection.run_sync(do_run_migrations)
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    asyncio.run(run_migrations_online())
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/migration/script.py.mako	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+    ${downgrades if downgrades else "pass"}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/migration/versions/129ac51807e4_create_virtual_table_for_full_text_.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,49 @@
+"""create virtual table for Full-Text Search
+
+Revision ID: 129ac51807e4
+Revises: 8974efc51d22
+Create Date: 2021-08-13 19:13:54.112538
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '129ac51807e4'
+down_revision = '8974efc51d22'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    queries = [
+        "CREATE VIRTUAL TABLE pubsub_items_fts "
+        "USING fts5(data, content=pubsub_items, content_rowid=id)",
+        "CREATE TRIGGER pubsub_items_fts_sync_ins AFTER INSERT ON pubsub_items BEGIN"
+        "  INSERT INTO pubsub_items_fts(rowid, data) VALUES (new.id, new.data);"
+        "END",
+        "CREATE TRIGGER pubsub_items_fts_sync_del AFTER DELETE ON pubsub_items BEGIN"
+        "  INSERT INTO pubsub_items_fts(pubsub_items_fts, rowid, data) "
+        "VALUES('delete', old.id, old.data);"
+        "END",
+        "CREATE TRIGGER pubsub_items_fts_sync_upd AFTER UPDATE ON pubsub_items BEGIN"
+        "  INSERT INTO pubsub_items_fts(pubsub_items_fts, rowid, data) VALUES"
+        "('delete', old.id, old.data);"
+        "  INSERT INTO pubsub_items_fts(rowid, data) VALUES(new.id, new.data);"
+        "END",
+        "INSERT INTO pubsub_items_fts(rowid, data) SELECT id, data from pubsub_items"
+    ]
+    for q in queries:
+        op.execute(sa.DDL(q))
+
+
+def downgrade():
+    queries = [
+        "DROP TRIGGER IF EXISTS pubsub_items_fts_sync_ins",
+        "DROP TRIGGER IF EXISTS pubsub_items_fts_sync_del",
+        "DROP TRIGGER IF EXISTS pubsub_items_fts_sync_upd",
+        "DROP TABLE IF EXISTS pubsub_items_fts",
+    ]
+    for q in queries:
+        op.execute(sa.DDL(q))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/migration/versions/4b002773cf92_add_origin_id_column_to_history_and_.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,60 @@
+"""add origin_id column to history and adapt constraints
+
+Revision ID: 4b002773cf92
+Revises: 79e5f3313fa4
+Create Date: 2022-06-13 16:10:39.711634
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '4b002773cf92'
+down_revision = '79e5f3313fa4'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    with op.batch_alter_table('history', schema=None) as batch_op:
+        batch_op.add_column(sa.Column('origin_id', sa.Text(), nullable=True))
+        batch_op.create_unique_constraint('uq_origin_id', ['profile_id', 'origin_id', 'source'])
+
+    with op.batch_alter_table('message', schema=None) as batch_op:
+        batch_op.alter_column('history_uid',
+               existing_type=sa.TEXT(),
+               nullable=False)
+        batch_op.alter_column('message',
+               existing_type=sa.TEXT(),
+               nullable=False)
+
+    with op.batch_alter_table('subject', schema=None) as batch_op:
+        batch_op.alter_column('history_uid',
+               existing_type=sa.TEXT(),
+               nullable=False)
+        batch_op.alter_column('subject',
+               existing_type=sa.TEXT(),
+               nullable=False)
+
+
+def downgrade():
+    with op.batch_alter_table('subject', schema=None) as batch_op:
+        batch_op.alter_column('subject',
+               existing_type=sa.TEXT(),
+               nullable=True)
+        batch_op.alter_column('history_uid',
+               existing_type=sa.TEXT(),
+               nullable=True)
+
+    with op.batch_alter_table('message', schema=None) as batch_op:
+        batch_op.alter_column('message',
+               existing_type=sa.TEXT(),
+               nullable=True)
+        batch_op.alter_column('history_uid',
+               existing_type=sa.TEXT(),
+               nullable=True)
+
+    with op.batch_alter_table('history', schema=None) as batch_op:
+        batch_op.drop_constraint('uq_origin_id', type_='unique')
+        batch_op.drop_column('origin_id')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/migration/versions/602caf848068_drop_message_types_table_fix_nullable.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,410 @@
+"""drop message_types table + fix nullable + rename constraints
+
+Revision ID: 602caf848068
+Revises:
+Create Date: 2021-06-26 12:42:54.148313
+
+"""
+from alembic import op
+from sqlalchemy import (
+    Table,
+    Column,
+    MetaData,
+    TEXT,
+    INTEGER,
+    Text,
+    Integer,
+    Float,
+    Enum,
+    ForeignKey,
+    Index,
+    PrimaryKeyConstraint,
+)
+from sqlalchemy.sql import table, column
+
+
+# revision identifiers, used by Alembic.
+revision = "602caf848068"
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # we have to recreate former tables for batch_alter_table's reflexion, otherwise the
+    # database will be used, and this will keep unammed UNIQUE constraints in addition
+    # to the named ones that we create
+    metadata = MetaData(
+        naming_convention={
+            "ix": "ix_%(column_0_label)s",
+            "uq": "uq_%(table_name)s_%(column_0_name)s",
+            "ck": "ck_%(table_name)s_%(constraint_name)s",
+            "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
+            "pk": "pk_%(table_name)s",
+        },
+    )
+
+    old_profiles_table = Table(
+        "profiles",
+        metadata,
+        Column("id", Integer, primary_key=True, nullable=True, autoincrement=False),
+        Column("name", Text, unique=True),
+    )
+
+    old_components_table = Table(
+        "components",
+        metadata,
+        Column(
+            "profile_id",
+            ForeignKey("profiles.id", ondelete="CASCADE"),
+            nullable=True,
+            primary_key=True,
+        ),
+        Column("entry_point", Text, nullable=False),
+    )
+
+    old_message_table = Table(
+        "message",
+        metadata,
+        Column("id", Integer, primary_key=True, nullable=True, autoincrement=False),
+        Column("history_uid", ForeignKey("history.uid", ondelete="CASCADE")),
+        Column("message", Text),
+        Column("language", Text),
+        Index("message__history_uid", "history_uid"),
+    )
+
+    old_subject_table = Table(
+        "subject",
+        metadata,
+        Column("id", Integer, primary_key=True, nullable=True, autoincrement=False),
+        Column("history_uid", ForeignKey("history.uid", ondelete="CASCADE")),
+        Column("subject", Text),
+        Column("language", Text),
+        Index("subject__history_uid", "history_uid"),
+    )
+
+    old_thread_table = Table(
+        "thread",
+        metadata,
+        Column("id", Integer, primary_key=True, nullable=True, autoincrement=False),
+        Column("history_uid", ForeignKey("history.uid", ondelete="CASCADE")),
+        Column("thread_id", Text),
+        Column("parent_id", Text),
+        Index("thread__history_uid", "history_uid"),
+    )
+
+    old_history_table = Table(
+        "history",
+        metadata,
+        Column("uid", Text, primary_key=True, nullable=True),
+        Column("stanza_id", Text),
+        Column("update_uid", Text),
+        Column("profile_id", Integer, ForeignKey("profiles.id", ondelete="CASCADE")),
+        Column("source", Text),
+        Column("dest", Text),
+        Column("source_res", Text),
+        Column("dest_res", Text),
+        Column("timestamp", Float, nullable=False),
+        Column("received_timestamp", Float),
+        Column("type", Text, ForeignKey("message_types.type")),
+        Column("extra", Text),
+        Index("history__profile_id_timestamp", "profile_id", "timestamp"),
+        Index(
+            "history__profile_id_received_timestamp", "profile_id", "received_timestamp"
+        ),
+    )
+
+    old_param_gen_table = Table(
+        "param_gen",
+        metadata,
+        Column("category", Text, primary_key=True),
+        Column("name", Text, primary_key=True),
+        Column("value", Text),
+    )
+
+    old_param_ind_table = Table(
+        "param_ind",
+        metadata,
+        Column("category", Text, primary_key=True),
+        Column("name", Text, primary_key=True),
+        Column(
+            "profile_id", ForeignKey("profiles.id", ondelete="CASCADE"), primary_key=True
+        ),
+        Column("value", Text),
+    )
+
+    old_private_gen_table = Table(
+        "private_gen",
+        metadata,
+        Column("namespace", Text, primary_key=True),
+        Column("key", Text, primary_key=True),
+        Column("value", Text),
+    )
+
+    old_private_ind_table = Table(
+        "private_ind",
+        metadata,
+        Column("namespace", Text, primary_key=True),
+        Column("key", Text, primary_key=True),
+        Column(
+            "profile_id", ForeignKey("profiles.id", ondelete="CASCADE"), primary_key=True
+        ),
+        Column("value", Text),
+    )
+
+    old_private_gen_bin_table = Table(
+        "private_gen_bin",
+        metadata,
+        Column("namespace", Text, primary_key=True),
+        Column("key", Text, primary_key=True),
+        Column("value", Text),
+    )
+
+    old_private_ind_bin_table = Table(
+        "private_ind_bin",
+        metadata,
+        Column("namespace", Text, primary_key=True),
+        Column("key", Text, primary_key=True),
+        Column(
+            "profile_id", ForeignKey("profiles.id", ondelete="CASCADE"), primary_key=True
+        ),
+        Column("value", Text),
+    )
+
+    old_files_table = Table(
+        "files",
+        metadata,
+        Column("id", Text, primary_key=True),
+        Column("public_id", Text, unique=True),
+        Column("version", Text, primary_key=True),
+        Column("parent", Text, nullable=False),
+        Column(
+            "type",
+            Enum("file", "directory", name="file_type", create_constraint=True),
+            nullable=False,
+            server_default="file",
+        ),
+        Column("file_hash", Text),
+        Column("hash_algo", Text),
+        Column("name", Text, nullable=False),
+        Column("size", Integer),
+        Column("namespace", Text),
+        Column("media_type", Text),
+        Column("media_subtype", Text),
+        Column("created", Float, nullable=False),
+        Column("modified", Float),
+        Column("owner", Text),
+        Column("access", Text),
+        Column("extra", Text),
+        Column("profile_id", ForeignKey("profiles.id", ondelete="CASCADE")),
+        Index("files__profile_id_owner_parent", "profile_id", "owner", "parent"),
+        Index(
+            "files__profile_id_owner_media_type_media_subtype",
+            "profile_id",
+            "owner",
+            "media_type",
+            "media_subtype",
+        ),
+    )
+
+    op.drop_table("message_types")
+
+    with op.batch_alter_table(
+        "profiles", copy_from=old_profiles_table, schema=None
+    ) as batch_op:
+        batch_op.create_unique_constraint(batch_op.f("uq_profiles_name"), ["name"])
+
+    with op.batch_alter_table(
+        "components",
+        copy_from=old_components_table,
+        naming_convention={
+            "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
+        },
+        schema=None,
+    ) as batch_op:
+        batch_op.create_unique_constraint(batch_op.f("uq_profiles_name"), ["name"])
+
+    with op.batch_alter_table(
+        "history",
+        copy_from=old_history_table,
+        naming_convention={
+            "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
+        },
+        schema=None,
+    ) as batch_op:
+        batch_op.alter_column("uid", existing_type=TEXT(), nullable=False)
+        batch_op.alter_column(
+            "type",
+            type_=Enum(
+                "chat",
+                "error",
+                "groupchat",
+                "headline",
+                "normal",
+                "info",
+                name="message_type",
+                create_constraint=True,
+            ),
+            existing_type=TEXT(),
+            nullable=False,
+        )
+        batch_op.create_unique_constraint(
+            batch_op.f("uq_history_profile_id"),
+            ["profile_id", "stanza_id", "source", "dest"],
+        )
+        batch_op.drop_constraint("fk_history_type_message_types", type_="foreignkey")
+
+    with op.batch_alter_table(
+        "message", copy_from=old_message_table, schema=None
+    ) as batch_op:
+        batch_op.alter_column(
+            "id", existing_type=INTEGER(), nullable=False, autoincrement=False
+        )
+
+    with op.batch_alter_table(
+        "subject", copy_from=old_subject_table, schema=None
+    ) as batch_op:
+        batch_op.alter_column(
+            "id", existing_type=INTEGER(), nullable=False, autoincrement=False
+        )
+
+    with op.batch_alter_table(
+        "thread", copy_from=old_thread_table, schema=None
+    ) as batch_op:
+        batch_op.alter_column(
+            "id", existing_type=INTEGER(), nullable=False, autoincrement=False
+        )
+
+    with op.batch_alter_table(
+        "param_gen", copy_from=old_param_gen_table, schema=None
+    ) as batch_op:
+        batch_op.alter_column("category", existing_type=TEXT(), nullable=False)
+        batch_op.alter_column("name", existing_type=TEXT(), nullable=False)
+
+    with op.batch_alter_table(
+        "param_ind", copy_from=old_param_ind_table, schema=None
+    ) as batch_op:
+        batch_op.alter_column("category", existing_type=TEXT(), nullable=False)
+        batch_op.alter_column("name", existing_type=TEXT(), nullable=False)
+        batch_op.alter_column("profile_id", existing_type=INTEGER(), nullable=False)
+
+    with op.batch_alter_table(
+        "private_gen", copy_from=old_private_gen_table, schema=None
+    ) as batch_op:
+        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=False)
+        batch_op.alter_column("key", existing_type=TEXT(), nullable=False)
+
+    with op.batch_alter_table(
+        "private_ind", copy_from=old_private_ind_table, schema=None
+    ) as batch_op:
+        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=False)
+        batch_op.alter_column("key", existing_type=TEXT(), nullable=False)
+        batch_op.alter_column("profile_id", existing_type=INTEGER(), nullable=False)
+
+    with op.batch_alter_table(
+        "private_gen_bin", copy_from=old_private_gen_bin_table, schema=None
+    ) as batch_op:
+        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=False)
+        batch_op.alter_column("key", existing_type=TEXT(), nullable=False)
+
+    # found some invalid rows in local database, maybe old values made during development,
+    # but in doubt we have to delete them
+    op.execute("DELETE FROM private_ind_bin WHERE namespace IS NULL")
+
+    with op.batch_alter_table(
+        "private_ind_bin", copy_from=old_private_ind_bin_table, schema=None
+    ) as batch_op:
+        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=False)
+        batch_op.alter_column("key", existing_type=TEXT(), nullable=False)
+        batch_op.alter_column("profile_id", existing_type=INTEGER(), nullable=False)
+
+    with op.batch_alter_table(
+        "files", copy_from=old_files_table, schema=None
+    ) as batch_op:
+        batch_op.create_unique_constraint(batch_op.f("uq_files_public_id"), ["public_id"])
+        batch_op.alter_column(
+            "type",
+            type_=Enum("file", "directory", name="file_type", create_constraint=True),
+            existing_type=Text(),
+            nullable=False,
+        )
+
+
+def downgrade():
+    # downgrade doesn't restore the exact same state as before upgrade, as it
+    # would be useless and waste of resource to restore broken things such as
+    # anonymous constraints
+    with op.batch_alter_table("thread", schema=None) as batch_op:
+        batch_op.alter_column(
+            "id", existing_type=INTEGER(), nullable=True, autoincrement=False
+        )
+
+    with op.batch_alter_table("subject", schema=None) as batch_op:
+        batch_op.alter_column(
+            "id", existing_type=INTEGER(), nullable=True, autoincrement=False
+        )
+
+    with op.batch_alter_table("private_ind_bin", schema=None) as batch_op:
+        batch_op.alter_column("profile_id", existing_type=INTEGER(), nullable=True)
+        batch_op.alter_column("key", existing_type=TEXT(), nullable=True)
+        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=True)
+
+    with op.batch_alter_table("private_ind", schema=None) as batch_op:
+        batch_op.alter_column("profile_id", existing_type=INTEGER(), nullable=True)
+        batch_op.alter_column("key", existing_type=TEXT(), nullable=True)
+        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=True)
+
+    with op.batch_alter_table("private_gen_bin", schema=None) as batch_op:
+        batch_op.alter_column("key", existing_type=TEXT(), nullable=True)
+        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=True)
+
+    with op.batch_alter_table("private_gen", schema=None) as batch_op:
+        batch_op.alter_column("key", existing_type=TEXT(), nullable=True)
+        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=True)
+
+    with op.batch_alter_table("param_ind", schema=None) as batch_op:
+        batch_op.alter_column("profile_id", existing_type=INTEGER(), nullable=True)
+        batch_op.alter_column("name", existing_type=TEXT(), nullable=True)
+        batch_op.alter_column("category", existing_type=TEXT(), nullable=True)
+
+    with op.batch_alter_table("param_gen", schema=None) as batch_op:
+        batch_op.alter_column("name", existing_type=TEXT(), nullable=True)
+        batch_op.alter_column("category", existing_type=TEXT(), nullable=True)
+
+    with op.batch_alter_table("message", schema=None) as batch_op:
+        batch_op.alter_column(
+            "id", existing_type=INTEGER(), nullable=True, autoincrement=False
+        )
+
+    op.create_table(
+        "message_types",
+        Column("type", TEXT(), nullable=True),
+        PrimaryKeyConstraint("type"),
+    )
+    message_types_table = table("message_types", column("type", TEXT()))
+    op.bulk_insert(
+        message_types_table,
+        [
+            {"type": "chat"},
+            {"type": "error"},
+            {"type": "groupchat"},
+            {"type": "headline"},
+            {"type": "normal"},
+            {"type": "info"},
+        ],
+    )
+
+    with op.batch_alter_table("history", schema=None) as batch_op:
+        batch_op.alter_column(
+            "type",
+            type_=TEXT(),
+            existing_type=TEXT(),
+            nullable=True,
+        )
+        batch_op.create_foreign_key(
+            batch_op.f("fk_history_type_message_types"),
+            "message_types",
+            ["type"],
+            ["type"],
+        )
+        batch_op.alter_column("uid", existing_type=TEXT(), nullable=True)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/migration/versions/79e5f3313fa4_create_table_for_pubsub_subscriptions.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,33 @@
+"""create table for pubsub subscriptions
+
+Revision ID: 79e5f3313fa4
+Revises: 129ac51807e4
+Create Date: 2022-03-14 17:15:00.689871
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from libervia.backend.memory.sqla_mapping import JID
+
+
+# revision identifiers, used by Alembic.
+revision = '79e5f3313fa4'
+down_revision = '129ac51807e4'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.create_table('pubsub_subs',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('node_id', sa.Integer(), nullable=False),
+    sa.Column('subscriber', JID(), nullable=True),
+    sa.Column('state', sa.Enum('SUBSCRIBED', 'PENDING', name='state'), nullable=True),
+    sa.ForeignKeyConstraint(['node_id'], ['pubsub_nodes.id'], name=op.f('fk_pubsub_subs_node_id_pubsub_nodes'), ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id', name=op.f('pk_pubsub_subs')),
+    sa.UniqueConstraint('node_id', 'subscriber', name=op.f('uq_pubsub_subs_node_id'))
+    )
+
+
+def downgrade():
+    op.drop_table('pubsub_subs')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/migration/versions/8974efc51d22_create_tables_for_pubsub_caching.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,57 @@
+"""create tables for Pubsub caching
+
+Revision ID: 8974efc51d22
+Revises: 602caf848068
+Create Date: 2021-07-27 16:38:54.658212
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from libervia.backend.memory.sqla_mapping import JID, Xml
+
+
+# revision identifiers, used by Alembic.
+revision = '8974efc51d22'
+down_revision = '602caf848068'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('pubsub_nodes',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('profile_id', sa.Integer(), nullable=True),
+    sa.Column('service', JID(), nullable=True),
+    sa.Column('name', sa.Text(), nullable=False),
+    sa.Column('subscribed', sa.Boolean(create_constraint=True, name='subscribed_bool'), nullable=False),
+    sa.Column('analyser', sa.Text(), nullable=True),
+    sa.Column('sync_state', sa.Enum('IN_PROGRESS', 'COMPLETED', 'ERROR', 'NO_SYNC', name='sync_state', create_constraint=True), nullable=True),
+    sa.Column('sync_state_updated', sa.Float(), nullable=False),
+    sa.Column('type', sa.Text(), nullable=True),
+    sa.Column('subtype', sa.Text(), nullable=True),
+    sa.Column('extra', sa.JSON(), nullable=True),
+    sa.ForeignKeyConstraint(['profile_id'], ['profiles.id'], name=op.f('fk_pubsub_nodes_profile_id_profiles'), ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id', name=op.f('pk_pubsub_nodes')),
+    sa.UniqueConstraint('profile_id', 'service', 'name', name=op.f('uq_pubsub_nodes_profile_id'))
+    )
+    op.create_table('pubsub_items',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('node_id', sa.Integer(), nullable=False),
+    sa.Column('name', sa.Text(), nullable=False),
+    sa.Column('data', Xml(), nullable=False),
+    sa.Column('created', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
+    sa.Column('updated', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
+    sa.Column('parsed', sa.JSON(), nullable=True),
+    sa.ForeignKeyConstraint(['node_id'], ['pubsub_nodes.id'], name=op.f('fk_pubsub_items_node_id_pubsub_nodes'), ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id', name=op.f('pk_pubsub_items')),
+    sa.UniqueConstraint('node_id', 'name', name=op.f('uq_pubsub_items_node_id'))
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_table('pubsub_items')
+    op.drop_table('pubsub_nodes')
+    # ### end Alembic commands ###
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/params.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1173 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.memory.crypto import BlockCipher, PasswordHasher
+from xml.dom import minidom, NotFoundErr
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.internet import defer
+from twisted.python.failure import Failure
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber import jid
+from libervia.backend.tools.xml_tools import params_xml_2_xmlui, get_text
+from libervia.backend.tools.common import data_format
+from xml.sax.saxutils import quoteattr
+
+# TODO: params should be rewritten using Twisted directly instead of minidom
+#       general params should be linked to sat.conf and kept synchronised
+#       this need an overall simplification to make maintenance easier
+
+
+def create_jid_elts(jids):
+    """Generator which return <jid/> elements from jids
+
+    @param jids(iterable[id.jID]): jids to use
+    @return (generator[domish.Element]): <jid/> elements
+    """
+    for jid_ in jids:
+        jid_elt = domish.Element((None, "jid"))
+        jid_elt.addContent(jid_.full())
+        yield jid_elt
+
+
+class Params(object):
+    """This class manage parameters with xml"""
+
+    ### TODO: add desciption in params
+
+    # TODO: when priority is changed, a new presence stanza must be emitted
+    # TODO: int type (Priority should be int instead of string)
+    default_xml = """
+    <params>
+    <general>
+    </general>
+    <individual>
+        <category name="General" label="%(category_general)s">
+            <param name="Password" value="" type="password" />
+            <param name="%(history_param)s" label="%(history_label)s" value="20" constraint="0;100" type="int" security="0" />
+            <param name="%(show_offline_contacts)s" label="%(show_offline_contacts_label)s" value="false" type="bool" security="0" />
+            <param name="%(show_empty_groups)s" label="%(show_empty_groups_label)s" value="true" type="bool" security="0" />
+        </category>
+        <category name="Connection" label="%(category_connection)s">
+            <param name="JabberID" value="name@example.org" type="string" security="10" />
+            <param name="Password" value="" type="password" security="10" />
+            <param name="Priority" value="50" type="int" constraint="-128;127" security="10" />
+            <param name="%(force_server_param)s" value="" type="string" security="50" />
+            <param name="%(force_port_param)s" value="" type="int" constraint="1;65535" security="50" />
+            <param name="autoconnect_backend" label="%(autoconnect_backend_label)s" value="false" type="bool" security="50" />
+            <param name="autoconnect" label="%(autoconnect_label)s" value="true" type="bool" security="50" />
+            <param name="autodisconnect" label="%(autodisconnect_label)s" value="false"  type="bool" security="50" />
+            <param name="check_certificate" label="%(check_certificate_label)s" value="true"  type="bool" security="4" />
+        </category>
+    </individual>
+    </params>
+    """ % {
+        "category_general": D_("General"),
+        "category_connection": D_("Connection"),
+        "history_param": C.HISTORY_LIMIT,
+        "history_label": D_("Chat history limit"),
+        "show_offline_contacts": C.SHOW_OFFLINE_CONTACTS,
+        "show_offline_contacts_label": D_("Show offline contacts"),
+        "show_empty_groups": C.SHOW_EMPTY_GROUPS,
+        "show_empty_groups_label": D_("Show empty groups"),
+        "force_server_param": C.FORCE_SERVER_PARAM,
+        "force_port_param": C.FORCE_PORT_PARAM,
+        "autoconnect_backend_label": D_("Connect on backend startup"),
+        "autoconnect_label": D_("Connect on frontend startup"),
+        "autodisconnect_label": D_("Disconnect on frontend closure"),
+        "check_certificate_label": D_("Check certificate (don't uncheck if unsure)"),
+    }
+
+    def load_default_params(self):
+        self.dom = minidom.parseString(Params.default_xml.encode("utf-8"))
+
+    def _merge_params(self, source_node, dest_node):
+        """Look for every node in source_node and recursively copy them to dest if they don't exists"""
+
+        def get_nodes_map(children):
+            ret = {}
+            for child in children:
+                if child.nodeType == child.ELEMENT_NODE:
+                    ret[(child.tagName, child.getAttribute("name"))] = child
+            return ret
+
+        source_map = get_nodes_map(source_node.childNodes)
+        dest_map = get_nodes_map(dest_node.childNodes)
+        source_set = set(source_map.keys())
+        dest_set = set(dest_map.keys())
+        to_add = source_set.difference(dest_set)
+
+        for node_key in to_add:
+            dest_node.appendChild(source_map[node_key].cloneNode(True))
+
+        to_recurse = source_set - to_add
+        for node_key in to_recurse:
+            self._merge_params(source_map[node_key], dest_map[node_key])
+
+    def load_xml(self, xml_file):
+        """Load parameters template from xml file"""
+        self.dom = minidom.parse(xml_file)
+        default_dom = minidom.parseString(Params.default_xml.encode("utf-8"))
+        self._merge_params(default_dom.documentElement, self.dom.documentElement)
+
+    def load_gen_params(self):
+        """Load general parameters data from storage
+
+        @return: deferred triggered once params are loaded
+        """
+        return self.storage.load_gen_params(self.params_gen)
+
+    def load_ind_params(self, profile, cache=None):
+        """Load individual parameters
+
+        set self.params cache or a temporary cache
+        @param profile: profile to load (*must exist*)
+        @param cache: if not None, will be used to store the value, as a short time cache
+        @return: deferred triggered once params are loaded
+        """
+        if cache is None:
+            self.params[profile] = {}
+        return self.storage.load_ind_params(
+            self.params[profile] if cache is None else cache, profile
+        )
+
+    def purge_profile(self, profile):
+        """Remove cache data of a profile
+
+        @param profile: %(doc_profile)s
+        """
+        try:
+            del self.params[profile]
+        except KeyError:
+            log.error(
+                _("Trying to purge cache of a profile not in memory: [%s]") % profile
+            )
+
+    def save_xml(self, filename):
+        """Save parameters template to xml file"""
+        with open(filename, "wb") as xml_file:
+            xml_file.write(self.dom.toxml("utf-8"))
+
+    def __init__(self, host, storage):
+        log.debug("Parameters init")
+        self.host = host
+        self.storage = storage
+        self.default_profile = None
+        self.params = {}
+        self.params_gen = {}
+
+    def create_profile(self, profile, component):
+        """Create a new profile
+
+        @param profile(unicode): name of the profile
+        @param component(unicode): entry point if profile is a component
+        @param callback: called when the profile actually exists in database and memory
+        @return: a Deferred instance
+        """
+        if self.storage.has_profile(profile):
+            log.info(_("The profile name already exists"))
+            return defer.fail(exceptions.ConflictError())
+        if not self.host.trigger.point("ProfileCreation", profile):
+            return defer.fail(exceptions.CancelError())
+        return self.storage.create_profile(profile, component or None)
+
+    def profile_delete_async(self, profile, force=False):
+        """Delete an existing profile
+
+        @param profile: name of the profile
+        @param force: force the deletion even if the profile is connected.
+        To be used for direct calls only (not through the bridge).
+        @return: a Deferred instance
+        """
+        if not self.storage.has_profile(profile):
+            log.info(_("Trying to delete an unknown profile"))
+            return defer.fail(Failure(exceptions.ProfileUnknownError(profile)))
+        if self.host.is_connected(profile):
+            if force:
+                self.host.disconnect(profile)
+            else:
+                log.info(_("Trying to delete a connected profile"))
+                return defer.fail(Failure(exceptions.ProfileConnected))
+        return self.storage.delete_profile(profile)
+
+    def get_profile_name(self, profile_key, return_profile_keys=False):
+        """return profile according to profile_key
+
+        @param profile_key: profile name or key which can be
+                            C.PROF_KEY_ALL for all profiles
+                            C.PROF_KEY_DEFAULT for default profile
+        @param return_profile_keys: if True, return unmanaged profile keys (like
+            C.PROF_KEY_ALL). This keys must be managed by the caller
+        @return: requested profile name
+        @raise exceptions.ProfileUnknownError: profile doesn't exists
+        @raise exceptions.ProfileNotSetError: if C.PROF_KEY_NONE is used
+        """
+        if profile_key == "@DEFAULT@":
+            default = self.host.memory.memory_data.get("Profile_default")
+            if not default:
+                log.info(_("No default profile, returning first one"))
+                try:
+                    default = self.host.memory.memory_data[
+                        "Profile_default"
+                    ] = self.storage.get_profiles_list()[0]
+                except IndexError:
+                    log.info(_("No profile exist yet"))
+                    raise exceptions.ProfileUnknownError(profile_key)
+            return (
+                default
+            )  # FIXME: temporary, must use real default value, and fallback to first one if it doesn't exists
+        elif profile_key == C.PROF_KEY_NONE:
+            raise exceptions.ProfileNotSetError
+        elif return_profile_keys and profile_key in [C.PROF_KEY_ALL]:
+            return profile_key  # this value must be managed by the caller
+        if not self.storage.has_profile(profile_key):
+            log.error(_("Trying to access an unknown profile (%s)") % profile_key)
+            raise exceptions.ProfileUnknownError(profile_key)
+        return profile_key
+
+    def __get_unique_node(self, parent, tag, name):
+        """return node with given tag
+
+        @param parent: parent of nodes to check (e.g. documentElement)
+        @param tag: tag to check (e.g. "category")
+        @param name: name to check (e.g. "JID")
+        @return: node if it exist or None
+        """
+        for node in parent.childNodes:
+            if node.nodeName == tag and node.getAttribute("name") == name:
+                # the node already exists
+                return node
+        # the node is new
+        return None
+
+    def update_params(self, xml, security_limit=C.NO_SECURITY_LIMIT, app=""):
+        """import xml in parameters, update if the param already exists
+
+        If security_limit is specified and greater than -1, the parameters
+        that have a security level greater than security_limit are skipped.
+        @param xml: parameters in xml form
+        @param security_limit: -1 means no security, 0 is the maximum security then the higher the less secure
+        @param app: name of the frontend registering the parameters or empty value
+        """
+        # TODO: should word with domish.Element
+        src_parent = minidom.parseString(xml.encode("utf-8")).documentElement
+
+        def pre_process_app_node(src_parent, security_limit, app):
+            """Parameters that are registered from a frontend must be checked"""
+            to_remove = []
+            for type_node in src_parent.childNodes:
+                if type_node.nodeName != C.INDIVIDUAL:
+                    to_remove.append(type_node)  # accept individual parameters only
+                    continue
+                for cat_node in type_node.childNodes:
+                    if cat_node.nodeName != "category":
+                        to_remove.append(cat_node)
+                        continue
+                    to_remove_count = (
+                        0
+                    )  # count the params to be removed from current category
+                    for node in cat_node.childNodes:
+                        if node.nodeName != "param" or not self.check_security_limit(
+                            node, security_limit
+                        ):
+                            to_remove.append(node)
+                            to_remove_count += 1
+                            continue
+                        node.setAttribute("app", app)
+                    if (
+                        len(cat_node.childNodes) == to_remove_count
+                    ):  # remove empty category
+                        for __ in range(0, to_remove_count):
+                            to_remove.pop()
+                        to_remove.append(cat_node)
+            for node in to_remove:
+                node.parentNode.removeChild(node)
+
+        def import_node(tgt_parent, src_parent):
+            for child in src_parent.childNodes:
+                if child.nodeName == "#text":
+                    continue
+                node = self.__get_unique_node(
+                    tgt_parent, child.nodeName, child.getAttribute("name")
+                )
+                if not node:  # The node is new
+                    tgt_parent.appendChild(child.cloneNode(True))
+                else:
+                    if child.nodeName == "param":
+                        # The child updates an existing parameter, we replace the node
+                        tgt_parent.replaceChild(child, node)
+                    else:
+                        # the node already exists, we recurse 1 more level
+                        import_node(node, child)
+
+        if app:
+            pre_process_app_node(src_parent, security_limit, app)
+        import_node(self.dom.documentElement, src_parent)
+
+    def params_register_app(self, xml, security_limit, app):
+        """Register frontend's specific parameters
+
+        If security_limit is specified and greater than -1, the parameters
+        that have a security level greater than security_limit are skipped.
+        @param xml: XML definition of the parameters to be added
+        @param security_limit: -1 means no security, 0 is the maximum security then the higher the less secure
+        @param app: name of the frontend registering the parameters
+        """
+        if not app:
+            log.warning(
+                _(
+                    "Trying to register frontends parameters with no specified app: aborted"
+                )
+            )
+            return
+        if not hasattr(self, "frontends_cache"):
+            self.frontends_cache = []
+        if app in self.frontends_cache:
+            log.debug(
+                _(
+                    "Trying to register twice frontends parameters for %(app)s: aborted"
+                    % {"app": app}
+                )
+            )
+            return
+        self.frontends_cache.append(app)
+        self.update_params(xml, security_limit, app)
+        log.debug("Frontends parameters registered for %(app)s" % {"app": app})
+
+    def __default_ok(self, value, name, category):
+        # FIXME: will not work with individual parameters
+        self.param_set(name, value, category)
+
+    def __default_ko(self, failure, name, category):
+        log.error(
+            _("Can't determine default value for [%(category)s/%(name)s]: %(reason)s")
+            % {"category": category, "name": name, "reason": str(failure.value)}
+        )
+
+    def set_default(self, name, category, callback, errback=None):
+        """Set default value of parameter
+
+        'default_cb' attibute of parameter must be set to 'yes'
+        @param name: name of the parameter
+        @param category: category of the parameter
+        @param callback: must return a string with the value (use deferred if needed)
+        @param errback: must manage the error with args failure, name, category
+        """
+        # TODO: send signal param update if value changed
+        # TODO: manage individual paramaters
+        log.debug(
+            "set_default called for %(category)s/%(name)s"
+            % {"category": category, "name": name}
+        )
+        node = self._get_param_node(name, category, "@ALL@")
+        if not node:
+            log.error(
+                _(
+                    "Requested param [%(name)s] in category [%(category)s] doesn't exist !"
+                )
+                % {"name": name, "category": category}
+            )
+            return
+        if node[1].getAttribute("default_cb") == "yes":
+            # del node[1].attributes['default_cb'] # default_cb is not used anymore as a flag to know if we have to set the default value,
+            # and we can still use it later e.g. to call a generic set_default method
+            value = self._get_param(category, name, C.GENERAL)
+            if value is None:  # no value set by the user: we have the default value
+                log.debug("Default value to set, using callback")
+                d = defer.maybeDeferred(callback)
+                d.addCallback(self.__default_ok, name, category)
+                d.addErrback(errback or self.__default_ko, name, category)
+
+    def _get_attr_internal(self, node, attr, value):
+        """Get attribute value.
+
+        /!\ This method would return encrypted password values.
+
+        @param node: XML param node
+        @param attr: name of the attribute to get (e.g.: 'value' or 'type')
+        @param value: user defined value
+        @return: value (can be str, bool, int, list, None)
+        """
+        if attr == "value":
+            value_to_use = (
+                value if value is not None else node.getAttribute(attr)
+            )  # we use value (user defined) if it exist, else we use node's default value
+            if node.getAttribute("type") == "bool":
+                return C.bool(value_to_use)
+            if node.getAttribute("type") == "int":
+                return int(value_to_use) if value_to_use else value_to_use
+            elif node.getAttribute("type") == "list":
+                if (
+                    not value_to_use
+                ):  # no user defined value, take default value from the XML
+                    options = [
+                        option
+                        for option in node.childNodes
+                        if option.nodeName == "option"
+                    ]
+                    selected = [
+                        option
+                        for option in options
+                        if option.getAttribute("selected") == "true"
+                    ]
+                    cat, param = (
+                        node.parentNode.getAttribute("name"),
+                        node.getAttribute("name"),
+                    )
+                    if len(selected) == 1:
+                        value_to_use = selected[0].getAttribute("value")
+                        log.info(
+                            _(
+                                "Unset parameter (%(cat)s, %(param)s) of type list will use the default option '%(value)s'"
+                            )
+                            % {"cat": cat, "param": param, "value": value_to_use}
+                        )
+                        return value_to_use
+                    if len(selected) == 0:
+                        log.error(
+                            _(
+                                "Parameter (%(cat)s, %(param)s) of type list has no default option!"
+                            )
+                            % {"cat": cat, "param": param}
+                        )
+                    else:
+                        log.error(
+                            _(
+                                "Parameter (%(cat)s, %(param)s) of type list has more than one default option!"
+                            )
+                            % {"cat": cat, "param": param}
+                        )
+                    raise exceptions.DataError
+            elif node.getAttribute("type") == "jids_list":
+                if value_to_use:
+                    jids = value_to_use.split(
+                        "\t"
+                    )  # FIXME: it's not good to use tabs as separator !
+                else:  # no user defined value, take default value from the XML
+                    jids = [get_text(jid_) for jid_ in node.getElementsByTagName("jid")]
+                to_delete = []
+                for idx, value in enumerate(jids):
+                    try:
+                        jids[idx] = jid.JID(value)
+                    except (RuntimeError, jid.InvalidFormat, AttributeError):
+                        log.warning(
+                            "Incorrect jid value found in jids list: [{}]".format(value)
+                        )
+                        to_delete.append(value)
+                for value in to_delete:
+                    jids.remove(value)
+                return jids
+            return value_to_use
+        return node.getAttribute(attr)
+
+    def _get_attr(self, node, attr, value):
+        """Get attribute value (synchronous).
+
+        /!\ This method can not be used to retrieve password values.
+        @param node: XML param node
+        @param attr: name of the attribute to get (e.g.: 'value' or 'type')
+        @param value: user defined value
+        @return (unicode, bool, int, list): value to retrieve
+        """
+        if attr == "value" and node.getAttribute("type") == "password":
+            raise exceptions.InternalError(
+                "To retrieve password values, use _async_get_attr instead of _get_attr"
+            )
+        return self._get_attr_internal(node, attr, value)
+
+    def _async_get_attr(self, node, attr, value, profile=None):
+        """Get attribute value.
+
+        Profile passwords are returned hashed (if not empty),
+        other passwords are returned decrypted (if not empty).
+        @param node: XML param node
+        @param attr: name of the attribute to get (e.g.: 'value' or 'type')
+        @param value: user defined value
+        @param profile: %(doc_profile)s
+        @return (unicode, bool, int, list): Deferred value to retrieve
+        """
+        value = self._get_attr_internal(node, attr, value)
+        if attr != "value" or node.getAttribute("type") != "password":
+            return defer.succeed(value)
+        param_cat = node.parentNode.getAttribute("name")
+        param_name = node.getAttribute("name")
+        if ((param_cat, param_name) == C.PROFILE_PASS_PATH) or not value:
+            return defer.succeed(
+                value
+            )  # profile password and empty passwords are returned "as is"
+        if not profile:
+            raise exceptions.ProfileNotSetError(
+                "The profile is needed to decrypt a password"
+            )
+        password = self.host.memory.decrypt_value(value, profile)
+
+        if password is None:
+            raise exceptions.InternalError("password should never be None")
+        return defer.succeed(password)
+
+    def _type_to_str(self, result):
+        """Convert result to string, according to its type """
+        if isinstance(result, bool):
+            return C.bool_const(result)
+        elif isinstance(result, (list, set, tuple)):
+            return ', '.join(self._type_to_str(r) for r in result)
+        else:
+            return str(result)
+
+    def get_string_param_a(self, name, category, attr="value", profile_key=C.PROF_KEY_NONE):
+        """ Same as param_get_a but for bridge: convert non string value to string """
+        return self._type_to_str(
+            self.param_get_a(name, category, attr, profile_key=profile_key)
+        )
+
+    def param_get_a(
+        self, name, category, attr="value", use_default=True, profile_key=C.PROF_KEY_NONE
+    ):
+        """Helper method to get a specific attribute.
+
+        /!\ This method would return encrypted password values,
+            to get the plain values you have to use param_get_a_async.
+        @param name: name of the parameter
+        @param category: category of the parameter
+        @param attr: name of the attribute (default: "value")
+        @parm use_default(bool): if True and attr=='value', return default value if not set
+            else return None if not set
+        @param profile: owner of the param (@ALL@ for everyone)
+        @return: attribute
+        """
+        # FIXME: looks really dirty and buggy, need to be reviewed/refactored
+        # FIXME: security_limit is not managed here !
+        node = self._get_param_node(name, category)
+        if not node:
+            log.error(
+                _(
+                    "Requested param [%(name)s] in category [%(category)s] doesn't exist !"
+                )
+                % {"name": name, "category": category}
+            )
+            raise exceptions.NotFound
+
+        if attr == "value" and node[1].getAttribute("type") == "password":
+            raise exceptions.InternalError(
+                "To retrieve password values, use param_get_a_async instead of param_get_a"
+            )
+
+        if node[0] == C.GENERAL:
+            value = self._get_param(category, name, C.GENERAL)
+            if value is None and attr == "value" and not use_default:
+                return value
+            return self._get_attr(node[1], attr, value)
+
+        assert node[0] == C.INDIVIDUAL
+
+        profile = self.get_profile_name(profile_key)
+        if not profile:
+            log.error(_("Requesting a param for an non-existant profile"))
+            raise exceptions.ProfileUnknownError(profile_key)
+
+        if profile not in self.params:
+            log.error(_("Requesting synchronous param for not connected profile"))
+            raise exceptions.ProfileNotConnected(profile)
+
+        if attr == "value":
+            value = self._get_param(category, name, profile=profile)
+            if value is None and attr == "value" and not use_default:
+                return value
+            return self._get_attr(node[1], attr, value)
+
+    async def async_get_string_param_a(
+        self, name, category, attr="value", security_limit=C.NO_SECURITY_LIMIT,
+        profile=C.PROF_KEY_NONE):
+        value = await self.param_get_a_async(
+            name, category, attr, security_limit, profile_key=profile)
+        return self._type_to_str(value)
+
+    def param_get_a_async(
+        self,
+        name,
+        category,
+        attr="value",
+        security_limit=C.NO_SECURITY_LIMIT,
+        profile_key=C.PROF_KEY_NONE,
+    ):
+        """Helper method to get a specific attribute.
+
+        @param name: name of the parameter
+        @param category: category of the parameter
+        @param attr: name of the attribute (default: "value")
+        @param profile: owner of the param (@ALL@ for everyone)
+        @return (defer.Deferred): parameter value, with corresponding type (bool, int, list, etc)
+        """
+        node = self._get_param_node(name, category)
+        if not node:
+            log.error(
+                _(
+                    "Requested param [%(name)s] in category [%(category)s] doesn't exist !"
+                )
+                % {"name": name, "category": category}
+            )
+            raise ValueError("Requested param doesn't exist")
+
+        if not self.check_security_limit(node[1], security_limit):
+            log.warning(
+                _(
+                    "Trying to get parameter '%(param)s' in category '%(cat)s' without authorization!!!"
+                    % {"param": name, "cat": category}
+                )
+            )
+            raise exceptions.PermissionError
+
+        if node[0] == C.GENERAL:
+            value = self._get_param(category, name, C.GENERAL)
+            return self._async_get_attr(node[1], attr, value)
+
+        assert node[0] == C.INDIVIDUAL
+
+        profile = self.get_profile_name(profile_key)
+        if not profile:
+            raise exceptions.InternalError(
+                _("Requesting a param for a non-existant profile")
+            )
+
+        if attr != "value":
+            return defer.succeed(node[1].getAttribute(attr))
+        try:
+            value = self._get_param(category, name, profile=profile)
+            return self._async_get_attr(node[1], attr, value, profile)
+        except exceptions.ProfileNotInCacheError:
+            # We have to ask data to the storage manager
+            d = self.storage.get_ind_param(category, name, profile)
+            return d.addCallback(
+                lambda value: self._async_get_attr(node[1], attr, value, profile)
+            )
+
+    def _get_params_values_from_category(
+        self, category, security_limit, app, extra_s, profile_key):
+        client = self.host.get_client(profile_key)
+        extra = data_format.deserialise(extra_s)
+        return defer.ensureDeferred(self.get_params_values_from_category(
+            client, category, security_limit, app, extra))
+
+    async def get_params_values_from_category(
+        self, client, category, security_limit, app='', extra=None):
+        """Get all parameters "attribute" for a category
+
+        @param category(unicode): the desired category
+        @param security_limit(int): NO_SECURITY_LIMIT (-1) to return all the params.
+            Otherwise sole the params which have a security level defined *and*
+            lower or equal to the specified value are returned.
+        @param app(str): see [get_params]
+        @param extra(dict): see [get_params]
+        @return (dict): key: param name, value: param value (converted to string if needed)
+        """
+        # TODO: manage category of general type (without existant profile)
+        if extra is None:
+            extra = {}
+        prof_xml = await self._construct_profile_xml(client, security_limit, app, extra)
+        ret = {}
+        for category_node in prof_xml.getElementsByTagName("category"):
+            if category_node.getAttribute("name") == category:
+                for param_node in category_node.getElementsByTagName("param"):
+                    name = param_node.getAttribute("name")
+                    if not name:
+                        log.warning(
+                            "ignoring attribute without name: {}".format(
+                                param_node.toxml()
+                            )
+                        )
+                        continue
+                    value = await self.async_get_string_param_a(
+                        name, category, security_limit=security_limit,
+                        profile=client.profile)
+
+                    ret[name] = value
+                break
+
+        prof_xml.unlink()
+        return ret
+
+    def _get_param(
+        self, category, name, type_=C.INDIVIDUAL, cache=None, profile=C.PROF_KEY_NONE
+    ):
+        """Return the param, or None if it doesn't exist
+
+        @param category: param category
+        @param name: param name
+        @param type_: GENERAL or INDIVIDUAL
+        @param cache: temporary cache, to use when profile is not logged
+        @param profile: the profile name (not profile key, i.e. name and not something like @DEFAULT@)
+        @return: param value or None if it doesn't exist
+        """
+        if type_ == C.GENERAL:
+            if (category, name) in self.params_gen:
+                return self.params_gen[(category, name)]
+            return None  # This general param has the default value
+        assert type_ == C.INDIVIDUAL
+        if profile == C.PROF_KEY_NONE:
+            raise exceptions.ProfileNotSetError
+        if profile in self.params:
+            cache = self.params[profile]  # if profile is in main cache, we use it,
+            # ignoring the temporary cache
+        elif (
+            cache is None
+        ):  # else we use the temporary cache if it exists, or raise an exception
+            raise exceptions.ProfileNotInCacheError
+        if (category, name) not in cache:
+            return None
+        return cache[(category, name)]
+
+    async def _construct_profile_xml(self, client, security_limit, app, extra):
+        """Construct xml for asked profile, filling values when needed
+
+        /!\ as noticed in doc, don't forget to unlink the minidom.Document
+        @param security_limit: NO_SECURITY_LIMIT (-1) to return all the params.
+        Otherwise sole the params which have a security level defined *and*
+        lower or equal to the specified value are returned.
+        @param app: name of the frontend requesting the parameters, or '' to get all parameters
+        @param profile: profile name (not key !)
+        @return: a deferred that fire a minidom.Document of the profile xml (cf warning above)
+        """
+        profile = client.profile
+
+        def check_node(node):
+            """Check the node against security_limit, app and extra"""
+            return (self.check_security_limit(node, security_limit)
+                    and self.check_app(node, app)
+                    and self.check_extra(node, extra))
+
+        if profile in self.params:
+            profile_cache = self.params[profile]
+        else:
+            # profile is not in cache, we load values in a short time cache
+            profile_cache = {}
+            await self.load_ind_params(profile, profile_cache)
+
+        # init the result document
+        prof_xml = minidom.parseString("<params/>")
+        cache = {}
+
+        for type_node in self.dom.documentElement.childNodes:
+            if type_node.nodeName != C.GENERAL and type_node.nodeName != C.INDIVIDUAL:
+                continue
+            # we use all params, general and individual
+            for cat_node in type_node.childNodes:
+                if cat_node.nodeName != "category":
+                    continue
+                category = cat_node.getAttribute("name")
+                dest_params = {}  # result (merged) params for category
+                if category not in cache:
+                    # we make a copy for the new xml
+                    cache[category] = dest_cat = cat_node.cloneNode(True)
+                    to_remove = []
+                    for node in dest_cat.childNodes:
+                        if node.nodeName != "param":
+                            continue
+                        if not check_node(node):
+                            to_remove.append(node)
+                            continue
+                        dest_params[node.getAttribute("name")] = node
+                    for node in to_remove:
+                        dest_cat.removeChild(node)
+                    new_node = True
+                else:
+                    # It's not a new node, we use the previously cloned one
+                    dest_cat = cache[category]
+                    new_node = False
+                params = cat_node.getElementsByTagName("param")
+
+                for param_node in params:
+                    # we have to merge new params (we are parsing individual parameters, we have to add them
+                    # to the previously parsed general ones)
+                    name = param_node.getAttribute("name")
+                    if not check_node(param_node):
+                        continue
+                    if name not in dest_params:
+                        # this is reached when a previous category exists
+                        dest_params[name] = param_node.cloneNode(True)
+                        dest_cat.appendChild(dest_params[name])
+
+                    profile_value = self._get_param(
+                        category,
+                        name,
+                        type_node.nodeName,
+                        cache=profile_cache,
+                        profile=profile,
+                    )
+                    if profile_value is not None:
+                        # there is a value for this profile, we must change the default
+                        if dest_params[name].getAttribute("type") == "list":
+                            for option in dest_params[name].getElementsByTagName(
+                                "option"
+                            ):
+                                if option.getAttribute("value") == profile_value:
+                                    option.setAttribute("selected", "true")
+                                else:
+                                    try:
+                                        option.removeAttribute("selected")
+                                    except NotFoundErr:
+                                        pass
+                        elif dest_params[name].getAttribute("type") == "jids_list":
+                            jids = profile_value.split("\t")
+                            for jid_elt in dest_params[name].getElementsByTagName(
+                                "jid"
+                            ):
+                                dest_params[name].removeChild(
+                                    jid_elt
+                                )  # remove all default
+                            for jid_ in jids:  # rebuilt the children with use values
+                                try:
+                                    jid.JID(jid_)
+                                except (
+                                    RuntimeError,
+                                    jid.InvalidFormat,
+                                    AttributeError,
+                                ):
+                                    log.warning(
+                                        "Incorrect jid value found in jids list: [{}]".format(
+                                            jid_
+                                        )
+                                    )
+                                else:
+                                    jid_elt = prof_xml.createElement("jid")
+                                    jid_elt.appendChild(prof_xml.createTextNode(jid_))
+                                    dest_params[name].appendChild(jid_elt)
+                        else:
+                            dest_params[name].setAttribute("value", profile_value)
+                if new_node:
+                    prof_xml.documentElement.appendChild(dest_cat)
+
+        to_remove = []
+        for cat_node in prof_xml.documentElement.childNodes:
+            # we remove empty categories
+            if cat_node.getElementsByTagName("param").length == 0:
+                to_remove.append(cat_node)
+        for node in to_remove:
+            prof_xml.documentElement.removeChild(node)
+
+        return prof_xml
+
+
+    def _get_params_ui(self, security_limit, app, extra_s, profile_key):
+        client = self.host.get_client(profile_key)
+        extra = data_format.deserialise(extra_s)
+        return defer.ensureDeferred(self.param_ui_get(client, security_limit, app, extra))
+
+    async def param_ui_get(self, client, security_limit, app, extra=None):
+        """Get XMLUI to handle parameters
+
+        @param security_limit: NO_SECURITY_LIMIT (-1) to return all the params.
+            Otherwise sole the params which have a security level defined *and*
+            lower or equal to the specified value are returned.
+        @param app: name of the frontend requesting the parameters, or '' to get all parameters
+        @param extra (dict, None): extra options. Key can be:
+            - ignore: list of (category/name) values to remove from parameters
+        @return(str): a SàT XMLUI for parameters
+        """
+        param_xml = await self.get_params(client, security_limit, app, extra)
+        return params_xml_2_xmlui(param_xml)
+
+    async def get_params(self, client, security_limit, app, extra=None):
+        """Construct xml for asked profile, take params xml as skeleton
+
+        @param security_limit: NO_SECURITY_LIMIT (-1) to return all the params.
+            Otherwise sole the params which have a security level defined *and*
+            lower or equal to the specified value are returned.
+        @param app: name of the frontend requesting the parameters, or '' to get all parameters
+        @param extra (dict, None): extra options. Key can be:
+            - ignore: list of (category/name) values to remove from parameters
+        @param profile_key: Profile key which can be either a magic (eg: @DEFAULT@) or the name of an existing profile.
+        @return: XML of parameters
+        """
+        if extra is None:
+            extra = {}
+        prof_xml = await self._construct_profile_xml(client, security_limit, app, extra)
+        return_xml = prof_xml.toxml()
+        prof_xml.unlink()
+        return "\n".join((line for line in return_xml.split("\n") if line))
+
+    def _get_param_node(self, name, category, type_="@ALL@"):  # FIXME: is type_ useful ?
+        """Return a node from the param_xml
+        @param name: name of the node
+        @param category: category of the node
+        @param type_: keyword for search:
+                                    @ALL@ search everywhere
+                                    @GENERAL@ only search in general type
+                                    @INDIVIDUAL@ only search in individual type
+        @return: a tuple (node type, node) or None if not found"""
+
+        for type_node in self.dom.documentElement.childNodes:
+            if (
+                (type_ == "@ALL@" or type_ == "@GENERAL@")
+                and type_node.nodeName == C.GENERAL
+            ) or (
+                (type_ == "@ALL@" or type_ == "@INDIVIDUAL@")
+                and type_node.nodeName == C.INDIVIDUAL
+            ):
+                for node in type_node.getElementsByTagName("category"):
+                    if node.getAttribute("name") == category:
+                        params = node.getElementsByTagName("param")
+                        for param in params:
+                            if param.getAttribute("name") == name:
+                                return (type_node.nodeName, param)
+        return None
+
+    def params_categories_get(self):
+        """return the categories availables"""
+        categories = []
+        for cat in self.dom.getElementsByTagName("category"):
+            name = cat.getAttribute("name")
+            if name not in categories:
+                categories.append(cat.getAttribute("name"))
+        return categories
+
+    def param_set(self, name, value, category, security_limit=C.NO_SECURITY_LIMIT,
+                 profile_key=C.PROF_KEY_NONE):
+        """Set a parameter, return None if the parameter is not in param xml.
+
+        Parameter of type 'password' that are not the SàT profile password are
+        stored encrypted (if not empty). The profile password is stored hashed
+        (if not empty).
+
+        @param name (str): the parameter name
+        @param value (str): the new value
+        @param category (str): the parameter category
+        @param security_limit (int)
+        @param profile_key (str): %(doc_profile_key)s
+        @return: a deferred None value when everything is done
+        """
+        # FIXME: param_set should accept the right type for value, not only str !
+        if profile_key != C.PROF_KEY_NONE:
+            profile = self.get_profile_name(profile_key)
+            if not profile:
+                log.error(_("Trying to set parameter for an unknown profile"))
+                raise exceptions.ProfileUnknownError(profile_key)
+
+        node = self._get_param_node(name, category, "@ALL@")
+        if not node:
+            log.error(
+                _("Requesting an unknown parameter (%(category)s/%(name)s)")
+                % {"category": category, "name": name}
+            )
+            return defer.succeed(None)
+
+        if not self.check_security_limit(node[1], security_limit):
+            msg = _(
+                "{profile!r} is trying to set parameter {name!r} in category "
+                "{category!r} without authorization!!!").format(
+                    profile=repr(profile),
+                    name=repr(name),
+                    category=repr(category)
+                )
+            log.warning(msg)
+            raise exceptions.PermissionError(msg)
+
+        type_ = node[1].getAttribute("type")
+        if type_ == "int":
+            if not value:  # replace with the default value (which might also be '')
+                value = node[1].getAttribute("value")
+            else:
+                try:
+                    int(value)
+                except ValueError:
+                    log.warning(_(
+                        "Trying to set parameter {name} in category {category} with"
+                        "an non-integer value"
+                    ).format(
+                        name=repr(name),
+                        category=repr(category)
+                    ))
+                    return defer.succeed(None)
+                if node[1].hasAttribute("constraint"):
+                    constraint = node[1].getAttribute("constraint")
+                    try:
+                        min_, max_ = [int(limit) for limit in constraint.split(";")]
+                    except ValueError:
+                        raise exceptions.InternalError(
+                            "Invalid integer parameter constraint: %s" % constraint
+                        )
+                    value = str(min(max(int(value), min_), max_))
+
+        log.info(
+            _("Setting parameter (%(category)s, %(name)s) = %(value)s")
+            % {
+                "category": category,
+                "name": name,
+                "value": value if type_ != "password" else "********",
+            }
+        )
+
+        if node[0] == C.GENERAL:
+            self.params_gen[(category, name)] = value
+            self.storage.set_gen_param(category, name, value)
+            for profile in self.storage.get_profiles_list():
+                if self.host.memory.is_session_started(profile):
+                    self.host.bridge.param_update(name, value, category, profile)
+                    self.host.trigger.point(
+                        "param_update_trigger", name, value, category, node[0], profile
+                    )
+            return defer.succeed(None)
+
+        assert node[0] == C.INDIVIDUAL
+        assert profile_key != C.PROF_KEY_NONE
+
+        if type_ == "button":
+            log.debug("Clicked param button %s" % node.toxml())
+            return defer.succeed(None)
+        elif type_ == "password":
+            try:
+                personal_key = self.host.memory.auth_sessions.profile_get_unique(profile)[
+                    C.MEMORY_CRYPTO_KEY
+                ]
+            except TypeError:
+                raise exceptions.InternalError(
+                    _("Trying to encrypt a password while the personal key is undefined!")
+                )
+            if (category, name) == C.PROFILE_PASS_PATH:
+                # using 'value' as the encryption key to encrypt another encryption key... could be confusing!
+                d = self.host.memory.encrypt_personal_data(
+                    data_key=C.MEMORY_CRYPTO_KEY,
+                    data_value=personal_key,
+                    crypto_key=value,
+                    profile=profile,
+                )
+                d.addCallback(
+                    lambda __: PasswordHasher.hash(value)
+                )  # profile password is hashed (empty value stays empty)
+            elif value:  # other non empty passwords are encrypted with the personal key
+                d = defer.succeed(BlockCipher.encrypt(personal_key, value))
+            else:
+                d = defer.succeed(value)
+        else:
+            d = defer.succeed(value)
+
+        def got_final_value(value):
+            if self.host.memory.is_session_started(profile):
+                self.params[profile][(category, name)] = value
+                self.host.bridge.param_update(name, value, category, profile)
+                self.host.trigger.point(
+                    "param_update_trigger", name, value, category, node[0], profile
+                )
+                return self.storage.set_ind_param(category, name, value, profile)
+            else:
+                raise exceptions.ProfileNotConnected
+
+        d.addCallback(got_final_value)
+        return d
+
+    def _get_nodes_of_types(self, attr_type, node_type="@ALL@"):
+        """Return all the nodes matching the given types.
+
+        TODO: using during the dev but not anymore... remove if not needed
+
+        @param attr_type (str): the attribute type (string, text, password, bool, int, button, list)
+        @param node_type (str): keyword for filtering:
+                                    @ALL@ search everywhere
+                                    @GENERAL@ only search in general type
+                                    @INDIVIDUAL@ only search in individual type
+        @return: dict{tuple: node}: a dict {key, value} where:
+            - key is a couple (attribute category, attribute name)
+            - value is a node
+        """
+        ret = {}
+        for type_node in self.dom.documentElement.childNodes:
+            if (
+                (node_type == "@ALL@" or node_type == "@GENERAL@")
+                and type_node.nodeName == C.GENERAL
+            ) or (
+                (node_type == "@ALL@" or node_type == "@INDIVIDUAL@")
+                and type_node.nodeName == C.INDIVIDUAL
+            ):
+                for cat_node in type_node.getElementsByTagName("category"):
+                    cat = cat_node.getAttribute("name")
+                    params = cat_node.getElementsByTagName("param")
+                    for param in params:
+                        if param.getAttribute("type") == attr_type:
+                            ret[(cat, param.getAttribute("name"))] = param
+        return ret
+
+    def check_security_limit(self, node, security_limit):
+        """Check the given node against the given security limit.
+        The value NO_SECURITY_LIMIT (-1) means that everything is allowed.
+        @return: True if this node can be accessed with the given security limit.
+        """
+        if security_limit < 0:
+            return True
+        if node.hasAttribute("security"):
+            if int(node.getAttribute("security")) <= security_limit:
+                return True
+        return False
+
+    def check_app(self, node, app):
+        """Check the given node against the given app.
+
+        @param node: parameter node
+        @param app: name of the frontend requesting the parameters, or '' to get all parameters
+        @return: True if this node concerns the given app.
+        """
+        if not app or not node.hasAttribute("app"):
+            return True
+        return node.getAttribute("app") == app
+
+    def check_extra(self, node, extra):
+        """Check the given node against the extra filters.
+
+        @param node: parameter node
+        @param app: name of the frontend requesting the parameters, or '' to get all parameters
+        @return: True if node doesn't match category/name of extra['ignore'] list
+        """
+        ignore_list = extra.get('ignore')
+        if not ignore_list:
+            return True
+        category = node.parentNode.getAttribute('name')
+        name = node.getAttribute('name')
+        ignore = [category, name] in ignore_list
+        if ignore:
+            log.debug(f"Ignoring parameter {category}/{name} as requested")
+            return False
+        return True
+
+
+def make_options(options, selected=None):
+    """Create option XML form dictionary
+
+    @param options(dict): option's name => option's label map
+    @param selected(None, str): value of selected option
+        None to use first value
+    @return (str): XML to use in parameters
+    """
+    str_list = []
+    if selected is None:
+        selected = next(iter(options.keys()))
+    selected_found = False
+    for value, label in options.items():
+        if value == selected:
+            selected = 'selected="true"'
+            selected_found = True
+        else:
+            selected = ''
+        str_list.append(
+            f'<option value={quoteattr(value)} label={quoteattr(label)} {selected}/>'
+        )
+    if not selected_found:
+        raise ValueError(f"selected value ({selected}) not found in options")
+    return '\n'.join(str_list)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/persistent.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,317 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from twisted.internet import defer
+from twisted.python import failure
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+
+log = getLogger(__name__)
+
+
+class MemoryNotInitializedError(Exception):
+    pass
+
+
+class PersistentDict:
+    r"""A dictionary which save persistently each value assigned
+
+    /!\ be careful, each assignment means a database write
+    /!\ Memory must be initialised before loading/setting value with instances of this class"""
+    storage = None
+    binary = False
+
+    def __init__(self, namespace, profile=None):
+        """
+
+        @param namespace: unique namespace for this dictionary
+        @param profile(unicode, None): profile which *MUST* exists, or None for general values
+        """
+        if not self.storage:
+            log.error(_("PersistentDict can't be used before memory initialisation"))
+            raise MemoryNotInitializedError
+        self._cache = {}
+        self.namespace = namespace
+        self.profile = profile
+
+    def _set_cache(self, data):
+        self._cache = data
+
+    def load(self):
+        """Load persistent data from storage.
+
+        need to be called before any other operation
+        @return: defers the PersistentDict instance itself
+        """
+        d = defer.ensureDeferred(self.storage.get_privates(
+            self.namespace, binary=self.binary, profile=self.profile
+        ))
+        d.addCallback(self._set_cache)
+        d.addCallback(lambda __: self)
+        return d
+
+    def iteritems(self):
+        return iter(self._cache.items())
+
+    def items(self):
+        return self._cache.items()
+
+    def __repr__(self):
+        return self._cache.__repr__()
+
+    def __str__(self):
+        return self._cache.__str__()
+
+    def __lt__(self, other):
+        return self._cache.__lt__(other)
+
+    def __le__(self, other):
+        return self._cache.__le__(other)
+
+    def __eq__(self, other):
+        return self._cache.__eq__(other)
+
+    def __ne__(self, other):
+        return self._cache.__ne__(other)
+
+    def __gt__(self, other):
+        return self._cache.__gt__(other)
+
+    def __ge__(self, other):
+        return self._cache.__ge__(other)
+
+    def __cmp__(self, other):
+        return self._cache.__cmp__(other)
+
+    def __hash__(self):
+        return self._cache.__hash__()
+
+    def __bool__(self):
+        return self._cache.__len__() != 0
+
+    def __contains__(self, key):
+        return self._cache.__contains__(key)
+
+    def __iter__(self):
+        return self._cache.__iter__()
+
+    def __getitem__(self, key):
+        return self._cache.__getitem__(key)
+
+    def __setitem__(self, key, value):
+        defer.ensureDeferred(
+            self.storage.set_private_value(
+                self.namespace, key, value, self.binary, self.profile
+            )
+        )
+        return self._cache.__setitem__(key, value)
+
+    def __delitem__(self, key):
+        self.storage.del_private_value(self.namespace, key, self.binary, self.profile)
+        return self._cache.__delitem__(key)
+
+    def clear(self):
+        """Delete all values from this namespace"""
+        self._cache.clear()
+        return self.storage.del_private_namespace(self.namespace, self.binary, self.profile)
+
+    def get(self, key, default=None):
+        return self._cache.get(key, default)
+
+    def aset(self, key, value):
+        """Async set, return a Deferred fired when value is actually stored"""
+        self._cache.__setitem__(key, value)
+        return defer.ensureDeferred(
+            self.storage.set_private_value(
+                self.namespace, key, value, self.binary, self.profile
+            )
+        )
+
+    def adel(self, key):
+        """Async del, return a Deferred fired when value is actually deleted"""
+        self._cache.__delitem__(key)
+        return self.storage.del_private_value(
+            self.namespace, key, self.binary, self.profile)
+
+    def setdefault(self, key, default):
+        try:
+            return self._cache[key]
+        except:
+            self.__setitem__(key, default)
+            return default
+
+    def force(self, name):
+        """Force saving of an attribute to storage
+
+        @return: deferred fired when data is actually saved
+        """
+        return defer.ensureDeferred(
+            self.storage.set_private_value(
+                self.namespace, name, self._cache[name], self.binary, self.profile
+            )
+        )
+
+
+class PersistentBinaryDict(PersistentDict):
+    """Persistent dict where value can be any python data (instead of string only)"""
+    binary = True
+
+
+class LazyPersistentBinaryDict(PersistentBinaryDict):
+    r"""PersistentBinaryDict which get key/value when needed
+
+    This Persistent need more database access, it is suitable for largest data,
+    to save memory.
+    /!\ most of methods return a Deferred
+    """
+    # TODO: missing methods should be implemented using database access
+    # TODO: a cache would be useful (which is deleted after a timeout)
+
+    def load(self):
+        # we show a warning as calling load on LazyPersistentBinaryDict sounds like a code mistake
+        log.warning(_("Calling load on LazyPersistentBinaryDict while it's not needed"))
+
+    def iteritems(self):
+        raise NotImplementedError
+
+    def items(self):
+        d = defer.ensureDeferred(self.storage.get_privates(
+            self.namespace, binary=self.binary, profile=self.profile
+        ))
+        d.addCallback(lambda data_dict: data_dict.items())
+        return d
+
+    def all(self):
+        return defer.ensureDeferred(self.storage.get_privates(
+            self.namespace, binary=self.binary, profile=self.profile
+        ))
+
+    def __repr__(self):
+        return self.__str__()
+
+    def __str__(self):
+        return "LazyPersistentBinaryDict (namespace: {})".format(self.namespace)
+
+    def __lt__(self, other):
+        raise NotImplementedError
+
+    def __le__(self, other):
+        raise NotImplementedError
+
+    def __eq__(self, other):
+        raise NotImplementedError
+
+    def __ne__(self, other):
+        raise NotImplementedError
+
+    def __gt__(self, other):
+        raise NotImplementedError
+
+    def __ge__(self, other):
+        raise NotImplementedError
+
+    def __cmp__(self, other):
+        raise NotImplementedError
+
+    def __hash__(self):
+        return hash(str(self.__class__) + self.namespace + (self.profile or ''))
+
+    def __bool__(self):
+        raise NotImplementedError
+
+    def __contains__(self, key):
+        raise NotImplementedError
+
+    def __iter__(self):
+        raise NotImplementedError
+
+    def _data2value(self, data, key):
+        try:
+            return data[key]
+        except KeyError as e:
+            # we return a Failure here to avoid the jump
+            # into debugger in debug mode.
+            raise failure.Failure(e)
+
+    def __getitem__(self, key):
+        """get the value as a Deferred"""
+        d = defer.ensureDeferred(self.storage.get_privates(
+            self.namespace, keys=[key], binary=self.binary, profile=self.profile
+        ))
+        d.addCallback(self._data2value, key)
+        return d
+
+    def __setitem__(self, key, value):
+        defer.ensureDeferred(
+            self.storage.set_private_value(
+                self.namespace, key, value, self.binary, self.profile
+            )
+        )
+
+    def __delitem__(self, key):
+        self.storage.del_private_value(self.namespace, key, self.binary, self.profile)
+
+    def _default_or_exception(self, failure_, default):
+        failure_.trap(KeyError)
+        return default
+
+    def get(self, key, default=None):
+        d = self.__getitem__(key)
+        d.addErrback(self._default_or_exception, default=default)
+        return d
+
+    def aset(self, key, value):
+        """Async set, return a Deferred fired when value is actually stored"""
+        # FIXME: redundant with force, force must be removed
+        # XXX: similar as PersistentDict.aset, but doesn't use cache
+        return defer.ensureDeferred(
+            self.storage.set_private_value(
+                self.namespace, key, value, self.binary, self.profile
+            )
+        )
+
+    def adel(self, key):
+        """Async del, return a Deferred fired when value is actually deleted"""
+        # XXX: similar as PersistentDict.adel, but doesn't use cache
+        return self.storage.del_private_value(
+            self.namespace, key, self.binary, self.profile)
+
+    def setdefault(self, key, default):
+        raise NotImplementedError
+
+    def force(self, name, value):
+        """Force saving of an attribute to storage
+
+        @param value(object): value is needed for LazyPersistentBinaryDict
+        @return: deferred fired when data is actually saved
+        """
+        return defer.ensureDeferred(
+            self.storage.set_private_value(
+                self.namespace, name, value, self.binary, self.profile
+            )
+        )
+
+    def remove(self, key):
+        """Delete a key from sotrage, and return a deferred called when it's done
+
+        @param key(unicode): key to delete
+        @return (D): A deferred fired when delete is done
+        """
+        return self.storage.del_private_value(self.namespace, key, self.binary, self.profile)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/sqla.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1704 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import asyncio
+from asyncio.subprocess import PIPE
+import copy
+from datetime import datetime
+from pathlib import Path
+import sys
+import time
+from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
+
+from alembic import config as al_config, script as al_script
+from alembic.runtime import migration as al_migration
+from sqlalchemy import and_, delete, event, func, or_, update
+from sqlalchemy import Integer, literal_column, text
+from sqlalchemy.dialects.sqlite import insert
+from sqlalchemy.engine import Connection, Engine
+from sqlalchemy.exc import IntegrityError, NoResultFound
+from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
+from sqlalchemy.future import select
+from sqlalchemy.orm import (
+    contains_eager,
+    joinedload,
+    selectinload,
+    sessionmaker,
+    subqueryload,
+)
+from sqlalchemy.orm.attributes import Mapped
+from sqlalchemy.orm.decl_api import DeclarativeMeta
+from sqlalchemy.sql.functions import coalesce, count, now, sum as sum_
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.memory import migration
+from libervia.backend.memory import sqla_config
+from libervia.backend.memory.sqla_mapping import (
+    Base,
+    Component,
+    File,
+    History,
+    Message,
+    NOT_IN_EXTRA,
+    ParamGen,
+    ParamInd,
+    PrivateGen,
+    PrivateGenBin,
+    PrivateInd,
+    PrivateIndBin,
+    Profile,
+    PubsubItem,
+    PubsubNode,
+    Subject,
+    SyncState,
+    Thread,
+)
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.utils import aio, as_future
+
+
+log = getLogger(__name__)
+migration_path = Path(migration.__file__).parent
+#: mapping of Libervia search query operators to SQLAlchemy method name
+OP_MAP = {
+    "==": "__eq__",
+    "eq": "__eq__",
+    "!=": "__ne__",
+    "ne": "__ne__",
+    ">": "__gt__",
+    "gt": "__gt__",
+    "<": "__le__",
+    "le": "__le__",
+    "between": "between",
+    "in": "in_",
+    "not_in": "not_in",
+    "overlap": "in_",
+    "ioverlap": "in_",
+    "disjoint": "in_",
+    "idisjoint": "in_",
+    "like": "like",
+    "ilike": "ilike",
+    "not_like": "notlike",
+    "not_ilike": "notilike",
+}
+
+
+@event.listens_for(Engine, "connect")
+def set_sqlite_pragma(dbapi_connection, connection_record):
+    cursor = dbapi_connection.cursor()
+    cursor.execute("PRAGMA foreign_keys=ON")
+    cursor.close()
+
+
+class Storage:
+
+    def __init__(self):
+        self.initialized = defer.Deferred()
+        # we keep cache for the profiles (key: profile name, value: profile id)
+        # profile id to name
+        self.profiles: Dict[str, int] = {}
+        # profile id to component entry point
+        self.components: Dict[int, str] = {}
+
+    def get_profile_by_id(self, profile_id):
+        return self.profiles.get(profile_id)
+
+    async def migrate_apply(self, *args: str, log_output: bool = False) -> None:
+        """Do a migration command
+
+        Commands are applied by running Alembic in a subprocess.
+        Arguments are alembic executables commands
+
+        @param log_output: manage stdout and stderr:
+            - if False, stdout and stderr are buffered, and logged only in case of error
+            - if True, stdout and stderr will be logged during the command execution
+        @raise exceptions.DatabaseError: something went wrong while running the
+            process
+        """
+        stdout, stderr = 2 * (None,) if log_output else 2 * (PIPE,)
+        proc = await asyncio.create_subprocess_exec(
+            sys.executable, "-m", "alembic", *args,
+            stdout=stdout, stderr=stderr, cwd=migration_path
+        )
+        log_out, log_err = await proc.communicate()
+        if proc.returncode != 0:
+            msg = _(
+                "Can't {operation} database (exit code {exit_code})"
+            ).format(
+                operation=args[0],
+                exit_code=proc.returncode
+            )
+            if log_out or log_err:
+                msg += f":\nstdout: {log_out.decode()}\nstderr: {log_err.decode()}"
+            log.error(msg)
+
+            raise exceptions.DatabaseError(msg)
+
+    async def create_db(self, engine: AsyncEngine, db_config: dict) -> None:
+        """Create a new database
+
+        The database is generated from SQLAlchemy model, then stamped by Alembic
+        """
+        # the dir may not exist if it's not the XDG recommended one
+        db_config["path"].parent.mkdir(0o700, True, True)
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+
+        log.debug("stamping the database")
+        await self.migrate_apply("stamp", "head")
+        log.debug("stamping done")
+
+    def _check_db_is_up_to_date(self, conn: Connection) -> bool:
+        al_ini_path = migration_path / "alembic.ini"
+        al_cfg = al_config.Config(al_ini_path)
+        directory = al_script.ScriptDirectory.from_config(al_cfg)
+        context = al_migration.MigrationContext.configure(conn)
+        return set(context.get_current_heads()) == set(directory.get_heads())
+
+    def _sqlite_set_journal_mode_wal(self, conn: Connection) -> None:
+        """Check if journal mode is WAL, and set it if necesssary"""
+        result = conn.execute(text("PRAGMA journal_mode"))
+        if result.scalar() != "wal":
+            log.info("WAL mode not activated, activating it")
+            conn.execute(text("PRAGMA journal_mode=WAL"))
+
+    async def check_and_update_db(self, engine: AsyncEngine, db_config: dict) -> None:
+        """Check that database is up-to-date, and update if necessary"""
+        async with engine.connect() as conn:
+            up_to_date = await conn.run_sync(self._check_db_is_up_to_date)
+        if up_to_date:
+            log.debug("Database is up-to-date")
+        else:
+            log.info("Database needs to be updated")
+            log.info("updating…")
+            await self.migrate_apply("upgrade", "head", log_output=True)
+            log.info("Database is now up-to-date")
+
+    @aio
+    async def initialise(self) -> None:
+        log.info(_("Connecting database"))
+
+        db_config = sqla_config.get_db_config()
+        engine = create_async_engine(
+            db_config["url"],
+            future=True,
+        )
+
+        new_base = not db_config["path"].exists()
+        if new_base:
+            log.info(_("The database is new, creating the tables"))
+            await self.create_db(engine, db_config)
+        else:
+            await self.check_and_update_db(engine, db_config)
+
+        async with engine.connect() as conn:
+            await conn.run_sync(self._sqlite_set_journal_mode_wal)
+
+        self.session = sessionmaker(
+            engine, expire_on_commit=False, class_=AsyncSession
+        )
+
+        async with self.session() as session:
+            result = await session.execute(select(Profile))
+            for p in result.scalars():
+                self.profiles[p.name] = p.id
+            result = await session.execute(select(Component))
+            for c in result.scalars():
+                self.components[c.profile_id] = c.entry_point
+
+        self.initialized.callback(None)
+
+    ## Generic
+
+    @aio
+    async def get(
+        self,
+        client: SatXMPPEntity,
+        db_cls: DeclarativeMeta,
+        db_id_col: Mapped,
+        id_value: Any,
+        joined_loads = None
+    ) -> Optional[DeclarativeMeta]:
+        stmt = select(db_cls).where(db_id_col==id_value)
+        if client is not None:
+            stmt = stmt.filter_by(profile_id=self.profiles[client.profile])
+        if joined_loads is not None:
+            for joined_load in joined_loads:
+                stmt = stmt.options(joinedload(joined_load))
+        async with self.session() as session:
+            result = await session.execute(stmt)
+        if joined_loads is not None:
+            result = result.unique()
+        return result.scalar_one_or_none()
+
+    @aio
+    async def add(self, db_obj: DeclarativeMeta) -> None:
+        """Add an object to database"""
+        async with self.session() as session:
+            async with session.begin():
+                session.add(db_obj)
+
+    @aio
+    async def delete(
+        self,
+        db_obj: Union[DeclarativeMeta, List[DeclarativeMeta]],
+        session_add: Optional[List[DeclarativeMeta]] = None
+    ) -> None:
+        """Delete an object from database
+
+        @param db_obj: object to delete or list of objects to delete
+        @param session_add: other objects to add to session.
+            This is useful when parents of deleted objects needs to be updated too, or if
+            other objects needs to be updated in the same transaction.
+        """
+        if not db_obj:
+            return
+        if not isinstance(db_obj, list):
+            db_obj = [db_obj]
+        async with self.session() as session:
+            async with session.begin():
+                if session_add is not None:
+                    for obj in session_add:
+                        session.add(obj)
+                for obj in db_obj:
+                    await session.delete(obj)
+                await session.commit()
+
+    ## Profiles
+
+    def get_profiles_list(self) -> List[str]:
+        """"Return list of all registered profiles"""
+        return list(self.profiles.keys())
+
+    def has_profile(self, profile_name: str) -> bool:
+        """return True if profile_name exists
+
+        @param profile_name: name of the profile to check
+        """
+        return profile_name in self.profiles
+
+    def profile_is_component(self, profile_name: str) -> bool:
+        try:
+            return self.profiles[profile_name] in self.components
+        except KeyError:
+            raise exceptions.NotFound("the requested profile doesn't exists")
+
+    def get_entry_point(self, profile_name: str) -> str:
+        try:
+            return self.components[self.profiles[profile_name]]
+        except KeyError:
+            raise exceptions.NotFound("the requested profile doesn't exists or is not a component")
+
+    @aio
+    async def create_profile(self, name: str, component_ep: Optional[str] = None) -> None:
+        """Create a new profile
+
+        @param name: name of the profile
+        @param component: if not None, must point to a component entry point
+        """
+        async with self.session() as session:
+            profile = Profile(name=name)
+            async with session.begin():
+                session.add(profile)
+            self.profiles[profile.name] = profile.id
+            if component_ep is not None:
+                async with session.begin():
+                    component = Component(profile=profile, entry_point=component_ep)
+                    session.add(component)
+                self.components[profile.id] = component_ep
+        return profile
+
+    @aio
+    async def delete_profile(self, name: str) -> None:
+        """Delete profile
+
+        @param name: name of the profile
+        """
+        async with self.session() as session:
+            result = await session.execute(select(Profile).where(Profile.name == name))
+            profile = result.scalar()
+            await session.delete(profile)
+            await session.commit()
+        del self.profiles[profile.name]
+        if profile.id in self.components:
+            del self.components[profile.id]
+        log.info(_("Profile {name!r} deleted").format(name = name))
+
+    ## Params
+
+    @aio
+    async def load_gen_params(self, params_gen: dict) -> None:
+        """Load general parameters
+
+        @param params_gen: dictionary to fill
+        """
+        log.debug(_("loading general parameters from database"))
+        async with self.session() as session:
+            result = await session.execute(select(ParamGen))
+        for p in result.scalars():
+            params_gen[(p.category, p.name)] = p.value
+
+    @aio
+    async def load_ind_params(self, params_ind: dict, profile: str) -> None:
+        """Load individual parameters
+
+        @param params_ind: dictionary to fill
+        @param profile: a profile which *must* exist
+        """
+        log.debug(_("loading individual parameters from database"))
+        async with self.session() as session:
+            result = await session.execute(
+                select(ParamInd).where(ParamInd.profile_id == self.profiles[profile])
+            )
+        for p in result.scalars():
+            params_ind[(p.category, p.name)] = p.value
+
+    @aio
+    async def get_ind_param(self, category: str, name: str, profile: str) -> Optional[str]:
+        """Ask database for the value of one specific individual parameter
+
+        @param category: category of the parameter
+        @param name: name of the parameter
+        @param profile: %(doc_profile)s
+        """
+        async with self.session() as session:
+            result = await session.execute(
+                select(ParamInd.value)
+                .filter_by(
+                    category=category,
+                    name=name,
+                    profile_id=self.profiles[profile]
+                )
+            )
+        return result.scalar_one_or_none()
+
+    @aio
+    async def get_ind_param_values(self, category: str, name: str) -> Dict[str, str]:
+        """Ask database for the individual values of a parameter for all profiles
+
+        @param category: category of the parameter
+        @param name: name of the parameter
+        @return dict: profile => value map
+        """
+        async with self.session() as session:
+            result = await session.execute(
+                select(ParamInd)
+                .filter_by(
+                    category=category,
+                    name=name
+                )
+                .options(subqueryload(ParamInd.profile))
+            )
+        return {param.profile.name: param.value for param in result.scalars()}
+
+    @aio
+    async def set_gen_param(self, category: str, name: str, value: Optional[str]) -> None:
+        """Save the general parameters in database
+
+        @param category: category of the parameter
+        @param name: name of the parameter
+        @param value: value to set
+        """
+        async with self.session() as session:
+            stmt = insert(ParamGen).values(
+                category=category,
+                name=name,
+                value=value
+            ).on_conflict_do_update(
+                index_elements=(ParamGen.category, ParamGen.name),
+                set_={
+                    ParamGen.value: value
+                }
+            )
+            await session.execute(stmt)
+            await session.commit()
+
+    @aio
+    async def set_ind_param(
+        self,
+        category:str,
+        name: str,
+        value: Optional[str],
+        profile: str
+    ) -> None:
+        """Save the individual parameters in database
+
+        @param category: category of the parameter
+        @param name: name of the parameter
+        @param value: value to set
+        @param profile: a profile which *must* exist
+        """
+        async with self.session() as session:
+            stmt = insert(ParamInd).values(
+                category=category,
+                name=name,
+                profile_id=self.profiles[profile],
+                value=value
+            ).on_conflict_do_update(
+                index_elements=(ParamInd.category, ParamInd.name, ParamInd.profile_id),
+                set_={
+                    ParamInd.value: value
+                }
+            )
+            await session.execute(stmt)
+            await session.commit()
+
+    def _jid_filter(self, jid_: jid.JID, dest: bool = False):
+        """Generate condition to filter on a JID, using relevant columns
+
+        @param dest: True if it's the destinee JID, otherwise it's the source one
+        @param jid_: JID to filter by
+        """
+        if jid_.resource:
+            if dest:
+                return and_(
+                    History.dest == jid_.userhost(),
+                    History.dest_res == jid_.resource
+                )
+            else:
+                return and_(
+                    History.source == jid_.userhost(),
+                    History.source_res == jid_.resource
+                )
+        else:
+            if dest:
+                return History.dest == jid_.userhost()
+            else:
+                return History.source == jid_.userhost()
+
+    @aio
+    async def history_get(
+        self,
+        from_jid: Optional[jid.JID],
+        to_jid: Optional[jid.JID],
+        limit: Optional[int] = None,
+        between: bool = True,
+        filters: Optional[Dict[str, str]] = None,
+        profile: Optional[str] = None,
+    ) -> List[Tuple[
+        str, int, str, str, Dict[str, str], Dict[str, str], str, str, str]
+    ]:
+        """Retrieve messages in history
+
+        @param from_jid: source JID (full, or bare for catchall)
+        @param to_jid: dest JID (full, or bare for catchall)
+        @param limit: maximum number of messages to get:
+            - 0 for no message (returns the empty list)
+            - None for unlimited
+        @param between: confound source and dest (ignore the direction)
+        @param filters: pattern to filter the history results
+        @return: list of messages as in [message_new], minus the profile which is already
+            known.
+        """
+        # we have to set a default value to profile because it's last argument
+        # and thus follow other keyword arguments with default values
+        # but None should not be used for it
+        assert profile is not None
+        if limit == 0:
+            return []
+        if filters is None:
+            filters = {}
+
+        stmt = (
+            select(History)
+            .filter_by(
+                profile_id=self.profiles[profile]
+            )
+            .outerjoin(History.messages)
+            .outerjoin(History.subjects)
+            .outerjoin(History.thread)
+            .options(
+                contains_eager(History.messages),
+                contains_eager(History.subjects),
+                contains_eager(History.thread),
+            )
+            .order_by(
+                # timestamp may be identical for 2 close messages (specially when delay is
+                # used) that's why we order ties by received_timestamp. We'll reverse the
+                # order when returning the result. We use DESC here so LIMIT keep the last
+                # messages
+                History.timestamp.desc(),
+                History.received_timestamp.desc()
+            )
+        )
+
+
+        if not from_jid and not to_jid:
+            # no jid specified, we want all one2one communications
+            pass
+        elif between:
+            if not from_jid or not to_jid:
+                # we only have one jid specified, we check all messages
+                # from or to this jid
+                jid_ = from_jid or to_jid
+                stmt = stmt.where(
+                    or_(
+                        self._jid_filter(jid_),
+                        self._jid_filter(jid_, dest=True)
+                    )
+                )
+            else:
+                # we have 2 jids specified, we check all communications between
+                # those 2 jids
+                stmt = stmt.where(
+                    or_(
+                        and_(
+                            self._jid_filter(from_jid),
+                            self._jid_filter(to_jid, dest=True),
+                        ),
+                        and_(
+                            self._jid_filter(to_jid),
+                            self._jid_filter(from_jid, dest=True),
+                        )
+                    )
+                )
+        else:
+            # we want one communication in specific direction (from somebody or
+            # to somebody).
+            if from_jid is not None:
+                stmt = stmt.where(self._jid_filter(from_jid))
+            if to_jid is not None:
+                stmt = stmt.where(self._jid_filter(to_jid, dest=True))
+
+        if filters:
+            if 'timestamp_start' in filters:
+                stmt = stmt.where(History.timestamp >= float(filters['timestamp_start']))
+            if 'before_uid' in filters:
+                # orignially this query was using SQLITE's rowid. This has been changed
+                # to use coalesce(received_timestamp, timestamp) to be SQL engine independant
+                stmt = stmt.where(
+                    coalesce(
+                        History.received_timestamp,
+                        History.timestamp
+                    ) < (
+                        select(coalesce(History.received_timestamp, History.timestamp))
+                        .filter_by(uid=filters["before_uid"])
+                    ).scalar_subquery()
+                )
+            if 'body' in filters:
+                # TODO: use REGEXP (function to be defined) instead of GLOB: https://www.sqlite.org/lang_expr.html
+                stmt = stmt.where(Message.message.like(f"%{filters['body']}%"))
+            if 'search' in filters:
+                search_term = f"%{filters['search']}%"
+                stmt = stmt.where(or_(
+                    Message.message.like(search_term),
+                    History.source_res.like(search_term)
+                ))
+            if 'types' in filters:
+                types = filters['types'].split()
+                stmt = stmt.where(History.type.in_(types))
+            if 'not_types' in filters:
+                types = filters['not_types'].split()
+                stmt = stmt.where(History.type.not_in(types))
+            if 'last_stanza_id' in filters:
+                # this request get the last message with a "stanza_id" that we
+                # have in history. This is mainly used to retrieve messages sent
+                # while we were offline, using MAM (XEP-0313).
+                if (filters['last_stanza_id'] is not True
+                    or limit != 1):
+                    raise ValueError("Unexpected values for last_stanza_id filter")
+                stmt = stmt.where(History.stanza_id.is_not(None))
+            if 'origin_id' in filters:
+                stmt = stmt.where(History.origin_id == filters["origin_id"])
+
+        if limit is not None:
+            stmt = stmt.limit(limit)
+
+        async with self.session() as session:
+            result = await session.execute(stmt)
+
+        result = result.scalars().unique().all()
+        result.reverse()
+        return [h.as_tuple() for h in result]
+
+    @aio
+    async def add_to_history(self, data: dict, profile: str) -> None:
+        """Store a new message in history
+
+        @param data: message data as build by SatMessageProtocol.onMessage
+        """
+        extra = {k: v for k, v in data["extra"].items() if k not in NOT_IN_EXTRA}
+        messages = [Message(message=mess, language=lang)
+                    for lang, mess in data["message"].items()]
+        subjects = [Subject(subject=mess, language=lang)
+                    for lang, mess in data["subject"].items()]
+        if "thread" in data["extra"]:
+            thread = Thread(thread_id=data["extra"]["thread"],
+                            parent_id=data["extra"].get["thread_parent"])
+        else:
+            thread = None
+        try:
+            async with self.session() as session:
+                async with session.begin():
+                    session.add(History(
+                        uid=data["uid"],
+                        origin_id=data["extra"].get("origin_id"),
+                        stanza_id=data["extra"].get("stanza_id"),
+                        update_uid=data["extra"].get("update_uid"),
+                        profile_id=self.profiles[profile],
+                        source_jid=data["from"],
+                        dest_jid=data["to"],
+                        timestamp=data["timestamp"],
+                        received_timestamp=data.get("received_timestamp"),
+                        type=data["type"],
+                        extra=extra,
+                        messages=messages,
+                        subjects=subjects,
+                        thread=thread,
+                    ))
+        except IntegrityError as e:
+            if "unique" in str(e.orig).lower():
+                log.debug(
+                    f"message {data['uid']!r} is already in history, not storing it again"
+                )
+            else:
+                log.error(f"Can't store message {data['uid']!r} in history: {e}")
+        except Exception as e:
+            log.critical(
+                f"Can't store message, unexpected exception (uid: {data['uid']}): {e}"
+            )
+
+    ## Private values
+
+    def _get_private_class(self, binary, profile):
+        """Get ORM class to use for private values"""
+        if profile is None:
+            return PrivateGenBin if binary else PrivateGen
+        else:
+            return PrivateIndBin if binary else PrivateInd
+
+
+    @aio
+    async def get_privates(
+        self,
+        namespace:str,
+        keys: Optional[Iterable[str]] = None,
+        binary: bool = False,
+        profile: Optional[str] = None
+    ) -> Dict[str, Any]:
+        """Get private value(s) from databases
+
+        @param namespace: namespace of the values
+        @param keys: keys of the values to get None to get all keys/values
+        @param binary: True to deserialise binary values
+        @param profile: profile to use for individual values
+            None to use general values
+        @return: gotten keys/values
+        """
+        if keys is not None:
+            keys = list(keys)
+        log.debug(
+            f"getting {'general' if profile is None else 'individual'}"
+            f"{' binary' if binary else ''} private values from database for namespace "
+            f"{namespace}{f' with keys {keys!r}' if keys is not None else ''}"
+        )
+        cls = self._get_private_class(binary, profile)
+        stmt = select(cls).filter_by(namespace=namespace)
+        if keys:
+            stmt = stmt.where(cls.key.in_(list(keys)))
+        if profile is not None:
+            stmt = stmt.filter_by(profile_id=self.profiles[profile])
+        async with self.session() as session:
+            result = await session.execute(stmt)
+        return {p.key: p.value for p in result.scalars()}
+
+    @aio
+    async def set_private_value(
+        self,
+        namespace: str,
+        key:str,
+        value: Any,
+        binary: bool = False,
+        profile: Optional[str] = None
+    ) -> None:
+        """Set a private value in database
+
+        @param namespace: namespace of the values
+        @param key: key of the value to set
+        @param value: value to set
+        @param binary: True if it's a binary values
+            binary values need to be serialised, used for everything but strings
+        @param profile: profile to use for individual value
+            if None, it's a general value
+        """
+        cls = self._get_private_class(binary, profile)
+
+        values = {
+            "namespace": namespace,
+            "key": key,
+            "value": value
+        }
+        index_elements = [cls.namespace, cls.key]
+
+        if profile is not None:
+            values["profile_id"] = self.profiles[profile]
+            index_elements.append(cls.profile_id)
+
+        async with self.session() as session:
+            await session.execute(
+                insert(cls).values(**values).on_conflict_do_update(
+                    index_elements=index_elements,
+                    set_={
+                        cls.value: value
+                    }
+                )
+            )
+            await session.commit()
+
+    @aio
+    async def del_private_value(
+        self,
+        namespace: str,
+        key: str,
+        binary: bool = False,
+        profile: Optional[str] = None
+    ) -> None:
+        """Delete private value from database
+
+        @param category: category of the privateeter
+        @param key: key of the private value
+        @param binary: True if it's a binary values
+        @param profile: profile to use for individual value
+            if None, it's a general value
+        """
+        cls = self._get_private_class(binary, profile)
+
+        stmt = delete(cls).filter_by(namespace=namespace, key=key)
+
+        if profile is not None:
+            stmt = stmt.filter_by(profile_id=self.profiles[profile])
+
+        async with self.session() as session:
+            await session.execute(stmt)
+            await session.commit()
+
+    @aio
+    async def del_private_namespace(
+        self,
+        namespace: str,
+        binary: bool = False,
+        profile: Optional[str] = None
+    ) -> None:
+        """Delete all data from a private namespace
+
+        Be really cautious when you use this method, as all data with given namespace are
+        removed.
+        Params are the same as for del_private_value
+        """
+        cls = self._get_private_class(binary, profile)
+
+        stmt = delete(cls).filter_by(namespace=namespace)
+
+        if profile is not None:
+            stmt = stmt.filter_by(profile_id=self.profiles[profile])
+
+        async with self.session() as session:
+            await session.execute(stmt)
+            await session.commit()
+
+    ## Files
+
+    @aio
+    async def get_files(
+        self,
+        client: Optional[SatXMPPEntity],
+        file_id: Optional[str] = None,
+        version: Optional[str] = '',
+        parent: Optional[str] = None,
+        type_: Optional[str] = None,
+        file_hash: Optional[str] = None,
+        hash_algo: Optional[str] = None,
+        name: Optional[str] = None,
+        namespace: Optional[str] = None,
+        mime_type: Optional[str] = None,
+        public_id: Optional[str] = None,
+        owner: Optional[jid.JID] = None,
+        access: Optional[dict] = None,
+        projection: Optional[List[str]] = None,
+        unique: bool = False
+    ) -> List[dict]:
+        """Retrieve files with with given filters
+
+        @param file_id: id of the file
+            None to ignore
+        @param version: version of the file
+            None to ignore
+            empty string to look for current version
+        @param parent: id of the directory containing the files
+            None to ignore
+            empty string to look for root files/directories
+        @param projection: name of columns to retrieve
+            None to retrieve all
+        @param unique: if True will remove duplicates
+        other params are the same as for [set_file]
+        @return: files corresponding to filters
+        """
+        if projection is None:
+            projection = [
+                'id', 'version', 'parent', 'type', 'file_hash', 'hash_algo', 'name',
+                'size', 'namespace', 'media_type', 'media_subtype', 'public_id',
+                'created', 'modified', 'owner', 'access', 'extra'
+            ]
+
+        stmt = select(*[getattr(File, f) for f in projection])
+
+        if unique:
+            stmt = stmt.distinct()
+
+        if client is not None:
+            stmt = stmt.filter_by(profile_id=self.profiles[client.profile])
+        else:
+            if public_id is None:
+                raise exceptions.InternalError(
+                    "client can only be omitted when public_id is set"
+                )
+        if file_id is not None:
+            stmt = stmt.filter_by(id=file_id)
+        if version is not None:
+            stmt = stmt.filter_by(version=version)
+        if parent is not None:
+            stmt = stmt.filter_by(parent=parent)
+        if type_ is not None:
+            stmt = stmt.filter_by(type=type_)
+        if file_hash is not None:
+            stmt = stmt.filter_by(file_hash=file_hash)
+        if hash_algo is not None:
+            stmt = stmt.filter_by(hash_algo=hash_algo)
+        if name is not None:
+            stmt = stmt.filter_by(name=name)
+        if namespace is not None:
+            stmt = stmt.filter_by(namespace=namespace)
+        if mime_type is not None:
+            if '/' in mime_type:
+                media_type, media_subtype = mime_type.split("/", 1)
+                stmt = stmt.filter_by(media_type=media_type, media_subtype=media_subtype)
+            else:
+                stmt = stmt.filter_by(media_type=mime_type)
+        if public_id is not None:
+            stmt = stmt.filter_by(public_id=public_id)
+        if owner is not None:
+            stmt = stmt.filter_by(owner=owner)
+        if access is not None:
+            raise NotImplementedError('Access check is not implemented yet')
+            # a JSON comparison is needed here
+
+        async with self.session() as session:
+            result = await session.execute(stmt)
+
+        return [dict(r) for r in result]
+
+    @aio
+    async def set_file(
+        self,
+        client: SatXMPPEntity,
+        name: str,
+        file_id: str,
+        version: str = "",
+        parent: str = "",
+        type_: str = C.FILE_TYPE_FILE,
+        file_hash: Optional[str] = None,
+        hash_algo: Optional[str] = None,
+        size: int = None,
+        namespace: Optional[str] = None,
+        mime_type: Optional[str] = None,
+        public_id: Optional[str] = None,
+        created: Optional[float] = None,
+        modified: Optional[float] = None,
+        owner: Optional[jid.JID] = None,
+        access: Optional[dict] = None,
+        extra: Optional[dict] = None
+    ) -> None:
+        """Set a file metadata
+
+        @param client: client owning the file
+        @param name: name of the file (must not contain "/")
+        @param file_id: unique id of the file
+        @param version: version of this file
+        @param parent: id of the directory containing this file
+            Empty string if it is a root file/directory
+        @param type_: one of:
+            - file
+            - directory
+        @param file_hash: unique hash of the payload
+        @param hash_algo: algorithm used for hashing the file (usually sha-256)
+        @param size: size in bytes
+        @param namespace: identifier (human readable is better) to group files
+            for instance, namespace could be used to group files in a specific photo album
+        @param mime_type: media type of the file, or None if not known/guessed
+        @param public_id: ID used to server the file publicly via HTTP
+        @param created: UNIX time of creation
+        @param modified: UNIX time of last modification, or None to use created date
+        @param owner: jid of the owner of the file (mainly useful for component)
+        @param access: serialisable dictionary with access rules. See [memory.memory] for
+            details
+        @param extra: serialisable dictionary of any extra data
+            will be encoded to json in database
+        """
+        if mime_type is None:
+            media_type = media_subtype = None
+        elif '/' in mime_type:
+            media_type, media_subtype = mime_type.split('/', 1)
+        else:
+            media_type, media_subtype = mime_type, None
+
+        async with self.session() as session:
+            async with session.begin():
+                session.add(File(
+                    id=file_id,
+                    version=version.strip(),
+                    parent=parent,
+                    type=type_,
+                    file_hash=file_hash,
+                    hash_algo=hash_algo,
+                    name=name,
+                    size=size,
+                    namespace=namespace,
+                    media_type=media_type,
+                    media_subtype=media_subtype,
+                    public_id=public_id,
+                    created=time.time() if created is None else created,
+                    modified=modified,
+                    owner=owner,
+                    access=access,
+                    extra=extra,
+                    profile_id=self.profiles[client.profile]
+                ))
+
+    @aio
+    async def file_get_used_space(self, client: SatXMPPEntity, owner: jid.JID) -> int:
+        async with self.session() as session:
+            result = await session.execute(
+                select(sum_(File.size)).filter_by(
+                    owner=owner,
+                    type=C.FILE_TYPE_FILE,
+                    profile_id=self.profiles[client.profile]
+                ))
+        return result.scalar_one_or_none() or 0
+
+    @aio
+    async def file_delete(self, file_id: str) -> None:
+        """Delete file metadata from the database
+
+        @param file_id: id of the file to delete
+        NOTE: file itself must still be removed, this method only handle metadata in
+            database
+        """
+        async with self.session() as session:
+            await session.execute(delete(File).filter_by(id=file_id))
+            await session.commit()
+
+    @aio
+    async def file_update(
+        self,
+        file_id: str,
+        column: str,
+        update_cb: Callable[[dict], None]
+    ) -> None:
+        """Update a column value using a method to avoid race conditions
+
+        the older value will be retrieved from database, then update_cb will be applied to
+        update it, and file will be updated checking that older value has not been changed
+        meanwhile by an other user. If it has changed, it tries again a couple of times
+        before failing
+        @param column: column name (only "access" or "extra" are allowed)
+        @param update_cb: method to update the value of the colum
+            the method will take older value as argument, and must update it in place
+            update_cb must not care about serialization,
+            it get the deserialized data (i.e. a Python object) directly
+        @raise exceptions.NotFound: there is not file with this id
+        """
+        if column not in ('access', 'extra'):
+            raise exceptions.InternalError('bad column name')
+        orm_col = getattr(File, column)
+
+        for i in range(5):
+            async with self.session() as session:
+                try:
+                    value = (await session.execute(
+                        select(orm_col).filter_by(id=file_id)
+                    )).scalar_one()
+                except NoResultFound:
+                    raise exceptions.NotFound
+                old_value = copy.deepcopy(value)
+                update_cb(value)
+                stmt = update(File).filter_by(id=file_id).values({column: value})
+                if not old_value:
+                    # because JsonDefaultDict convert NULL to an empty dict, we have to
+                    # test both for empty dict and None when we have an empty dict
+                    stmt = stmt.where((orm_col == None) | (orm_col == old_value))
+                else:
+                    stmt = stmt.where(orm_col == old_value)
+                result = await session.execute(stmt)
+                await session.commit()
+
+            if result.rowcount == 1:
+                break
+
+            log.warning(
+                _("table not updated, probably due to race condition, trying again "
+                  "({tries})").format(tries=i+1)
+            )
+
+        else:
+            raise exceptions.DatabaseError(
+                _("Can't update file {file_id} due to race condition")
+                .format(file_id=file_id)
+            )
+
+    @aio
+    async def get_pubsub_node(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        name: str,
+        with_items: bool = False,
+        with_subscriptions: bool = False,
+        create: bool = False,
+        create_kwargs: Optional[dict] = None
+    ) -> Optional[PubsubNode]:
+        """Retrieve a PubsubNode from DB
+
+        @param service: service hosting the node
+        @param name: node's name
+        @param with_items: retrieve items in the same query
+        @param with_subscriptions: retrieve subscriptions in the same query
+        @param create: if the node doesn't exist in DB, create it
+        @param create_kwargs: keyword arguments to use with ``set_pubsub_node`` if the node
+            needs to be created.
+        """
+        async with self.session() as session:
+            stmt = (
+                select(PubsubNode)
+                .filter_by(
+                    service=service,
+                    name=name,
+                    profile_id=self.profiles[client.profile],
+                )
+            )
+            if with_items:
+                stmt = stmt.options(
+                    joinedload(PubsubNode.items)
+                )
+            if with_subscriptions:
+                stmt = stmt.options(
+                    joinedload(PubsubNode.subscriptions)
+                )
+            result = await session.execute(stmt)
+        ret = result.unique().scalar_one_or_none()
+        if ret is None and create:
+            # we auto-create the node
+            if create_kwargs is None:
+                create_kwargs = {}
+            try:
+                return await as_future(self.set_pubsub_node(
+                    client, service, name, **create_kwargs
+                ))
+            except IntegrityError as e:
+                if "unique" in str(e.orig).lower():
+                    # the node may already exist, if it has been created just after
+                    # get_pubsub_node above
+                    log.debug("ignoring UNIQUE constraint error")
+                    cached_node = await as_future(self.get_pubsub_node(
+                        client,
+                        service,
+                        name,
+                        with_items=with_items,
+                        with_subscriptions=with_subscriptions
+                    ))
+                else:
+                    raise e
+        else:
+            return ret
+
+    @aio
+    async def set_pubsub_node(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        name: str,
+        analyser: Optional[str] = None,
+        type_: Optional[str] = None,
+        subtype: Optional[str] = None,
+        subscribed: bool = False,
+    ) -> PubsubNode:
+        node = PubsubNode(
+            profile_id=self.profiles[client.profile],
+            service=service,
+            name=name,
+            subscribed=subscribed,
+            analyser=analyser,
+            type_=type_,
+            subtype=subtype,
+            subscriptions=[],
+        )
+        async with self.session() as session:
+            async with session.begin():
+                session.add(node)
+        return node
+
+    @aio
+    async def update_pubsub_node_sync_state(
+        self,
+        node: PubsubNode,
+        state: SyncState
+    ) -> None:
+        async with self.session() as session:
+            async with session.begin():
+                await session.execute(
+                    update(PubsubNode)
+                    .filter_by(id=node.id)
+                    .values(
+                        sync_state=state,
+                        sync_state_updated=time.time(),
+                    )
+                )
+
+    @aio
+    async def delete_pubsub_node(
+        self,
+        profiles: Optional[List[str]],
+        services: Optional[List[jid.JID]],
+        names: Optional[List[str]]
+    ) -> None:
+        """Delete items cached for a node
+
+        @param profiles: profile names from which nodes must be deleted.
+            None to remove nodes from ALL profiles
+        @param services: JIDs of pubsub services from which nodes must be deleted.
+            None to remove nodes from ALL services
+        @param names: names of nodes which must be deleted.
+            None to remove ALL nodes whatever is their names
+        """
+        stmt = delete(PubsubNode)
+        if profiles is not None:
+            stmt = stmt.where(
+                PubsubNode.profile.in_(
+                    [self.profiles[p] for p in profiles]
+                )
+            )
+        if services is not None:
+            stmt = stmt.where(PubsubNode.service.in_(services))
+        if names is not None:
+            stmt = stmt.where(PubsubNode.name.in_(names))
+        async with self.session() as session:
+            await session.execute(stmt)
+            await session.commit()
+
+    @aio
+    async def cache_pubsub_items(
+        self,
+        client: SatXMPPEntity,
+        node: PubsubNode,
+        items: List[domish.Element],
+        parsed_items: Optional[List[dict]] = None,
+    ) -> None:
+        """Add items to database, using an upsert taking care of "updated" field"""
+        if parsed_items is not None and len(items) != len(parsed_items):
+            raise exceptions.InternalError(
+                "parsed_items must have the same lenght as items"
+            )
+        async with self.session() as session:
+            async with session.begin():
+                for idx, item in enumerate(items):
+                    parsed = parsed_items[idx] if parsed_items else None
+                    stmt = insert(PubsubItem).values(
+                        node_id = node.id,
+                        name = item["id"],
+                        data = item,
+                        parsed = parsed,
+                    ).on_conflict_do_update(
+                        index_elements=(PubsubItem.node_id, PubsubItem.name),
+                        set_={
+                            PubsubItem.data: item,
+                            PubsubItem.parsed: parsed,
+                            PubsubItem.updated: now()
+                        }
+                    )
+                    await session.execute(stmt)
+                await session.commit()
+
+    @aio
+    async def delete_pubsub_items(
+        self,
+        node: PubsubNode,
+        items_names: Optional[List[str]] = None
+    ) -> None:
+        """Delete items cached for a node
+
+        @param node: node from which items must be deleted
+        @param items_names: names of items to delete
+            if None, ALL items will be deleted
+        """
+        stmt = delete(PubsubItem)
+        if node is not None:
+            if isinstance(node, list):
+                stmt = stmt.where(PubsubItem.node_id.in_([n.id for n in node]))
+            else:
+                stmt = stmt.filter_by(node_id=node.id)
+        if items_names is not None:
+            stmt = stmt.where(PubsubItem.name.in_(items_names))
+        async with self.session() as session:
+            await session.execute(stmt)
+            await session.commit()
+
+    @aio
+    async def purge_pubsub_items(
+        self,
+        services: Optional[List[jid.JID]] = None,
+        names: Optional[List[str]] = None,
+        types: Optional[List[str]] = None,
+        subtypes: Optional[List[str]] = None,
+        profiles: Optional[List[str]] = None,
+        created_before: Optional[datetime] = None,
+        updated_before: Optional[datetime] = None,
+    ) -> None:
+        """Delete items cached for a node
+
+        @param node: node from which items must be deleted
+        @param items_names: names of items to delete
+            if None, ALL items will be deleted
+        """
+        stmt = delete(PubsubItem)
+        node_fields = {
+            "service": services,
+            "name": names,
+            "type_": types,
+            "subtype": subtypes,
+        }
+        if profiles is not None:
+            node_fields["profile_id"] = [self.profiles[p] for p in profiles]
+
+        if any(x is not None for x in node_fields.values()):
+            sub_q = select(PubsubNode.id)
+            for col, values in node_fields.items():
+                if values is None:
+                    continue
+                sub_q = sub_q.where(getattr(PubsubNode, col).in_(values))
+            stmt = (
+                stmt
+                .where(PubsubItem.node_id.in_(sub_q))
+                .execution_options(synchronize_session=False)
+            )
+
+        if created_before is not None:
+            stmt = stmt.where(PubsubItem.created < created_before)
+
+        if updated_before is not None:
+            stmt = stmt.where(PubsubItem.updated < updated_before)
+
+        async with self.session() as session:
+            await session.execute(stmt)
+            await session.commit()
+
+    @aio
+    async def get_items(
+        self,
+        node: PubsubNode,
+        max_items: Optional[int] = None,
+        item_ids: Optional[list[str]] = None,
+        before: Optional[str] = None,
+        after: Optional[str] = None,
+        from_index: Optional[int] = None,
+        order_by: Optional[List[str]] = None,
+        desc: bool = True,
+        force_rsm: bool = False,
+    ) -> Tuple[List[PubsubItem], dict]:
+        """Get Pubsub Items from cache
+
+        @param node: retrieve items from this node (must be synchronised)
+        @param max_items: maximum number of items to retrieve
+        @param before: get items which are before the item with this name in given order
+            empty string is not managed here, use desc order to reproduce RSM
+            behaviour.
+        @param after: get items which are after the item with this name in given order
+        @param from_index: get items with item index (as defined in RSM spec)
+            starting from this number
+        @param order_by: sorting order of items (one of C.ORDER_BY_*)
+        @param desc: direction or ordering
+        @param force_rsm: if True, force the use of RSM worklow.
+            RSM workflow is automatically used if any of before, after or
+            from_index is used, but if only RSM max_items is used, it won't be
+            used by default. This parameter let's use RSM workflow in this
+            case. Note that in addition to RSM metadata, the result will not be
+            the same (max_items without RSM will returns most recent items,
+            i.e. last items in modification order, while max_items with RSM
+            will return the oldest ones (i.e. first items in modification
+            order).
+            to be used when max_items is used from RSM
+        """
+
+        metadata = {
+            "service": node.service,
+            "node": node.name,
+            "uri": uri.build_xmpp_uri(
+                "pubsub",
+                path=node.service.full(),
+                node=node.name,
+            ),
+        }
+        if max_items is None:
+            max_items = 20
+
+        use_rsm = any((before, after, from_index is not None))
+        if force_rsm and not use_rsm:
+            #
+            use_rsm = True
+            from_index = 0
+
+        stmt = (
+            select(PubsubItem)
+            .filter_by(node_id=node.id)
+            .limit(max_items)
+        )
+
+        if item_ids is not None:
+            stmt = stmt.where(PubsubItem.name.in_(item_ids))
+
+        if not order_by:
+            order_by = [C.ORDER_BY_MODIFICATION]
+
+        order = []
+        for order_type in order_by:
+            if order_type == C.ORDER_BY_MODIFICATION:
+                if desc:
+                    order.extend((PubsubItem.updated.desc(), PubsubItem.id.desc()))
+                else:
+                    order.extend((PubsubItem.updated.asc(), PubsubItem.id.asc()))
+            elif order_type == C.ORDER_BY_CREATION:
+                if desc:
+                    order.append(PubsubItem.id.desc())
+                else:
+                    order.append(PubsubItem.id.asc())
+            else:
+                raise exceptions.InternalError(f"Unknown order type {order_type!r}")
+
+        stmt = stmt.order_by(*order)
+
+        if use_rsm:
+            # CTE to have result row numbers
+            row_num_q = select(
+                PubsubItem.id,
+                PubsubItem.name,
+                # row_number starts from 1, but RSM index must start from 0
+                (func.row_number().over(order_by=order)-1).label("item_index")
+            ).filter_by(node_id=node.id)
+
+            row_num_cte = row_num_q.cte()
+
+            if max_items > 0:
+                # as we can't simply use PubsubItem.id when we order by modification,
+                # we need to use row number
+                item_name = before or after
+                row_num_limit_q = (
+                    select(row_num_cte.c.item_index)
+                    .where(row_num_cte.c.name==item_name)
+                ).scalar_subquery()
+
+                stmt = (
+                    select(row_num_cte.c.item_index, PubsubItem)
+                    .join(row_num_cte, PubsubItem.id == row_num_cte.c.id)
+                    .limit(max_items)
+                )
+                if before:
+                    stmt = (
+                        stmt
+                        .where(row_num_cte.c.item_index<row_num_limit_q)
+                        .order_by(row_num_cte.c.item_index.desc())
+                    )
+                elif after:
+                    stmt = (
+                        stmt
+                        .where(row_num_cte.c.item_index>row_num_limit_q)
+                        .order_by(row_num_cte.c.item_index.asc())
+                    )
+                else:
+                    stmt = (
+                        stmt
+                        .where(row_num_cte.c.item_index>=from_index)
+                        .order_by(row_num_cte.c.item_index.asc())
+                    )
+                    # from_index is used
+
+            async with self.session() as session:
+                if max_items == 0:
+                    items = result = []
+                else:
+                    result = await session.execute(stmt)
+                    result = result.all()
+                    if before:
+                        result.reverse()
+                    items = [row[-1] for row in result]
+                rows_count = (
+                    await session.execute(row_num_q.with_only_columns(count()))
+                ).scalar_one()
+
+            try:
+                index = result[0][0]
+            except IndexError:
+                index = None
+
+            try:
+                first = result[0][1].name
+            except IndexError:
+                first = None
+                last = None
+            else:
+                last = result[-1][1].name
+
+            metadata["rsm"] = {
+                k: v for k, v in {
+                    "index": index,
+                    "count": rows_count,
+                    "first": first,
+                    "last": last,
+                }.items() if v is not None
+            }
+            metadata["complete"] = (index or 0) + len(result) == rows_count
+
+            return items, metadata
+
+        async with self.session() as session:
+            result = await session.execute(stmt)
+
+        result = result.scalars().all()
+        if desc:
+            result.reverse()
+        return result, metadata
+
+    def _get_sqlite_path(
+        self,
+        path: List[Union[str, int]]
+    ) -> str:
+        """generate path suitable to query JSON element with SQLite"""
+        return f"${''.join(f'[{p}]' if isinstance(p, int) else f'.{p}' for p in path)}"
+
+    @aio
+    async def search_pubsub_items(
+        self,
+        query: dict,
+    ) -> Tuple[List[PubsubItem]]:
+        """Search for pubsub items in cache
+
+        @param query: search terms. Keys can be:
+            :fts (str):
+                Full-Text Search query. Currently SQLite FT5 engine is used, its query
+                syntax can be used, see `FTS5 Query documentation
+                <https://sqlite.org/fts5.html#full_text_query_syntax>`_
+            :profiles (list[str]):
+                filter on nodes linked to those profiles
+            :nodes (list[str]):
+                filter on nodes with those names
+            :services (list[jid.JID]):
+                filter on nodes from those services
+            :types (list[str|None]):
+                filter on nodes with those types. None can be used to filter on nodes with
+                no type set
+            :subtypes (list[str|None]):
+                filter on nodes with those subtypes. None can be used to filter on nodes with
+                no subtype set
+            :names (list[str]):
+                filter on items with those names
+            :parsed (list[dict]):
+                Filter on a parsed data field. The dict must contain 3 keys: ``path``
+                which is a list of str or int giving the path to the field of interest
+                (str for a dict key, int for a list index), ``operator`` with indicate the
+                operator to use to check the condition, and ``value`` which depends of
+                field type and operator.
+
+                See documentation for details on operators (it's currently explained at
+                ``doc/libervia-cli/pubsub_cache.rst`` in ``search`` command
+                documentation).
+
+            :order-by (list[dict]):
+                Indicates how to order results. The dict can contain either a ``order``
+                for a well-know order or a ``path`` for a parsed data field path
+                (``order`` and ``path`` can't be used at the same time), an an optional
+                ``direction`` which can be ``asc`` or ``desc``. See documentation for
+                details on well-known orders (it's currently explained at
+                ``doc/libervia-cli/pubsub_cache.rst`` in ``search`` command
+                documentation).
+
+            :index (int):
+                starting index of items to return from the query result. It's translated
+                to SQL's OFFSET
+
+            :limit (int):
+                maximum number of items to return. It's translated to SQL's LIMIT.
+
+        @result: found items (the ``node`` attribute will be filled with suitable
+            PubsubNode)
+        """
+        # TODO: FTS and parsed data filters use SQLite specific syntax
+        #   when other DB engines will be used, this will have to be adapted
+        stmt = select(PubsubItem)
+
+        # Full-Text Search
+        fts = query.get("fts")
+        if fts:
+            fts_select = text(
+                "SELECT rowid, rank FROM pubsub_items_fts(:fts_query)"
+            ).bindparams(fts_query=fts).columns(rowid=Integer).subquery()
+            stmt = (
+                stmt
+                .select_from(fts_select)
+                .outerjoin(PubsubItem, fts_select.c.rowid == PubsubItem.id)
+            )
+
+        # node related filters
+        profiles = query.get("profiles")
+        if (profiles
+            or any(query.get(k) for k in ("nodes", "services", "types", "subtypes"))
+        ):
+            stmt = stmt.join(PubsubNode).options(contains_eager(PubsubItem.node))
+            if profiles:
+                try:
+                    stmt = stmt.where(
+                        PubsubNode.profile_id.in_(self.profiles[p] for p in profiles)
+                    )
+                except KeyError as e:
+                    raise exceptions.ProfileUnknownError(
+                        f"This profile doesn't exist: {e.args[0]!r}"
+                    )
+            for key, attr in (
+                ("nodes", "name"),
+                ("services", "service"),
+                ("types", "type_"),
+                ("subtypes", "subtype")
+            ):
+                value = query.get(key)
+                if not value:
+                    continue
+                if key in ("types", "subtypes") and None in value:
+                    # NULL can't be used with SQL's IN, so we have to add a condition with
+                    # IS NULL, and use a OR if there are other values to check
+                    value.remove(None)
+                    condition = getattr(PubsubNode, attr).is_(None)
+                    if value:
+                        condition = or_(
+                            getattr(PubsubNode, attr).in_(value),
+                            condition
+                        )
+                else:
+                    condition = getattr(PubsubNode, attr).in_(value)
+                stmt = stmt.where(condition)
+        else:
+            stmt = stmt.options(selectinload(PubsubItem.node))
+
+        # names
+        names = query.get("names")
+        if names:
+            stmt = stmt.where(PubsubItem.name.in_(names))
+
+        # parsed data filters
+        parsed = query.get("parsed", [])
+        for filter_ in parsed:
+            try:
+                path = filter_["path"]
+                operator = filter_["op"]
+                value = filter_["value"]
+            except KeyError as e:
+                raise ValueError(
+                    f'missing mandatory key {e.args[0]!r} in "parsed" filter'
+                )
+            try:
+                op_attr = OP_MAP[operator]
+            except KeyError:
+                raise ValueError(f"invalid operator: {operator!r}")
+            sqlite_path = self._get_sqlite_path(path)
+            if operator in ("overlap", "ioverlap", "disjoint", "idisjoint"):
+                col = literal_column("json_each.value")
+                if operator[0] == "i":
+                    col = func.lower(col)
+                    value = [str(v).lower() for v in value]
+                condition = (
+                    select(1)
+                    .select_from(func.json_each(PubsubItem.parsed, sqlite_path))
+                    .where(col.in_(value))
+                ).scalar_subquery()
+                if operator in ("disjoint", "idisjoint"):
+                    condition = condition.is_(None)
+                stmt = stmt.where(condition)
+            elif operator == "between":
+                try:
+                    left, right = value
+                except (ValueError, TypeError):
+                    raise ValueError(_(
+                        "invalid value for \"between\" filter, you must use a 2 items "
+                        "array: {value!r}"
+                    ).format(value=value))
+                col = func.json_extract(PubsubItem.parsed, sqlite_path)
+                stmt = stmt.where(col.between(left, right))
+            else:
+                # we use func.json_extract instead of generic JSON way because SQLAlchemy
+                # add a JSON_QUOTE to the value, and we want SQL value
+                col = func.json_extract(PubsubItem.parsed, sqlite_path)
+                stmt = stmt.where(getattr(col, op_attr)(value))
+
+        # order
+        order_by = query.get("order-by") or [{"order": "creation"}]
+
+        for order_data in order_by:
+            order, path = order_data.get("order"), order_data.get("path")
+            if order and path:
+                raise ValueError(_(
+                    '"order" and "path" can\'t be used at the same time in '
+                    '"order-by" data'
+                ))
+            if order:
+                if order == "creation":
+                    col = PubsubItem.id
+                elif order == "modification":
+                    col = PubsubItem.updated
+                elif order == "item_id":
+                    col = PubsubItem.name
+                elif order == "rank":
+                    if not fts:
+                        raise ValueError(
+                            "'rank' order can only be used with Full-Text Search (fts)"
+                        )
+                    col = literal_column("rank")
+                else:
+                    raise NotImplementedError(f"Unknown {order!r} order")
+            else:
+                # we have a JSON path
+                # sqlite_path = self._get_sqlite_path(path)
+                col = PubsubItem.parsed[path]
+            direction = order_data.get("direction", "ASC").lower()
+            if not direction in ("asc", "desc"):
+                raise ValueError(f"Invalid order-by direction: {direction!r}")
+            stmt = stmt.order_by(getattr(col, direction)())
+
+        # offset, limit
+        index = query.get("index")
+        if index:
+            stmt = stmt.offset(index)
+        limit = query.get("limit")
+        if limit:
+            stmt = stmt.limit(limit)
+
+        async with self.session() as session:
+            result = await session.execute(stmt)
+
+        return result.scalars().all()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/sqla_config.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from pathlib import Path
+from urllib.parse import quote
+from libervia.backend.core.constants import Const as C
+from libervia.backend.tools import config
+
+
+def get_db_config() -> dict:
+    """Get configuration for database
+
+    @return: dict with following keys:
+        - type: only "sqlite" for now
+        - path: path to the sqlite DB
+    """
+    main_conf = config.parse_main_conf()
+    local_dir = Path(config.config_get(main_conf, "", "local_dir"))
+    database_path = (local_dir / C.SAVEFILE_DATABASE).expanduser()
+    url = f"sqlite+aiosqlite:///{quote(str(database_path))}?timeout=30"
+    return {
+        "type": "sqlite",
+        "path": database_path,
+        "url": url,
+    }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/memory/sqla_mapping.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,640 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Dict, Any
+from datetime import datetime
+import enum
+import json
+import pickle
+import time
+
+from sqlalchemy import (
+    Boolean,
+    Column,
+    DDL,
+    DateTime,
+    Enum,
+    Float,
+    ForeignKey,
+    Index,
+    Integer,
+    JSON,
+    MetaData,
+    Text,
+    UniqueConstraint,
+    event,
+)
+from sqlalchemy.orm import declarative_base, relationship
+from sqlalchemy.sql.functions import now
+from sqlalchemy.types import TypeDecorator
+from twisted.words.protocols.jabber import jid
+from wokkel import generic
+
+
+Base = declarative_base(
+    metadata=MetaData(
+        naming_convention={
+            "ix": 'ix_%(column_0_label)s',
+            "uq": "uq_%(table_name)s_%(column_0_name)s",
+            "ck": "ck_%(table_name)s_%(constraint_name)s",
+            "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
+            "pk": "pk_%(table_name)s"
+        }
+    )
+)
+# keys which are in message data extra but not stored in extra field this is
+# because those values are stored in separate fields
+NOT_IN_EXTRA = ('origin_id', 'stanza_id', 'received_timestamp', 'update_uid')
+
+
+class SyncState(enum.Enum):
+    #: synchronisation is currently in progress
+    IN_PROGRESS = 1
+    #: synchronisation is done
+    COMPLETED = 2
+    #: something wrong happened during synchronisation, won't sync
+    ERROR = 3
+    #: synchronisation won't be done even if a syncing analyser matches
+    NO_SYNC = 4
+
+
+class SubscriptionState(enum.Enum):
+    SUBSCRIBED = 1
+    PENDING = 2
+
+
+class LegacyPickle(TypeDecorator):
+    """Handle troubles with data pickled by former version of SàT
+
+    This type is temporary until we do migration to a proper data type
+    """
+    # Blob is used on SQLite but gives errors when used here, while Text works fine
+    impl = Text
+    cache_ok = True
+
+    def process_bind_param(self, value, dialect):
+        if value is None:
+            return None
+        return pickle.dumps(value, 0)
+
+    def process_result_value(self, value, dialect):
+        if value is None:
+            return None
+        # value types are inconsistent (probably a consequence of Python 2/3 port
+        # and/or SQLite dynamic typing)
+        try:
+            value = value.encode()
+        except AttributeError:
+            pass
+        # "utf-8" encoding is needed to handle Python 2 pickled data
+        return pickle.loads(value, encoding="utf-8")
+
+
+class Json(TypeDecorator):
+    """Handle JSON field in DB independant way"""
+    # Blob is used on SQLite but gives errors when used here, while Text works fine
+    impl = Text
+    cache_ok = True
+
+    def process_bind_param(self, value, dialect):
+        if value is None:
+            return None
+        return json.dumps(value)
+
+    def process_result_value(self, value, dialect):
+        if value is None:
+            return None
+        return json.loads(value)
+
+
+class JsonDefaultDict(Json):
+    """Json type which convert NULL to empty dict instead of None"""
+
+    def process_result_value(self, value, dialect):
+        if value is None:
+            return {}
+        return json.loads(value)
+
+
+class Xml(TypeDecorator):
+    impl = Text
+    cache_ok = True
+
+    def process_bind_param(self, value, dialect):
+        if value is None:
+            return None
+        return value.toXml()
+
+    def process_result_value(self, value, dialect):
+        if value is None:
+            return None
+        return generic.parseXml(value.encode())
+
+
+class JID(TypeDecorator):
+    """Store twisted JID in text fields"""
+    impl = Text
+    cache_ok = True
+
+    def process_bind_param(self, value, dialect):
+        if value is None:
+            return None
+        return value.full()
+
+    def process_result_value(self, value, dialect):
+        if value is None:
+            return None
+        return jid.JID(value)
+
+
+class Profile(Base):
+    __tablename__ = "profiles"
+
+    id = Column(
+        Integer,
+        primary_key=True,
+        nullable=True,
+    )
+    name = Column(Text, unique=True)
+
+    params = relationship("ParamInd", back_populates="profile", passive_deletes=True)
+    private_data = relationship(
+        "PrivateInd", back_populates="profile", passive_deletes=True
+    )
+    private_bin_data = relationship(
+        "PrivateIndBin", back_populates="profile", passive_deletes=True
+    )
+
+
+class Component(Base):
+    __tablename__ = "components"
+
+    profile_id = Column(
+        ForeignKey("profiles.id", ondelete="CASCADE"),
+        nullable=True,
+        primary_key=True
+    )
+    entry_point = Column(Text, nullable=False)
+    profile = relationship("Profile")
+
+
+class History(Base):
+    __tablename__ = "history"
+    __table_args__ = (
+        UniqueConstraint("profile_id", "stanza_id", "source", "dest"),
+        UniqueConstraint("profile_id", "origin_id", "source", name="uq_origin_id"),
+        Index("history__profile_id_timestamp", "profile_id", "timestamp"),
+        Index(
+            "history__profile_id_received_timestamp", "profile_id", "received_timestamp"
+        )
+    )
+
+    uid = Column(Text, primary_key=True)
+    origin_id = Column(Text)
+    stanza_id = Column(Text)
+    update_uid = Column(Text)
+    profile_id = Column(ForeignKey("profiles.id", ondelete="CASCADE"))
+    source = Column(Text)
+    dest = Column(Text)
+    source_res = Column(Text)
+    dest_res = Column(Text)
+    timestamp = Column(Float, nullable=False)
+    received_timestamp = Column(Float)
+    type = Column(
+        Enum(
+            "chat",
+            "error",
+            "groupchat",
+            "headline",
+            "normal",
+            # info is not XMPP standard, but used to keep track of info like join/leave
+            # in a MUC
+            "info",
+            name="message_type",
+            create_constraint=True,
+        ),
+        nullable=False,
+    )
+    extra = Column(LegacyPickle)
+
+    profile = relationship("Profile")
+    messages = relationship("Message", backref="history", passive_deletes=True)
+    subjects = relationship("Subject", backref="history", passive_deletes=True)
+    thread = relationship(
+        "Thread", uselist=False, back_populates="history", passive_deletes=True
+    )
+
+    def __init__(self, *args, **kwargs):
+        source_jid = kwargs.pop("source_jid", None)
+        if source_jid is not None:
+            kwargs["source"] = source_jid.userhost()
+            kwargs["source_res"] = source_jid.resource
+        dest_jid = kwargs.pop("dest_jid", None)
+        if dest_jid is not None:
+            kwargs["dest"] = dest_jid.userhost()
+            kwargs["dest_res"] = dest_jid.resource
+        super().__init__(*args, **kwargs)
+
+    @property
+    def source_jid(self) -> jid.JID:
+        return jid.JID(f"{self.source}/{self.source_res or ''}")
+
+    @source_jid.setter
+    def source_jid(self, source_jid: jid.JID) -> None:
+        self.source = source_jid.userhost
+        self.source_res = source_jid.resource
+
+    @property
+    def dest_jid(self):
+        return jid.JID(f"{self.dest}/{self.dest_res or ''}")
+
+    @dest_jid.setter
+    def dest_jid(self, dest_jid: jid.JID) -> None:
+        self.dest = dest_jid.userhost
+        self.dest_res = dest_jid.resource
+
+    def __repr__(self):
+        dt = datetime.fromtimestamp(self.timestamp)
+        return f"History<{self.source_jid.full()}->{self.dest_jid.full()} [{dt}]>"
+
+    def serialise(self):
+        extra = self.extra or {}
+        if self.origin_id is not None:
+            extra["origin_id"] = self.origin_id
+        if self.stanza_id is not None:
+            extra["stanza_id"] = self.stanza_id
+        if self.update_uid is not None:
+            extra["update_uid"] = self.update_uid
+        if self.received_timestamp is not None:
+            extra["received_timestamp"] = self.received_timestamp
+        if self.thread is not None:
+            extra["thread"] = self.thread.thread_id
+            if self.thread.parent_id is not None:
+                extra["thread_parent"] = self.thread.parent_id
+
+
+        return {
+            "from": f"{self.source}/{self.source_res}" if self.source_res
+                else self.source,
+            "to": f"{self.dest}/{self.dest_res}" if self.dest_res else self.dest,
+            "uid": self.uid,
+            "message": {m.language or '': m.message for m in self.messages},
+            "subject": {m.language or '': m.subject for m in self.subjects},
+            "type": self.type,
+            "extra": extra,
+            "timestamp": self.timestamp,
+        }
+
+    def as_tuple(self):
+        d = self.serialise()
+        return (
+            d['uid'], d['timestamp'], d['from'], d['to'], d['message'], d['subject'],
+            d['type'], d['extra']
+        )
+
+    @staticmethod
+    def debug_collection(history_collection):
+        for idx, history in enumerate(history_collection):
+            history.debug_msg(idx)
+
+    def debug_msg(self, idx=None):
+        """Print messages"""
+        dt = datetime.fromtimestamp(self.timestamp)
+        if idx is not None:
+            dt = f"({idx}) {dt}"
+        parts = []
+        parts.append(f"[{dt}]<{self.source_jid.full()}->{self.dest_jid.full()}> ")
+        for message in self.messages:
+            if message.language:
+                parts.append(f"[{message.language}] ")
+            parts.append(f"{message.message}\n")
+        print("".join(parts))
+
+
+class Message(Base):
+    __tablename__ = "message"
+    __table_args__ = (
+        Index("message__history_uid", "history_uid"),
+    )
+
+    id = Column(
+        Integer,
+        primary_key=True,
+    )
+    history_uid = Column(ForeignKey("history.uid", ondelete="CASCADE"), nullable=False)
+    message = Column(Text, nullable=False)
+    language = Column(Text)
+
+    def serialise(self) -> Dict[str, Any]:
+        s = {}
+        if self.message:
+            s["message"] = str(self.message)
+        if self.language:
+            s["language"] = str(self.language)
+        return s
+
+    def __repr__(self):
+        lang_str = f"[{self.language}]" if self.language else ""
+        msg = f"{self.message[:20]}…" if len(self.message)>20 else self.message
+        content = f"{lang_str}{msg}"
+        return f"Message<{content}>"
+
+
+class Subject(Base):
+    __tablename__ = "subject"
+    __table_args__ = (
+        Index("subject__history_uid", "history_uid"),
+    )
+
+    id = Column(
+        Integer,
+        primary_key=True,
+    )
+    history_uid = Column(ForeignKey("history.uid", ondelete="CASCADE"), nullable=False)
+    subject = Column(Text, nullable=False)
+    language = Column(Text)
+
+    def serialise(self) -> Dict[str, Any]:
+        s = {}
+        if self.subject:
+            s["subject"] = str(self.subject)
+        if self.language:
+            s["language"] = str(self.language)
+        return s
+
+    def __repr__(self):
+        lang_str = f"[{self.language}]" if self.language else ""
+        msg = f"{self.subject[:20]}…" if len(self.subject)>20 else self.subject
+        content = f"{lang_str}{msg}"
+        return f"Subject<{content}>"
+
+
+class Thread(Base):
+    __tablename__ = "thread"
+    __table_args__ = (
+        Index("thread__history_uid", "history_uid"),
+    )
+
+    id = Column(
+        Integer,
+        primary_key=True,
+    )
+    history_uid = Column(ForeignKey("history.uid", ondelete="CASCADE"))
+    thread_id = Column(Text)
+    parent_id = Column(Text)
+
+    history = relationship("History", uselist=False, back_populates="thread")
+
+    def __repr__(self):
+        return f"Thread<{self.thread_id} [parent: {self.parent_id}]>"
+
+
+class ParamGen(Base):
+    __tablename__ = "param_gen"
+
+    category = Column(Text, primary_key=True)
+    name = Column(Text, primary_key=True)
+    value = Column(Text)
+
+
+class ParamInd(Base):
+    __tablename__ = "param_ind"
+
+    category = Column(Text, primary_key=True)
+    name = Column(Text, primary_key=True)
+    profile_id = Column(
+        ForeignKey("profiles.id", ondelete="CASCADE"), primary_key=True
+    )
+    value = Column(Text)
+
+    profile = relationship("Profile", back_populates="params")
+
+
+class PrivateGen(Base):
+    __tablename__ = "private_gen"
+
+    namespace = Column(Text, primary_key=True)
+    key = Column(Text, primary_key=True)
+    value = Column(Text)
+
+
+class PrivateInd(Base):
+    __tablename__ = "private_ind"
+
+    namespace = Column(Text, primary_key=True)
+    key = Column(Text, primary_key=True)
+    profile_id = Column(
+        ForeignKey("profiles.id", ondelete="CASCADE"), primary_key=True
+    )
+    value = Column(Text)
+
+    profile = relationship("Profile", back_populates="private_data")
+
+
+class PrivateGenBin(Base):
+    __tablename__ = "private_gen_bin"
+
+    namespace = Column(Text, primary_key=True)
+    key = Column(Text, primary_key=True)
+    value = Column(LegacyPickle)
+
+
+class PrivateIndBin(Base):
+    __tablename__ = "private_ind_bin"
+
+    namespace = Column(Text, primary_key=True)
+    key = Column(Text, primary_key=True)
+    profile_id = Column(
+        ForeignKey("profiles.id", ondelete="CASCADE"), primary_key=True
+    )
+    value = Column(LegacyPickle)
+
+    profile = relationship("Profile", back_populates="private_bin_data")
+
+
+class File(Base):
+    __tablename__ = "files"
+    __table_args__ = (
+        Index("files__profile_id_owner_parent", "profile_id", "owner", "parent"),
+        Index(
+            "files__profile_id_owner_media_type_media_subtype",
+            "profile_id",
+            "owner",
+            "media_type",
+            "media_subtype"
+        )
+    )
+
+    id = Column(Text, primary_key=True)
+    public_id = Column(Text, unique=True)
+    version = Column(Text, primary_key=True)
+    parent = Column(Text, nullable=False)
+    type = Column(
+        Enum(
+            "file", "directory",
+            name="file_type",
+            create_constraint=True
+        ),
+        nullable=False,
+        server_default="file",
+    )
+    file_hash = Column(Text)
+    hash_algo = Column(Text)
+    name = Column(Text, nullable=False)
+    size = Column(Integer)
+    namespace = Column(Text)
+    media_type = Column(Text)
+    media_subtype = Column(Text)
+    created = Column(Float, nullable=False)
+    modified = Column(Float)
+    owner = Column(JID)
+    access = Column(JsonDefaultDict)
+    extra = Column(JsonDefaultDict)
+    profile_id = Column(ForeignKey("profiles.id", ondelete="CASCADE"))
+
+    profile = relationship("Profile")
+
+
+class PubsubNode(Base):
+    __tablename__ = "pubsub_nodes"
+    __table_args__ = (
+        UniqueConstraint("profile_id", "service", "name"),
+    )
+
+    id = Column(Integer, primary_key=True)
+    profile_id = Column(
+        ForeignKey("profiles.id", ondelete="CASCADE")
+    )
+    service = Column(JID)
+    name = Column(Text, nullable=False)
+    subscribed = Column(
+        Boolean(create_constraint=True, name="subscribed_bool"), nullable=False
+    )
+    analyser = Column(Text)
+    sync_state = Column(
+        Enum(
+            SyncState,
+            name="sync_state",
+            create_constraint=True,
+        ),
+        nullable=True
+    )
+    sync_state_updated = Column(
+        Float,
+        nullable=False,
+        default=time.time()
+    )
+    type_ = Column(
+        Text, name="type", nullable=True
+    )
+    subtype = Column(
+        Text, nullable=True
+    )
+    extra = Column(JSON)
+
+    items = relationship("PubsubItem", back_populates="node", passive_deletes=True)
+    subscriptions = relationship("PubsubSub", back_populates="node", passive_deletes=True)
+
+    def __str__(self):
+        return f"Pubsub node {self.name!r} at {self.service}"
+
+
+class PubsubSub(Base):
+    """Subscriptions to pubsub nodes
+
+    Used by components managing a pubsub service
+    """
+    __tablename__ = "pubsub_subs"
+    __table_args__ = (
+        UniqueConstraint("node_id", "subscriber"),
+    )
+
+    id = Column(Integer, primary_key=True)
+    node_id = Column(ForeignKey("pubsub_nodes.id", ondelete="CASCADE"), nullable=False)
+    subscriber = Column(JID)
+    state = Column(
+        Enum(
+            SubscriptionState,
+            name="state",
+            create_constraint=True,
+        ),
+        nullable=True
+    )
+
+    node = relationship("PubsubNode", back_populates="subscriptions")
+
+
+class PubsubItem(Base):
+    __tablename__ = "pubsub_items"
+    __table_args__ = (
+        UniqueConstraint("node_id", "name"),
+    )
+    id = Column(Integer, primary_key=True)
+    node_id = Column(ForeignKey("pubsub_nodes.id", ondelete="CASCADE"), nullable=False)
+    name = Column(Text, nullable=False)
+    data = Column(Xml, nullable=False)
+    created = Column(DateTime, nullable=False, server_default=now())
+    updated = Column(DateTime, nullable=False, server_default=now(), onupdate=now())
+    parsed = Column(JSON)
+
+    node = relationship("PubsubNode", back_populates="items")
+
+
+## Full-Text Search
+
+# create
+
+@event.listens_for(PubsubItem.__table__, "after_create")
+def fts_create(target, connection, **kw):
+    """Full-Text Search table creation"""
+    if connection.engine.name == "sqlite":
+        # Using SQLite FTS5
+        queries = [
+            "CREATE VIRTUAL TABLE pubsub_items_fts "
+            "USING fts5(data, content=pubsub_items, content_rowid=id)",
+            "CREATE TRIGGER pubsub_items_fts_sync_ins AFTER INSERT ON pubsub_items BEGIN"
+            "  INSERT INTO pubsub_items_fts(rowid, data) VALUES (new.id, new.data);"
+            "END",
+            "CREATE TRIGGER pubsub_items_fts_sync_del AFTER DELETE ON pubsub_items BEGIN"
+            "  INSERT INTO pubsub_items_fts(pubsub_items_fts, rowid, data) "
+            "VALUES('delete', old.id, old.data);"
+            "END",
+            "CREATE TRIGGER pubsub_items_fts_sync_upd AFTER UPDATE ON pubsub_items BEGIN"
+            "  INSERT INTO pubsub_items_fts(pubsub_items_fts, rowid, data) VALUES"
+            "('delete', old.id, old.data);"
+            "  INSERT INTO pubsub_items_fts(rowid, data) VALUES(new.id, new.data);"
+            "END"
+        ]
+        for q in queries:
+            connection.execute(DDL(q))
+
+# drop
+
+@event.listens_for(PubsubItem.__table__, "before_drop")
+def fts_drop(target, connection, **kw):
+    "Full-Text Search table drop" ""
+    if connection.engine.name == "sqlite":
+        # Using SQLite FTS5
+        queries = [
+            "DROP TRIGGER IF EXISTS pubsub_items_fts_sync_ins",
+            "DROP TRIGGER IF EXISTS pubsub_items_fts_sync_del",
+            "DROP TRIGGER IF EXISTS pubsub_items_fts_sync_upd",
+            "DROP TABLE IF EXISTS pubsub_items_fts",
+        ]
+        for q in queries:
+            connection.execute(DDL(q))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_adhoc_dbus.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,478 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for adding D-Bus to Ad-Hoc Commands
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from wokkel import data_form
+
+try:
+    from lxml import etree
+except ImportError:
+    etree = None
+    log.warning("Missing module lxml, please download/install it from http://lxml.de/ ."
+                "Auto D-Bus discovery will be disabled")
+from collections import OrderedDict
+import os.path
+import uuid
+try:
+    import dbus
+    from dbus.mainloop.glib import DBusGMainLoop
+except ImportError:
+    dbus = None
+    log.warning("Missing module dbus, please download/install it, "
+                "auto D-Bus discovery will be disabled")
+
+else:
+    DBusGMainLoop(set_as_default=True)
+
+NS_MEDIA_PLAYER = "org.libervia.mediaplayer"
+FD_NAME = "org.freedesktop.DBus"
+FD_PATH = "/org/freedekstop/DBus"
+INTROSPECT_IFACE = "org.freedesktop.DBus.Introspectable"
+MPRIS_PREFIX = "org.mpris.MediaPlayer2"
+CMD_GO_BACK = "GoBack"
+CMD_GO_FWD = "GoFW"
+SEEK_OFFSET = 5 * 1000 * 1000
+MPRIS_COMMANDS = ["org.mpris.MediaPlayer2.Player." + cmd for cmd in (
+    "Previous", CMD_GO_BACK, "PlayPause", CMD_GO_FWD, "Next")]
+MPRIS_PATH = "/org/mpris/MediaPlayer2"
+MPRIS_PROPERTIES = OrderedDict((
+    ("org.mpris.MediaPlayer2", (
+        "Identity",
+        )),
+    ("org.mpris.MediaPlayer2.Player", (
+        "Metadata",
+        "PlaybackStatus",
+        "Volume",
+        )),
+    ))
+MPRIS_METADATA_KEY = "Metadata"
+MPRIS_METADATA_MAP = OrderedDict((
+    ("xesam:title", "Title"),
+    ))
+
+INTROSPECT_METHOD = "Introspect"
+IGNORED_IFACES_START = (
+    "org.freedesktop",
+    "org.qtproject",
+    "org.kde.KMainWindow",
+)  # commands in interface starting with these values will be ignored
+FLAG_LOOP = "LOOP"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Ad-Hoc Commands - D-Bus",
+    C.PI_IMPORT_NAME: "AD_HOC_DBUS",
+    C.PI_TYPE: "Misc",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0050"],
+    C.PI_MAIN: "AdHocDBus",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Add D-Bus management to Ad-Hoc commands"""),
+}
+
+
+class AdHocDBus(object):
+
+    def __init__(self, host):
+        log.info(_("plugin Ad-Hoc D-Bus initialization"))
+        self.host = host
+        if etree is not None:
+            host.bridge.add_method(
+                "ad_hoc_dbus_add_auto",
+                ".plugin",
+                in_sign="sasasasasasass",
+                out_sign="(sa(sss))",
+                method=self._ad_hoc_dbus_add_auto,
+                async_=True,
+            )
+        host.bridge.add_method(
+            "ad_hoc_remotes_get",
+            ".plugin",
+            in_sign="s",
+            out_sign="a(sss)",
+            method=self._ad_hoc_remotes_get,
+            async_=True,
+        )
+        self._c = host.plugins["XEP-0050"]
+        host.register_namespace("mediaplayer", NS_MEDIA_PLAYER)
+        if dbus is not None:
+            self.session_bus = dbus.SessionBus()
+            self.fd_object = self.session_bus.get_object(
+                FD_NAME, FD_PATH, introspect=False)
+
+    def profile_connected(self, client):
+        if dbus is not None:
+            self._c.add_ad_hoc_command(
+                client, self.local_media_cb, D_("Media Players"),
+                node=NS_MEDIA_PLAYER,
+                timeout=60*60*6  # 6 hours timeout, to avoid breaking remote
+                                 # in the middle of a movie
+            )
+
+    def _dbus_async_call(self, proxy, method, *args, **kwargs):
+        """ Call a DBus method asynchronously and return a deferred
+
+        @param proxy: DBus object proxy, as returner by get_object
+        @param method: name of the method to call
+        @param args: will be transmitted to the method
+        @param kwargs: will be transmetted to the method, except for the following poped
+                       values:
+                       - interface: name of the interface to use
+        @return: a deferred
+
+        """
+        d = defer.Deferred()
+        interface = kwargs.pop("interface", None)
+        kwargs["reply_handler"] = lambda ret=None: d.callback(ret)
+        kwargs["error_handler"] = d.errback
+        proxy.get_dbus_method(method, dbus_interface=interface)(*args, **kwargs)
+        return d
+
+    def _dbus_get_property(self, proxy, interface, name):
+        return self._dbus_async_call(
+            proxy, "Get", interface, name, interface="org.freedesktop.DBus.Properties")
+
+
+    def _dbus_list_names(self):
+        return self._dbus_async_call(self.fd_object, "ListNames")
+
+    def _dbus_introspect(self, proxy):
+        return self._dbus_async_call(proxy, INTROSPECT_METHOD, interface=INTROSPECT_IFACE)
+
+    def _accept_method(self, method):
+        """ Return True if we accept the method for a command
+        @param method: etree.Element
+        @return: True if the method is acceptable
+
+        """
+        if method.xpath(
+            "arg[@direction='in']"
+        ):  # we don't accept method with argument for the moment
+            return False
+        return True
+
+    @defer.inlineCallbacks
+    def _introspect(self, methods, bus_name, proxy):
+        log.debug("introspecting path [%s]" % proxy.object_path)
+        introspect_xml = yield self._dbus_introspect(proxy)
+        el = etree.fromstring(introspect_xml)
+        for node in el.iterchildren("node", "interface"):
+            if node.tag == "node":
+                new_path = os.path.join(proxy.object_path, node.get("name"))
+                new_proxy = self.session_bus.get_object(
+                    bus_name, new_path, introspect=False
+                )
+                yield self._introspect(methods, bus_name, new_proxy)
+            elif node.tag == "interface":
+                name = node.get("name")
+                if any(name.startswith(ignored) for ignored in IGNORED_IFACES_START):
+                    log.debug("interface [%s] is ignored" % name)
+                    continue
+                log.debug("introspecting interface [%s]" % name)
+                for method in node.iterchildren("method"):
+                    if self._accept_method(method):
+                        method_name = method.get("name")
+                        log.debug("method accepted: [%s]" % method_name)
+                        methods.add((proxy.object_path, name, method_name))
+
+    def _ad_hoc_dbus_add_auto(self, prog_name, allowed_jids, allowed_groups, allowed_magics,
+                          forbidden_jids, forbidden_groups, flags, profile_key):
+        client = self.host.get_client(profile_key)
+        return self.ad_hoc_dbus_add_auto(
+            client, prog_name, allowed_jids, allowed_groups, allowed_magics,
+            forbidden_jids, forbidden_groups, flags)
+
+    @defer.inlineCallbacks
+    def ad_hoc_dbus_add_auto(self, client, prog_name, allowed_jids=None, allowed_groups=None,
+                         allowed_magics=None, forbidden_jids=None, forbidden_groups=None,
+                         flags=None):
+        bus_names = yield self._dbus_list_names()
+        bus_names = [bus_name for bus_name in bus_names if "." + prog_name in bus_name]
+        if not bus_names:
+            log.info("Can't find any bus for [%s]" % prog_name)
+            defer.returnValue(("", []))
+        bus_names.sort()
+        for bus_name in bus_names:
+            if bus_name.endswith(prog_name):
+                break
+        log.info("bus name found: [%s]" % bus_name)
+        proxy = self.session_bus.get_object(bus_name, "/", introspect=False)
+        methods = set()
+
+        yield self._introspect(methods, bus_name, proxy)
+
+        if methods:
+            self._add_command(
+                client,
+                prog_name,
+                bus_name,
+                methods,
+                allowed_jids=allowed_jids,
+                allowed_groups=allowed_groups,
+                allowed_magics=allowed_magics,
+                forbidden_jids=forbidden_jids,
+                forbidden_groups=forbidden_groups,
+                flags=flags,
+            )
+
+        defer.returnValue((str(bus_name), methods))
+
+    def _add_command(self, client, adhoc_name, bus_name, methods, allowed_jids=None,
+                    allowed_groups=None, allowed_magics=None, forbidden_jids=None,
+                    forbidden_groups=None, flags=None):
+        if flags is None:
+            flags = set()
+
+        def d_bus_callback(client, command_elt, session_data, action, node):
+            actions = session_data.setdefault("actions", [])
+            names_map = session_data.setdefault("names_map", {})
+            actions.append(action)
+
+            if len(actions) == 1:
+                # it's our first request, we ask the desired new status
+                status = self._c.STATUS.EXECUTING
+                form = data_form.Form("form", title=_("Command selection"))
+                options = []
+                for path, iface, command in methods:
+                    label = command.rsplit(".", 1)[-1]
+                    name = str(uuid.uuid4())
+                    names_map[name] = (path, iface, command)
+                    options.append(data_form.Option(name, label))
+
+                field = data_form.Field(
+                    "list-single", "command", options=options, required=True
+                )
+                form.addField(field)
+
+                payload = form.toElement()
+                note = None
+
+            elif len(actions) == 2:
+                # we should have the answer here
+                try:
+                    x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
+                    answer_form = data_form.Form.fromElement(x_elt)
+                    command = answer_form["command"]
+                except (KeyError, StopIteration):
+                    raise self._c.AdHocError(self._c.ERROR.BAD_PAYLOAD)
+
+                if command not in names_map:
+                    raise self._c.AdHocError(self._c.ERROR.BAD_PAYLOAD)
+
+                path, iface, command = names_map[command]
+                proxy = self.session_bus.get_object(bus_name, path)
+
+                self._dbus_async_call(proxy, command, interface=iface)
+
+                # job done, we can end the session, except if we have FLAG_LOOP
+                if FLAG_LOOP in flags:
+                    # We have a loop, so we clear everything and we execute again the
+                    # command as we had a first call (command_elt is not used, so None
+                    # is OK)
+                    del actions[:]
+                    names_map.clear()
+                    return d_bus_callback(
+                        client, None, session_data, self._c.ACTION.EXECUTE, node
+                    )
+                form = data_form.Form("form", title=_("Updated"))
+                form.addField(data_form.Field("fixed", "Command sent"))
+                status = self._c.STATUS.COMPLETED
+                payload = None
+                note = (self._c.NOTE.INFO, _("Command sent"))
+            else:
+                raise self._c.AdHocError(self._c.ERROR.INTERNAL)
+
+            return (payload, status, None, note)
+
+        self._c.add_ad_hoc_command(
+            client,
+            d_bus_callback,
+            adhoc_name,
+            allowed_jids=allowed_jids,
+            allowed_groups=allowed_groups,
+            allowed_magics=allowed_magics,
+            forbidden_jids=forbidden_jids,
+            forbidden_groups=forbidden_groups,
+        )
+
+    ## Local media ##
+
+    def _ad_hoc_remotes_get(self, profile):
+        return self.ad_hoc_remotes_get(self.host.get_client(profile))
+
+    @defer.inlineCallbacks
+    def ad_hoc_remotes_get(self, client):
+        """Retrieve available remote media controlers in our devices
+        @return (list[tuple[unicode, unicode, unicode]]): list of devices with:
+            - entity full jid
+            - device name
+            - device label
+        """
+        found_data = yield defer.ensureDeferred(self.host.find_by_features(
+            client, [self.host.ns_map['commands']], service=False, roster=False,
+            own_jid=True, local_device=True))
+
+        remotes = []
+
+        for found in found_data:
+            for device_jid_s in found:
+                device_jid = jid.JID(device_jid_s)
+                cmd_list = yield self._c.list(client, device_jid)
+                for cmd in cmd_list:
+                    if cmd.nodeIdentifier == NS_MEDIA_PLAYER:
+                        try:
+                            result_elt = yield self._c.do(client, device_jid,
+                                                          NS_MEDIA_PLAYER, timeout=5)
+                            command_elt = self._c.get_command_elt(result_elt)
+                            form = data_form.findForm(command_elt, NS_MEDIA_PLAYER)
+                            if form is None:
+                                continue
+                            mp_options = form.fields['media_player'].options
+                            session_id = command_elt.getAttribute('sessionid')
+                            if mp_options and session_id:
+                                # we just want to discover player, so we cancel the
+                                # session
+                                self._c.do(client, device_jid, NS_MEDIA_PLAYER,
+                                           action=self._c.ACTION.CANCEL,
+                                           session_id=session_id)
+
+                            for opt in mp_options:
+                                remotes.append((device_jid_s,
+                                                opt.value,
+                                                opt.label or opt.value))
+                        except Exception as e:
+                            log.warning(_(
+                                "Can't retrieve remote controllers on {device_jid}: "
+                                "{reason}".format(device_jid=device_jid, reason=e)))
+                        break
+        defer.returnValue(remotes)
+
+    def do_mpris_command(self, proxy, command):
+        iface, command = command.rsplit(".", 1)
+        if command == CMD_GO_BACK:
+            command = 'Seek'
+            args = [-SEEK_OFFSET]
+        elif command == CMD_GO_FWD:
+            command = 'Seek'
+            args = [SEEK_OFFSET]
+        else:
+            args = []
+        return self._dbus_async_call(proxy, command, *args, interface=iface)
+
+    def add_mpris_metadata(self, form, metadata):
+        """Serialise MRPIS Metadata according to MPRIS_METADATA_MAP"""
+        for mpris_key, name in MPRIS_METADATA_MAP.items():
+            if mpris_key in metadata:
+                value = str(metadata[mpris_key])
+                form.addField(data_form.Field(fieldType="fixed",
+                                              var=name,
+                                              value=value))
+
+    @defer.inlineCallbacks
+    def local_media_cb(self, client, command_elt, session_data, action, node):
+        try:
+            x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
+            command_form = data_form.Form.fromElement(x_elt)
+        except StopIteration:
+            command_form = None
+
+        if command_form is None or len(command_form.fields) == 0:
+            # root request, we looks for media players
+            bus_names = yield self._dbus_list_names()
+            bus_names = [b for b in bus_names if b.startswith(MPRIS_PREFIX)]
+            if len(bus_names) == 0:
+                note = (self._c.NOTE.INFO, D_("No media player found."))
+                defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
+            options = []
+            status = self._c.STATUS.EXECUTING
+            form = data_form.Form("form", title=D_("Media Player Selection"),
+                                  formNamespace=NS_MEDIA_PLAYER)
+            for bus in bus_names:
+                player_name = bus[len(MPRIS_PREFIX)+1:]
+                if not player_name:
+                    log.warning(_("Ignoring MPRIS bus without suffix"))
+                    continue
+                options.append(data_form.Option(bus, player_name))
+            field = data_form.Field(
+                "list-single", "media_player", options=options, required=True
+            )
+            form.addField(field)
+            payload = form.toElement()
+            defer.returnValue((payload, status, None, None))
+        else:
+            # player request
+            try:
+                bus_name = command_form["media_player"]
+            except KeyError:
+                raise ValueError(_("missing media_player value"))
+
+            if not bus_name.startswith(MPRIS_PREFIX):
+                log.warning(_("Media player ad-hoc command trying to use non MPRIS bus. "
+                              "Hack attempt? Refused bus: {bus_name}").format(
+                              bus_name=bus_name))
+                note = (self._c.NOTE.ERROR, D_("Invalid player name."))
+                defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
+
+            try:
+                proxy = self.session_bus.get_object(bus_name, MPRIS_PATH)
+            except dbus.exceptions.DBusException as e:
+                log.warning(_("Can't get D-Bus proxy: {reason}").format(reason=e))
+                note = (self._c.NOTE.ERROR, D_("Media player is not available anymore"))
+                defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
+            try:
+                command = command_form["command"]
+            except KeyError:
+                pass
+            else:
+                yield self.do_mpris_command(proxy, command)
+
+            # we construct the remote control form
+            form = data_form.Form("form", title=D_("Media Player Selection"))
+            form.addField(data_form.Field(fieldType="hidden",
+                                          var="media_player",
+                                          value=bus_name))
+            for iface, properties_names in MPRIS_PROPERTIES.items():
+                for name in properties_names:
+                    try:
+                        value = yield self._dbus_get_property(proxy, iface, name)
+                    except Exception as e:
+                        log.warning(_("Can't retrieve attribute {name}: {reason}")
+                                    .format(name=name, reason=e))
+                        continue
+                    if name == MPRIS_METADATA_KEY:
+                        self.add_mpris_metadata(form, value)
+                    else:
+                        form.addField(data_form.Field(fieldType="fixed",
+                                                      var=name,
+                                                      value=str(value)))
+
+            commands = [data_form.Option(c, c.rsplit(".", 1)[1]) for c in MPRIS_COMMANDS]
+            form.addField(data_form.Field(fieldType="list-single",
+                                          var="command",
+                                          options=commands,
+                                          required=True))
+
+            payload = form.toElement()
+            status = self._c.STATUS.EXECUTING
+            defer.returnValue((payload, status, None, None))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_app_manager_docker/__init__.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+
+# SàT plugin to manage Docker
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from pathlib import Path
+from twisted.python.procutils import which
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import async_process
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Docker Applications Manager",
+    C.PI_IMPORT_NAME: "APP_MANAGER_DOCKER",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_DEPENDENCIES: ["APP_MANAGER"],
+    C.PI_MAIN: "AppManagerDocker",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _(
+        """Applications Manager for Docker"""),
+}
+
+
+class AppManagerDocker:
+    name = "docker-compose"
+    discover_path = Path(__file__).parent
+
+    def __init__(self, host):
+        log.info(_("Docker App Manager initialization"))
+        try:
+            self.docker_compose_path = which('docker-compose')[0]
+        except IndexError:
+            raise exceptions.NotFound(
+                '"docker-compose" executable not found, Docker can\'t be used with '
+                'application manager')
+        self.host = host
+        self._am = host.plugins['APP_MANAGER']
+        self._am.register(self)
+
+    async def start(self, app_data: dict) -> None:
+        await self._am.start_common(app_data)
+        working_dir = app_data['_instance_dir_path']
+        try:
+            override = app_data['override']
+        except KeyError:
+            pass
+        else:
+            log.debug("writting override file")
+            override_path = working_dir / "docker-compose.override.yml"
+            with override_path.open("w") as f:
+                self._am.dump(override, f)
+        await async_process.run(
+            self.docker_compose_path,
+            "up",
+            "--detach",
+            path=str(working_dir),
+        )
+
+    async def stop(self, app_data: dict) -> None:
+        working_dir = app_data['_instance_dir_path']
+        await async_process.run(
+            self.docker_compose_path,
+            "down",
+            path=str(working_dir),
+        )
+
+    async def compute_expose(self, app_data: dict) -> dict:
+        working_dir = app_data['_instance_dir_path']
+        expose = app_data['expose']
+        ports = expose.get('ports', {})
+        for name, port_data in list(ports.items()):
+            try:
+                service = port_data['service']
+                private = port_data['private']
+                int(private)
+            except (KeyError, ValueError):
+                log.warning(
+                    f"invalid value found for {name!r} port in {app_data['_file_path']}")
+                continue
+            exposed_port = await async_process.run(
+                self.docker_compose_path,
+                "port",
+                service,
+                str(private),
+                path=str(working_dir),
+            )
+            exposed_port = exposed_port.decode().strip()
+            try:
+                addr, port = exposed_port.split(':')
+                int(port)
+            except ValueError:
+                log.warning(
+                    f"invalid exposed port for {name}, ignoring: {exposed_port!r}")
+                del ports[name]
+            else:
+                ports[name] = exposed_port
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_app_manager_docker/sat_app_weblate.yaml	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,37 @@
+type: docker-compose
+prepare:
+  git: https://github.com/WeblateOrg/docker-compose.git
+files:
+  settings-override.py:
+    content: |
+      USE_X_FORWARDED_HOST = True
+override:
+  version: "3"
+  services:
+    weblate:
+      ports:
+        - "8080"
+      environment:
+        WEBLATE_DEBUG: 0
+        WEBLATE_URL_PREFIX: !sat_param [url_prefix, /weblate]
+        WEBLATE_EMAIL_HOST: !sat_conf ["", "email_server"]
+        WEBLATE_EMAIL_HOST_USER: !sat_conf ["", "email_username"]
+        WEBLATE_EMAIL_HOST_PASSWORD: !sat_conf ["", "email_password"]
+        WEBLATE_SERVER_EMAIL: !sat_conf ["", "email_from", "weblate@example.com"]
+        WEBLATE_DEFAULT_FROM_EMAIL: !sat_conf ["", "email_from", "weblate@example.com"]
+        WEBLATE_SITE_DOMAIN: !sat_conf ["", "public_url"]
+        WEBLATE_ADMIN_PASSWORD: !sat_generate_pwd
+        WEBLATE_ADMIN_EMAIL: !sat_conf ["", "email_admins_list", "", "first"]
+        WEBLATE_ENABLE_HTTPS: !sat_conf ["", "weblate_enable_https", "1"]
+      volumes:
+        - ./settings-override.py:/app/data/settings-override.py:ro
+expose:
+  url_prefix: [override, services, weblate, environment, WEBLATE_URL_PREFIX]
+  front_url: !sat_param [front_url, /translate]
+  web_label: Translate
+  ports:
+    web:
+      service: weblate
+      private: 8080
+  passwords:
+    admin: [override, services, weblate, environment, WEBLATE_ADMIN_PASSWORD]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_blog_import.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,323 @@
+#!/usr/bin/env python3
+
+
+# SàT plugin for import external blogs
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.internet import defer
+from twisted.web import client as web_client
+from twisted.words.xish import domish
+from libervia.backend.core import exceptions
+from libervia.backend.tools import xml_tools
+import os
+import os.path
+import tempfile
+import urllib.parse
+import shortuuid
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "blog import",
+    C.PI_IMPORT_NAME: "BLOG_IMPORT",
+    C.PI_TYPE: (C.PLUG_TYPE_BLOG, C.PLUG_TYPE_IMPORT),
+    C.PI_DEPENDENCIES: ["IMPORT", "XEP-0060", "XEP-0277", "TEXT_SYNTAXES", "UPLOAD"],
+    C.PI_MAIN: "BlogImportPlugin",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _(
+        """Blog import management:
+This plugin manage the different blog importers which can register to it, and handle generic importing tasks."""
+    ),
+}
+
+OPT_HOST = "host"
+OPT_UPLOAD_IMAGES = "upload_images"
+OPT_UPLOAD_IGNORE_HOST = "upload_ignore_host"
+OPT_IGNORE_TLS = "ignore_tls_errors"
+URL_REDIRECT_PREFIX = "url_redirect_"
+
+
+class BlogImportPlugin(object):
+    BOOL_OPTIONS = (OPT_UPLOAD_IMAGES, OPT_IGNORE_TLS)
+    JSON_OPTIONS = ()
+    OPT_DEFAULTS = {OPT_UPLOAD_IMAGES: True, OPT_IGNORE_TLS: False}
+
+    def __init__(self, host):
+        log.info(_("plugin Blog import initialization"))
+        self.host = host
+        self._u = host.plugins["UPLOAD"]
+        self._p = host.plugins["XEP-0060"]
+        self._m = host.plugins["XEP-0277"]
+        self._s = self.host.plugins["TEXT_SYNTAXES"]
+        host.plugins["IMPORT"].initialize(self, "blog")
+
+    def import_item(
+        self, client, item_import_data, session, options, return_data, service, node
+    ):
+        """import_item specialized for blog import
+
+        @param item_import_data(dict):
+            * mandatory keys:
+                'blog' (dict): microblog data of the blog post (cf. http://wiki.goffi.org/wiki/Bridge_API_-_Microblogging/en)
+                    the importer MUST NOT create node or call XEP-0277 plugin itself
+                    'comments*' key MUST NOT be used in this microblog_data, see bellow for comments
+                    It is recommanded to use a unique id in the "id" key which is constant per blog item,
+                    so if the import fail, a new import will overwrite the failed items and avoid duplicates.
+
+                'comments' (list[list[dict]],None): Dictionaries must have the same keys as main item (i.e. 'blog' and 'comments')
+                    a list of list is used because XEP-0277 can handler several comments nodes,
+                    but in most cases, there will we only one item it the first list (something like [[{comment1_data},{comment2_data}, ...]])
+                    blog['allow_comments'] must be True if there is any comment, and False (or not present) if comments are not allowed.
+                    If allow_comments is False and some comments are present, an exceptions.DataError will be raised
+            * optional keys:
+                'url' (unicode): former url of the post (only the path, without host part)
+                    if present the association to the new path will be displayed to user, so it can make redirections if necessary
+        @param options(dict, None): Below are the generic options,
+            blog importer can have specific ones. All options have unicode values
+            generic options:
+                - OPT_HOST (unicode): original host
+                - OPT_UPLOAD_IMAGES (bool): upload images to XMPP server if True
+                    see OPT_UPLOAD_IGNORE_HOST.
+                    Default: True
+                - OPT_UPLOAD_IGNORE_HOST (unicode): don't upload images from this host
+                - OPT_IGNORE_TLS (bool): ignore TLS error for image upload.
+                    Default: False
+        @param return_data(dict): will contain link between former posts and new items
+
+        """
+        mb_data = item_import_data["blog"]
+        try:
+            item_id = mb_data["id"]
+        except KeyError:
+            item_id = mb_data["id"] = str(shortuuid.uuid())
+
+        try:
+            # we keep the link between old url and new blog item
+            # so the user can redirect its former blog urls
+            old_uri = item_import_data["url"]
+        except KeyError:
+            pass
+        else:
+            new_uri = return_data[URL_REDIRECT_PREFIX + old_uri] = self._p.get_node_uri(
+                service if service is not None else client.jid.userhostJID(),
+                node or self._m.namespace,
+                item_id,
+            )
+            log.info("url link from {old} to {new}".format(old=old_uri, new=new_uri))
+
+        return mb_data
+
+    @defer.inlineCallbacks
+    def import_sub_items(self, client, item_import_data, mb_data, session, options):
+        # comments data
+        if len(item_import_data["comments"]) != 1:
+            raise NotImplementedError("can't manage multiple comment links")
+        allow_comments = C.bool(mb_data.get("allow_comments", C.BOOL_FALSE))
+        if allow_comments:
+            comments_service = yield self._m.get_comments_service(client)
+            comments_node = self._m.get_comments_node(mb_data["id"])
+            mb_data["comments_service"] = comments_service.full()
+            mb_data["comments_node"] = comments_node
+            recurse_kwargs = {
+                "items_import_data": item_import_data["comments"][0],
+                "service": comments_service,
+                "node": comments_node,
+            }
+            defer.returnValue(recurse_kwargs)
+        else:
+            if item_import_data["comments"][0]:
+                raise exceptions.DataError(
+                    "allow_comments set to False, but comments are there"
+                )
+            defer.returnValue(None)
+
+    def publish_item(self, client, mb_data, service, node, session):
+        log.debug(
+            "uploading item [{id}]: {title}".format(
+                id=mb_data["id"], title=mb_data.get("title", "")
+            )
+        )
+        return self._m.send(client, mb_data, service, node)
+
+    @defer.inlineCallbacks
+    def item_filters(self, client, mb_data, session, options):
+        """Apply filters according to options
+
+        modify mb_data in place
+        @param posts_data(list[dict]): data as returned by importer callback
+        @param options(dict): dict as given in [blogImport]
+        """
+        # FIXME: blog filters don't work on text content
+        # TODO: text => XHTML conversion should handler links with <a/>
+        #       filters can then be used by converting text to XHTML
+        if not options:
+            return
+
+        # we want only XHTML content
+        for prefix in (
+            "content",
+        ):  # a tuple is use, if title need to be added in the future
+            try:
+                rich = mb_data["{}_rich".format(prefix)]
+            except KeyError:
+                pass
+            else:
+                if "{}_xhtml".format(prefix) in mb_data:
+                    raise exceptions.DataError(
+                        "importer gave {prefix}_rich and {prefix}_xhtml at the same time, this is not allowed".format(
+                            prefix=prefix
+                        )
+                    )
+                # we convert rich syntax to XHTML here, so we can handle filters easily
+                converted = yield self._s.convert(
+                    rich, self._s.get_current_syntax(client.profile), safe=False
+                )
+                mb_data["{}_xhtml".format(prefix)] = converted
+                del mb_data["{}_rich".format(prefix)]
+
+            try:
+                mb_data["txt"]
+            except KeyError:
+                pass
+            else:
+                if "{}_xhtml".format(prefix) in mb_data:
+                    log.warning(
+                        "{prefix}_text will be replaced by converted {prefix}_xhtml, so filters can be handled".format(
+                            prefix=prefix
+                        )
+                    )
+                    del mb_data["{}_text".format(prefix)]
+                else:
+                    log.warning(
+                        "importer gave a text {prefix}, blog filters don't work on text {prefix}".format(
+                            prefix=prefix
+                        )
+                    )
+                    return
+
+        # at this point, we have only XHTML version of content
+        try:
+            top_elt = xml_tools.ElementParser()(
+                mb_data["content_xhtml"], namespace=C.NS_XHTML
+            )
+        except domish.ParserError:
+            # we clean the xml and try again our luck
+            cleaned = yield self._s.clean_xhtml(mb_data["content_xhtml"])
+            top_elt = xml_tools.ElementParser()(cleaned, namespace=C.NS_XHTML)
+        opt_host = options.get(OPT_HOST)
+        if opt_host:
+            # we normalise the domain
+            parsed_host = urllib.parse.urlsplit(opt_host)
+            opt_host = urllib.parse.urlunsplit(
+                (
+                    parsed_host.scheme or "http",
+                    parsed_host.netloc or parsed_host.path,
+                    "",
+                    "",
+                    "",
+                )
+            )
+
+        tmp_dir = tempfile.mkdtemp()
+        try:
+            # TODO: would be nice to also update the hyperlinks to these images, e.g. when you have <a href="{url}"><img src="{url}"></a>
+            for img_elt in xml_tools.find_all(top_elt, names=["img"]):
+                yield self.img_filters(client, img_elt, options, opt_host, tmp_dir)
+        finally:
+            os.rmdir(tmp_dir)  # XXX: tmp_dir should be empty, or something went wrong
+
+        # we now replace the content with filtered one
+        mb_data["content_xhtml"] = top_elt.toXml()
+
+    @defer.inlineCallbacks
+    def img_filters(self, client, img_elt, options, opt_host, tmp_dir):
+        """Filters handling images
+
+        url without host are fixed (if possible)
+        according to options, images are uploaded to XMPP server
+        @param img_elt(domish.Element): <img/> element to handle
+        @param options(dict): filters options
+        @param opt_host(unicode): normalised host given in options
+        @param tmp_dir(str): path to temp directory
+        """
+        try:
+            url = img_elt["src"]
+            if url[0] == "/":
+                if not opt_host:
+                    log.warning(
+                        "host was not specified, we can't deal with src without host ({url}) and have to ignore the following <img/>:\n{xml}".format(
+                            url=url, xml=img_elt.toXml()
+                        )
+                    )
+                    return
+                else:
+                    url = urllib.parse.urljoin(opt_host, url)
+            filename = url.rsplit("/", 1)[-1].strip()
+            if not filename:
+                raise KeyError
+        except (KeyError, IndexError):
+            log.warning("ignoring invalid img element: {}".format(img_elt.toXml()))
+            return
+
+        # we change the url for the normalized one
+        img_elt["src"] = url
+
+        if options.get(OPT_UPLOAD_IMAGES, False):
+            # upload is requested
+            try:
+                ignore_host = options[OPT_UPLOAD_IGNORE_HOST]
+            except KeyError:
+                pass
+            else:
+                # host is the ignored one, we skip
+                parsed_url = urllib.parse.urlsplit(url)
+                if ignore_host in parsed_url.hostname:
+                    log.info(
+                        "Don't upload image at {url} because of {opt} option".format(
+                            url=url, opt=OPT_UPLOAD_IGNORE_HOST
+                        )
+                    )
+                    return
+
+            # we download images and re-upload them via XMPP
+            tmp_file = os.path.join(tmp_dir, filename).encode("utf-8")
+            upload_options = {"ignore_tls_errors": options.get(OPT_IGNORE_TLS, False)}
+
+            try:
+                yield web_client.downloadPage(url.encode("utf-8"), tmp_file)
+                filename = filename.replace(
+                    "%", "_"
+                )  # FIXME: tmp workaround for a bug in prosody http upload
+                __, download_d = yield self._u.upload(
+                    client, tmp_file, filename, extra=upload_options
+                )
+                download_url = yield download_d
+            except Exception as e:
+                log.warning(
+                    "can't download image at {url}: {reason}".format(url=url, reason=e)
+                )
+            else:
+                img_elt["src"] = download_url
+
+            try:
+                os.unlink(tmp_file)
+            except OSError:
+                pass
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_blog_import_dokuwiki.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,414 @@
+#!/usr/bin/env python3
+
+
+# SàT plugin to import dokuwiki blogs
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+from libervia.backend.tools import xml_tools
+from twisted.internet import threads
+from collections import OrderedDict
+import calendar
+import urllib.request, urllib.parse, urllib.error
+import urllib.parse
+import tempfile
+import re
+import time
+import os.path
+
+try:
+    from dokuwiki import DokuWiki, DokuWikiError  # this is a new dependency
+except ImportError:
+    raise exceptions.MissingModule(
+        'Missing module dokuwiki, please install it with "pip install dokuwiki"'
+    )
+try:
+    from PIL import Image  # this is already needed by plugin XEP-0054
+except:
+    raise exceptions.MissingModule(
+        "Missing module pillow, please download/install it from https://python-pillow.github.io"
+    )
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Dokuwiki import",
+    C.PI_IMPORT_NAME: "IMPORT_DOKUWIKI",
+    C.PI_TYPE: C.PLUG_TYPE_BLOG,
+    C.PI_DEPENDENCIES: ["BLOG_IMPORT"],
+    C.PI_MAIN: "DokuwikiImport",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Blog importer for Dokuwiki blog engine."""),
+}
+
+SHORT_DESC = D_("import posts from Dokuwiki blog engine")
+
+LONG_DESC = D_(
+    """This importer handle Dokuwiki blog engine.
+
+To use it, you need an admin access to a running Dokuwiki website
+(local or on the Internet). The importer retrieves the data using
+the XMLRPC Dokuwiki API.
+
+You can specify a namespace (that could be a namespace directory
+or a single post) or leave it empty to use the root namespace "/"
+and import all the posts.
+
+You can specify a new media repository to modify the internal
+media links and make them point to the URL of your choice, but
+note that the upload is not done automatically: a temporary
+directory will be created on your local drive and you will
+need to upload it yourself to your repository via SSH or FTP.
+
+Following options are recognized:
+
+location: DokuWiki site URL
+user: DokuWiki admin user
+passwd: DokuWiki admin password
+namespace: DokuWiki namespace to import (default: root namespace "/")
+media_repo: URL to the new remote media repository (default: none)
+limit: maximal number of posts to import (default: 100)
+
+Example of usage (with jp frontend):
+
+jp import dokuwiki -p dave --pwd xxxxxx --connect
+    http://127.0.1.1 -o user souliane -o passwd qwertz
+    -o namespace public:2015:10
+    -o media_repo http://media.diekulturvermittlung.at
+
+This retrieves the 100 last blog posts from http://127.0.1.1 that
+are inside the namespace "public:2015:10" using the Dokuwiki user
+"souliane", and it imports them to sat profile dave's microblog node.
+Internal Dokuwiki media that were hosted on http://127.0.1.1 are now
+pointing to http://media.diekulturvermittlung.at.
+"""
+)
+DEFAULT_MEDIA_REPO = ""
+DEFAULT_NAMESPACE = "/"
+DEFAULT_LIMIT = 100  # you might get a DBUS timeout (no reply) if it lasts too long
+
+
+class Importer(DokuWiki):
+    def __init__(
+        self, url, user, passwd, media_repo=DEFAULT_MEDIA_REPO, limit=DEFAULT_LIMIT
+    ):
+        """
+
+        @param url (unicode): DokuWiki site URL
+        @param user (unicode): DokuWiki admin user
+        @param passwd (unicode): DokuWiki admin password
+        @param media_repo (unicode): New remote media repository
+        """
+        DokuWiki.__init__(self, url, user, passwd)
+        self.url = url
+        self.media_repo = media_repo
+        self.temp_dir = tempfile.mkdtemp() if self.media_repo else None
+        self.limit = limit
+        self.posts_data = OrderedDict()
+
+    def get_post_id(self, post):
+        """Return a unique and constant post id
+
+        @param post(dict): parsed post data
+        @return (unicode): post unique item id
+        """
+        return str(post["id"])
+
+    def get_post_updated(self, post):
+        """Return the update date.
+
+        @param post(dict): parsed post data
+        @return (unicode): update date
+        """
+        return str(post["mtime"])
+
+    def get_post_published(self, post):
+        """Try to parse the date from the message ID, else use "mtime".
+
+        The date can be extracted if the message ID looks like one of:
+            - namespace:YYMMDD_short_title
+            - namespace:YYYYMMDD_short_title
+        @param post (dict):  parsed post data
+        @return (unicode): publication date
+        """
+        id_, default = str(post["id"]), str(post["mtime"])
+        try:
+            date = id_.split(":")[-1].split("_")[0]
+        except KeyError:
+            return default
+        try:
+            time_struct = time.strptime(date, "%y%m%d")
+        except ValueError:
+            try:
+                time_struct = time.strptime(date, "%Y%m%d")
+            except ValueError:
+                return default
+        return str(calendar.timegm(time_struct))
+
+    def process_post(self, post, profile_jid):
+        """Process a single page.
+
+        @param post (dict): parsed post data
+        @param profile_jid
+        """
+        # get main information
+        id_ = self.get_post_id(post)
+        updated = self.get_post_updated(post)
+        published = self.get_post_published(post)
+
+        # manage links
+        backlinks = self.pages.backlinks(id_)
+        for link in self.pages.links(id_):
+            if link["type"] != "extern":
+                assert link["type"] == "local"
+                page = link["page"]
+                backlinks.append(page[1:] if page.startswith(":") else page)
+
+        self.pages.get(id_)
+        content_xhtml = self.process_content(self.pages.html(id_), backlinks, profile_jid)
+
+        # XXX: title is already in content_xhtml and difficult to remove, so leave it
+        # title = content.split("\n")[0].strip(u"\ufeff= ")
+
+        # build the extra data dictionary
+        mb_data = {
+            "id": id_,
+            "published": published,
+            "updated": updated,
+            "author": profile_jid.user,
+            # "content": content,  # when passed, it is displayed in Libervia instead of content_xhtml
+            "content_xhtml": content_xhtml,
+            # "title": title,
+            "allow_comments": "true",
+        }
+
+        # find out if the message access is public or restricted
+        namespace = id_.split(":")[0]
+        if namespace and namespace.lower() not in ("public", "/"):
+            mb_data["group"] = namespace  # roster group must exist
+
+        self.posts_data[id_] = {"blog": mb_data, "comments": [[]]}
+
+    def process(self, client, namespace=DEFAULT_NAMESPACE):
+        """Process a namespace or a single page.
+
+        @param namespace (unicode): DokuWiki namespace (or page) to import
+        """
+        profile_jid = client.jid
+        log.info("Importing data from DokuWiki %s" % self.version)
+        try:
+            pages_list = self.pages.list(namespace)
+        except DokuWikiError:
+            log.warning(
+                'Could not list Dokuwiki pages: please turn the "display_errors" setting to "Off" in the php.ini of the webserver hosting DokuWiki.'
+            )
+            return
+
+        if not pages_list:  # namespace is actually a page?
+            names = namespace.split(":")
+            real_namespace = ":".join(names[0:-1])
+            pages_list = self.pages.list(real_namespace)
+            pages_list = [page for page in pages_list if page["id"] == namespace]
+            namespace = real_namespace
+
+        count = 0
+        for page in pages_list:
+            self.process_post(page, profile_jid)
+            count += 1
+            if count >= self.limit:
+                break
+
+        return (iter(self.posts_data.values()), len(self.posts_data))
+
+    def process_content(self, text, backlinks, profile_jid):
+        """Do text substitutions and file copy.
+
+        @param text (unicode): message content
+        @param backlinks (list[unicode]): list of backlinks
+        """
+        text = text.strip("\ufeff")  # this is at the beginning of the file (BOM)
+
+        for backlink in backlinks:
+            src = '/doku.php?id=%s"' % backlink
+            tgt = '/blog/%s/%s" target="#"' % (profile_jid.user, backlink)
+            text = text.replace(src, tgt)
+
+        subs = {}
+
+        link_pattern = r"""<(img|a)[^>]* (src|href)="([^"]+)"[^>]*>"""
+        for tag in re.finditer(link_pattern, text):
+            type_, attr, link = tag.group(1), tag.group(2), tag.group(3)
+            assert (type_ == "img" and attr == "src") or (type_ == "a" and attr == "href")
+            if re.match(r"^\w*://", link):  # absolute URL to link directly
+                continue
+            if self.media_repo:
+                self.move_media(link, subs)
+            elif link not in subs:
+                subs[link] = urllib.parse.urljoin(self.url, link)
+
+        for url, new_url in subs.items():
+            text = text.replace(url, new_url)
+        return text
+
+    def move_media(self, link, subs):
+        """Move a media from the DokuWiki host to the new repository.
+
+        This also updates the hyperlinks to internal media files.
+        @param link (unicode): media link
+        @param subs (dict): substitutions data
+        """
+        url = urllib.parse.urljoin(self.url, link)
+        user_media = re.match(r"(/lib/exe/\w+.php\?)(.*)", link)
+        thumb_width = None
+
+        if user_media:  # media that has been added by the user
+            params = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
+            try:
+                media = params["media"][0]
+            except KeyError:
+                log.warning("No media found in fetch URL: %s" % user_media.group(2))
+                return
+            if re.match(r"^\w*://", media):  # external URL to link directly
+                subs[link] = media
+                return
+            try:  # create thumbnail
+                thumb_width = params["w"][0]
+            except KeyError:
+                pass
+
+            filename = media.replace(":", "/")
+            # XXX: avoid "precondition failed" error (only keep the media parameter)
+            url = urllib.parse.urljoin(self.url, "/lib/exe/fetch.php?media=%s" % media)
+
+        elif link.startswith("/lib/plugins/"):
+            # other link added by a plugin or something else
+            filename = link[13:]
+        else:  # fake alert... there's no media (or we don't handle it yet)
+            return
+
+        filepath = os.path.join(self.temp_dir, filename)
+        self.download_media(url, filepath)
+
+        if thumb_width:
+            filename = os.path.join("thumbs", thumb_width, filename)
+            thumbnail = os.path.join(self.temp_dir, filename)
+            self.create_thumbnail(filepath, thumbnail, thumb_width)
+
+        new_url = os.path.join(self.media_repo, filename)
+        subs[link] = new_url
+
+    def download_media(self, source, dest):
+        """Copy media to localhost.
+
+        @param source (unicode): source url
+        @param dest (unicode): target path
+        """
+        dirname = os.path.dirname(dest)
+        if not os.path.exists(dest):
+            if not os.path.exists(dirname):
+                os.makedirs(dirname)
+            urllib.request.urlretrieve(source, dest)
+            log.debug("DokuWiki media file copied to %s" % dest)
+
+    def create_thumbnail(self, source, dest, width):
+        """Create a thumbnail.
+
+        @param source (unicode): source file path
+        @param dest (unicode): destination file path
+        @param width (unicode): thumbnail's width
+        """
+        thumb_dir = os.path.dirname(dest)
+        if not os.path.exists(thumb_dir):
+            os.makedirs(thumb_dir)
+        try:
+            im = Image.open(source)
+            im.thumbnail((width, int(width) * im.size[0] / im.size[1]))
+            im.save(dest)
+            log.debug("DokuWiki media thumbnail created: %s" % dest)
+        except IOError:
+            log.error("Cannot create DokuWiki media thumbnail %s" % dest)
+
+
+class DokuwikiImport(object):
+    def __init__(self, host):
+        log.info(_("plugin Dokuwiki import initialization"))
+        self.host = host
+        self._blog_import = host.plugins["BLOG_IMPORT"]
+        self._blog_import.register("dokuwiki", self.dk_import, SHORT_DESC, LONG_DESC)
+
+    def dk_import(self, client, location, options=None):
+        """import from DokuWiki to PubSub
+
+        @param location (unicode): DokuWiki site URL
+        @param options (dict, None): DokuWiki import parameters
+            - user (unicode): DokuWiki admin user
+            - passwd (unicode): DokuWiki admin password
+            - namespace (unicode): DokuWiki namespace to import
+            - media_repo (unicode): New remote media repository
+        """
+        options[self._blog_import.OPT_HOST] = location
+        try:
+            user = options["user"]
+        except KeyError:
+            raise exceptions.DataError('parameter "user" is required')
+        try:
+            passwd = options["passwd"]
+        except KeyError:
+            raise exceptions.DataError('parameter "passwd" is required')
+
+        opt_upload_images = options.get(self._blog_import.OPT_UPLOAD_IMAGES, None)
+        try:
+            media_repo = options["media_repo"]
+            if opt_upload_images:
+                options[
+                    self._blog_import.OPT_UPLOAD_IMAGES
+                ] = False  # force using --no-images-upload
+            info_msg = _(
+                "DokuWiki media files will be *downloaded* to {temp_dir} - to finish the import you have to upload them *manually* to {media_repo}"
+            )
+        except KeyError:
+            media_repo = DEFAULT_MEDIA_REPO
+            if opt_upload_images:
+                info_msg = _(
+                    "DokuWiki media files will be *uploaded* to the XMPP server. Hyperlinks to these media may not been updated though."
+                )
+            else:
+                info_msg = _(
+                    "DokuWiki media files will *stay* on {location} - some of them may be protected by DokuWiki ACL and will not be accessible."
+                )
+
+        try:
+            namespace = options["namespace"]
+        except KeyError:
+            namespace = DEFAULT_NAMESPACE
+        try:
+            limit = options["limit"]
+        except KeyError:
+            limit = DEFAULT_LIMIT
+
+        dk_importer = Importer(location, user, passwd, media_repo, limit)
+        info_msg = info_msg.format(
+            temp_dir=dk_importer.temp_dir, media_repo=media_repo, location=location
+        )
+        self.host.action_new(
+            {"xmlui": xml_tools.note(info_msg).toXml()}, profile=client.profile
+        )
+        d = threads.deferToThread(dk_importer.process, client, namespace)
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_blog_import_dotclear.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,279 @@
+#!/usr/bin/env python3
+
+
+# SàT plugin for import external blogs
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+from libervia.backend.tools.common import data_format
+from twisted.internet import threads
+from collections import OrderedDict
+import itertools
+import time
+import cgi
+import os.path
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Dotclear import",
+    C.PI_IMPORT_NAME: "IMPORT_DOTCLEAR",
+    C.PI_TYPE: C.PLUG_TYPE_BLOG,
+    C.PI_DEPENDENCIES: ["BLOG_IMPORT"],
+    C.PI_MAIN: "DotclearImport",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Blog importer for Dotclear blog engine."""),
+}
+
+SHORT_DESC = D_("import posts from Dotclear blog engine")
+
+LONG_DESC = D_(
+    """This importer handle Dotclear blog engine.
+
+To use it, you'll need to export your blog to a flat file.
+You must go in your admin interface and select Plugins/Maintenance then Backup.
+Export only one blog if you have many, i.e. select "Download database of current blog"
+Depending on your configuration, your may need to use import/Export plugin and export as a flat file.
+
+location: you must use the absolute path to your backup for the location parameter
+"""
+)
+POST_ID_PREFIX = "sat_dc_"
+KNOWN_DATA_TYPES = (
+    "link",
+    "setting",
+    "post",
+    "meta",
+    "media",
+    "post_media",
+    "comment",
+    "captcha",
+)
+ESCAPE_MAP = {"r": "\r", "n": "\n", '"': '"', "\\": "\\"}
+
+
+class DotclearParser(object):
+    # XXX: we have to parse all file to build data
+    #      this can be ressource intensive on huge blogs
+
+    def __init__(self):
+        self.posts_data = OrderedDict()
+        self.tags = {}
+
+    def get_post_id(self, post):
+        """Return a unique and constant post id
+
+        @param post(dict): parsed post data
+        @return (unicode): post unique item id
+        """
+        return "{}_{}_{}_{}:{}".format(
+            POST_ID_PREFIX,
+            post["blog_id"],
+            post["user_id"],
+            post["post_id"],
+            post["post_url"],
+        )
+
+    def get_comment_id(self, comment):
+        """Return a unique and constant comment id
+
+        @param comment(dict): parsed comment
+        @return (unicode): comment unique comment id
+        """
+        post_id = comment["post_id"]
+        parent_item_id = self.posts_data[post_id]["blog"]["id"]
+        return "{}_comment_{}".format(parent_item_id, comment["comment_id"])
+
+    def getTime(self, data, key):
+        """Parse time as given by dotclear, with timezone handling
+
+        @param data(dict): dotclear data (post or comment)
+        @param key(unicode): key to get (e.g. "post_creadt")
+        @return (float): Unix time
+        """
+        return time.mktime(time.strptime(data[key], "%Y-%m-%d %H:%M:%S"))
+
+    def read_fields(self, fields_data):
+        buf = []
+        idx = 0
+        while True:
+            if fields_data[idx] != '"':
+                raise exceptions.ParsingError
+            while True:
+                idx += 1
+                try:
+                    char = fields_data[idx]
+                except IndexError:
+                    raise exceptions.ParsingError("Data was expected")
+                if char == '"':
+                    # we have reached the end of this field,
+                    # we try to parse a new one
+                    yield "".join(buf)
+                    buf = []
+                    idx += 1
+                    try:
+                        separator = fields_data[idx]
+                    except IndexError:
+                        return
+                    if separator != ",":
+                        raise exceptions.ParsingError("Field separator was expeceted")
+                    idx += 1
+                    break  # we have a new field
+                elif char == "\\":
+                    idx += 1
+                    try:
+                        char = ESCAPE_MAP[fields_data[idx]]
+                    except IndexError:
+                        raise exceptions.ParsingError("Escaped char was expected")
+                    except KeyError:
+                        char = fields_data[idx]
+                        log.warning("Unknown key to escape: {}".format(char))
+                buf.append(char)
+
+    def parseFields(self, headers, data):
+        return dict(zip(headers, self.read_fields(data)))
+
+    def post_handler(self, headers, data, index):
+        post = self.parseFields(headers, data)
+        log.debug("({}) post found: {}".format(index, post["post_title"]))
+        mb_data = {
+            "id": self.get_post_id(post),
+            "published": self.getTime(post, "post_creadt"),
+            "updated": self.getTime(post, "post_upddt"),
+            "author": post["user_id"],  # there use info are not in the archive
+            # TODO: option to specify user info
+            "content_xhtml": "{}{}".format(
+                post["post_content_xhtml"], post["post_excerpt_xhtml"]
+            ),
+            "title": post["post_title"],
+            "allow_comments": C.bool_const(bool(int(post["post_open_comment"]))),
+        }
+        self.posts_data[post["post_id"]] = {
+            "blog": mb_data,
+            "comments": [[]],
+            "url": "/post/{}".format(post["post_url"]),
+        }
+
+    def meta_handler(self, headers, data, index):
+        meta = self.parseFields(headers, data)
+        if meta["meta_type"] == "tag":
+            tags = self.tags.setdefault(meta["post_id"], set())
+            tags.add(meta["meta_id"])
+
+    def meta_finished_handler(self):
+        for post_id, tags in self.tags.items():
+            data_format.iter2dict("tag", tags, self.posts_data[post_id]["blog"])
+        del self.tags
+
+    def comment_handler(self, headers, data, index):
+        comment = self.parseFields(headers, data)
+        if comment["comment_site"]:
+            # we don't use atom:uri because it's used for jid in XMPP
+            content = '{}\n<hr>\n<a href="{}">author website</a>'.format(
+                comment["comment_content"],
+                cgi.escape(comment["comment_site"]).replace('"', "%22"),
+            )
+        else:
+            content = comment["comment_content"]
+        mb_data = {
+            "id": self.get_comment_id(comment),
+            "published": self.getTime(comment, "comment_dt"),
+            "updated": self.getTime(comment, "comment_upddt"),
+            "author": comment["comment_author"],
+            # we don't keep email addresses to avoid the author to be spammed
+            # (they would be available publicly else)
+            # 'author_email': comment['comment_email'],
+            "content_xhtml": content,
+        }
+        self.posts_data[comment["post_id"]]["comments"][0].append(
+            {"blog": mb_data, "comments": [[]]}
+        )
+
+    def parse(self, db_path):
+        with open(db_path) as f:
+            signature = f.readline()
+            try:
+                version = signature.split("|")[1]
+            except IndexError:
+                version = None
+            log.debug("Dotclear version: {}".format(version))
+            data_type = None
+            data_headers = None
+            index = None
+            while True:
+                buf = f.readline()
+                if not buf:
+                    break
+                if buf.startswith("["):
+                    header = buf.split(" ", 1)
+                    data_type = header[0][1:]
+                    if data_type not in KNOWN_DATA_TYPES:
+                        log.warning("unkown data type: {}".format(data_type))
+                    index = 0
+                    try:
+                        data_headers = header[1].split(",")
+                        # we need to remove the ']' from the last header
+                        last_header = data_headers[-1]
+                        data_headers[-1] = last_header[: last_header.rfind("]")]
+                    except IndexError:
+                        log.warning("Can't read data)")
+                else:
+                    if data_type is None:
+                        continue
+                    buf = buf.strip()
+                    if not buf and data_type in KNOWN_DATA_TYPES:
+                        try:
+                            finished_handler = getattr(
+                                self, "{}FinishedHandler".format(data_type)
+                            )
+                        except AttributeError:
+                            pass
+                        else:
+                            finished_handler()
+                        log.debug("{} data finished".format(data_type))
+                        data_type = None
+                        continue
+                    assert data_type
+                    try:
+                        fields_handler = getattr(self, "{}Handler".format(data_type))
+                    except AttributeError:
+                        pass
+                    else:
+                        fields_handler(data_headers, buf, index)
+                    index += 1
+        return (iter(self.posts_data.values()), len(self.posts_data))
+
+
+class DotclearImport(object):
+    def __init__(self, host):
+        log.info(_("plugin Dotclear import initialization"))
+        self.host = host
+        host.plugins["BLOG_IMPORT"].register(
+            "dotclear", self.dc_import, SHORT_DESC, LONG_DESC
+        )
+
+    def dc_import(self, client, location, options=None):
+        if not os.path.isabs(location):
+            raise exceptions.DataError(
+                "An absolute path to backup data need to be given as location"
+            )
+        dc_parser = DotclearParser()
+        d = threads.deferToThread(dc_parser.parse, location)
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_comp_ap_gateway/__init__.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,2781 @@
+#!/usr/bin/env python3
+
+# Libervia ActivityPub Gateway
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import base64
+import calendar
+import hashlib
+import json
+from pathlib import Path
+from pprint import pformat
+import re
+from typing import (
+    Any,
+    Awaitable,
+    Callable,
+    Dict,
+    List,
+    Optional,
+    Set,
+    Tuple,
+    Union,
+    overload,
+)
+from urllib import parse
+
+from cryptography.exceptions import InvalidSignature
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.primitives.asymmetric import padding
+import dateutil
+from dateutil.parser import parserinfo
+import shortuuid
+from sqlalchemy.exc import IntegrityError
+import treq
+from treq.response import _Response as TReqResponse
+from twisted.internet import defer, reactor, threads
+from twisted.web import http
+from twisted.words.protocols.jabber import error, jid
+from twisted.words.xish import domish
+from wokkel import pubsub, rsm
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.memory import persistent
+from libervia.backend.memory.sqla_mapping import History, SubscriptionState
+from libervia.backend.tools import utils
+from libervia.backend.tools.common import data_format, date_utils, tls, uri
+from libervia.backend.tools.common.async_utils import async_lru
+
+from .ad_hoc import APAdHocService
+from .events import APEvents
+from .constants import (
+    ACTIVITY_OBJECT_MANDATORY,
+    ACTIVITY_TARGET_MANDATORY,
+    ACTIVITY_TYPES,
+    ACTIVITY_TYPES_LOWER,
+    COMMENTS_MAX_PARENTS,
+    CONF_SECTION,
+    IMPORT_NAME,
+    LRU_MAX_SIZE,
+    MEDIA_TYPE_AP,
+    NS_AP,
+    NS_AP_PUBLIC,
+    PUBLIC_TUPLE,
+    TYPE_ACTOR,
+    TYPE_EVENT,
+    TYPE_FOLLOWERS,
+    TYPE_ITEM,
+    TYPE_LIKE,
+    TYPE_MENTION,
+    TYPE_REACTION,
+    TYPE_TOMBSTONE,
+    TYPE_JOIN,
+    TYPE_LEAVE
+)
+from .http_server import HTTPServer
+from .pubsub_service import APPubsubService
+from .regex import RE_MENTION
+
+
+log = getLogger(__name__)
+
+IMPORT_NAME = "ap-gateway"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "ActivityPub Gateway component",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_MODES: [C.PLUG_MODE_COMPONENT],
+    C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT,
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [
+        "XEP-0050", "XEP-0054", "XEP-0060", "XEP-0084", "XEP-0106", "XEP-0277",
+        "XEP-0292", "XEP-0329", "XEP-0372", "XEP-0424", "XEP-0465", "XEP-0470",
+        "XEP-0447", "XEP-0471", "PUBSUB_CACHE", "TEXT_SYNTAXES", "IDENTITY"
+    ],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "APGateway",
+    C.PI_HANDLER: C.BOOL_TRUE,
+    C.PI_DESCRIPTION: _(
+        "Gateway for bidirectional communication between XMPP and ActivityPub."
+    ),
+}
+
+HEXA_ENC = r"(?P<hex>[0-9a-fA-f]{2})"
+RE_PERIOD_ENC = re.compile(f"\\.{HEXA_ENC}")
+RE_PERCENT_ENC = re.compile(f"%{HEXA_ENC}")
+RE_ALLOWED_UNQUOTED = re.compile(r"^[a-zA-Z0-9_-]+$")
+
+
+class APGateway:
+    IMPORT_NAME = IMPORT_NAME
+    # show data send or received through HTTP, used for debugging
+    # 1: log POST objects
+    # 2: log POST and GET objects
+    # 3: log POST and GET objects with HTTP headers for GET requests
+    verbose = 0
+
+    def __init__(self, host):
+        self.host = host
+        self.initialised = False
+        self.client = None
+        self._p = host.plugins["XEP-0060"]
+        self._a = host.plugins["XEP-0084"]
+        self._e = host.plugins["XEP-0106"]
+        self._m = host.plugins["XEP-0277"]
+        self._v = host.plugins["XEP-0292"]
+        self._refs = host.plugins["XEP-0372"]
+        self._r = host.plugins["XEP-0424"]
+        self._sfs = host.plugins["XEP-0447"]
+        self._pps = host.plugins["XEP-0465"]
+        self._pa = host.plugins["XEP-0470"]
+        self._c = host.plugins["PUBSUB_CACHE"]
+        self._t = host.plugins["TEXT_SYNTAXES"]
+        self._i = host.plugins["IDENTITY"]
+        self._events = host.plugins["XEP-0471"]
+        self._p.add_managed_node(
+            "",
+            items_cb=self._items_received,
+            # we want to be sure that the callbacks are launched before pubsub cache's
+            # one, as we need to inspect items before they are actually removed from cache
+            # or updated
+            priority=1000
+        )
+        self.pubsub_service = APPubsubService(self)
+        self.ad_hoc = APAdHocService(self)
+        self.ap_events = APEvents(self)
+        host.trigger.add("message_received", self._message_received_trigger, priority=-1000)
+        host.trigger.add("XEP-0424_retractReceived", self._on_message_retract)
+        host.trigger.add("XEP-0372_ref_received", self._on_reference_received)
+
+        host.bridge.add_method(
+            "ap_send",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._publish_message,
+            async_=True,
+        )
+
+    def get_handler(self, __):
+        return self.pubsub_service
+
+    async def init(self, client):
+        if self.initialised:
+            return
+
+        self.initialised = True
+        log.info(_("ActivityPub Gateway initialization"))
+
+        # RSA keys
+        stored_data = await self.host.memory.storage.get_privates(
+            IMPORT_NAME, ["rsa_key"], profile=client.profile
+        )
+        private_key_pem = stored_data.get("rsa_key")
+        if private_key_pem is None:
+            self.private_key = await threads.deferToThread(
+                rsa.generate_private_key,
+                public_exponent=65537,
+                key_size=4096,
+            )
+            private_key_pem = self.private_key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.PKCS8,
+                encryption_algorithm=serialization.NoEncryption()
+            ).decode()
+            await self.host.memory.storage.set_private_value(
+                IMPORT_NAME, "rsa_key", private_key_pem, profile=client.profile
+            )
+        else:
+            self.private_key = serialization.load_pem_private_key(
+                private_key_pem.encode(),
+                password=None,
+            )
+        self.public_key = self.private_key.public_key()
+        self.public_key_pem = self.public_key.public_bytes(
+            encoding=serialization.Encoding.PEM,
+            format=serialization.PublicFormat.SubjectPublicKeyInfo
+        ).decode()
+
+        # params
+        # URL and port
+        self.public_url = self.host.memory.config_get(
+            CONF_SECTION, "public_url"
+        ) or self.host.memory.config_get(
+            CONF_SECTION, "xmpp_domain"
+        )
+        if self.public_url is None:
+            log.error(
+                '"public_url" not set in configuration, this is mandatory to have'
+                "ActivityPub Gateway running. Please set this option it to public facing "
+                f"url in {CONF_SECTION!r} configuration section."
+            )
+            return
+        if parse.urlparse(self.public_url).scheme:
+            log.error(
+                "Scheme must not be specified in \"public_url\", please remove it from "
+                "\"public_url\" configuration option. ActivityPub Gateway won't be run."
+            )
+            return
+        self.http_port = int(self.host.memory.config_get(
+            CONF_SECTION, 'http_port', 8123))
+        connection_type = self.host.memory.config_get(
+            CONF_SECTION, 'http_connection_type', 'https')
+        if connection_type not in ('http', 'https'):
+            raise exceptions.ConfigError(
+                'bad ap-gateay http_connection_type, you must use one of "http" or '
+                '"https"'
+            )
+        self.max_items = int(self.host.memory.config_get(
+            CONF_SECTION, 'new_node_max_items', 50
+
+        ))
+        self.comments_max_depth = int(self.host.memory.config_get(
+            CONF_SECTION, 'comments_max_depth', 0
+        ))
+        self.ap_path = self.host.memory.config_get(CONF_SECTION, 'ap_path', '_ap')
+        self.base_ap_url = parse.urljoin(f"https://{self.public_url}", f"{self.ap_path}/")
+        # True (default) if we provide gateway only to entities/services from our server
+        self.local_only = C.bool(
+            self.host.memory.config_get(CONF_SECTION, 'local_only', C.BOOL_TRUE)
+        )
+        # if True (default), mention will be parsed in non-private content coming from
+        # XMPP. This is necessary as XEP-0372 are coming separately from item where the
+        # mention is done, which is hard to impossible to translate to ActivityPub (where
+        # mention specified inside the item directly). See documentation for details.
+        self.auto_mentions = C.bool(
+            self.host.memory.config_get(CONF_SECTION, "auto_mentions", C.BOOL_TRUE)
+        )
+
+        html_redirect: Dict[str, Union[str, dict]] = self.host.memory.config_get(
+            CONF_SECTION, 'html_redirect_dict', {}
+        )
+        self.html_redirect: Dict[str, List[dict]] = {}
+        for url_type, target in html_redirect.items():
+            if isinstance(target, str):
+                target = {"url": target}
+            elif not isinstance(target, dict):
+                raise exceptions.ConfigError(
+                    f"html_redirect target must be a URL or a dict, not {target!r}"
+                )
+            filters = target.setdefault("filters", {})
+            if "url" not in target:
+                log.warning(f"invalid HTML redirection, missing target URL: {target}")
+                continue
+            # a slash in the url_type is a syntactic shortcut to have a node filter
+            if "/" in url_type:
+                url_type, node_filter = url_type.split("/", 1)
+                filters["node"] = node_filter
+            self.html_redirect.setdefault(url_type, []).append(target)
+
+        # HTTP server launch
+        self.server = HTTPServer(self)
+        if connection_type == 'http':
+            reactor.listenTCP(self.http_port, self.server)
+        else:
+            options = tls.get_options_from_config(
+                self.host.memory.config, CONF_SECTION)
+            tls.tls_options_check(options)
+            context_factory = tls.get_tls_context_factory(options)
+            reactor.listenSSL(self.http_port, self.server, context_factory)
+
+    async def profile_connecting(self, client):
+        self.client = client
+        client.sendHistory = True
+        client._ap_storage = persistent.LazyPersistentBinaryDict(
+            IMPORT_NAME,
+            client.profile
+        )
+        await self.init(client)
+
+    def profile_connected(self, client):
+        self.ad_hoc.init(client)
+
+    async def _items_received(
+        self,
+        client: SatXMPPEntity,
+        itemsEvent: pubsub.ItemsEvent
+    ) -> None:
+        """Callback called when pubsub items are received
+
+        if the items are adressed to a JID corresponding to an AP actor, they are
+        converted to AP items and sent to the corresponding AP server.
+
+        If comments nodes are linked, they are automatically subscribed to get new items
+        from there too.
+        """
+        if client != self.client:
+            return
+        # we need recipient as JID and not gateway own JID to be able to use methods such
+        # as "subscribe"
+        client = self.client.get_virtual_client(itemsEvent.sender)
+        recipient = itemsEvent.recipient
+        if not recipient.user:
+            log.debug("ignoring items event without local part specified")
+            return
+
+        ap_account = self._e.unescape(recipient.user)
+
+        if self._pa.is_attachment_node(itemsEvent.nodeIdentifier):
+            await self.convert_and_post_attachments(
+                client, ap_account, itemsEvent.sender, itemsEvent.nodeIdentifier,
+                itemsEvent.items
+            )
+        else:
+            await self.convert_and_post_items(
+                client, ap_account, itemsEvent.sender, itemsEvent.nodeIdentifier,
+                itemsEvent.items
+            )
+
+    async def get_virtual_client(self, actor_id: str) -> SatXMPPEntity:
+        """Get client for this component with a specified jid
+
+        This is needed to perform operations with the virtual JID corresponding to the AP
+        actor instead of the JID of the gateway itself.
+        @param actor_id: ID of the actor
+        @return: virtual client
+        """
+        local_jid = await self.get_jid_from_id(actor_id)
+        return self.client.get_virtual_client(local_jid)
+
+    def is_activity(self, data: dict) -> bool:
+        """Return True if the data has an activity type"""
+        try:
+            return (data.get("type") or "").lower() in ACTIVITY_TYPES_LOWER
+        except (KeyError, TypeError):
+            return False
+
+    async def ap_get(self, url: str) -> dict:
+        """Retrieve AP JSON from given URL
+
+        @raise error.StanzaError: "service-unavailable" is sent when something went wrong
+            with AP server
+        """
+        resp = await treq.get(
+            url,
+            headers = {
+                "Accept": [MEDIA_TYPE_AP],
+                "Content-Type": [MEDIA_TYPE_AP],
+            }
+        )
+        if resp.code >= 300:
+            text = await resp.text()
+            if resp.code == 404:
+                raise exceptions.NotFound(f"Can't find resource at {url}")
+            else:
+                msg = f"HTTP error {resp.code} (url: {url}): {text}"
+                raise exceptions.ExternalRequestError(msg)
+        try:
+            return await treq.json_content(resp)
+        except Exception as e:
+            raise error.StanzaError(
+                "service-unavailable",
+                text=f"Can't get AP data at {url}: {e}"
+            )
+
+    @overload
+    async def ap_get_object(self, data: dict, key: str) -> Optional[dict]:
+        ...
+
+    @overload
+    async def ap_get_object(
+        self, data: Union[str, dict], key: None = None
+    ) -> dict:
+        ...
+
+    async def ap_get_object(self, data, key = None):
+        """Retrieve an AP object, dereferencing when necessary
+
+        This method is to be used with attributes marked as "Functional" in
+        https://www.w3.org/TR/activitystreams-vocabulary
+        @param data: AP object where an other object is looked for, or the object itself
+        @param key: name of the object to look for, or None if data is the object directly
+        @return: found object if any
+        """
+        if key is not None:
+            value = data.get(key)
+        else:
+            value = data
+        if value is None:
+            if key is None:
+                raise ValueError("None can't be used with ap_get_object is key is None")
+            return None
+        elif isinstance(value, dict):
+            return value
+        elif isinstance(value, str):
+            if self.is_local_url(value):
+                return await self.ap_get_local_object(value)
+            else:
+                return await self.ap_get(value)
+        else:
+            raise NotImplementedError(
+                "was expecting a string or a dict, got {type(value)}: {value!r}}"
+            )
+
+    async def ap_get_local_object(
+        self,
+        url: str
+    ) -> dict:
+        """Retrieve or generate local object
+
+        for now, only handle XMPP items to convert to AP
+        """
+        url_type, url_args = self.parse_apurl(url)
+        if url_type == TYPE_ITEM:
+            try:
+                account, item_id = url_args
+            except ValueError:
+                raise ValueError(f"invalid URL: {url}")
+            author_jid, node = await self.get_jid_and_node(account)
+            if node is None:
+                node = self._m.namespace
+            cached_node = await self.host.memory.storage.get_pubsub_node(
+                self.client, author_jid, node
+            )
+            if not cached_node:
+                log.debug(f"node {node!r} at {author_jid} is not found in cache")
+                found_item = None
+            else:
+                cached_items, __ = await self.host.memory.storage.get_items(
+                    cached_node, item_ids=[item_id]
+                )
+                if not cached_items:
+                    log.debug(
+                        f"item {item_id!r} of {node!r} at {author_jid} is not found in "
+                        "cache"
+                    )
+                    found_item = None
+                else:
+                    found_item = cached_items[0].data
+
+            if found_item is None:
+                # the node is not in cache, we have to make a request to retrieve the item
+                # If the node doesn't exist, get_items will raise a NotFound exception
+                found_items, __ = await self._p.get_items(
+                    self.client, author_jid, node, item_ids=[item_id]
+                )
+                try:
+                    found_item = found_items[0]
+                except IndexError:
+                    raise exceptions.NotFound(f"requested item at {url} can't be found")
+
+            if node.startswith(self._events.namespace):
+                # this is an event
+                event_data = self._events.event_elt_2_event_data(found_item)
+                ap_item = await self.ap_events.event_data_2_ap_item(
+                    event_data, author_jid
+                )
+                # the URL must return the object and not the activity
+                ap_item["object"]["@context"] = ap_item["@context"]
+                return ap_item["object"]
+            else:
+                # this is a blog item
+                mb_data = await self._m.item_2_mb_data(
+                    self.client, found_item, author_jid, node
+                )
+                ap_item = await self.mb_data_2_ap_item(self.client, mb_data)
+                # the URL must return the object and not the activity
+                return ap_item["object"]
+        else:
+            raise NotImplementedError(
+                'only object from "item" URLs can be retrieved for now'
+            )
+
+    async def ap_get_list(
+        self,
+        data: dict,
+        key: str,
+        only_ids: bool = False
+    ) -> Optional[List[Dict[str, Any]]]:
+        """Retrieve a list of objects from AP data, dereferencing when necessary
+
+        This method is to be used with non functional vocabularies. Use ``ap_get_object``
+        otherwise.
+        If the value is a dictionary, it will be wrapped in a list
+        @param data: AP object where a list of objects is looked for
+        @param key: key of the list to look for
+        @param only_ids: if Trye, only items IDs are retrieved
+        @return: list of objects, or None if the key is not present
+        """
+        value = data.get(key)
+        if value is None:
+            return None
+        elif isinstance(value, str):
+            if self.is_local_url(value):
+                value = await self.ap_get_local_object(value)
+            else:
+                value = await self.ap_get(value)
+        if isinstance(value, dict):
+            return [value]
+        if not isinstance(value, list):
+            raise ValueError(f"A list was expected, got {type(value)}: {value!r}")
+        if only_ids:
+            return [
+                {"id": v["id"]} if isinstance(v, dict) else {"id": v}
+                for v in value
+            ]
+        else:
+            return [await self.ap_get_object(i) for i in value]
+
+    async def ap_get_actors(
+        self,
+        data: dict,
+        key: str,
+        as_account: bool = True
+    ) -> List[str]:
+        """Retrieve AP actors from data
+
+        @param data: AP object containing a field with actors
+        @param key: field to use to retrieve actors
+        @param as_account: if True returns account handles, otherwise will return actor
+            IDs
+        @raise exceptions.DataError: there is not actor data or it is invalid
+        """
+        value = data.get(key)
+        if value is None:
+            raise exceptions.DataError(
+                f"no actor associated to object {data.get('id')!r}"
+            )
+        elif isinstance(value, dict):
+            actor_id = value.get("id")
+            if actor_id is None:
+                raise exceptions.DataError(
+                    f"invalid actor associated to object {data.get('id')!r}: {value!r}"
+                )
+            value = [actor_id]
+        elif isinstance(value, str):
+            value = [value]
+        elif isinstance(value, list):
+            try:
+                value = [a if isinstance(a, str) else a["id"] for a in value]
+            except (TypeError, KeyError):
+                raise exceptions.DataError(
+                    f"invalid actors list to object {data.get('id')!r}: {value!r}"
+                )
+        if not value:
+            raise exceptions.DataError(
+                f"list of actors is empty"
+            )
+        if as_account:
+            return [await self.get_ap_account_from_id(actor_id) for actor_id in value]
+        else:
+            return value
+
+    async def ap_get_sender_actor(
+        self,
+        data: dict,
+    ) -> str:
+        """Retrieve actor who sent data
+
+        This is done by checking "actor" field first, then "attributedTo" field.
+        Only the first found actor is taken into account
+        @param data: AP object
+        @return: actor id of the sender
+        @raise exceptions.NotFound: no actor has been found in data
+        """
+        try:
+            actors = await self.ap_get_actors(data, "actor", as_account=False)
+        except exceptions.DataError:
+            actors = None
+        if not actors:
+            try:
+                actors = await self.ap_get_actors(data, "attributedTo", as_account=False)
+            except exceptions.DataError:
+                raise exceptions.NotFound(
+                    'actor not specified in "actor" or "attributedTo"'
+                )
+        try:
+            return actors[0]
+        except IndexError:
+            raise exceptions.NotFound("list of actors is empty")
+
+    def must_encode(self, text: str) -> bool:
+        """Indicate if a text must be period encoded"""
+        return (
+            not RE_ALLOWED_UNQUOTED.match(text)
+            or text.startswith("___")
+            or "---" in text
+        )
+
+    def period_encode(self, text: str) -> str:
+        """Period encode a text
+
+        see [get_jid_and_node] for reasons of period encoding
+        """
+        return (
+            parse.quote(text, safe="")
+            .replace("---", "%2d%2d%2d")
+            .replace("___", "%5f%5f%5f")
+            .replace(".", "%2e")
+            .replace("~", "%7e")
+            .replace("%", ".")
+        )
+
+    async def get_ap_account_from_jid_and_node(
+        self,
+        jid_: jid.JID,
+        node: Optional[str]
+    ) -> str:
+        """Construct AP account from JID and node
+
+        The account construction will use escaping when necessary
+        """
+        if not node or node == self._m.namespace:
+            node = None
+
+        if self.client is None:
+            raise exceptions.InternalError("Client is not set yet")
+
+        if self.is_virtual_jid(jid_):
+            # this is an proxy JID to an AP Actor
+            return self._e.unescape(jid_.user)
+
+        if node and not jid_.user and not self.must_encode(node):
+            is_pubsub = await self.is_pubsub(jid_)
+            # when we have a pubsub service, the user part can be used to set the node
+            # this produces more user-friendly AP accounts
+            if is_pubsub:
+                jid_.user = node
+                node = None
+
+        is_local = self.is_local(jid_)
+        user = jid_.user if is_local else jid_.userhost()
+        if user is None:
+            user = ""
+        account_elts = []
+        if node and self.must_encode(node) or self.must_encode(user):
+            account_elts = ["___"]
+            if node:
+                node = self.period_encode(node)
+            user = self.period_encode(user)
+
+        if not user:
+            raise exceptions.InternalError("there should be a user part")
+
+        if node:
+            account_elts.extend((node, "---"))
+
+        account_elts.extend((
+            user, "@", jid_.host if is_local else self.client.jid.userhost()
+        ))
+        return "".join(account_elts)
+
+    def is_local(self, jid_: jid.JID) -> bool:
+        """Returns True if jid_ use a domain or subdomain of gateway's host"""
+        local_host = self.client.host.split(".")
+        assert local_host
+        return jid_.host.split(".")[-len(local_host):] == local_host
+
+    async def is_pubsub(self, jid_: jid.JID) -> bool:
+        """Indicate if a JID is a Pubsub service"""
+        host_disco = await self.host.get_disco_infos(self.client, jid_)
+        return (
+            ("pubsub", "service") in host_disco.identities
+            and not ("pubsub", "pep") in host_disco.identities
+        )
+
+    async def get_jid_and_node(self, ap_account: str) -> Tuple[jid.JID, Optional[str]]:
+        """Decode raw AP account handle to get XMPP JID and Pubsub Node
+
+        Username are case insensitive.
+
+        By default, the username correspond to local username (i.e. username from
+        component's server).
+
+        If local name's domain is a pubsub service (and not PEP), the username is taken as
+        a pubsub node.
+
+        If ``---`` is present in username, the part before is used as pubsub node, and the
+        rest as a JID user part.
+
+        If username starts with ``___``, characters are encoded using period encoding
+        (i.e. percent encoding where a ``.`` is used instead of ``%``).
+
+        This horror is necessary due to limitation in some AP implementation (notably
+        Mastodon), cf. https://github.com/mastodon/mastodon/issues/17222
+
+        examples:
+
+        ``toto@example.org`` => JID = toto@example.org, node = None
+
+        ``___toto.40example.net@example.org`` => JID = toto@example.net (this one is a
+        non-local JID, and will work only if setings ``local_only`` is False), node = None
+
+        ``toto@pubsub.example.org`` (with pubsub.example.org being a pubsub service) =>
+        JID = pubsub.example.org, node = toto
+
+        ``tata---toto@example.org`` => JID = toto@example.org, node = tata
+
+        ``___urn.3axmpp.3amicroblog.3a0@pubsub.example.org`` (with pubsub.example.org
+        being a pubsub service) ==> JID = pubsub.example.org, node = urn:xmpp:microblog:0
+
+        @param ap_account: ActivityPub account handle (``username@domain.tld``)
+        @return: service JID and pubsub node
+            if pubsub node is None, default microblog pubsub node (and possibly other
+            nodes that plugins may hanlde) will be used
+        @raise ValueError: invalid account
+        @raise PermissionError: non local jid is used when gateway doesn't allow them
+        """
+        if ap_account.count("@") != 1:
+            raise ValueError("Invalid AP account")
+        if ap_account.startswith("___"):
+            encoded = True
+            ap_account = ap_account[3:]
+        else:
+            encoded = False
+
+        username, domain = ap_account.split("@")
+
+        if "---" in username:
+            node, username = username.rsplit("---", 1)
+        else:
+            node = None
+
+        if encoded:
+            username = parse.unquote(
+                RE_PERIOD_ENC.sub(r"%\g<hex>", username),
+                errors="strict"
+            )
+            if node:
+                node = parse.unquote(
+                    RE_PERIOD_ENC.sub(r"%\g<hex>", node),
+                    errors="strict"
+                )
+
+        if "@" in username:
+            username, domain = username.rsplit("@", 1)
+
+        if not node:
+            # we need to check host disco, because disco request to user may be
+            # blocked for privacy reason (see
+            # https://xmpp.org/extensions/xep-0030.html#security)
+            is_pubsub = await self.is_pubsub(jid.JID(domain))
+
+            if is_pubsub:
+                # if the host is a pubsub service and not a PEP, we consider that username
+                # is in fact the node name
+                node = username
+                username = None
+
+        jid_s = f"{username}@{domain}" if username else domain
+        try:
+            jid_ = jid.JID(jid_s)
+        except RuntimeError:
+            raise ValueError(f"Invalid jid: {jid_s!r}")
+
+        if self.local_only and not self.is_local(jid_):
+            raise exceptions.PermissionError(
+                "This gateway is configured to map only local entities and services"
+            )
+
+        return jid_, node
+
+    def get_local_jid_from_account(self, account: str) -> jid.JID:
+        """Compute JID linking to an AP account
+
+        The local jid is computer by escaping AP actor handle and using it as local part
+        of JID, where domain part is this gateway own JID
+        """
+        return jid.JID(
+            None,
+            (
+                self._e.escape(account),
+                self.client.jid.host,
+                None
+            )
+        )
+
+    async def get_jid_from_id(self, actor_id: str) -> jid.JID:
+        """Compute JID linking to an AP Actor ID
+
+        The local jid is computer by escaping AP actor handle and using it as local part
+        of JID, where domain part is this gateway own JID
+        If the actor_id comes from local server (checked with self.public_url), it means
+        that we have an XMPP entity, and the original JID is returned
+        """
+        if self.is_local_url(actor_id):
+            request_type, extra_args = self.parse_apurl(actor_id)
+            if request_type != TYPE_ACTOR or len(extra_args) != 1:
+                raise ValueError(f"invalid actor id: {actor_id!r}")
+            actor_jid, __ = await self.get_jid_and_node(extra_args[0])
+            return actor_jid
+
+        account = await self.get_ap_account_from_id(actor_id)
+        return self.get_local_jid_from_account(account)
+
+    def parse_apurl(self, url: str) -> Tuple[str, List[str]]:
+        """Parse an URL leading to an AP endpoint
+
+        @param url: URL to parse (schema is not mandatory)
+        @return: endpoint type and extra arguments
+        """
+        path = parse.urlparse(url).path.lstrip("/")
+        type_, *extra_args = path[len(self.ap_path):].lstrip("/").split("/")
+        return type_, [parse.unquote(a) for a in extra_args]
+
+    def build_apurl(self, type_:str , *args: str) -> str:
+        """Build an AP endpoint URL
+
+        @param type_: type of AP endpoing
+        @param arg: endpoint dependant arguments
+        """
+        return parse.urljoin(
+            self.base_ap_url,
+            str(Path(type_).joinpath(*(parse.quote_plus(a, safe="@") for a in args)))
+        )
+
+    def is_local_url(self, url: str) -> bool:
+        """Tells if an URL link to this component
+
+        ``public_url`` and ``ap_path`` are used to check the URL
+        """
+        return url.startswith(self.base_ap_url)
+
+    def is_virtual_jid(self, jid_: jid.JID) -> bool:
+        """Tell if a JID is an AP actor mapped through this gateway"""
+        return jid_.host == self.client.jid.userhost()
+
+    def build_signature_header(self, values: Dict[str, str]) -> str:
+        """Build key="<value>" signature header from signature data"""
+        fields = []
+        for key, value in values.items():
+            if key not in ("(created)", "(expired)"):
+                if '"' in value:
+                    raise NotImplementedError(
+                        "string escaping is not implemented, double-quote can't be used "
+                        f"in {value!r}"
+                    )
+                value = f'"{value}"'
+            fields.append(f"{key}={value}")
+
+        return ",".join(fields)
+
+    def get_digest(self, body: bytes, algo="SHA-256") -> Tuple[str, str]:
+        """Get digest data to use in header and signature
+
+        @param body: body of the request
+        @return: hash name and digest
+        """
+        if algo != "SHA-256":
+            raise NotImplementedError("only SHA-256 is implemented for now")
+        return algo, base64.b64encode(hashlib.sha256(body).digest()).decode()
+
+    @async_lru(maxsize=LRU_MAX_SIZE)
+    async def get_actor_data(self, actor_id) -> dict:
+        """Retrieve actor data with LRU cache"""
+        return await self.ap_get(actor_id)
+
+    @async_lru(maxsize=LRU_MAX_SIZE)
+    async def get_actor_pub_key_data(
+        self,
+        actor_id: str
+    ) -> Tuple[str, str, rsa.RSAPublicKey]:
+        """Retrieve Public Key data from actor ID
+
+        @param actor_id: actor ID (url)
+        @return: key_id, owner and public_key
+        @raise KeyError: publicKey is missing from actor data
+        """
+        actor_data = await self.get_actor_data(actor_id)
+        pub_key_data = actor_data["publicKey"]
+        key_id = pub_key_data["id"]
+        owner = pub_key_data["owner"]
+        pub_key_pem = pub_key_data["publicKeyPem"]
+        pub_key = serialization.load_pem_public_key(pub_key_pem.encode())
+        return key_id, owner, pub_key
+
+    def create_activity(
+        self,
+        activity: str,
+        actor_id: str,
+        object_: Optional[Union[str, dict]] = None,
+        target: Optional[Union[str, dict]] = None,
+        activity_id: Optional[str] = None,
+        **kwargs,
+    ) -> Dict[str, Any]:
+        """Generate base data for an activity
+
+        @param activity: one of ACTIVITY_TYPES
+        @param actor_id: AP actor ID of the sender
+        @param object_: content of "object" field
+        @param target: content of "target" field
+        @param activity_id: ID to use for the activity
+            if not set it will be automatically generated, but it is usually desirable to
+            set the ID manually so it can be retrieved (e.g. for Undo)
+        """
+        if activity not in ACTIVITY_TYPES:
+            raise exceptions.InternalError(f"invalid activity: {activity!r}")
+        if object_ is None and activity in ACTIVITY_OBJECT_MANDATORY:
+            raise exceptions.InternalError(
+                f'"object_" is mandatory for activity {activity!r}'
+            )
+        if target is None and activity in ACTIVITY_TARGET_MANDATORY:
+            raise exceptions.InternalError(
+                f'"target" is mandatory for activity {activity!r}'
+            )
+        if activity_id is None:
+            activity_id = f"{actor_id}#{activity.lower()}_{shortuuid.uuid()}"
+        data: Dict[str, Any] = {
+            "@context": [NS_AP],
+            "actor": actor_id,
+            "id": activity_id,
+            "type": activity,
+        }
+        data.update(kwargs)
+        if object_ is not None:
+            data["object"] = object_
+        if target is not None:
+            data["target"] = target
+
+        return data
+
+    def get_key_id(self, actor_id: str) -> str:
+        """Get local key ID from actor ID"""
+        return f"{actor_id}#main-key"
+
+    async def check_signature(
+        self,
+        signature: str,
+        key_id: str,
+        headers: Dict[str, str]
+    ) -> str:
+        """Verify that signature matches given headers
+
+        see https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-06#section-3.1.2
+
+        @param signature: Base64 encoded signature
+        @param key_id: ID of the key used to sign the data
+        @param headers: headers and their values, including pseudo-headers
+        @return: id of the signing actor
+
+        @raise InvalidSignature: signature doesn't match headers
+        """
+        to_sign = "\n".join(f"{k.lower()}: {v}" for k,v in headers.items())
+        if key_id.startswith("acct:"):
+            actor = key_id[5:]
+            actor_id = await self.get_ap_actor_id_from_account(actor)
+        else:
+            actor_id = key_id.split("#", 1)[0]
+
+        pub_key_id, pub_key_owner, pub_key = await self.get_actor_pub_key_data(actor_id)
+        if pub_key_id != key_id or pub_key_owner != actor_id:
+            raise exceptions.EncryptionError("Public Key mismatch")
+
+        try:
+            pub_key.verify(
+                base64.b64decode(signature),
+                to_sign.encode(),
+                # we have to use PKCS1v15 padding to be compatible with Mastodon
+                padding.PKCS1v15(),  # type: ignore
+                hashes.SHA256()  # type: ignore
+            )
+        except InvalidSignature:
+            raise exceptions.EncryptionError(
+                "Invalid signature (using PKC0S1 v1.5 and SHA-256)"
+            )
+
+        return actor_id
+
+    def get_signature_data(
+            self,
+            key_id: str,
+            headers: Dict[str, str]
+    ) -> Tuple[Dict[str, str], Dict[str, str]]:
+        """Generate and return signature and corresponding headers
+
+        @param parsed_url: URL where the request is sent/has been received
+        @param key_id: ID of the key (URL linking to the data with public key)
+        @param date: HTTP datetime string of signature generation
+        @param body: body of the HTTP request
+        @param headers: headers to sign and their value:
+            default value will be used if not specified
+
+        @return: headers and signature data
+            ``headers`` is an updated copy of ``headers`` arguments, with pseudo-headers
+            removed, and ``Signature`` added.
+        """
+        # headers must be lower case
+        l_headers: Dict[str, str] = {k.lower(): v for k, v in headers.items()}
+        to_sign = "\n".join(f"{k}: {v}" for k,v in l_headers.items())
+        signature = base64.b64encode(self.private_key.sign(
+            to_sign.encode(),
+            # we have to use PKCS1v15 padding to be compatible with Mastodon
+            padding.PKCS1v15(),  # type: ignore
+            hashes.SHA256()  # type: ignore
+        )).decode()
+        sign_data = {
+            "keyId": key_id,
+            "Algorithm": "rsa-sha256",
+            "headers": " ".join(l_headers.keys()),
+            "signature": signature
+        }
+        new_headers = {k: v for k,v in headers.items() if not k.startswith("(")}
+        new_headers["Signature"] = self.build_signature_header(sign_data)
+        return new_headers, sign_data
+
+    async def convert_and_post_items(
+        self,
+        client: SatXMPPEntity,
+        ap_account: str,
+        service: jid.JID,
+        node: str,
+        items: List[domish.Element],
+        subscribe_extra_nodes: bool = True,
+    ) -> None:
+        """Convert XMPP items to AP items and post them to actor inbox
+
+        @param ap_account: account of ActivityPub actor receiving the item
+        @param service: JID of the (virtual) pubsub service where the item has been
+            published
+        @param node: (virtual) node corresponding where the item has been published
+        @param subscribe_extra_nodes: if True, extra data nodes will be automatically
+            subscribed, that is comment nodes if present and attachments nodes.
+        """
+        actor_id = await self.get_ap_actor_id_from_account(ap_account)
+        inbox = await self.get_ap_inbox_from_id(actor_id)
+        for item in items:
+            if item.name == "item":
+                cached_item = await self.host.memory.storage.search_pubsub_items({
+                    "profiles": [self.client.profile],
+                    "services": [service],
+                    "nodes": [node],
+                    "names": [item["id"]]
+                })
+                is_new = not bool(cached_item)
+                if node.startswith(self._events.namespace):
+                    # event item
+                    event_data = self._events.event_elt_2_event_data(item)
+                    try:
+                        author_jid = jid.JID(item["publisher"]).userhostJID()
+                    except (KeyError, RuntimeWarning):
+                        root_elt = item
+                        while root_elt.parent is not None:
+                            root_elt = root_elt.parent
+                        author_jid = jid.JID(root_elt["from"]).userhostJID()
+                    if subscribe_extra_nodes and not self.is_virtual_jid(author_jid):
+                        # we subscribe automatically to comment nodes if any
+                        recipient_jid = self.get_local_jid_from_account(ap_account)
+                        recipient_client = self.client.get_virtual_client(recipient_jid)
+                        comments_data = event_data.get("comments")
+                        if comments_data:
+                            comment_service = jid.JID(comments_data["jid"])
+                            comment_node = comments_data["node"]
+                            await self._p.subscribe(
+                                recipient_client, comment_service, comment_node
+                            )
+                        try:
+                            await self._pa.subscribe(
+                                recipient_client, service, node, event_data["id"]
+                            )
+                        except exceptions.NotFound:
+                            log.debug(
+                                f"no attachment node found for item {event_data['id']!r} "
+                                f"on {node!r} at {service}"
+                            )
+                    ap_item = await self.ap_events.event_data_2_ap_item(
+                        event_data, author_jid, is_new=is_new
+                    )
+                else:
+                    # blog item
+                    mb_data = await self._m.item_2_mb_data(client, item, service, node)
+                    author_jid = jid.JID(mb_data["author_jid"])
+                    if subscribe_extra_nodes and not self.is_virtual_jid(author_jid):
+                        # we subscribe automatically to comment nodes if any
+                        recipient_jid = self.get_local_jid_from_account(ap_account)
+                        recipient_client = self.client.get_virtual_client(recipient_jid)
+                        for comment_data in mb_data.get("comments", []):
+                            comment_service = jid.JID(comment_data["service"])
+                            if self.is_virtual_jid(comment_service):
+                                log.debug(
+                                    f"ignoring virtual comment service: {comment_data}"
+                                )
+                                continue
+                            comment_node = comment_data["node"]
+                            await self._p.subscribe(
+                                recipient_client, comment_service, comment_node
+                            )
+                        try:
+                            await self._pa.subscribe(
+                                recipient_client, service, node, mb_data["id"]
+                            )
+                        except exceptions.NotFound:
+                            log.debug(
+                                f"no attachment node found for item {mb_data['id']!r} on "
+                                f"{node!r} at {service}"
+                            )
+                    ap_item = await self.mb_data_2_ap_item(client, mb_data, is_new=is_new)
+
+                url_actor = ap_item["actor"]
+            elif item.name == "retract":
+                url_actor, ap_item = await self.ap_delete_item(
+                    client.jid, node, item["id"]
+                )
+            else:
+                raise exceptions.InternalError(f"unexpected element: {item.toXml()}")
+            await self.sign_and_post(inbox, url_actor, ap_item)
+
+    async def convert_and_post_attachments(
+        self,
+        client: SatXMPPEntity,
+        ap_account: str,
+        service: jid.JID,
+        node: str,
+        items: List[domish.Element],
+        publisher: Optional[jid.JID] = None
+    ) -> None:
+        """Convert XMPP item attachments to AP activities and post them to actor inbox
+
+        @param ap_account: account of ActivityPub actor receiving the item
+        @param service: JID of the (virtual) pubsub service where the item has been
+            published
+        @param node: (virtual) node corresponding where the item has been published
+            subscribed, that is comment nodes if present and attachments nodes.
+        @param items: attachments items
+        @param publisher: publisher of the attachments item (it's NOT the PEP/Pubsub
+            service, it's the publisher of the item). To be filled only when the publisher
+            is known for sure, otherwise publisher will be determined either if
+            "publisher" attribute is set by pubsub service, or as a last resort, using
+            item's ID (which MUST be publisher bare JID according to pubsub-attachments
+            specification).
+        """
+        if len(items) != 1:
+            log.warning(
+                "we should get exactly one attachment item for an entity, got "
+                f"{len(items)})"
+            )
+
+        actor_id = await self.get_ap_actor_id_from_account(ap_account)
+        inbox = await self.get_ap_inbox_from_id(actor_id)
+
+        item_elt = items[0]
+        item_id = item_elt["id"]
+
+        if publisher is None:
+            item_pub_s = item_elt.getAttribute("publisher")
+            publisher = jid.JID(item_pub_s) if item_pub_s else jid.JID(item_id)
+
+        if publisher.userhost() != item_id:
+            log.warning(
+                "attachments item ID must be publisher's bare JID, ignoring: "
+                f"{item_elt.toXml()}"
+            )
+            return
+
+        if self.is_virtual_jid(publisher):
+            log.debug(f"ignoring item coming from local virtual JID {publisher}")
+            return
+
+        if publisher is not None:
+            item_elt["publisher"] = publisher.userhost()
+
+        item_service, item_node, item_id = self._pa.attachment_node_2_item(node)
+        item_account = await self.get_ap_account_from_jid_and_node(item_service, item_node)
+        if self.is_virtual_jid(item_service):
+            # it's a virtual JID mapping to an external AP actor, we can use the
+            # item_id directly
+            item_url = item_id
+            if not item_url.startswith("https:"):
+                log.warning(
+                    "item ID of external AP actor is not an https link, ignoring: "
+                    f"{item_id!r}"
+                )
+                return
+        else:
+            item_url = self.build_apurl(TYPE_ITEM, item_account, item_id)
+
+        old_attachment_pubsub_items = await self.host.memory.storage.search_pubsub_items({
+            "profiles": [self.client.profile],
+            "services": [service],
+            "nodes": [node],
+            "names": [item_elt["id"]]
+        })
+        if not old_attachment_pubsub_items:
+            old_attachment = {}
+        else:
+            old_attachment_items = [i.data for i in old_attachment_pubsub_items]
+            old_attachments = self._pa.items_2_attachment_data(client, old_attachment_items)
+            try:
+                old_attachment = old_attachments[0]
+            except IndexError:
+                # no known element was present in attachments
+                old_attachment = {}
+        publisher_account = await self.get_ap_account_from_jid_and_node(
+            publisher,
+            None
+        )
+        publisher_actor_id = self.build_apurl(TYPE_ACTOR, publisher_account)
+        try:
+            attachments = self._pa.items_2_attachment_data(client, [item_elt])[0]
+        except IndexError:
+            # no known element was present in attachments
+            attachments = {}
+
+        # noticed
+        if "noticed" in attachments:
+            if not "noticed" in old_attachment:
+                # new "noticed" attachment, we translate to "Like" activity
+                activity_id = self.build_apurl("like", item_account, item_id)
+                activity = self.create_activity(
+                    TYPE_LIKE, publisher_actor_id, item_url, activity_id=activity_id
+                )
+                activity["to"] = [ap_account]
+                activity["cc"] = [NS_AP_PUBLIC]
+                await self.sign_and_post(inbox, publisher_actor_id, activity)
+        else:
+            if "noticed" in old_attachment:
+                # "noticed" attachment has been removed, we undo the "Like" activity
+                activity_id = self.build_apurl("like", item_account, item_id)
+                activity = self.create_activity(
+                    TYPE_LIKE, publisher_actor_id, item_url, activity_id=activity_id
+                )
+                activity["to"] = [ap_account]
+                activity["cc"] = [NS_AP_PUBLIC]
+                undo = self.create_activity("Undo", publisher_actor_id, activity)
+                await self.sign_and_post(inbox, publisher_actor_id, undo)
+
+        # reactions
+        new_reactions = set(attachments.get("reactions", {}).get("reactions", []))
+        old_reactions = set(old_attachment.get("reactions", {}).get("reactions", []))
+        reactions_remove = old_reactions - new_reactions
+        reactions_add = new_reactions - old_reactions
+        for reactions, undo in ((reactions_remove, True), (reactions_add, False)):
+            for reaction in reactions:
+                activity_id = self.build_apurl(
+                    "reaction", item_account, item_id, reaction.encode().hex()
+                )
+                reaction_activity = self.create_activity(
+                    TYPE_REACTION, publisher_actor_id, item_url,
+                    activity_id=activity_id
+                )
+                reaction_activity["content"] = reaction
+                reaction_activity["to"] = [ap_account]
+                reaction_activity["cc"] = [NS_AP_PUBLIC]
+                if undo:
+                    activy = self.create_activity(
+                        "Undo", publisher_actor_id, reaction_activity
+                    )
+                else:
+                    activy = reaction_activity
+                await self.sign_and_post(inbox, publisher_actor_id, activy)
+
+        # RSVP
+        if "rsvp" in attachments:
+            attending = attachments["rsvp"].get("attending", "no")
+            old_attending = old_attachment.get("rsvp", {}).get("attending", "no")
+            if attending != old_attending:
+                activity_type = TYPE_JOIN if attending == "yes" else TYPE_LEAVE
+                activity_id = self.build_apurl(activity_type.lower(), item_account, item_id)
+                activity = self.create_activity(
+                    activity_type, publisher_actor_id, item_url, activity_id=activity_id
+                )
+                activity["to"] = [ap_account]
+                activity["cc"] = [NS_AP_PUBLIC]
+                await self.sign_and_post(inbox, publisher_actor_id, activity)
+        else:
+            if "rsvp" in old_attachment:
+                old_attending = old_attachment.get("rsvp", {}).get("attending", "no")
+                if old_attending == "yes":
+                    activity_id = self.build_apurl(TYPE_LEAVE.lower(), item_account, item_id)
+                    activity = self.create_activity(
+                        TYPE_LEAVE, publisher_actor_id, item_url, activity_id=activity_id
+                    )
+                    activity["to"] = [ap_account]
+                    activity["cc"] = [NS_AP_PUBLIC]
+                    await self.sign_and_post(inbox, publisher_actor_id, activity)
+
+        if service.user and self.is_virtual_jid(service):
+            # the item is on a virtual service, we need to store it in cache
+            log.debug("storing attachments item in cache")
+            cached_node = await self.host.memory.storage.get_pubsub_node(
+                client, service, node, with_subscriptions=True, create=True
+            )
+            await self.host.memory.storage.cache_pubsub_items(
+                self.client,
+                cached_node,
+                [item_elt],
+                [attachments]
+            )
+
+    async def sign_and_post(self, url: str, actor_id: str, doc: dict) -> TReqResponse:
+        """Sign a documentent and post it to AP server
+
+        @param url: AP server endpoint
+        @param actor_id: originating actor ID (URL)
+        @param doc: document to send
+        """
+        if self.verbose:
+            __, actor_args = self.parse_apurl(actor_id)
+            actor_account = actor_args[0]
+            to_log = [
+                "",
+                f">>> {actor_account} is signing and posting to {url}:\n{pformat(doc)}"
+            ]
+
+        p_url = parse.urlparse(url)
+        body = json.dumps(doc).encode()
+        digest_algo, digest_hash = self.get_digest(body)
+        digest = f"{digest_algo}={digest_hash}"
+
+        headers = {
+            "(request-target)": f"post {p_url.path}",
+            "Host": p_url.hostname,
+            "Date": http.datetimeToString().decode(),
+            "Digest": digest
+        }
+        headers["Content-Type"] = (
+            'application/activity+json'
+        )
+        headers, __ = self.get_signature_data(self.get_key_id(actor_id), headers)
+
+        if self.verbose:
+            if self.verbose>=3:
+                h_to_log = "\n".join(f"    {k}: {v}" for k,v in headers.items())
+                to_log.append(f"  headers:\n{h_to_log}")
+            to_log.append("---")
+            log.info("\n".join(to_log))
+
+        resp = await treq.post(
+            url,
+            body,
+            headers=headers,
+        )
+        if resp.code >= 300:
+            text = await resp.text()
+            log.warning(f"POST request to {url} failed [{resp.code}]: {text}")
+        elif self.verbose:
+            log.info(f"==> response code: {resp.code}")
+        return resp
+
+    def _publish_message(self, mess_data_s: str, service_s: str, profile: str):
+        mess_data: dict = data_format.deserialise(mess_data_s) # type: ignore
+        service = jid.JID(service_s)
+        client = self.host.get_client(profile)
+        return defer.ensureDeferred(self.publish_message(client, mess_data, service))
+
+    @async_lru(maxsize=LRU_MAX_SIZE)
+    async def get_ap_actor_id_from_account(self, account: str) -> str:
+        """Retrieve account ID from it's handle using WebFinger
+
+        Don't use this method to get local actor id from a local account derivated for
+        JID: in this case, the actor ID is retrieve with
+        ``self.build_apurl(TYPE_ACTOR, ap_account)``
+
+        @param account: AP handle (user@domain.tld)
+        @return: Actor ID (which is an URL)
+        """
+        if account.count("@") != 1 or "/" in account:
+            raise ValueError(f"Invalid account: {account!r}")
+        host = account.split("@")[1]
+        try:
+            finger_data = await treq.json_content(await treq.get(
+                f"https://{host}/.well-known/webfinger?"
+                f"resource=acct:{parse.quote_plus(account)}",
+            ))
+        except Exception as e:
+            raise exceptions.DataError(f"Can't get webfinger data for {account!r}: {e}")
+        for link in finger_data.get("links", []):
+            if (
+                link.get("type") == "application/activity+json"
+                and link.get("rel") == "self"
+            ):
+                href = link.get("href", "").strip()
+                if not href:
+                    raise ValueError(
+                        f"Invalid webfinger data for {account:r}: missing href"
+                    )
+                break
+        else:
+            raise ValueError(
+                f"No ActivityPub link found for {account!r}"
+            )
+        return href
+
+    async def get_ap_actor_data_from_account(self, account: str) -> dict:
+        """Retrieve ActivityPub Actor data
+
+        @param account: ActivityPub Actor identifier
+        """
+        href = await self.get_ap_actor_id_from_account(account)
+        return await self.ap_get(href)
+
+    async def get_ap_inbox_from_id(self, actor_id: str, use_shared: bool = True) -> str:
+        """Retrieve inbox of an actor_id
+
+        @param use_shared: if True, and a shared inbox exists, it will be used instead of
+            the user inbox
+        """
+        data = await self.get_actor_data(actor_id)
+        if use_shared:
+            try:
+                return data["endpoints"]["sharedInbox"]
+            except KeyError:
+                pass
+        return data["inbox"]
+
+    @async_lru(maxsize=LRU_MAX_SIZE)
+    async def get_ap_account_from_id(self, actor_id: str) -> str:
+        """Retrieve AP account from the ID URL
+
+        Works with external or local actor IDs.
+        @param actor_id: AP ID of the actor (URL to the actor data)
+        @return: AP handle
+        """
+        if self.is_local_url(actor_id):
+            url_type, url_args = self.parse_apurl(actor_id)
+            if url_type != "actor" or not url_args:
+                raise exceptions.DataError(
+                    f"invalid local actor ID: {actor_id}"
+                )
+            account = url_args[0]
+            try:
+                account_user, account_host = account.split('@')
+            except ValueError:
+                raise exceptions.DataError(
+                    f"invalid account from url: {actor_id}"
+                )
+            if not account_user or account_host != self.public_url:
+                raise exceptions.DataError(
+                    f"{account!r} is not a valid local account (from {actor_id})"
+                )
+            return account
+
+        url_parsed = parse.urlparse(actor_id)
+        actor_data = await self.get_actor_data(actor_id)
+        username = actor_data.get("preferredUsername")
+        if not username:
+            raise exceptions.DataError(
+                'No "preferredUsername" field found, can\'t retrieve actor account'
+            )
+        account = f"{username}@{url_parsed.hostname}"
+        # we try to retrieve the actor ID from the account to check it
+        found_id = await self.get_ap_actor_id_from_account(account)
+        if found_id != actor_id:
+            # cf. https://socialhub.activitypub.rocks/t/how-to-retrieve-user-server-tld-handle-from-actors-url/2196
+            msg = (
+                f"Account ID found on WebFinger {found_id!r} doesn't match our actor ID "
+                f"({actor_id!r}). This AP instance doesn't seems to use "
+                '"preferredUsername" as we expect.'
+            )
+            log.warning(msg)
+            raise exceptions.DataError(msg)
+        return account
+
+    async def get_ap_items(
+        self,
+        collection: dict,
+        max_items: Optional[int] = None,
+        chronological_pagination: bool = True,
+        after_id: Optional[str] = None,
+        start_index: Optional[int] = None,
+        parser: Optional[Callable[[dict], Awaitable[domish.Element]]] = None,
+        only_ids: bool = False,
+    ) -> Tuple[List[domish.Element], rsm.RSMResponse]:
+        """Retrieve AP items and convert them to XMPP items
+
+        @param account: AP account handle to get items from
+        @param max_items: maximum number of items to retrieve
+            retrieve all items by default
+        @param chronological_pagination: get pages in chronological order
+            AP use reversed chronological order for pagination, "first" page returns more
+            recent items. If "chronological_pagination" is True, "last" AP page will be
+            retrieved first.
+        @param after_id: if set, retrieve items starting from given ID
+            Due to ActivityStream Collection Paging limitations, this is inefficient and
+            if ``after_id`` is not already in cache, we have to retrieve every page until
+            we find it.
+            In most common cases, ``after_id`` should be in cache though (client usually
+            use known ID when in-order pagination is used).
+        @param start_index: start retrieving items from the one with given index
+            Due to ActivityStream Collection Paging limitations, this is inefficient and
+            all pages before the requested index will be retrieved to count items.
+        @param parser: method to use to parse AP items and get XMPP item elements
+            if None, use default generic parser
+        @param only_ids: if True, only retrieve items IDs
+            Retrieving only item IDs avoid HTTP requests to retrieve items, it may be
+            sufficient in some use cases (e.g. when retrieving following/followers
+            collections)
+        @return: XMPP Pubsub items and corresponding RSM Response
+            Items are always returned in chronological order in the result
+        """
+        if parser is None:
+            parser = self.ap_item_2_mb_elt
+
+        rsm_resp: Dict[str, Union[bool, int]] = {}
+        try:
+            count = collection["totalItems"]
+        except KeyError:
+            log.warning(
+                f'"totalItems" not found in collection {collection.get("id")}, '
+                "defaulting to 20"
+            )
+            count = 20
+        else:
+            log.info(f"{collection.get('id')} has {count} item(s)")
+
+            rsm_resp["count"] = count
+
+        if start_index is not None:
+            assert chronological_pagination and after_id is None
+            if start_index >= count:
+                return [], rsm_resp
+            elif start_index == 0:
+                # this is the default behaviour
+                pass
+            elif start_index > 5000:
+                raise error.StanzaError(
+                    "feature-not-implemented",
+                    text="Maximum limit for previous_index has been reached, this limit"
+                    "is set to avoid DoS"
+                )
+            else:
+                # we'll convert "start_index" to "after_id", thus we need the item just
+                # before "start_index"
+                previous_index = start_index - 1
+                retrieved_items = 0
+                current_page = collection["last"]
+                while retrieved_items < count:
+                    page_data, items = await self.parse_ap_page(
+                        current_page, parser, only_ids
+                    )
+                    if not items:
+                        log.warning(f"found an empty AP page at {current_page}")
+                        return [], rsm_resp
+                    page_start_idx = retrieved_items
+                    retrieved_items += len(items)
+                    if previous_index <= retrieved_items:
+                        after_id = items[previous_index - page_start_idx]["id"]
+                        break
+                    try:
+                        current_page = page_data["prev"]
+                    except KeyError:
+                        log.warning(
+                            f"missing previous page link at {current_page}: {page_data!r}"
+                        )
+                        raise error.StanzaError(
+                            "service-unavailable",
+                            "Error while retrieving previous page from AP service at "
+                            f"{current_page}"
+                        )
+
+        init_page = "last" if chronological_pagination else "first"
+        page = collection.get(init_page)
+        if not page:
+            raise exceptions.DataError(
+                f"Initial page {init_page!r} not found for collection "
+                f"{collection.get('id')})"
+            )
+        items = []
+        page_items = []
+        retrieved_items = 0
+        found_after_id = False
+
+        while retrieved_items < count:
+            __, page_items = await self.parse_ap_page(page, parser, only_ids)
+            if not page_items:
+                break
+            retrieved_items += len(page_items)
+            if after_id is not None and not found_after_id:
+                # if we have an after_id, we ignore all items until the requested one is
+                # found
+                try:
+                    limit_idx = [i["id"] for i in page_items].index(after_id)
+                except ValueError:
+                    # if "after_id" is not found, we don't add any item from this page
+                    page_id = page.get("id") if isinstance(page, dict) else page
+                    log.debug(f"{after_id!r} not found at {page_id}, skipping")
+                else:
+                    found_after_id = True
+                    if chronological_pagination:
+                        start_index = retrieved_items - len(page_items) + limit_idx + 1
+                        page_items = page_items[limit_idx+1:]
+                    else:
+                        start_index = count - (retrieved_items - len(page_items) +
+                                               limit_idx + 1)
+                        page_items = page_items[:limit_idx]
+                    items.extend(page_items)
+            else:
+                items.extend(page_items)
+            if max_items is not None and len(items) >= max_items:
+                if chronological_pagination:
+                    items = items[:max_items]
+                else:
+                    items = items[-max_items:]
+                break
+            page = collection.get("prev" if chronological_pagination else "next")
+            if not page:
+                break
+
+        if after_id is not None and not found_after_id:
+            raise error.StanzaError("item-not-found")
+
+        if items:
+            if after_id is None:
+                rsm_resp["index"] = 0 if chronological_pagination else count - len(items)
+            if start_index is not None:
+                rsm_resp["index"] = start_index
+            elif after_id is not None:
+                log.warning("Can't determine index of first element")
+            elif chronological_pagination:
+                rsm_resp["index"] = 0
+            else:
+                rsm_resp["index"] = count - len(items)
+            rsm_resp.update({
+                "first": items[0]["id"],
+                "last": items[-1]["id"]
+            })
+
+        return items, rsm.RSMResponse(**rsm_resp)
+
+    async def ap_item_2_mb_data_and_elt(self, ap_item: dict) -> Tuple[dict, domish.Element]:
+        """Convert AP item to parsed microblog data and corresponding item element"""
+        mb_data = await self.ap_item_2_mb_data(ap_item)
+        item_elt = await self._m.mb_data_2_entry_elt(
+            self.client, mb_data, mb_data["id"], None, self._m.namespace
+        )
+        if "repeated" in mb_data["extra"]:
+            item_elt["publisher"] = mb_data["extra"]["repeated"]["by"]
+        else:
+            item_elt["publisher"] = mb_data["author_jid"]
+        return mb_data, item_elt
+
+    async def ap_item_2_mb_elt(self, ap_item: dict) -> domish.Element:
+        """Convert AP item to XMPP item element"""
+        __, item_elt = await self.ap_item_2_mb_data_and_elt(ap_item)
+        return item_elt
+
+    async def parse_ap_page(
+        self,
+        page: Union[str, dict],
+        parser: Callable[[dict], Awaitable[domish.Element]],
+        only_ids: bool = False
+    ) -> Tuple[dict, List[domish.Element]]:
+        """Convert AP objects from an AP page to XMPP items
+
+        @param page: Can be either url linking and AP page, or the page data directly
+        @param parser: method to use to parse AP items and get XMPP item elements
+        @param only_ids: if True, only retrieve items IDs
+        @return: page data, pubsub items
+        """
+        page_data = await self.ap_get_object(page)
+        if page_data is None:
+            log.warning('No data found in collection')
+            return {}, []
+        ap_items = await self.ap_get_list(page_data, "orderedItems", only_ids=only_ids)
+        if ap_items is None:
+            ap_items = await self.ap_get_list(page_data, "items", only_ids=only_ids)
+            if not ap_items:
+                log.warning(f'No item field found in collection: {page_data!r}')
+                return page_data, []
+            else:
+                log.warning(
+                    "Items are not ordered, this is not spec compliant"
+                )
+        items = []
+        # AP Collections are in antichronological order, but we expect chronological in
+        # Pubsub, thus we reverse it
+        for ap_item in reversed(ap_items):
+            try:
+                items.append(await parser(ap_item))
+            except (exceptions.DataError, NotImplementedError, error.StanzaError):
+                continue
+
+        return page_data, items
+
+    async def get_comments_nodes(
+        self,
+        item_id: str,
+        parent_id: Optional[str]
+    ) -> Tuple[Optional[str], Optional[str]]:
+        """Get node where this item is and node to use for comments
+
+        if config option "comments_max_depth" is set, a common node will be used below the
+        given depth
+        @param item_id: ID of the reference item
+        @param parent_id: ID of the parent item if any (the ID set in "inReplyTo")
+        @return: a tuple with parent_node_id, comments_node_id:
+            - parent_node_id is the ID of the node where reference item must be. None is
+              returned when the root node (i.e. not a comments node) must be used.
+            - comments_node_id: is the ID of the node to use for comments. None is
+              returned when no comment node must be used (happens when we have reached
+              "comments_max_depth")
+        """
+        if parent_id is None or not self.comments_max_depth:
+            return (
+                self._m.get_comments_node(parent_id) if parent_id is not None else None,
+                self._m.get_comments_node(item_id)
+            )
+        parent_url = parent_id
+        parents = []
+        for __ in range(COMMENTS_MAX_PARENTS):
+            parent_item = await self.ap_get(parent_url)
+            parents.insert(0, parent_item)
+            parent_url = parent_item.get("inReplyTo")
+            if parent_url is None:
+                break
+        parent_limit = self.comments_max_depth-1
+        if len(parents) <= parent_limit:
+            return (
+                self._m.get_comments_node(parents[-1]["id"]),
+                self._m.get_comments_node(item_id)
+            )
+        else:
+            last_level_item = parents[parent_limit]
+            return (
+                self._m.get_comments_node(last_level_item["id"]),
+                None
+            )
+
+    async def ap_item_2_mb_data(self, ap_item: dict) -> dict:
+        """Convert AP activity or object to microblog data
+
+        @param ap_item: ActivityPub item to convert
+            Can be either an activity of an object
+        @return: AP Item's Object and microblog data
+        @raise exceptions.DataError: something is invalid in the AP item
+        @raise NotImplementedError: some AP data is not handled yet
+        @raise error.StanzaError: error while contacting the AP server
+        """
+        is_activity = self.is_activity(ap_item)
+        if is_activity:
+            ap_object = await self.ap_get_object(ap_item, "object")
+            if not ap_object:
+                log.warning(f'No "object" found in AP item {ap_item!r}')
+                raise exceptions.DataError
+        else:
+            ap_object = ap_item
+        item_id = ap_object.get("id")
+        if not item_id:
+            log.warning(f'No "id" found in AP item: {ap_object!r}')
+            raise exceptions.DataError
+        mb_data = {"id": item_id, "extra": {}}
+
+        # content
+        try:
+            language, content_xhtml = ap_object["contentMap"].popitem()
+        except (KeyError, AttributeError):
+            try:
+                mb_data["content_xhtml"] = ap_object["content"]
+            except KeyError:
+                log.warning(f"no content found:\n{ap_object!r}")
+                raise exceptions.DataError
+        else:
+            mb_data["language"] = language
+            mb_data["content_xhtml"] = content_xhtml
+
+        mb_data["content"] = await self._t.convert(
+            mb_data["content_xhtml"],
+            self._t.SYNTAX_XHTML,
+            self._t.SYNTAX_TEXT,
+            False,
+        )
+
+        if "attachment" in ap_object:
+            attachments = mb_data["extra"][C.KEY_ATTACHMENTS] = []
+            for ap_attachment in ap_object["attachment"]:
+                try:
+                    url = ap_attachment["url"]
+                except KeyError:
+                    log.warning(
+                        f'"url" missing in AP attachment, ignoring: {ap_attachment}'
+                    )
+                    continue
+
+                if not url.startswith("http"):
+                    log.warning(f"non HTTP URL in attachment, ignoring: {ap_attachment}")
+                    continue
+                attachment = {"url": url}
+                for ap_key, key in (
+                    ("mediaType", "media_type"),
+                    # XXX: as weird as it seems, "name" is actually used for description
+                    #   in AP world
+                    ("name", "desc"),
+                ):
+                    value = ap_attachment.get(ap_key)
+                    if value:
+                        attachment[key] = value
+                attachments.append(attachment)
+
+        # author
+        if is_activity:
+            authors = await self.ap_get_actors(ap_item, "actor")
+        else:
+            authors = await self.ap_get_actors(ap_object, "attributedTo")
+        if len(authors) > 1:
+            # we only keep first item as author
+            # TODO: handle multiple actors
+            log.warning("multiple actors are not managed")
+
+        account = authors[0]
+        author_jid = self.get_local_jid_from_account(account).full()
+
+        mb_data["author"] = account.split("@", 1)[0]
+        mb_data["author_jid"] = author_jid
+
+        # published/updated
+        for field in ("published", "updated"):
+            value = ap_object.get(field)
+            if not value and field == "updated":
+                value = ap_object.get("published")
+            if value:
+                try:
+                    mb_data[field] = calendar.timegm(
+                        dateutil.parser.parse(str(value)).utctimetuple()
+                    )
+                except dateutil.parser.ParserError as e:
+                    log.warning(f"Can't parse {field!r} field: {e}")
+
+        # repeat
+        if "_repeated" in ap_item:
+            mb_data["extra"]["repeated"] = ap_item["_repeated"]
+
+        # comments
+        in_reply_to = ap_object.get("inReplyTo")
+        __, comments_node = await self.get_comments_nodes(item_id, in_reply_to)
+        if comments_node is not None:
+            comments_data = {
+                "service": author_jid,
+                "node": comments_node,
+                "uri": uri.build_xmpp_uri(
+                    "pubsub",
+                    path=author_jid,
+                    node=comments_node
+                )
+            }
+            mb_data["comments"] = [comments_data]
+
+        return mb_data
+
+    async def get_reply_to_id_from_xmpp_node(
+        self,
+        client: SatXMPPEntity,
+        ap_account: str,
+        parent_item: str,
+        mb_data: dict
+    ) -> str:
+        """Get URL to use for ``inReplyTo`` field in AP item.
+
+        There is currently no way to know the parent service of a comment with XEP-0277.
+        To work around that, we try to check if we have this item in the cache (we
+        should). If there is more that one item with this ID, we first try to find one
+        with this author_jid. If nothing is found, we use ap_account to build `inReplyTo`.
+
+        @param ap_account: AP account corresponding to the publication author
+        @param parent_item: ID of the node where the publication this item is replying to
+             has been posted
+        @param mb_data: microblog data of the publication
+        @return: URL to use in ``inReplyTo`` field
+        """
+        # FIXME: propose a protoXEP to properly get parent item, node and service
+
+        found_items = await self.host.memory.storage.search_pubsub_items({
+            "profiles": [client.profile],
+            "names": [parent_item]
+        })
+        if not found_items:
+            log.warning(f"parent item {parent_item!r} not found in cache")
+            parent_ap_account = ap_account
+        elif len(found_items) == 1:
+            cached_node = found_items[0].node
+            parent_ap_account = await self.get_ap_account_from_jid_and_node(
+                cached_node.service,
+                cached_node.name
+            )
+        else:
+            # we found several cached item with given ID, we check if there is one
+            # corresponding to this author
+            try:
+                author = jid.JID(mb_data["author_jid"]).userhostJID()
+                cached_item = next(
+                    i for i in found_items
+                    if jid.JID(i.data["publisher"]).userhostJID()
+                    == author
+                )
+            except StopIteration:
+                # no item corresponding to this author, we use ap_account
+                log.warning(
+                    "Can't find a single cached item for parent item "
+                    f"{parent_item!r}"
+                )
+                parent_ap_account = ap_account
+            else:
+                cached_node = cached_item.node
+                parent_ap_account = await self.get_ap_account_from_jid_and_node(
+                    cached_node.service,
+                    cached_node.name
+                )
+
+        return self.build_apurl(
+            TYPE_ITEM, parent_ap_account, parent_item
+        )
+
+    async def repeated_mb_2_ap_item(
+        self,
+        mb_data: dict
+    ) -> dict:
+        """Convert repeated blog item to suitable AP Announce activity
+
+        @param mb_data: microblog metadata of an item repeating an other blog post
+        @return: Announce activity linking to the repeated item
+        """
+        repeated = mb_data["extra"]["repeated"]
+        repeater = jid.JID(repeated["by"])
+        repeater_account = await self.get_ap_account_from_jid_and_node(
+            repeater,
+            None
+        )
+        repeater_id = self.build_apurl(TYPE_ACTOR, repeater_account)
+        repeated_uri = repeated["uri"]
+
+        if not repeated_uri.startswith("xmpp:"):
+            log.warning(
+                "Only xmpp: URL are handled for repeated item at the moment, ignoring "
+                f"item {mb_data}"
+            )
+            raise NotImplementedError
+        parsed_url = uri.parse_xmpp_uri(repeated_uri)
+        if parsed_url["type"] != "pubsub":
+            log.warning(
+                "Only pubsub URL are handled for repeated item at the moment, ignoring "
+                f"item {mb_data}"
+            )
+            raise NotImplementedError
+        rep_service = jid.JID(parsed_url["path"])
+        rep_item = parsed_url["item"]
+        activity_id = self.build_apurl("item", repeater.userhost(), mb_data["id"])
+
+        if self.is_virtual_jid(rep_service):
+            # it's an AP actor linked through this gateway
+            # in this case we can simply use the item ID
+            if not rep_item.startswith("https:"):
+                log.warning(
+                    f"Was expecting an HTTPS url as item ID and got {rep_item!r}\n"
+                    f"{mb_data}"
+                )
+            announced_uri = rep_item
+            repeated_account = self._e.unescape(rep_service.user)
+        else:
+            # the repeated item is an XMPP publication, we build the corresponding ID
+            rep_node = parsed_url["node"]
+            repeated_account = await self.get_ap_account_from_jid_and_node(
+                rep_service, rep_node
+            )
+            announced_uri = self.build_apurl("item", repeated_account, rep_item)
+
+        announce = self.create_activity(
+            "Announce", repeater_id, announced_uri, activity_id=activity_id
+        )
+        announce["to"] = [NS_AP_PUBLIC]
+        announce["cc"] = [
+            self.build_apurl(TYPE_FOLLOWERS, repeater_account),
+            await self.get_ap_actor_id_from_account(repeated_account)
+        ]
+        return announce
+
+    async def mb_data_2_ap_item(
+        self,
+        client: SatXMPPEntity,
+        mb_data: dict,
+        public: bool =True,
+        is_new: bool = True,
+    ) -> dict:
+        """Convert Libervia Microblog Data to ActivityPub item
+
+        @param mb_data: microblog data (as used in plugin XEP-0277) to convert
+            If ``public`` is True, ``service`` and ``node`` keys must be set.
+            If ``published`` is not set, current datetime will be used
+        @param public: True if the message is not a private/direct one
+            if True, the AP Item will be marked as public, and AP followers of target AP
+            account (which retrieve from ``service``) will be put in ``cc``.
+            ``inReplyTo`` will also be set if suitable
+            if False, no destinee will be set (i.e., no ``to`` or ``cc`` or public flag).
+            This is usually used for direct messages.
+        @param is_new: if True, the item is a new one (no instance has been found in
+            cache).
+            If True, a "Create" activity will be generated, otherwise an "Update" one will
+            be.
+        @return: Activity item
+        """
+        extra = mb_data.get("extra", {})
+        if "repeated" in extra:
+            return await self.repeated_mb_2_ap_item(mb_data)
+        if not mb_data.get("id"):
+            mb_data["id"] = shortuuid.uuid()
+        if not mb_data.get("author_jid"):
+            mb_data["author_jid"] = client.jid.userhost()
+        ap_account = await self.get_ap_account_from_jid_and_node(
+            jid.JID(mb_data["author_jid"]),
+            None
+        )
+        url_actor = self.build_apurl(TYPE_ACTOR, ap_account)
+        url_item = self.build_apurl(TYPE_ITEM, ap_account, mb_data["id"])
+        ap_object = {
+            "id": url_item,
+            "type": "Note",
+            "published": utils.xmpp_date(mb_data.get("published")),
+            "attributedTo": url_actor,
+            "content": mb_data.get("content_xhtml") or mb_data["content"],
+        }
+
+        language = mb_data.get("language")
+        if language:
+            ap_object["contentMap"] = {language: ap_object["content"]}
+
+        attachments = extra.get(C.KEY_ATTACHMENTS)
+        if attachments:
+            ap_attachments = ap_object["attachment"] = []
+            for attachment in attachments:
+                try:
+                    url = next(
+                        s['url'] for s in attachment["sources"] if 'url' in s
+                    )
+                except (StopIteration, KeyError):
+                    log.warning(
+                        f"Ignoring attachment without URL: {attachment}"
+                    )
+                    continue
+                ap_attachment = {
+                    "url": url
+                }
+                for key, ap_key in (
+                    ("media_type", "mediaType"),
+                    # XXX: yes "name", cf. [ap_item_2_mb_data]
+                    ("desc", "name"),
+                ):
+                    value = attachment.get(key)
+                    if value:
+                        ap_attachment[ap_key] = value
+                ap_attachments.append(ap_attachment)
+
+        if public:
+            ap_object["to"] = [NS_AP_PUBLIC]
+            if self.auto_mentions:
+                for m in RE_MENTION.finditer(ap_object["content"]):
+                    mention = m.group()
+                    mentioned = mention[1:]
+                    __, m_host = mentioned.split("@", 1)
+                    if m_host in (self.public_url, self.client.jid.host):
+                        # we ignore mention of local users, they should be sent as XMPP
+                        # references
+                        continue
+                    try:
+                        mentioned_id = await self.get_ap_actor_id_from_account(mentioned)
+                    except Exception as e:
+                        log.warning(f"Can't add mention to {mentioned!r}: {e}")
+                    else:
+                        ap_object["to"].append(mentioned_id)
+                        ap_object.setdefault("tag", []).append({
+                            "type": TYPE_MENTION,
+                            "href": mentioned_id,
+                            "name": mention,
+                        })
+            try:
+                node = mb_data["node"]
+                service = jid.JID(mb_data["service"])
+            except KeyError:
+                # node and service must always be specified when this method is used
+                raise exceptions.InternalError(
+                    "node or service is missing in mb_data"
+                )
+            target_ap_account = await self.get_ap_account_from_jid_and_node(
+                service, node
+            )
+            if self.is_virtual_jid(service):
+                # service is a proxy JID for AP account
+                actor_data = await self.get_ap_actor_data_from_account(target_ap_account)
+                followers = actor_data.get("followers")
+            else:
+                # service is a real XMPP entity
+                followers = self.build_apurl(TYPE_FOLLOWERS, target_ap_account)
+            if followers:
+                ap_object["cc"] = [followers]
+            if self._m.is_comment_node(node):
+                parent_item = self._m.get_parent_item(node)
+                if self.is_virtual_jid(service):
+                    # the publication is on a virtual node (i.e. an XMPP node managed by
+                    # this gateway and linking to an ActivityPub actor)
+                    ap_object["inReplyTo"] = parent_item
+                else:
+                    # the publication is from a followed real XMPP node
+                    ap_object["inReplyTo"] = await self.get_reply_to_id_from_xmpp_node(
+                        client,
+                        ap_account,
+                        parent_item,
+                        mb_data
+                    )
+
+        return self.create_activity(
+            "Create" if is_new else "Update", url_actor, ap_object, activity_id=url_item
+        )
+
+    async def publish_message(
+        self,
+        client: SatXMPPEntity,
+        mess_data: dict,
+        service: jid.JID
+    ) -> None:
+        """Send an AP message
+
+        .. note::
+
+            This is a temporary method used for development only
+
+        @param mess_data: message data. Following keys must be set:
+
+            ``node``
+              identifier of message which is being replied (this will
+              correspond to pubsub node in the future)
+
+            ``content_xhtml`` or ``content``
+              message body (respectively in XHTML or plain text)
+
+        @param service: JID corresponding to the AP actor.
+        """
+        if not service.user:
+            raise ValueError("service must have a local part")
+        account = self._e.unescape(service.user)
+        ap_actor_data = await self.get_ap_actor_data_from_account(account)
+
+        try:
+            inbox_url = ap_actor_data["endpoints"]["sharedInbox"]
+        except KeyError:
+            raise exceptions.DataError("Can't get ActivityPub actor inbox")
+
+        item_data = await self.mb_data_2_ap_item(client, mess_data)
+        url_actor = item_data["actor"]
+        resp = await self.sign_and_post(inbox_url, url_actor, item_data)
+
+    async def ap_delete_item(
+        self,
+        jid_: jid.JID,
+        node: Optional[str],
+        item_id: str,
+        public: bool = True
+    ) -> Tuple[str, Dict[str, Any]]:
+        """Build activity to delete an AP item
+
+        @param jid_: JID of the entity deleting an item
+        @param node: node where the item is deleted
+            None if it's microblog or a message
+        @param item_id: ID of the item to delete
+            it's the Pubsub ID or message's origin ID
+        @param public: if True, the activity will be addressed to public namespace
+        @return: actor_id of the entity deleting the item, activity to send
+        """
+        if node is None:
+            node = self._m.namespace
+
+        author_account = await self.get_ap_account_from_jid_and_node(jid_, node)
+        author_actor_id = self.build_apurl(TYPE_ACTOR, author_account)
+
+        items = await self.host.memory.storage.search_pubsub_items({
+            "profiles": [self.client.profile],
+            "services": [jid_],
+            "names": [item_id]
+        })
+        if not items:
+            log.warning(
+                f"Deleting an unknown item at service {jid_}, node {node} and id "
+                f"{item_id}"
+            )
+        else:
+            try:
+                mb_data = await self._m.item_2_mb_data(self.client, items[0].data, jid_, node)
+                if "repeated" in mb_data["extra"]:
+                    # we are deleting a repeated item, we must translate this to an
+                    # "Undo" of the "Announce" activity instead of a "Delete" one
+                    announce = await self.repeated_mb_2_ap_item(mb_data)
+                    undo = self.create_activity("Undo", author_actor_id, announce)
+                    return author_actor_id, undo
+            except Exception as e:
+                log.debug(
+                    f"Can't parse item, maybe it's not a blog item: {e}\n"
+                    f"{items[0].toXml()}"
+                )
+
+        url_item = self.build_apurl(TYPE_ITEM, author_account, item_id)
+        ap_item = self.create_activity(
+            "Delete",
+            author_actor_id,
+            {
+                "id": url_item,
+                "type": TYPE_TOMBSTONE
+            }
+        )
+        if public:
+            ap_item["to"] = [NS_AP_PUBLIC]
+        return author_actor_id, ap_item
+
+    def _message_received_trigger(
+        self,
+        client: SatXMPPEntity,
+        message_elt: domish.Element,
+        post_treat: defer.Deferred
+    ) -> bool:
+        """add the gateway workflow on post treatment"""
+        if self.client is None:
+            log.debug(f"no client set, ignoring message: {message_elt.toXml()}")
+            return True
+        post_treat.addCallback(
+            lambda mess_data: defer.ensureDeferred(self.onMessage(client, mess_data))
+        )
+        return True
+
+    async def onMessage(self, client: SatXMPPEntity, mess_data: dict) -> dict:
+        """Called once message has been parsed
+
+        this method handle the conversion to AP items and posting
+        """
+        if client != self.client:
+            return mess_data
+        if mess_data["type"] not in ("chat", "normal"):
+            log.warning(f"ignoring message with unexpected type: {mess_data}")
+            return mess_data
+        if not self.is_local(mess_data["from"]):
+            log.warning(f"ignoring non local message: {mess_data}")
+            return mess_data
+        if not mess_data["to"].user:
+            log.warning(
+                f"ignoring message addressed to gateway itself: {mess_data}"
+            )
+            return mess_data
+
+        actor_account = self._e.unescape(mess_data["to"].user)
+        actor_id = await self.get_ap_actor_id_from_account(actor_account)
+        inbox = await self.get_ap_inbox_from_id(actor_id, use_shared=False)
+
+        try:
+            language, message = next(iter(mess_data["message"].items()))
+        except (KeyError, StopIteration):
+            log.warning(f"ignoring empty message: {mess_data}")
+            return mess_data
+
+        mb_data = {
+            "content": message,
+        }
+        if language:
+            mb_data["language"] = language
+        origin_id = mess_data["extra"].get("origin_id")
+        if origin_id:
+            # we need to use origin ID when present to be able to retract the message
+            mb_data["id"] = origin_id
+        attachments = mess_data["extra"].get(C.KEY_ATTACHMENTS)
+        if attachments:
+            mb_data["extra"] = {
+                C.KEY_ATTACHMENTS: attachments
+            }
+
+        client = self.client.get_virtual_client(mess_data["from"])
+        ap_item = await self.mb_data_2_ap_item(client, mb_data, public=False)
+        ap_object = ap_item["object"]
+        ap_object["to"] = ap_item["to"] = [actor_id]
+        # we add a mention to direct message, otherwise peer is not notified in some AP
+        # implementations (notably Mastodon), and the message may be missed easily.
+        ap_object.setdefault("tag", []).append({
+            "type": TYPE_MENTION,
+            "href": actor_id,
+            "name": f"@{actor_account}",
+        })
+
+        await self.sign_and_post(inbox, ap_item["actor"], ap_item)
+        return mess_data
+
+    async def _on_message_retract(
+        self,
+        client: SatXMPPEntity,
+        message_elt: domish.Element,
+        retract_elt: domish.Element,
+        fastened_elts
+    ) -> bool:
+        if client != self.client:
+            return True
+        from_jid = jid.JID(message_elt["from"])
+        if not self.is_local(from_jid):
+            log.debug(
+                f"ignoring retract request from non local jid {from_jid}"
+            )
+            return False
+        to_jid = jid.JID(message_elt["to"])
+        if (to_jid.host != self.client.jid.full() or not to_jid.user):
+            # to_jid should be a virtual JID from this gateway
+            raise exceptions.InternalError(
+                f"Invalid destinee's JID: {to_jid.full()}"
+            )
+        ap_account = self._e.unescape(to_jid.user)
+        actor_id = await self.get_ap_actor_id_from_account(ap_account)
+        inbox = await self.get_ap_inbox_from_id(actor_id, use_shared=False)
+        url_actor, ap_item = await self.ap_delete_item(
+            from_jid.userhostJID(), None, fastened_elts.id, public=False
+        )
+        resp = await self.sign_and_post(inbox, url_actor, ap_item)
+        return False
+
+    async def _on_reference_received(
+        self,
+        client: SatXMPPEntity,
+        message_elt: domish.Element,
+        reference_data: Dict[str, Union[str, int]]
+    ) -> bool:
+        parsed_uri: dict = reference_data.get("parsed_uri")
+        if not parsed_uri:
+            log.warning(f"no parsed URI available in reference {reference_data}")
+            return False
+
+        try:
+            mentioned = jid.JID(parsed_uri["path"])
+        except RuntimeError:
+            log.warning(f"invalid target: {reference_data['uri']}")
+            return False
+
+        if mentioned.host != self.client.jid.full() or not mentioned.user:
+            log.warning(
+                f"ignoring mentioned user {mentioned}, it's not a JID mapping an AP "
+                "account"
+            )
+            return False
+
+        ap_account = self._e.unescape(mentioned.user)
+        actor_id = await self.get_ap_actor_id_from_account(ap_account)
+
+        parsed_anchor: dict = reference_data.get("parsed_anchor")
+        if not parsed_anchor:
+            log.warning(f"no XMPP anchor, ignoring reference {reference_data!r}")
+            return False
+
+        if parsed_anchor["type"] != "pubsub":
+            log.warning(
+                f"ignoring reference with non pubsub anchor, this is not supported: "
+                "{reference_data!r}"
+            )
+            return False
+
+        try:
+            pubsub_service = jid.JID(parsed_anchor["path"])
+        except RuntimeError:
+            log.warning(f"invalid anchor: {reference_data['anchor']}")
+            return False
+        pubsub_node = parsed_anchor.get("node")
+        if not pubsub_node:
+            log.warning(f"missing pubsub node in anchor: {reference_data['anchor']}")
+            return False
+        pubsub_item = parsed_anchor.get("item")
+        if not pubsub_item:
+            log.warning(f"missing pubsub item in anchor: {reference_data['anchor']}")
+            return False
+
+        cached_node = await self.host.memory.storage.get_pubsub_node(
+            client, pubsub_service, pubsub_node
+        )
+        if not cached_node:
+            log.warning(f"Anchored node not found in cache: {reference_data['anchor']}")
+            return False
+
+        cached_items, __ = await self.host.memory.storage.get_items(
+            cached_node, item_ids=[pubsub_item]
+        )
+        if not cached_items:
+            log.warning(
+                f"Anchored pubsub item not found in cache: {reference_data['anchor']}"
+            )
+            return False
+
+        cached_item = cached_items[0]
+
+        mb_data = await self._m.item_2_mb_data(
+            client, cached_item.data, pubsub_service, pubsub_node
+        )
+        ap_item = await self.mb_data_2_ap_item(client, mb_data)
+        ap_object = ap_item["object"]
+        ap_object["to"] = [actor_id]
+        ap_object.setdefault("tag", []).append({
+            "type": TYPE_MENTION,
+            "href": actor_id,
+            "name": ap_account,
+        })
+
+        inbox = await self.get_ap_inbox_from_id(actor_id, use_shared=False)
+
+        resp = await self.sign_and_post(inbox, ap_item["actor"], ap_item)
+
+        return False
+
+    async def new_reply_to_xmpp_item(
+        self,
+        client: SatXMPPEntity,
+        ap_item: dict,
+        targets: Dict[str, Set[str]],
+        mentions: List[Dict[str, str]],
+    ) -> None:
+        """We got an AP item which is a reply to an XMPP item"""
+        in_reply_to = ap_item["inReplyTo"]
+        url_type, url_args = self.parse_apurl(in_reply_to)
+        if url_type != "item":
+            log.warning(
+                "Ignoring AP item replying to an XMPP item with an unexpected URL "
+                f"type({url_type!r}):\n{pformat(ap_item)}"
+            )
+            return
+        try:
+            parent_item_account, parent_item_id = url_args[0], '/'.join(url_args[1:])
+        except (IndexError, ValueError):
+            log.warning(
+                "Ignoring AP item replying to an XMPP item with invalid inReplyTo URL "
+                f"({in_reply_to!r}):\n{pformat(ap_item)}"
+            )
+            return
+        parent_item_service, parent_item_node = await self.get_jid_and_node(
+            parent_item_account
+        )
+        if parent_item_node is None:
+            parent_item_node = self._m.namespace
+        items, __ = await self._p.get_items(
+            client, parent_item_service, parent_item_node, item_ids=[parent_item_id]
+        )
+        try:
+            parent_item_elt = items[0]
+        except IndexError:
+            log.warning(
+                f"Can't find parent item at {parent_item_service} (node "
+                f"{parent_item_node!r})\n{pformat(ap_item)}")
+            return
+        parent_item_parsed = await self._m.item_2_mb_data(
+            client, parent_item_elt, parent_item_service, parent_item_node
+        )
+        try:
+            comment_service = jid.JID(parent_item_parsed["comments"][0]["service"])
+            comment_node = parent_item_parsed["comments"][0]["node"]
+        except (KeyError, IndexError):
+            # we don't have a comment node set for this item
+            from libervia.backend.tools.xml_tools import pp_elt
+            log.info(f"{pp_elt(parent_item_elt.toXml())}")
+            raise NotImplementedError()
+        else:
+            __, item_elt = await self.ap_item_2_mb_data_and_elt(ap_item)
+            await self._p.publish(client, comment_service, comment_node, [item_elt])
+            await self.notify_mentions(
+                targets, mentions, comment_service, comment_node, item_elt["id"]
+            )
+
+    def get_ap_item_targets(
+        self,
+        item: Dict[str, Any]
+    ) -> Tuple[bool, Dict[str, Set[str]], List[Dict[str, str]]]:
+        """Retrieve targets of an AP item, and indicate if it's a public one
+
+        @param item: AP object payload
+        @return: Are returned:
+            - is_public flag, indicating if the item is world-readable
+            - a dict mapping target type to targets
+        """
+        targets: Dict[str, Set[str]] = {}
+        is_public = False
+        # TODO: handle "audience"
+        for key in ("to", "bto", "cc", "bcc"):
+            values = item.get(key)
+            if not values:
+                continue
+            if isinstance(values, str):
+                values = [values]
+            for value in values:
+                if value in PUBLIC_TUPLE:
+                    is_public = True
+                    continue
+                if not value:
+                    continue
+                if not self.is_local_url(value):
+                    continue
+                target_type = self.parse_apurl(value)[0]
+                if target_type != TYPE_ACTOR:
+                    log.debug(f"ignoring non actor type as a target: {href}")
+                else:
+                    targets.setdefault(target_type, set()).add(value)
+
+        mentions = []
+        tags = item.get("tag")
+        if tags:
+            for tag in tags:
+                if tag.get("type") != TYPE_MENTION:
+                    continue
+                href = tag.get("href")
+                if not href:
+                    log.warning('Missing "href" field from mention object: {tag!r}')
+                    continue
+                if not self.is_local_url(href):
+                    continue
+                uri_type = self.parse_apurl(href)[0]
+                if uri_type != TYPE_ACTOR:
+                    log.debug(f"ignoring non actor URI as a target: {href}")
+                    continue
+                mention = {"uri": href}
+                mentions.append(mention)
+                name = tag.get("name")
+                if name:
+                    mention["content"] = name
+
+        return is_public, targets, mentions
+
+    async def new_ap_item(
+        self,
+        client: SatXMPPEntity,
+        destinee: Optional[jid.JID],
+        node: str,
+        item: dict,
+    ) -> None:
+        """Analyse, cache and send notification for received AP item
+
+        @param destinee: jid of the destinee,
+        @param node: XMPP pubsub node
+        @param item: AP object payload
+        """
+        is_public, targets, mentions = self.get_ap_item_targets(item)
+        if not is_public and targets.keys() == {TYPE_ACTOR}:
+            # this is a direct message
+            await self.handle_message_ap_item(
+                client, targets, mentions, destinee, item
+            )
+        else:
+            await self.handle_pubsub_ap_item(
+                client, targets, mentions, destinee, node, item, is_public
+            )
+
+    async def handle_message_ap_item(
+        self,
+        client: SatXMPPEntity,
+        targets: Dict[str, Set[str]],
+        mentions: List[Dict[str, str]],
+        destinee: Optional[jid.JID],
+        item: dict,
+    ) -> None:
+        """Parse and deliver direct AP items translating to XMPP messages
+
+        @param targets: actors where the item must be delivered
+        @param destinee: jid of the destinee,
+        @param item: AP object payload
+        """
+        targets_jids = {
+            await self.get_jid_from_id(t)
+            for t_set in targets.values()
+            for t in t_set
+        }
+        if destinee is not None:
+            targets_jids.add(destinee)
+        mb_data = await self.ap_item_2_mb_data(item)
+        extra = {
+            "origin_id": mb_data["id"]
+        }
+        attachments = mb_data["extra"].get(C.KEY_ATTACHMENTS)
+        if attachments:
+            extra[C.KEY_ATTACHMENTS] = attachments
+
+        defer_l = []
+        for target_jid in targets_jids:
+            defer_l.append(
+                client.sendMessage(
+                    target_jid,
+                    {'': mb_data.get("content", "")},
+                    mb_data.get("title"),
+                    extra=extra
+                )
+            )
+        await defer.DeferredList(defer_l)
+
+    async def notify_mentions(
+        self,
+        targets: Dict[str, Set[str]],
+        mentions: List[Dict[str, str]],
+        service: jid.JID,
+        node: str,
+        item_id: str,
+    ) -> None:
+        """Send mention notifications to recipients and mentioned entities
+
+        XEP-0372 (References) is used.
+
+        Mentions are also sent to recipients as they are primary audience (see
+        https://www.w3.org/TR/activitystreams-vocabulary/#microsyntaxes).
+
+        """
+        anchor = uri.build_xmpp_uri("pubsub", path=service.full(), node=node, item=item_id)
+        seen = set()
+        # we start with explicit mentions because mentions' content will be used in the
+        # future to fill "begin" and "end" reference attributes (we can't do it at the
+        # moment as there is no way to specify the XML element to use in the blog item).
+        for mention in mentions:
+            mentioned_jid = await self.get_jid_from_id(mention["uri"])
+            self._refs.send_reference(
+                self.client,
+                to_jid=mentioned_jid,
+                anchor=anchor
+            )
+            seen.add(mentioned_jid)
+
+        remaining = {
+            await self.get_jid_from_id(t)
+            for t_set in targets.values()
+            for t in t_set
+        } - seen
+        for target in remaining:
+            self._refs.send_reference(
+                self.client,
+                to_jid=target,
+                anchor=anchor
+            )
+
+    async def handle_pubsub_ap_item(
+        self,
+        client: SatXMPPEntity,
+        targets: Dict[str, Set[str]],
+        mentions: List[Dict[str, str]],
+        destinee: Optional[jid.JID],
+        node: str,
+        item: dict,
+        public: bool
+    ) -> None:
+        """Analyse, cache and deliver AP items translating to Pubsub
+
+        @param targets: actors/collections where the item must be delivered
+        @param destinee: jid of the destinee,
+        @param node: XMPP pubsub node
+        @param item: AP object payload
+        @param public: True if the item is public
+        """
+        # XXX: "public" is not used for now
+        service = client.jid
+        in_reply_to = item.get("inReplyTo")
+
+        if in_reply_to and isinstance(in_reply_to, list):
+            in_reply_to = in_reply_to[0]
+        if in_reply_to and isinstance(in_reply_to, str):
+            if self.is_local_url(in_reply_to):
+                # this is a reply to an XMPP item
+                await self.new_reply_to_xmpp_item(client, item, targets, mentions)
+                return
+
+            # this item is a reply to an AP item, we use or create a corresponding node
+            # for comments
+            parent_node, __ = await self.get_comments_nodes(item["id"], in_reply_to)
+            node = parent_node or node
+            cached_node = await self.host.memory.storage.get_pubsub_node(
+                client, service, node, with_subscriptions=True, create=True,
+                create_kwargs={"subscribed": True}
+            )
+        else:
+            # it is a root item (i.e. not a reply to an other item)
+            create = node == self._events.namespace
+            cached_node = await self.host.memory.storage.get_pubsub_node(
+                client, service, node, with_subscriptions=True, create=create
+            )
+            if cached_node is None:
+                log.warning(
+                    f"Received item in unknown node {node!r} at {service}. This may be "
+                    f"due to a cache purge. We synchronise the node\n{item}"
+
+                )
+                return
+        if item.get("type") == TYPE_EVENT:
+            data, item_elt = await self.ap_events.ap_item_2_event_data_and_elt(item)
+        else:
+            data, item_elt = await self.ap_item_2_mb_data_and_elt(item)
+        await self.host.memory.storage.cache_pubsub_items(
+            client,
+            cached_node,
+            [item_elt],
+            [data]
+        )
+
+        for subscription in cached_node.subscriptions:
+            if subscription.state != SubscriptionState.SUBSCRIBED:
+                continue
+            self.pubsub_service.notifyPublish(
+                service,
+                node,
+                [(subscription.subscriber, None, [item_elt])]
+            )
+
+        await self.notify_mentions(targets, mentions, service, node, item_elt["id"])
+
+    async def new_ap_delete_item(
+        self,
+        client: SatXMPPEntity,
+        destinee: Optional[jid.JID],
+        node: str,
+        item: dict,
+    ) -> None:
+        """Analyse, cache and send notification for received AP item
+
+        @param destinee: jid of the destinee,
+        @param node: XMPP pubsub node
+        @param activity: parent AP activity
+        @param item: AP object payload
+            only the "id" field is used
+        """
+        item_id = item.get("id")
+        if not item_id:
+            raise exceptions.DataError('"id" attribute is missing in item')
+        if not item_id.startswith("http"):
+            raise exceptions.DataError(f"invalid id: {item_id!r}")
+        if self.is_local_url(item_id):
+            raise ValueError("Local IDs should not be used")
+
+        # we have no way to know if a deleted item is a direct one (thus a message) or one
+        # converted to pubsub. We check if the id is in message history to decide what to
+        # do.
+        history = await self.host.memory.storage.get(
+            client,
+            History,
+            History.origin_id,
+            item_id,
+            (History.messages, History.subjects)
+        )
+
+        if history is not None:
+            # it's a direct message
+            if history.source_jid != client.jid:
+                log.warning(
+                    f"retraction received from an entity ''{client.jid}) which is "
+                    f"not the original sender of the message ({history.source_jid}), "
+                    "hack attemps?"
+                )
+                raise exceptions.PermissionError("forbidden")
+
+            await self._r.retract_by_history(client, history)
+        else:
+            # no history in cache with this ID, it's probably a pubsub item
+            cached_node = await self.host.memory.storage.get_pubsub_node(
+                client, client.jid, node, with_subscriptions=True
+            )
+            if cached_node is None:
+                log.warning(
+                    f"Received an item retract for node {node!r} at {client.jid} "
+                    "which is not cached"
+                )
+                raise exceptions.NotFound
+            await self.host.memory.storage.delete_pubsub_items(cached_node, [item_id])
+            # notifyRetract is expecting domish.Element instances
+            item_elt = domish.Element((None, "item"))
+            item_elt["id"] = item_id
+            for subscription in cached_node.subscriptions:
+                if subscription.state != SubscriptionState.SUBSCRIBED:
+                    continue
+                self.pubsub_service.notifyRetract(
+                    client.jid,
+                    node,
+                    [(subscription.subscriber, None, [item_elt])]
+                )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_comp_ap_gateway/ad_hoc.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+
+# Libervia ActivityPub Gateway
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
+from wokkel import data_form
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+
+log = getLogger(__name__)
+NS_XMPP_JID_NODE_2_AP = "https://libervia.org/ap_gateway/xmpp_jid_node_2_ap_actor"
+
+class APAdHocService:
+    """Ad-Hoc commands for AP Gateway"""
+
+    def __init__(self, apg):
+        self.host = apg.host
+        self.apg = apg
+        self._c = self.host.plugins["XEP-0050"]
+
+    def init(self, client: SatXMPPEntity) -> None:
+        self._c.add_ad_hoc_command(
+            client,
+            self.xmpp_jid_node_2_ap_actor,
+            "Convert XMPP JID/Node to AP actor",
+            node=NS_XMPP_JID_NODE_2_AP,
+            allowed_magics=C.ENTITY_ALL,
+        )
+
+    async def xmpp_jid_node_2_ap_actor(
+        self,
+        client: SatXMPPEntity,
+        command_elt: domish.Element,
+        session_data: dict,
+        action: str,
+        node: str
+    ):
+        try:
+            x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
+            command_form = data_form.Form.fromElement(x_elt)
+        except StopIteration:
+            command_form = None
+        if command_form is None or len(command_form.fields) == 0:
+            # root request
+            status = self._c.STATUS.EXECUTING
+            form = data_form.Form(
+                "form", title="XMPP JID/node to AP actor conversion",
+                formNamespace=NS_XMPP_JID_NODE_2_AP
+            )
+
+            field = data_form.Field(
+                "text-single", "jid", required=True
+            )
+            form.addField(field)
+
+            field = data_form.Field(
+                "text-single", "node", required=False
+            )
+            form.addField(field)
+
+            payload = form.toElement()
+            return payload, status, None, None
+        else:
+            xmpp_jid = jid.JID(command_form["jid"])
+            xmpp_node = command_form.get("node")
+            actor = await self.apg.get_ap_account_from_jid_and_node(xmpp_jid, xmpp_node)
+            note = (self._c.NOTE.INFO, actor)
+            status = self._c.STATUS.COMPLETED
+            payload = None
+            return (payload, status, None, note)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_comp_ap_gateway/constants.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+
+# Libervia ActivityPub Gateway
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+IMPORT_NAME = "ap-gateway"
+CONF_SECTION = f"component {IMPORT_NAME}"
+CONTENT_TYPE_AP = "application/activity+json; charset=utf-8"
+TYPE_ACTOR = "actor"
+TYPE_INBOX = "inbox"
+TYPE_SHARED_INBOX = "shared_inbox"
+TYPE_OUTBOX = "outbox"
+TYPE_FOLLOWERS = "followers"
+TYPE_FOLLOWING = "following"
+TYPE_ITEM = "item"
+TYPE_TOMBSTONE = "Tombstone"
+TYPE_MENTION = "Mention"
+TYPE_LIKE = "Like"
+TYPE_REACTION = "EmojiReact"
+TYPE_EVENT = "Event"
+TYPE_JOIN = "Join"
+TYPE_LEAVE = "Leave"
+MEDIA_TYPE_AP = "application/activity+json"
+NS_AP = "https://www.w3.org/ns/activitystreams"
+NS_AP_PUBLIC = f"{NS_AP}#Public"
+# 3 values can be used, see https://www.w3.org/TR/activitypub/#public-addressing
+PUBLIC_TUPLE = (NS_AP_PUBLIC, "as:Public", "Public")
+AP_REQUEST_TYPES = {
+    "GET": {TYPE_ACTOR, TYPE_OUTBOX, TYPE_FOLLOWERS, TYPE_FOLLOWING},
+    "POST": {"inbox"},
+}
+AP_REQUEST_TYPES["HEAD"] = AP_REQUEST_TYPES["GET"]
+# headers to check for signature
+SIGN_HEADERS = {
+    # headers needed for all HTTP methods
+    None: [
+        # tuples are equivalent headers/pseudo headers, one of them must be present
+        ("date", "(created)"),
+        ("digest", "(request-target)"),
+    ],
+    b"GET": ["host"],
+    b"POST": ["digest"]
+}
+PAGE_SIZE = 10
+HS2019 = "hs2019"
+# delay after which a signed request is not accepted anymore
+SIGN_EXP = 12*60*60  # 12 hours (same value as for Mastodon)
+
+LRU_MAX_SIZE = 200
+ACTIVITY_TYPES = (
+    "Accept", "Add", "Announce", "Arrive", "Block", "Create", "Delete", "Dislike", "Flag",
+    "Follow", "Ignore", "Invite", "Join", "Leave", "Like", "Listen", "Move", "Offer",
+    "Question", "Reject", "Read", "Remove", "TentativeReject", "TentativeAccept",
+    "Travel", "Undo", "Update", "View",
+    # non-standard activities
+    "EmojiReact"
+)
+ACTIVITY_TYPES_LOWER = [a.lower() for a in ACTIVITY_TYPES]
+ACTIVITY_OBJECT_MANDATORY = (
+    "Create", "Update", "Delete", "Follow", "Add", "Remove", "Like", "Block", "Undo"
+)
+ACTIVITY_TARGET_MANDATORY = ("Add", "Remove")
+# activities which can be used with Shared Inbox (i.e. with no account specified)
+# must be lowercase
+ACTIVIY_NO_ACCOUNT_ALLOWED = (
+    "create", "update", "delete", "announce", "undo", "like", "emojireact", "join",
+    "leave"
+)
+# maximum number of parents to retrieve when comments_max_depth option is set
+COMMENTS_MAX_PARENTS = 100
+# maximum size of avatar, in bytes
+MAX_AVATAR_SIZE = 1024 * 1024 * 5
+
+# storage prefixes
+ST_AVATAR = "[avatar]"
+ST_AP_CACHE = "[AP_item_cache]"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_comp_ap_gateway/events.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,407 @@
+#!/usr/bin/env python3
+
+# Libervia ActivityPub Gateway
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Tuple
+
+import mimetypes
+import html
+
+import shortuuid
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber import jid
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.tools.common import date_utils, uri
+
+from .constants import NS_AP_PUBLIC, TYPE_ACTOR, TYPE_EVENT, TYPE_ITEM
+
+
+log = getLogger(__name__)
+
+# direct copy of what Mobilizon uses
+AP_EVENTS_CONTEXT = {
+    "@language": "und",
+    "Hashtag": "as:Hashtag",
+    "PostalAddress": "sc:PostalAddress",
+    "PropertyValue": "sc:PropertyValue",
+    "address": {"@id": "sc:address", "@type": "sc:PostalAddress"},
+    "addressCountry": "sc:addressCountry",
+    "addressLocality": "sc:addressLocality",
+    "addressRegion": "sc:addressRegion",
+    "anonymousParticipationEnabled": {"@id": "mz:anonymousParticipationEnabled",
+                                      "@type": "sc:Boolean"},
+    "category": "sc:category",
+    "commentsEnabled": {"@id": "pt:commentsEnabled",
+                        "@type": "sc:Boolean"},
+    "discoverable": "toot:discoverable",
+    "discussions": {"@id": "mz:discussions", "@type": "@id"},
+    "events": {"@id": "mz:events", "@type": "@id"},
+    "ical": "http://www.w3.org/2002/12/cal/ical#",
+    "inLanguage": "sc:inLanguage",
+    "isOnline": {"@id": "mz:isOnline", "@type": "sc:Boolean"},
+    "joinMode": {"@id": "mz:joinMode", "@type": "mz:joinModeType"},
+    "joinModeType": {"@id": "mz:joinModeType",
+                     "@type": "rdfs:Class"},
+    "location": {"@id": "sc:location", "@type": "sc:Place"},
+    "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+    "maximumAttendeeCapacity": "sc:maximumAttendeeCapacity",
+    "memberCount": {"@id": "mz:memberCount", "@type": "sc:Integer"},
+    "members": {"@id": "mz:members", "@type": "@id"},
+    "mz": "https://joinmobilizon.org/ns#",
+    "openness": {"@id": "mz:openness", "@type": "@id"},
+    "participantCount": {"@id": "mz:participantCount",
+                         "@type": "sc:Integer"},
+    "participationMessage": {"@id": "mz:participationMessage",
+                             "@type": "sc:Text"},
+    "postalCode": "sc:postalCode",
+    "posts": {"@id": "mz:posts", "@type": "@id"},
+    "propertyID": "sc:propertyID",
+    "pt": "https://joinpeertube.org/ns#",
+    "remainingAttendeeCapacity": "sc:remainingAttendeeCapacity",
+    "repliesModerationOption": {"@id": "mz:repliesModerationOption",
+                                "@type": "mz:repliesModerationOptionType"},
+    "repliesModerationOptionType": {"@id": "mz:repliesModerationOptionType",
+                                    "@type": "rdfs:Class"},
+    "resources": {"@id": "mz:resources", "@type": "@id"},
+    "sc": "http://schema.org#",
+    "streetAddress": "sc:streetAddress",
+    "timezone": {"@id": "mz:timezone", "@type": "sc:Text"},
+    "todos": {"@id": "mz:todos", "@type": "@id"},
+    "toot": "http://joinmastodon.org/ns#",
+    "uuid": "sc:identifier",
+    "value": "sc:value"
+}
+
+
+class APEvents:
+    """XMPP Events <=> AP Events conversion"""
+
+    def __init__(self, apg):
+        self.host = apg.host
+        self.apg = apg
+        self._events = self.host.plugins["XEP-0471"]
+
+    async def event_data_2_ap_item(
+        self, event_data: dict, author_jid: jid.JID, is_new: bool=True
+    ) -> dict:
+        """Convert event data to AP activity
+
+        @param event_data: event data as used in [plugin_exp_events]
+        @param author_jid: jid of the published of the event
+        @param is_new: if True, the item is a new one (no instance has been found in
+            cache).
+            If True, a "Create" activity will be generated, otherwise an "Update" one will
+            be
+        @return: AP activity wrapping an Event object
+        """
+        if not event_data.get("id"):
+            event_data["id"] = shortuuid.uuid()
+        ap_account = await self.apg.get_ap_account_from_jid_and_node(
+            author_jid,
+            self._events.namespace
+        )
+        url_actor = self.apg.build_apurl(TYPE_ACTOR, ap_account)
+        url_item = self.apg.build_apurl(TYPE_ITEM, ap_account, event_data["id"])
+        ap_object = {
+            "actor": url_actor,
+            "attributedTo": url_actor,
+            "to": [NS_AP_PUBLIC],
+            "id": url_item,
+            "type": TYPE_EVENT,
+            "name": next(iter(event_data["name"].values())),
+            "startTime": date_utils.date_fmt(event_data["start"], "iso"),
+            "endTime": date_utils.date_fmt(event_data["end"], "iso"),
+            "url": url_item,
+        }
+
+        attachment = ap_object["attachment"] = []
+
+        # FIXME: we only handle URL head-picture for now
+        # TODO: handle jingle and use file metadata
+        try:
+            head_picture_url = event_data["head-picture"]["sources"][0]["url"]
+        except (KeyError, IndexError, TypeError):
+            pass
+        else:
+            media_type = mimetypes.guess_type(head_picture_url, False)[0] or "image/jpeg"
+            attachment.append({
+                "name": "Banner",
+                "type": "Document",
+                "mediaType": media_type,
+                "url": head_picture_url,
+            })
+
+        descriptions = event_data.get("descriptions")
+        if descriptions:
+            for description in descriptions:
+                content = description["description"]
+                if description["type"] == "xhtml":
+                    break
+            else:
+                content = f"<p>{html.escape(content)}</p>"  # type: ignore
+            ap_object["content"] = content
+
+        categories = event_data.get("categories")
+        if categories:
+            tag = ap_object["tag"] = []
+            for category in categories:
+                tag.append({
+                    "name": f"#{category['term']}",
+                    "type": "Hashtag",
+                })
+
+        locations = event_data.get("locations")
+        if locations:
+            ap_loc = ap_object["location"] = {}
+            # we only use the first found location
+            location = locations[0]
+            for source, dest in (
+                ("description", "name"),
+                ("lat", "latitude"),
+                ("lon", "longitude"),
+            ):
+                value = location.get(source)
+                if value is not None:
+                    ap_loc[dest] = value
+            for source, dest in (
+                ("country", "addressCountry"),
+                ("locality", "addressLocality"),
+                ("region", "addressRegion"),
+                ("postalcode", "postalCode"),
+                ("street", "streetAddress"),
+            ):
+                value = location.get(source)
+                if value is not None:
+                    ap_loc.setdefault("address", {})[dest] = value
+
+        if event_data.get("comments"):
+            ap_object["commentsEnabled"] = True
+
+        extra = event_data.get("extra")
+
+        if extra:
+            status = extra.get("status")
+            if status:
+                ap_object["ical:status"] = status.upper()
+
+            website = extra.get("website")
+            if website:
+                attachment.append({
+                    "href": website,
+                    "mediaType": "text/html",
+                    "name": "Website",
+                    "type": "Link"
+                })
+
+            accessibility = extra.get("accessibility")
+            if accessibility:
+                wheelchair = accessibility.get("wheelchair")
+                if wheelchair:
+                    if wheelchair == "full":
+                        ap_wc_value = "fully"
+                    elif wheelchair == "partial":
+                        ap_wc_value = "partially"
+                    elif wheelchair == "no":
+                        ap_wc_value = "no"
+                    else:
+                        log.error(f"unexpected wheelchair value: {wheelchair}")
+                        ap_wc_value = None
+                    if ap_wc_value is not None:
+                        attachment.append({
+                            "propertyID": "mz:accessibility:wheelchairAccessible",
+                            "type": "PropertyValue",
+                            "value": ap_wc_value
+                        })
+
+        activity = self.apg.create_activity(
+            "Create" if is_new else "Update", url_actor, ap_object, activity_id=url_item
+        )
+        activity["@context"].append(AP_EVENTS_CONTEXT)
+        return activity
+
+    async def ap_item_2_event_data(self, ap_item: dict) -> dict:
+        """Convert AP activity or object to event data
+
+        @param ap_item: ActivityPub item to convert
+            Can be either an activity of an object
+        @return: AP Item's Object and event data
+        @raise exceptions.DataError: something is invalid in the AP item
+        """
+        is_activity = self.apg.is_activity(ap_item)
+        if is_activity:
+            ap_object = await self.apg.ap_get_object(ap_item, "object")
+            if not ap_object:
+                log.warning(f'No "object" found in AP item {ap_item!r}')
+                raise exceptions.DataError
+        else:
+            ap_object = ap_item
+
+        # id
+        if "_repeated" in ap_item:
+            # if the event is repeated, we use the original one ID
+            repeated_uri = ap_item["_repeated"]["uri"]
+            parsed_uri = uri.parse_xmpp_uri(repeated_uri)
+            object_id = parsed_uri["item"]
+        else:
+            object_id = ap_object.get("id")
+            if not object_id:
+                raise exceptions.DataError('"id" is missing in AP object')
+
+        if ap_item["type"] != TYPE_EVENT:
+            raise exceptions.DataError("AP Object is not an event")
+
+        # author
+        actor = await self.apg.ap_get_sender_actor(ap_object)
+
+        account = await self.apg.get_ap_account_from_id(actor)
+        author_jid = self.apg.get_local_jid_from_account(account).full()
+
+        # name, start, end
+        event_data = {
+            "id": object_id,
+            "name": {"": ap_object.get("name") or "unnamed"},
+            "start": date_utils.date_parse(ap_object["startTime"]),
+            "end": date_utils.date_parse(ap_object["endTime"]),
+        }
+
+        # attachments/extra
+        event_data["extra"] = extra = {}
+        attachments = ap_object.get("attachment") or []
+        for attachment in attachments:
+            name = attachment.get("name")
+            if name == "Banner":
+                try:
+                    url = attachment["url"]
+                except KeyError:
+                    log.warning(f"invalid attachment: {attachment}")
+                    continue
+                event_data["head-picture"] = {"sources": [{"url": url}]}
+            elif name == "Website":
+                try:
+                    url = attachment["href"]
+                except KeyError:
+                    log.warning(f"invalid attachment: {attachment}")
+                    continue
+                extra["website"] = url
+            else:
+                log.debug(f"unmanaged attachment: {attachment}")
+
+        # description
+        content = ap_object.get("content")
+        if content:
+            event_data["descriptions"] = [{
+                "type": "xhtml",
+                "description": content
+            }]
+
+        # categories
+        tags = ap_object.get("tag")
+        if tags:
+            categories = event_data["categories"] = []
+            for tag in tags:
+                if tag.get("type") == "Hashtag":
+                    try:
+                        term = tag["name"][1:]
+                    except KeyError:
+                        log.warning(f"invalid tag: {tag}")
+                        continue
+                    categories.append({"term": term})
+
+        #location
+        ap_location = ap_object.get("location")
+        if ap_location:
+            location = {}
+            for source, dest in (
+                ("name", "description"),
+                ("latitude", "lat"),
+                ("longitude", "lon"),
+            ):
+                value = ap_location.get(source)
+                if value is not None:
+                    location[dest] = value
+            address = ap_location.get("address")
+            if address:
+                for source, dest in (
+                    ("addressCountry", "country"),
+                    ("addressLocality", "locality"),
+                    ("addressRegion", "region"),
+                    ("postalCode", "postalcode"),
+                    ("streetAddress", "street"),
+                ):
+                    value = address.get(source)
+                    if value is not None:
+                        location[dest] = value
+            if location:
+                event_data["locations"] = [location]
+
+        # rsvp
+        # So far Mobilizon seems to only handle participate/don't participate, thus we use
+        # a simple "yes"/"no" form.
+        rsvp_data = {"fields": []}
+        event_data["rsvp"] = [rsvp_data]
+        rsvp_data["fields"].append({
+            "type": "list-single",
+            "name": "attending",
+            "label": "Attending",
+            "options": [
+                {"label": "yes", "value": "yes"},
+                {"label": "no", "value": "no"}
+            ],
+            "required": True
+        })
+
+        # comments
+
+        if ap_object.get("commentsEnabled"):
+            __, comments_node = await self.apg.get_comments_nodes(object_id, None)
+            event_data["comments"] = {
+                "service": author_jid,
+                "node": comments_node,
+            }
+
+        # extra
+        # part of extra come from "attachment" above
+
+        status = ap_object.get("ical:status")
+        if status is None:
+            pass
+        elif status in ("CONFIRMED", "CANCELLED", "TENTATIVE"):
+            extra["status"] = status.lower()
+        else:
+            log.warning(f"unknown event status: {status}")
+
+        return event_data
+
+    async def ap_item_2_event_data_and_elt(
+        self,
+        ap_item: dict
+    ) -> Tuple[dict, domish.Element]:
+        """Convert AP item to parsed event data and corresponding item element"""
+        event_data = await self.ap_item_2_event_data(ap_item)
+        event_elt = self._events.event_data_2_event_elt(event_data)
+        item_elt = domish.Element((None, "item"))
+        item_elt["id"] = event_data["id"]
+        item_elt.addChild(event_elt)
+        return event_data, item_elt
+
+    async def ap_item_2_event_elt(self, ap_item: dict) -> domish.Element:
+        """Convert AP item to XMPP item element"""
+        __, item_elt = await self.ap_item_2_event_data_and_elt(ap_item)
+        return item_elt
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_comp_ap_gateway/http_server.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1328 @@
+#!/usr/bin/env python3
+
+# Libervia ActivityPub Gateway
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import time
+import html
+from typing import Optional, Dict, List, Any
+import json
+from urllib import parse
+from collections import deque
+import unicodedata
+
+from twisted.web import http, resource as web_resource, server
+from twisted.web import static
+from twisted.web import util as web_util
+from twisted.python import failure
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid, error
+from wokkel import pubsub, rsm
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import date_utils, uri
+from libervia.backend.memory.sqla_mapping import SubscriptionState
+
+from .constants import (
+    NS_AP, MEDIA_TYPE_AP, CONTENT_TYPE_AP, TYPE_ACTOR, TYPE_INBOX, TYPE_SHARED_INBOX,
+    TYPE_OUTBOX, TYPE_EVENT, AP_REQUEST_TYPES, PAGE_SIZE, ACTIVITY_TYPES_LOWER,
+    ACTIVIY_NO_ACCOUNT_ALLOWED, SIGN_HEADERS, HS2019, SIGN_EXP, TYPE_FOLLOWERS,
+    TYPE_FOLLOWING, TYPE_ITEM, TYPE_LIKE, TYPE_REACTION, ST_AP_CACHE
+)
+from .regex import RE_SIG_PARAM
+
+
+log = getLogger(__name__)
+
+VERSION = unicodedata.normalize(
+    'NFKD',
+    f"{C.APP_NAME} ActivityPub Gateway {C.APP_VERSION}"
+)
+
+
+class HTTPAPGServer(web_resource.Resource):
+    """HTTP Server handling ActivityPub S2S protocol"""
+    isLeaf = True
+
+    def __init__(self, ap_gateway):
+        self.apg = ap_gateway
+        self._seen_digest = deque(maxlen=50)
+        super().__init__()
+
+    def response_code(
+        self,
+        request: "HTTPRequest",
+        http_code: int,
+        msg: Optional[str] = None
+    ) -> None:
+        """Log and set HTTP return code and associated message"""
+        if msg is not None:
+            log.warning(msg)
+        request.setResponseCode(http_code, None if msg is None else msg.encode())
+
+    def _on_request_error(self, failure_: failure.Failure, request: "HTTPRequest") -> None:
+        exc = failure_.value
+        if isinstance(exc, exceptions.NotFound):
+            self.response_code(
+                request,
+                http.NOT_FOUND,
+                str(exc)
+            )
+        else:
+            log.exception(f"Internal error: {failure_.value}")
+            self.response_code(
+                request,
+                http.INTERNAL_SERVER_ERROR,
+                f"internal error: {failure_.value}"
+            )
+            request.finish()
+            raise failure_
+
+        request.finish()
+
+    async def webfinger(self, request):
+        url_parsed = parse.urlparse(request.uri.decode())
+        query = parse.parse_qs(url_parsed.query)
+        resource = query.get("resource", [""])[0]
+        account = resource[5:].strip()
+        if not resource.startswith("acct:") or not account:
+            return web_resource.ErrorPage(
+                http.BAD_REQUEST, "Bad Request" , "Invalid webfinger resource"
+            ).render(request)
+
+        actor_url = self.apg.build_apurl(TYPE_ACTOR, account)
+
+        resp = {
+            "aliases": [actor_url],
+            "subject": resource,
+            "links": [
+                {
+                    "rel": "self",
+                    "type": "application/activity+json",
+                    "href": actor_url
+                }
+            ]
+        }
+        request.setHeader("content-type", CONTENT_TYPE_AP)
+        request.write(json.dumps(resp).encode())
+        request.finish()
+
+    async def handle_undo_activity(
+        self,
+        request: "HTTPRequest",
+        data: dict,
+        account_jid: jid.JID,
+        node: Optional[str],
+        ap_account: str,
+        ap_url: str,
+        signing_actor: str
+    ) -> None:
+        if node is None:
+            node = self.apg._m.namespace
+        client = await self.apg.get_virtual_client(signing_actor)
+        object_ = data.get("object")
+        if isinstance(object_, str):
+            # we check first if it's not a cached object
+            ap_cache_key = f"{ST_AP_CACHE}{object_}"
+            value = await self.apg.client._ap_storage.get(ap_cache_key)
+        else:
+            value = None
+        if value is not None:
+            objects = [value]
+            # because we'll undo the activity, we can remove it from cache
+            await self.apg.client._ap_storage.remove(ap_cache_key)
+        else:
+            objects = await self.apg.ap_get_list(data, "object")
+        for obj in objects:
+            type_ = obj.get("type")
+            actor = await self.apg.ap_get_sender_actor(obj)
+            if actor != signing_actor:
+                log.warning(f"ignoring object not attributed to signing actor: {data}")
+                continue
+
+            if type_ == "Follow":
+                try:
+                    target_account = obj["object"]
+                except KeyError:
+                    log.warning(f'ignoring invalid object, missing "object" key: {data}')
+                    continue
+                if not self.apg.is_local_url(target_account):
+                    log.warning(f"ignoring unfollow request to non local actor: {data}")
+                    continue
+                await self.apg._p.unsubscribe(
+                    client,
+                    account_jid,
+                    node,
+                    sender=client.jid,
+                )
+            elif type_ == "Announce":
+                # we can use directly the Announce object, as only the "id" field is
+                # needed
+                await self.apg.new_ap_delete_item(client, None, node, obj)
+            elif type_ == TYPE_LIKE:
+                await self.handle_attachment_item(client, obj, {"noticed": False})
+            elif type_ == TYPE_REACTION:
+                await self.handle_attachment_item(client, obj, {
+                    "reactions": {"operation": "update", "remove": [obj["content"]]}
+                })
+            else:
+                log.warning(f"Unmanaged undo type: {type_!r}")
+
+    async def handle_follow_activity(
+        self,
+        request: "HTTPRequest",
+        data: dict,
+        account_jid: jid.JID,
+        node: Optional[str],
+        ap_account: str,
+        ap_url: str,
+        signing_actor: str
+    ) -> None:
+        if node is None:
+            node = self.apg._m.namespace
+        client = await self.apg.get_virtual_client(signing_actor)
+        try:
+            subscription = await self.apg._p.subscribe(
+                client,
+                account_jid,
+                node,
+                # subscriptions from AP are always public
+                options=self.apg._pps.set_public_opt()
+            )
+        except pubsub.SubscriptionPending:
+            log.info(f"subscription to node {node!r} of {account_jid} is pending")
+        # TODO: manage SubscriptionUnconfigured
+        else:
+            if subscription.state != "subscribed":
+                # other states should raise an Exception
+                raise exceptions.InternalError('"subscribed" state was expected')
+            inbox = await self.apg.get_ap_inbox_from_id(signing_actor, use_shared=False)
+            actor_id = self.apg.build_apurl(TYPE_ACTOR, ap_account)
+            accept_data = self.apg.create_activity(
+                "Accept", actor_id, object_=data
+            )
+            await self.apg.sign_and_post(inbox, actor_id, accept_data)
+        await self.apg._c.synchronise(client, account_jid, node, resync=False)
+
+    async def handle_accept_activity(
+        self,
+        request: "HTTPRequest",
+        data: dict,
+        account_jid: jid.JID,
+        node: Optional[str],
+        ap_account: str,
+        ap_url: str,
+        signing_actor: str
+    ) -> None:
+        if node is None:
+            node = self.apg._m.namespace
+        client = await self.apg.get_virtual_client(signing_actor)
+        objects = await self.apg.ap_get_list(data, "object")
+        for obj in objects:
+            type_ = obj.get("type")
+            if type_ == "Follow":
+                follow_node = await self.apg.host.memory.storage.get_pubsub_node(
+                    client, client.jid, node, with_subscriptions=True
+                )
+                if follow_node is None:
+                    log.warning(
+                        f"Received a follow accept on an unknown node: {node!r} at "
+                        f"{client.jid}. Ignoring it"
+                    )
+                    continue
+                try:
+                    sub = next(
+                        s for s in follow_node.subscriptions if s.subscriber==account_jid
+                    )
+                except StopIteration:
+                    log.warning(
+                        "Received a follow accept on a node without subscription: "
+                        f"{node!r} at {client.jid}. Ignoring it"
+                    )
+                else:
+                    if sub.state == SubscriptionState.SUBSCRIBED:
+                        log.warning(f"Already subscribed to {node!r} at {client.jid}")
+                    elif sub.state == SubscriptionState.PENDING:
+                        follow_node.subscribed = True
+                        sub.state = SubscriptionState.SUBSCRIBED
+                        await self.apg.host.memory.storage.add(follow_node)
+                    else:
+                        raise exceptions.InternalError(
+                            f"Unhandled subscription state {sub.state!r}"
+                        )
+            else:
+                log.warning(f"Unmanaged accept type: {type_!r}")
+
+    async def handle_delete_activity(
+        self,
+        request: "HTTPRequest",
+        data: dict,
+        account_jid: Optional[jid.JID],
+        node: Optional[str],
+        ap_account: Optional[str],
+        ap_url: str,
+        signing_actor: str
+    ):
+        if node is None:
+            node = self.apg._m.namespace
+        client = await self.apg.get_virtual_client(signing_actor)
+        objects = await self.apg.ap_get_list(data, "object")
+        for obj in objects:
+            await self.apg.new_ap_delete_item(client, account_jid, node, obj)
+
+    async def handle_new_ap_items(
+        self,
+        request: "HTTPRequest",
+        data: dict,
+        account_jid: Optional[jid.JID],
+        node: Optional[str],
+        signing_actor: str,
+        repeated: bool = False,
+    ):
+        """Helper method to handle workflow for new AP items
+
+        accept globally the same parameter as for handle_create_activity
+        @param repeated: if True, the item is an item republished from somewhere else
+        """
+        if "_repeated" in data:
+            log.error(
+                '"_repeated" field already present in given AP item, this should not '
+                f"happen. Ignoring object from {signing_actor}\n{data}"
+            )
+            raise exceptions.DataError("unexpected field in item")
+        client = await self.apg.get_virtual_client(signing_actor)
+        objects = await self.apg.ap_get_list(data, "object")
+        for obj in objects:
+            if node is None:
+                if obj.get("type") == TYPE_EVENT:
+                    node = self.apg._events.namespace
+                else:
+                    node = self.apg._m.namespace
+            sender = await self.apg.ap_get_sender_actor(obj)
+            if repeated:
+                # we don't check sender when item is repeated, as it should be different
+                # from post author in this case
+                sender_jid = await self.apg.get_jid_from_id(sender)
+                repeater_jid = await self.apg.get_jid_from_id(signing_actor)
+                repeated_item_id = obj["id"]
+                if self.apg.is_local_url(repeated_item_id):
+                    # the repeated object is from XMPP, we need to parse the URL to find
+                    # the right ID
+                    url_type, url_args = self.apg.parse_apurl(repeated_item_id)
+                    if url_type != "item":
+                        raise exceptions.DataError(
+                            "local URI is not an item: {repeated_id}"
+                        )
+                    try:
+                        url_account, url_item_id = url_args
+                        if not url_account or not url_item_id:
+                            raise ValueError
+                    except (RuntimeError, ValueError):
+                        raise exceptions.DataError(
+                            "local URI is invalid: {repeated_id}"
+                        )
+                    else:
+                        url_jid, url_node = await self.apg.get_jid_and_node(url_account)
+                        if ((url_jid != sender_jid
+                             or url_node and url_node != self.apg._m.namespace)):
+                            raise exceptions.DataError(
+                                "announced ID doesn't match sender ({sender}): "
+                                f"[repeated_item_id]"
+                            )
+
+                    repeated_item_id = url_item_id
+
+                obj["_repeated"] = {
+                    "by": repeater_jid.full(),
+                    "at": data.get("published"),
+                    "uri": uri.build_xmpp_uri(
+                        "pubsub",
+                        path=sender_jid.full(),
+                        node=self.apg._m.namespace,
+                        item=repeated_item_id
+                    )
+                }
+                # we must use activity's id and targets, not the original item ones
+                for field in ("id", "to", "bto", "cc", "bcc"):
+                    obj[field] = data.get(field)
+            else:
+                if sender != signing_actor:
+                    log.warning(
+                        "Ignoring object not attributed to signing actor: {obj}"
+                    )
+                    continue
+
+            await self.apg.new_ap_item(client, account_jid, node, obj)
+
+    async def handle_create_activity(
+        self,
+        request: "HTTPRequest",
+        data: dict,
+        account_jid: Optional[jid.JID],
+        node: Optional[str],
+        ap_account: Optional[str],
+        ap_url: str,
+        signing_actor: str
+    ):
+        await self.handle_new_ap_items(request, data, account_jid, node, signing_actor)
+
+    async def handle_update_activity(
+        self,
+        request: "HTTPRequest",
+        data: dict,
+        account_jid: Optional[jid.JID],
+        node: Optional[str],
+        ap_account: Optional[str],
+        ap_url: str,
+        signing_actor: str
+    ):
+        # Update is the same as create: the item ID stays the same, thus the item will be
+        # overwritten
+        await self.handle_new_ap_items(request, data, account_jid, node, signing_actor)
+
+    async def handle_announce_activity(
+        self,
+        request: "HTTPRequest",
+        data: dict,
+        account_jid: Optional[jid.JID],
+        node: Optional[str],
+        ap_account: Optional[str],
+        ap_url: str,
+        signing_actor: str
+    ):
+        # we create a new item
+        await self.handle_new_ap_items(
+            request,
+            data,
+            account_jid,
+            node,
+            signing_actor,
+            repeated=True
+        )
+
+    async def handle_attachment_item(
+        self,
+        client: SatXMPPEntity,
+        data: dict,
+        attachment_data: dict
+    ) -> None:
+        target_ids = data.get("object")
+        if not target_ids:
+            raise exceptions.DataError("object should be set")
+        elif isinstance(target_ids, list):
+            try:
+                target_ids = [o["id"] for o in target_ids]
+            except (KeyError, TypeError):
+                raise exceptions.DataError(f"invalid object: {target_ids!r}")
+        elif isinstance(target_ids, dict):
+            obj_id = target_ids.get("id")
+            if not obj_id or not isinstance(obj_id, str):
+                raise exceptions.DataError(f"invalid object: {target_ids!r}")
+            target_ids = [obj_id]
+        elif isinstance(target_ids, str):
+            target_ids = [target_ids]
+
+        # XXX: we have to cache AP items because some implementation (Pleroma notably)
+        #   don't keep object accessible, and we need to be able to retrieve them for
+        #   UNDO. Current implementation will grow, we need to add a way to flush it after
+        #   a while.
+        # TODO: add a way to flush old cached AP items.
+        await client._ap_storage.aset(f"{ST_AP_CACHE}{data['id']}", data)
+
+        for target_id in target_ids:
+            if not self.apg.is_local_url(target_id):
+                log.debug(f"ignoring non local target ID: {target_id}")
+                continue
+            url_type, url_args = self.apg.parse_apurl(target_id)
+            if url_type != TYPE_ITEM:
+                log.warning(f"unexpected local URL for attachment on item {target_id}")
+                continue
+            try:
+                account, item_id = url_args
+            except ValueError:
+                raise ValueError(f"invalid URL: {target_id}")
+            author_jid, item_node = await self.apg.get_jid_and_node(account)
+            if item_node is None:
+                item_node = self.apg._m.namespace
+            attachment_node = self.apg._pa.get_attachment_node_name(
+                author_jid, item_node, item_id
+            )
+            cached_node = await self.apg.host.memory.storage.get_pubsub_node(
+                client,
+                author_jid,
+                attachment_node,
+                with_subscriptions=True,
+                create=True
+            )
+            found_items, __ = await self.apg.host.memory.storage.get_items(
+                cached_node, item_ids=[client.jid.userhost()]
+            )
+            if not found_items:
+                old_item_elt = None
+            else:
+                found_item = found_items[0]
+                old_item_elt = found_item.data
+
+            item_elt = await self.apg._pa.apply_set_handler(
+                client,
+                {"extra": attachment_data},
+                old_item_elt,
+                None
+            )
+            # we reparse the element, as there can be other attachments
+            attachments_data = self.apg._pa.items_2_attachment_data(client, [item_elt])
+            # and we update the cache
+            await self.apg.host.memory.storage.cache_pubsub_items(
+                client,
+                cached_node,
+                [item_elt],
+                attachments_data or [{}]
+            )
+
+            if self.apg.is_virtual_jid(author_jid):
+                # the attachment is on t a virtual pubsub service (linking to an AP item),
+                # we notify all subscribers
+                for subscription in cached_node.subscriptions:
+                    if subscription.state != SubscriptionState.SUBSCRIBED:
+                        continue
+                    self.apg.pubsub_service.notifyPublish(
+                        author_jid,
+                        attachment_node,
+                        [(subscription.subscriber, None, [item_elt])]
+                    )
+            else:
+                # the attachment is on an XMPP item, we publish it to the attachment node
+                await self.apg._p.send_items(
+                    client, author_jid, attachment_node, [item_elt]
+                )
+
+    async def handle_like_activity(
+        self,
+        request: "HTTPRequest",
+        data: dict,
+        account_jid: Optional[jid.JID],
+        node: Optional[str],
+        ap_account: Optional[str],
+        ap_url: str,
+        signing_actor: str
+    ) -> None:
+        client = await self.apg.get_virtual_client(signing_actor)
+        await self.handle_attachment_item(client, data, {"noticed": True})
+
+    async def handle_emojireact_activity(
+        self,
+        request: "HTTPRequest",
+        data: dict,
+        account_jid: Optional[jid.JID],
+        node: Optional[str],
+        ap_account: Optional[str],
+        ap_url: str,
+        signing_actor: str
+    ) -> None:
+        client = await self.apg.get_virtual_client(signing_actor)
+        await self.handle_attachment_item(client, data, {
+            "reactions": {"operation": "update", "add": [data["content"]]}
+        })
+
+    async def handle_join_activity(
+        self,
+        request: "HTTPRequest",
+        data: dict,
+        account_jid: Optional[jid.JID],
+        node: Optional[str],
+        ap_account: Optional[str],
+        ap_url: str,
+        signing_actor: str
+    ) -> None:
+        client = await self.apg.get_virtual_client(signing_actor)
+        await self.handle_attachment_item(client, data, {"rsvp": {"attending": "yes"}})
+
+    async def handle_leave_activity(
+        self,
+        request: "HTTPRequest",
+        data: dict,
+        account_jid: Optional[jid.JID],
+        node: Optional[str],
+        ap_account: Optional[str],
+        ap_url: str,
+        signing_actor: str
+    ) -> None:
+        client = await self.apg.get_virtual_client(signing_actor)
+        await self.handle_attachment_item(client, data, {"rsvp": {"attending": "no"}})
+
+    async def ap_actor_request(
+        self,
+        request: "HTTPRequest",
+        data: Optional[dict],
+        account_jid: jid.JID,
+        node: Optional[str],
+        ap_account: str,
+        ap_url: str,
+        signing_actor: Optional[str]
+    ) -> dict:
+        inbox = self.apg.build_apurl(TYPE_INBOX, ap_account)
+        shared_inbox = self.apg.build_apurl(TYPE_SHARED_INBOX)
+        outbox = self.apg.build_apurl(TYPE_OUTBOX, ap_account)
+        followers = self.apg.build_apurl(TYPE_FOLLOWERS, ap_account)
+        following = self.apg.build_apurl(TYPE_FOLLOWING, ap_account)
+
+        # we have to use AP account as preferredUsername because it is used to retrieve
+        # actor handle (see https://socialhub.activitypub.rocks/t/how-to-retrieve-user-server-tld-handle-from-actors-url/2196)
+        preferred_username = ap_account.split("@", 1)[0]
+
+        identity_data = await self.apg._i.get_identity(self.apg.client, account_jid)
+        if node and node.startswith(self.apg._events.namespace):
+            events = outbox
+        else:
+            events_account = await self.apg.get_ap_account_from_jid_and_node(
+                account_jid, self.apg._events.namespace
+            )
+            events = self.apg.build_apurl(TYPE_OUTBOX, events_account)
+
+        actor_data = {
+            "@context": [
+                "https://www.w3.org/ns/activitystreams",
+                "https://w3id.org/security/v1"
+            ],
+
+            # XXX: Mastodon doesn't like percent-encode arobas, so we have to unescape it
+            #   if it is escaped
+            "id": ap_url.replace("%40", "@"),
+            "type": "Person",
+            "preferredUsername": preferred_username,
+            "inbox": inbox,
+            "outbox": outbox,
+            "events": events,
+            "followers": followers,
+            "following": following,
+            "publicKey": {
+                "id": f"{ap_url}#main-key",
+                "owner": ap_url,
+                "publicKeyPem": self.apg.public_key_pem
+            },
+            "endpoints": {
+                "sharedInbox": shared_inbox,
+                "events": events,
+            },
+        }
+
+        if identity_data.get("nicknames"):
+            actor_data["name"] = identity_data["nicknames"][0]
+        if identity_data.get("description"):
+            # description is plain text while summary expects HTML
+            actor_data["summary"] = html.escape(identity_data["description"])
+        if identity_data.get("avatar"):
+            avatar_data = identity_data["avatar"]
+            try:
+                filename = avatar_data["filename"]
+                media_type = avatar_data["media_type"]
+            except KeyError:
+                log.error(f"incomplete avatar data: {identity_data!r}")
+            else:
+                avatar_url = self.apg.build_apurl("avatar", filename)
+                actor_data["icon"] = {
+                    "type": "Image",
+                    "url": avatar_url,
+                    "mediaType": media_type
+                }
+
+        return actor_data
+
+    def get_canonical_url(self, request: "HTTPRequest") -> str:
+        return parse.urljoin(
+            f"https://{self.apg.public_url}",
+            request.path.decode().rstrip("/")
+        # we unescape "@" for the same reason as in [ap_actor_request]
+        ).replace("%40", "@")
+
+    def query_data_2_rsm_request(
+        self,
+        query_data: Dict[str, List[str]]
+    ) -> rsm.RSMRequest:
+        """Get RSM kwargs to use with RSMRequest from query data"""
+        page = query_data.get("page")
+
+        if page == ["first"]:
+            return rsm.RSMRequest(max_=PAGE_SIZE, before="")
+        elif page == ["last"]:
+            return rsm.RSMRequest(max_=PAGE_SIZE)
+        else:
+            for query_key in ("index", "before", "after"):
+                try:
+                    kwargs={query_key: query_data[query_key][0], "max_": PAGE_SIZE}
+                except (KeyError, IndexError, ValueError):
+                    pass
+                else:
+                    return rsm.RSMRequest(**kwargs)
+        raise ValueError(f"Invalid query data: {query_data!r}")
+
+    async def ap_outbox_page_request(
+        self,
+        request: "HTTPRequest",
+        data: Optional[dict],
+        account_jid: jid.JID,
+        node: Optional[str],
+        ap_account: str,
+        ap_url: str,
+        query_data: Dict[str, List[str]]
+    ) -> dict:
+        if node is None:
+            node = self.apg._m.namespace
+        # we only keep useful keys, and sort to have consistent URL which can
+        # be used as ID
+        url_keys = sorted(set(query_data) & {"page", "index", "before", "after"})
+        query_data = {k: query_data[k] for k in url_keys}
+        try:
+            items, metadata = await self.apg._p.get_items(
+                client=self.apg.client,
+                service=account_jid,
+                node=node,
+                rsm_request=self.query_data_2_rsm_request(query_data),
+                extra = {C.KEY_USE_CACHE: False}
+            )
+        except error.StanzaError as e:
+            log.warning(f"Can't get data from pubsub node {node} at {account_jid}: {e}")
+            return {}
+
+        base_url = self.get_canonical_url(request)
+        url = f"{base_url}?{parse.urlencode(query_data, True)}"
+        if node and node.startswith(self.apg._events.namespace):
+            ordered_items = [
+                await self.apg.ap_events.event_data_2_ap_item(
+                    self.apg._events.event_elt_2_event_data(item),
+                    account_jid
+                )
+                for item in reversed(items)
+            ]
+        else:
+            ordered_items = [
+                await self.apg.mb_data_2_ap_item(
+                    self.apg.client,
+                    await self.apg._m.item_2_mb_data(
+                        self.apg.client,
+                        item,
+                        account_jid,
+                        node
+                    )
+                )
+                for item in reversed(items)
+            ]
+        ret_data = {
+            "@context": ["https://www.w3.org/ns/activitystreams"],
+            "id": url,
+            "type": "OrderedCollectionPage",
+            "partOf": base_url,
+            "orderedItems": ordered_items
+        }
+
+        if "rsm" not in metadata:
+            # no RSM available, we return what we have
+            return ret_data
+
+        # AP OrderedCollection must be in reversed chronological order, thus the opposite
+        # of what we get with RSM (at least with Libervia Pubsub)
+        if not metadata["complete"]:
+            try:
+                last= metadata["rsm"]["last"]
+            except KeyError:
+                last = None
+            ret_data["prev"] = f"{base_url}?{parse.urlencode({'after': last})}"
+        if metadata["rsm"]["index"] != 0:
+            try:
+                first= metadata["rsm"]["first"]
+            except KeyError:
+                first = None
+            ret_data["next"] = f"{base_url}?{parse.urlencode({'before': first})}"
+
+        return ret_data
+
+    async def ap_outbox_request(
+        self,
+        request: "HTTPRequest",
+        data: Optional[dict],
+        account_jid: jid.JID,
+        node: Optional[str],
+        ap_account: str,
+        ap_url: str,
+        signing_actor: Optional[str]
+    ) -> dict:
+        if node is None:
+            node = self.apg._m.namespace
+
+        parsed_url = parse.urlparse(request.uri.decode())
+        query_data = parse.parse_qs(parsed_url.query)
+        if query_data:
+            return await self.ap_outbox_page_request(
+                request, data, account_jid, node, ap_account, ap_url, query_data
+            )
+
+        # XXX: we can't use disco#info here because this request won't work on a bare jid
+        # due to security considerations of XEP-0030 (we don't have presence
+        # subscription).
+        # The current workaround is to do a request as if RSM was available, and actually
+        # check its availability according to result.
+        try:
+            __, metadata = await self.apg._p.get_items(
+                client=self.apg.client,
+                service=account_jid,
+                node=node,
+                max_items=0,
+                rsm_request=rsm.RSMRequest(max_=0),
+                extra = {C.KEY_USE_CACHE: False}
+            )
+        except error.StanzaError as e:
+            log.warning(f"Can't get data from pubsub node {node} at {account_jid}: {e}")
+            return {}
+        try:
+            items_count = metadata["rsm"]["count"]
+        except KeyError:
+            log.warning(
+                f"No RSM metadata found when requesting pubsub node {node} at "
+                f"{account_jid}, defaulting to items_count=20"
+            )
+            items_count = 20
+
+        url = self.get_canonical_url(request)
+        url_first_page = f"{url}?{parse.urlencode({'page': 'first'})}"
+        url_last_page = f"{url}?{parse.urlencode({'page': 'last'})}"
+        return {
+            "@context": ["https://www.w3.org/ns/activitystreams"],
+            "id": url,
+            "totalItems": items_count,
+            "type": "OrderedCollection",
+            "first": url_first_page,
+            "last": url_last_page,
+        }
+
+    async def ap_inbox_request(
+        self,
+        request: "HTTPRequest",
+        data: Optional[dict],
+        account_jid: Optional[jid.JID],
+        node: Optional[str],
+        ap_account: Optional[str],
+        ap_url: str,
+        signing_actor: Optional[str]
+    ) -> None:
+        assert data is not None
+        if signing_actor is None:
+            raise exceptions.InternalError("signing_actor must be set for inbox requests")
+        await self.check_signing_actor(data, signing_actor)
+        activity_type = (data.get("type") or "").lower()
+        if not activity_type in ACTIVITY_TYPES_LOWER:
+            return self.response_code(
+                request,
+                http.UNSUPPORTED_MEDIA_TYPE,
+                f"request is not an activity, ignoring"
+            )
+
+        if account_jid is None and activity_type not in ACTIVIY_NO_ACCOUNT_ALLOWED:
+            return self.response_code(
+                request,
+                http.UNSUPPORTED_MEDIA_TYPE,
+                f"{activity_type.title()!r} activity must target an account"
+            )
+
+        try:
+            method = getattr(self, f"handle_{activity_type}_activity")
+        except AttributeError:
+            return self.response_code(
+                request,
+                http.UNSUPPORTED_MEDIA_TYPE,
+                f"{activity_type.title()} activity is not yet supported"
+            )
+        else:
+            await method(
+                request, data, account_jid, node, ap_account, ap_url, signing_actor
+            )
+
+    async def ap_followers_request(
+        self,
+        request: "HTTPRequest",
+        data: Optional[dict],
+        account_jid: jid.JID,
+        node: Optional[str],
+        ap_account: Optional[str],
+        ap_url: str,
+        signing_actor: Optional[str]
+    ) -> dict:
+        if node is None:
+            node = self.apg._m.namespace
+        client = self.apg.client
+        subscribers = await self.apg._pps.get_public_node_subscriptions(
+            client, account_jid, node
+        )
+        followers = []
+        for subscriber in subscribers.keys():
+            if self.apg.is_virtual_jid(subscriber):
+                # the subscriber is an AP user subscribed with this gateway
+                ap_account = self.apg._e.unescape(subscriber.user)
+            else:
+                # regular XMPP user
+                ap_account = await self.apg.get_ap_account_from_jid_and_node(subscriber, node)
+            followers.append(ap_account)
+
+        url = self.get_canonical_url(request)
+        return {
+          "@context": ["https://www.w3.org/ns/activitystreams"],
+          "type": "OrderedCollection",
+          "id": url,
+          "totalItems": len(subscribers),
+          "first": {
+            "type": "OrderedCollectionPage",
+            "id": url,
+            "orderedItems": followers
+          }
+        }
+
+    async def ap_following_request(
+        self,
+        request: "HTTPRequest",
+        data: Optional[dict],
+        account_jid: jid.JID,
+        node: Optional[str],
+        ap_account: Optional[str],
+        ap_url: str,
+        signing_actor: Optional[str]
+    ) -> dict[str, Any]:
+        client = self.apg.client
+        subscriptions = await self.apg._pps.subscriptions(
+            client, account_jid, node
+        )
+        following = []
+        for sub_dict in subscriptions:
+            service = jid.JID(sub_dict["service"])
+            if self.apg.is_virtual_jid(service):
+                # the subscription is to an AP actor with this gateway
+                ap_account = self.apg._e.unescape(service.user)
+            else:
+                # regular XMPP user
+                ap_account = await self.apg.get_ap_account_from_jid_and_node(
+                    service, sub_dict["node"]
+                )
+            following.append(ap_account)
+
+        url = self.get_canonical_url(request)
+        return {
+          "@context": ["https://www.w3.org/ns/activitystreams"],
+          "type": "OrderedCollection",
+          "id": url,
+          "totalItems": len(subscriptions),
+          "first": {
+            "type": "OrderedCollectionPage",
+            "id": url,
+            "orderedItems": following
+          }
+        }
+
+    def _get_to_log(
+        self,
+        request: "HTTPRequest",
+        data: Optional[dict] = None,
+    ) -> List[str]:
+        """Get base data to logs in verbose mode"""
+        from pprint import pformat
+        to_log = [
+            "",
+            f"<<< got {request.method.decode()} request - {request.uri.decode()}"
+        ]
+        if data is not None:
+            to_log.append(pformat(data))
+        if self.apg.verbose>=3:
+            headers = "\n".join(
+                f"    {k.decode()}: {v.decode()}"
+                for k,v in request.getAllHeaders().items()
+            )
+            to_log.append(f"  headers:\n{headers}")
+        return to_log
+
+    async def ap_request(
+        self,
+        request: "HTTPRequest",
+        data: Optional[dict] = None,
+        signing_actor: Optional[str] = None
+    ) -> None:
+        if self.apg.verbose:
+            to_log = self._get_to_log(request, data)
+
+        path = request.path.decode()
+        ap_url = parse.urljoin(
+            f"https://{self.apg.public_url}",
+            path
+        )
+        request_type, extra_args = self.apg.parse_apurl(ap_url)
+        if ((MEDIA_TYPE_AP not in (request.getHeader("accept") or "")
+             and request_type in self.apg.html_redirect)):
+            # this is not a AP request, and we have a redirections for it
+            kw = {}
+            if extra_args:
+                kw["jid"], kw["node"] = await self.apg.get_jid_and_node(extra_args[0])
+                kw["jid_user"] = kw["jid"].user
+                if kw["node"] is None:
+                    kw["node"] = self.apg._m.namespace
+                if len(extra_args) > 1:
+                    kw["item"] = extra_args[1]
+                else:
+                    kw["item"] = ""
+            else:
+                kw["jid"], kw["jid_user"], kw["node"], kw["item"] = "", "", "", ""
+
+            redirections = self.apg.html_redirect[request_type]
+            for redirection in redirections:
+                filters = redirection["filters"]
+                if not filters:
+                    break
+                # if we have filter, they must all match
+                elif all(v in kw[k] for k,v in filters.items()):
+                    break
+            else:
+                # no redirection is matching
+                redirection = None
+
+            if redirection is not None:
+                kw = {k: parse.quote(str(v), safe="") for k,v in kw.items()}
+                target_url = redirection["url"].format(**kw)
+                content = web_util.redirectTo(target_url.encode(), request)
+                request.write(content)
+                request.finish()
+                return
+
+        if len(extra_args) == 0:
+            if request_type != "shared_inbox":
+                raise exceptions.DataError(f"Invalid request type: {request_type!r}")
+            ret_data = await self.ap_inbox_request(
+                request, data, None, None, None, ap_url, signing_actor
+            )
+        elif request_type == "avatar":
+            if len(extra_args) != 1:
+                raise exceptions.DataError("avatar argument expected in URL")
+            avatar_filename = extra_args[0]
+            avatar_path = self.apg.host.common_cache.getPath(avatar_filename)
+            return static.File(str(avatar_path)).render(request)
+        elif request_type == "item":
+            ret_data = await self.apg.ap_get_local_object(ap_url)
+            if "@context" not in ret_data:
+                ret_data["@context"] = [NS_AP]
+        else:
+            if len(extra_args) > 1:
+                log.warning(f"unexpected extra arguments: {extra_args!r}")
+            ap_account = extra_args[0]
+            account_jid, node = await self.apg.get_jid_and_node(ap_account)
+            if request_type not in AP_REQUEST_TYPES.get(
+                    request.method.decode().upper(), []
+            ):
+                raise exceptions.DataError(f"Invalid request type: {request_type!r}")
+            method = getattr(self, f"AP{request_type.title()}Request")
+            ret_data = await method(
+                request, data, account_jid, node, ap_account, ap_url, signing_actor
+            )
+        if ret_data is not None:
+            request.setHeader("content-type", CONTENT_TYPE_AP)
+            request.write(json.dumps(ret_data).encode())
+        if self.apg.verbose:
+            to_log.append(f"--- RET (code: {request.code})---")
+            if self.apg.verbose>=2:
+                if ret_data is not None:
+                    from pprint import pformat
+                    to_log.append(f"{pformat(ret_data)}")
+                    to_log.append("---")
+            log.info("\n".join(to_log))
+        request.finish()
+
+    async def ap_post_request(self, request: "HTTPRequest") -> None:
+        try:
+            data = json.load(request.content)
+            if not isinstance(data, dict):
+                log.warning(f"JSON data should be an object (uri={request.uri.decode()})")
+                self.response_code(
+                    request,
+                    http.BAD_REQUEST,
+                    f"invalid body, was expecting a JSON object"
+                )
+                request.finish()
+                return
+        except (json.JSONDecodeError, ValueError) as e:
+            self.response_code(
+                request,
+                http.BAD_REQUEST,
+                f"invalid json in inbox request: {e}"
+            )
+            request.finish()
+            return
+        else:
+            request.content.seek(0)
+
+        try:
+            if data["type"] == "Delete" and data["actor"] == data["object"]:
+                # we don't handle actor deletion
+                request.setResponseCode(http.ACCEPTED)
+                log.debug(f"ignoring actor deletion ({data['actor']})")
+                # TODO: clean data in cache coming from this actor, maybe with a tombstone
+                request.finish()
+                return
+        except KeyError:
+            pass
+
+        try:
+            signing_actor = await self.check_signature(request)
+        except exceptions.EncryptionError as e:
+            if self.apg.verbose:
+                to_log = self._get_to_log(request)
+                to_log.append(f"  body: {request.content.read()!r}")
+                request.content.seek(0)
+                log.info("\n".join(to_log))
+            self.response_code(
+                request,
+                http.FORBIDDEN,
+                f"invalid signature: {e}"
+            )
+            request.finish()
+            return
+        except Exception as e:
+            self.response_code(
+                request,
+                http.INTERNAL_SERVER_ERROR,
+                f"Can't check signature: {e}"
+            )
+            request.finish()
+            return
+
+        request.setResponseCode(http.ACCEPTED)
+
+        digest = request.getHeader("digest")
+        if digest in self._seen_digest:
+            log.debug(f"Ignoring duplicated request (digest: {digest!r})")
+            request.finish()
+            return
+        self._seen_digest.append(digest)
+
+        # default response code, may be changed, e.g. in case of exception
+        try:
+            return await self.ap_request(request, data, signing_actor)
+        except Exception as e:
+            self._on_request_error(failure.Failure(e), request)
+
+    async def check_signing_actor(self, data: dict, signing_actor: str) -> None:
+        """That that signing actor correspond to actor declared in data
+
+        @param data: request payload
+        @param signing_actor: actor ID of the signing entity, as returned by
+            check_signature
+        @raise exceptions.NotFound: no actor found in data
+        @raise exceptions.EncryptionError: signing actor doesn't match actor in data
+        """
+        actor = await self.apg.ap_get_sender_actor(data)
+
+        if signing_actor != actor:
+            raise exceptions.EncryptionError(
+                f"signing actor ({signing_actor}) doesn't match actor in data ({actor})"
+            )
+
+    async def check_signature(self, request: "HTTPRequest") -> str:
+        """Check and validate HTTP signature
+
+        @return: id of the signing actor
+
+        @raise exceptions.EncryptionError: signature is not present or doesn't match
+        """
+        signature = request.getHeader("Signature")
+        if signature is None:
+            raise exceptions.EncryptionError("No signature found")
+        sign_data = {
+            m["key"]: m["uq_value"] or m["quoted_value"][1:-1]
+            for m in RE_SIG_PARAM.finditer(signature)
+        }
+        try:
+            key_id = sign_data["keyId"]
+        except KeyError:
+            raise exceptions.EncryptionError('"keyId" is missing from signature')
+        algorithm = sign_data.get("algorithm", HS2019)
+        signed_headers = sign_data.get(
+            "headers",
+            "(created)" if algorithm==HS2019 else "date"
+        ).lower().split()
+        try:
+            headers_to_check = SIGN_HEADERS[None] + SIGN_HEADERS[request.method]
+        except KeyError:
+            raise exceptions.InternalError(
+                f"there should be a list of headers for {request.method} method"
+            )
+        if not headers_to_check:
+            raise exceptions.InternalError("headers_to_check must not be empty")
+
+        for header in headers_to_check:
+            if isinstance(header, tuple):
+                if len(set(header).intersection(signed_headers)) == 0:
+                    raise exceptions.EncryptionError(
+                        f"at least one of following header must be signed: {header}"
+                    )
+            elif header not in signed_headers:
+                raise exceptions.EncryptionError(
+                    f"the {header!r} header must be signed"
+                )
+
+        body = request.content.read()
+        request.content.seek(0)
+        headers = {}
+        for to_sign in signed_headers:
+            if to_sign == "(request-target)":
+                method = request.method.decode().lower()
+                uri = request.uri.decode()
+                headers[to_sign] = f"{method} /{uri.lstrip('/')}"
+            elif to_sign in ("(created)", "(expires)"):
+                if algorithm != HS2019:
+                    raise exceptions.EncryptionError(
+                        f"{to_sign!r} pseudo-header can only be used with {HS2019} "
+                        "algorithm"
+                    )
+                key = to_sign[1:-1]
+                value = sign_data.get(key)
+                if not value:
+                    raise exceptions.EncryptionError(
+                        "{key!r} parameter is missing from signature"
+                    )
+                try:
+                    if float(value) < 0:
+                        raise ValueError
+                except ValueError:
+                    raise exceptions.EncryptionError(
+                        f"{to_sign} must be a Unix timestamp"
+                    )
+                headers[to_sign] = value
+            else:
+                value = request.getHeader(to_sign)
+                if not value:
+                    raise exceptions.EncryptionError(
+                        f"value of header {to_sign!r} is missing!"
+                    )
+                elif to_sign == "host":
+                    # we check Forwarded/X-Forwarded-Host headers
+                    # as we need original host if a proxy has modified the header
+                    forwarded = request.getHeader("forwarded")
+                    if forwarded is not None:
+                        try:
+                            host = [
+                                f[5:] for f in forwarded.split(";")
+                                if f.startswith("host=")
+                            ][0] or None
+                        except IndexError:
+                            host = None
+                    else:
+                        host = None
+                    if host is None:
+                        host = request.getHeader("x-forwarded-host")
+                    if host:
+                        value = host
+                elif to_sign == "digest":
+                    hashes = {
+                        algo.lower(): hash_ for algo, hash_ in (
+                            digest.split("=", 1) for digest in value.split(",")
+                        )
+                    }
+                    try:
+                        given_digest = hashes["sha-256"]
+                    except KeyError:
+                        raise exceptions.EncryptionError(
+                            "Only SHA-256 algorithm is currently supported for digest"
+                        )
+                    __, computed_digest = self.apg.get_digest(body)
+                    if given_digest != computed_digest:
+                        raise exceptions.EncryptionError(
+                            f"SHA-256 given and computed digest differ:\n"
+                            f"given: {given_digest!r}\ncomputed: {computed_digest!r}"
+                        )
+                headers[to_sign] = value
+
+        # date check
+        limit_ts = time.time() + SIGN_EXP
+        if "(created)" in headers:
+            created = float(headers["created"])
+        else:
+            created = date_utils.date_parse(headers["date"])
+
+
+        try:
+            expires = float(headers["expires"])
+        except KeyError:
+            pass
+        else:
+            if expires < created:
+                log.warning(
+                    f"(expires) [{expires}] set in the past of (created) [{created}] "
+                    "ignoring it according to specs"
+                )
+            else:
+                limit_ts = min(limit_ts, expires)
+
+        if created > limit_ts:
+            raise exceptions.EncryptionError("Signature has expired")
+
+        try:
+            return await self.apg.check_signature(
+                sign_data["signature"],
+                key_id,
+                headers
+            )
+        except exceptions.EncryptionError:
+            method, url = headers["(request-target)"].rsplit(' ', 1)
+            headers["(request-target)"] = f"{method} {parse.unquote(url)}"
+            log.debug(
+                "Using workaround for (request-target) encoding bug in signature, "
+                "see https://github.com/mastodon/mastodon/issues/18871"
+            )
+            return await self.apg.check_signature(
+                sign_data["signature"],
+                key_id,
+                headers
+            )
+
+    def render(self, request):
+        request.setHeader("server", VERSION)
+        return super().render(request)
+
+    def render_GET(self, request):
+        path = request.path.decode().lstrip("/")
+        if path.startswith(".well-known/webfinger"):
+            defer.ensureDeferred(self.webfinger(request))
+            return server.NOT_DONE_YET
+        elif path.startswith(self.apg.ap_path):
+            d = defer.ensureDeferred(self.ap_request(request))
+            d.addErrback(self._on_request_error, request)
+            return server.NOT_DONE_YET
+
+        return web_resource.NoResource().render(request)
+
+    def render_POST(self, request):
+        path = request.path.decode().lstrip("/")
+        if not path.startswith(self.apg.ap_path):
+            return web_resource.NoResource().render(request)
+        defer.ensureDeferred(self.ap_post_request(request))
+        return server.NOT_DONE_YET
+
+
+class HTTPRequest(server.Request):
+    pass
+
+
+class HTTPServer(server.Site):
+    requestFactory = HTTPRequest
+
+    def __init__(self, ap_gateway):
+        super().__init__(HTTPAPGServer(ap_gateway))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_comp_ap_gateway/pubsub_service.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,570 @@
+#!/usr/bin/env python3
+
+# Libervia ActivityPub Gateway
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional, Tuple, List, Dict, Any, Union
+from urllib.parse import urlparse
+from pathlib import Path
+from base64 import b64encode
+import tempfile
+
+from twisted.internet import defer, threads
+from twisted.words.protocols.jabber import jid, error
+from twisted.words.xish import domish
+from wokkel import rsm, pubsub, disco
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.constants import Const as C
+from libervia.backend.tools import image
+from libervia.backend.tools.utils import ensure_deferred
+from libervia.backend.tools.web import download_file
+from libervia.backend.memory.sqla_mapping import PubsubSub, SubscriptionState
+
+from .constants import (
+    TYPE_ACTOR,
+    ST_AVATAR,
+    MAX_AVATAR_SIZE
+)
+
+
+log = getLogger(__name__)
+
+# all nodes have the same config
+NODE_CONFIG = [
+    {"var": "pubsub#persist_items", "type": "boolean", "value": True},
+    {"var": "pubsub#max_items", "value": "max"},
+    {"var": "pubsub#access_model", "type": "list-single", "value": "open"},
+    {"var": "pubsub#publish_model", "type": "list-single", "value": "open"},
+
+]
+
+NODE_CONFIG_VALUES = {c["var"]: c["value"] for c in NODE_CONFIG}
+NODE_OPTIONS = {c["var"]: {} for c in NODE_CONFIG}
+for c in NODE_CONFIG:
+    NODE_OPTIONS[c["var"]].update({k:v for k,v in c.items() if k not in ("var", "value")})
+
+
+class APPubsubService(rsm.PubSubService):
+    """Pubsub service for XMPP requests"""
+
+    def __init__(self, apg):
+        super(APPubsubService, self).__init__()
+        self.host = apg.host
+        self.apg = apg
+        self.discoIdentity = {
+            "category": "pubsub",
+            "type": "service",
+            "name": "Libervia ActivityPub Gateway",
+        }
+
+    async def get_ap_actor_ids_and_inbox(
+        self,
+        requestor: jid.JID,
+        recipient: jid.JID,
+    ) -> Tuple[str, str, str]:
+        """Get AP actor IDs from requestor and destinee JIDs
+
+        @param requestor: XMPP entity doing a request to an AP actor via the gateway
+        @param recipient: JID mapping an AP actor via the gateway
+        @return: requestor actor ID, recipient actor ID and recipient inbox
+        @raise error.StanzaError: "item-not-found" is raised if not user part is specified
+            in requestor
+        """
+        if not recipient.user:
+            raise error.StanzaError(
+                "item-not-found",
+                text="No user part specified"
+            )
+        requestor_actor_id = self.apg.build_apurl(TYPE_ACTOR, requestor.userhost())
+        recipient_account = self.apg._e.unescape(recipient.user)
+        recipient_actor_id = await self.apg.get_ap_actor_id_from_account(recipient_account)
+        inbox = await self.apg.get_ap_inbox_from_id(recipient_actor_id, use_shared=False)
+        return requestor_actor_id, recipient_actor_id, inbox
+
+
+    @ensure_deferred
+    async def publish(self, requestor, service, nodeIdentifier, items):
+        if self.apg.local_only and not self.apg.is_local(requestor):
+            raise error.StanzaError(
+                "forbidden",
+                "Only local users can publish on this gateway."
+            )
+        if not service.user:
+            raise error.StanzaError(
+                "bad-request",
+                "You must specify an ActivityPub actor account in JID user part."
+            )
+        ap_account = self.apg._e.unescape(service.user)
+        if ap_account.count("@") != 1:
+            raise error.StanzaError(
+                "bad-request",
+                f"{ap_account!r} is not a valid ActivityPub actor account."
+            )
+
+        client = self.apg.client.get_virtual_client(requestor)
+        if self.apg._pa.is_attachment_node(nodeIdentifier):
+            await self.apg.convert_and_post_attachments(
+                client, ap_account, service, nodeIdentifier, items, publisher=requestor
+            )
+        else:
+            await self.apg.convert_and_post_items(
+                client, ap_account, service, nodeIdentifier, items
+            )
+            cached_node = await self.host.memory.storage.get_pubsub_node(
+                client, service, nodeIdentifier, with_subscriptions=True, create=True
+            )
+            await self.host.memory.storage.cache_pubsub_items(
+                client,
+                cached_node,
+                items
+            )
+            for subscription in cached_node.subscriptions:
+                if subscription.state != SubscriptionState.SUBSCRIBED:
+                    continue
+                self.notifyPublish(
+                    service,
+                    nodeIdentifier,
+                    [(subscription.subscriber, None, items)]
+                )
+
+    async def ap_following_2_elt(self, ap_item: dict) -> domish.Element:
+        """Convert actor ID from following collection to XMPP item"""
+        actor_id = ap_item["id"]
+        actor_jid = await self.apg.get_jid_from_id(actor_id)
+        subscription_elt = self.apg._pps.build_subscription_elt(
+            self.apg._m.namespace, actor_jid
+        )
+        item_elt = pubsub.Item(id=actor_id, payload=subscription_elt)
+        return item_elt
+
+    async def ap_follower_2_elt(self, ap_item: dict) -> domish.Element:
+        """Convert actor ID from followers collection to XMPP item"""
+        actor_id = ap_item["id"]
+        actor_jid = await self.apg.get_jid_from_id(actor_id)
+        subscriber_elt = self.apg._pps.build_subscriber_elt(actor_jid)
+        item_elt = pubsub.Item(id=actor_id, payload=subscriber_elt)
+        return item_elt
+
+    async def generate_v_card(self, ap_account: str) -> domish.Element:
+        """Generate vCard4 (XEP-0292) item element from ap_account's metadata"""
+        actor_data = await self.apg.get_ap_actor_data_from_account(ap_account)
+        identity_data = {}
+
+        summary = actor_data.get("summary")
+        # summary is HTML, we have to convert it to text
+        if summary:
+            identity_data["description"] = await self.apg._t.convert(
+                summary,
+                self.apg._t.SYNTAX_XHTML,
+                self.apg._t.SYNTAX_TEXT,
+                False,
+            )
+
+        for field in ("name", "preferredUsername"):
+            value = actor_data.get(field)
+            if value:
+                identity_data.setdefault("nicknames", []).append(value)
+        vcard_elt = self.apg._v.dict_2_v_card(identity_data)
+        item_elt = domish.Element((pubsub.NS_PUBSUB, "item"))
+        item_elt.addChild(vcard_elt)
+        item_elt["id"] = self.apg._p.ID_SINGLETON
+        return item_elt
+
+    async def get_avatar_data(
+        self,
+        client: SatXMPPEntity,
+        ap_account: str
+    ) -> Dict[str, Any]:
+        """Retrieve actor's avatar if any, cache it and file actor_data
+
+        ``cache_uid``, `path``` and ``media_type`` keys are always files
+        ``base64`` key is only filled if the file was not already in cache
+        """
+        actor_data = await self.apg.get_ap_actor_data_from_account(ap_account)
+
+        for icon in await self.apg.ap_get_list(actor_data, "icon"):
+            url = icon.get("url")
+            if icon["type"] != "Image" or not url:
+                continue
+            parsed_url = urlparse(url)
+            if not parsed_url.scheme in ("http", "https"):
+                log.warning(f"unexpected URL scheme: {url!r}")
+                continue
+            filename = Path(parsed_url.path).name
+            if not filename:
+                log.warning(f"ignoring URL with invald path: {url!r}")
+                continue
+            break
+        else:
+            raise error.StanzaError("item-not-found")
+
+        key = f"{ST_AVATAR}{url}"
+        cache_uid = await client._ap_storage.get(key)
+
+        if cache_uid is None:
+            cache = None
+        else:
+            cache = self.apg.host.common_cache.get_metadata(cache_uid)
+
+        if cache is None:
+            with tempfile.TemporaryDirectory() as dir_name:
+                dest_path = Path(dir_name, filename)
+                await download_file(url, dest_path, max_size=MAX_AVATAR_SIZE)
+                avatar_data = {
+                    "path": dest_path,
+                    "filename": filename,
+                    'media_type': image.guess_type(dest_path),
+                }
+
+                await self.apg._i.cache_avatar(
+                    self.apg.IMPORT_NAME,
+                    avatar_data
+                )
+        else:
+            avatar_data = {
+            "cache_uid": cache["uid"],
+            "path": cache["path"],
+            "media_type": cache["mime_type"]
+        }
+
+        return avatar_data
+
+    async def generate_avatar_metadata(
+        self,
+        client: SatXMPPEntity,
+        ap_account: str
+    ) -> domish.Element:
+        """Generate the metadata element for user avatar
+
+        @raise StanzaError("item-not-found"): no avatar is present in actor data (in
+            ``icon`` field)
+        """
+        avatar_data = await self.get_avatar_data(client, ap_account)
+        return self.apg._a.build_item_metadata_elt(avatar_data)
+
+    def _blocking_b_6_4_encode_avatar(self, avatar_data: Dict[str, Any]) -> None:
+        with avatar_data["path"].open("rb") as f:
+            avatar_data["base64"] = b64encode(f.read()).decode()
+
+    async def generate_avatar_data(
+        self,
+        client: SatXMPPEntity,
+        ap_account: str,
+        itemIdentifiers: Optional[List[str]],
+    ) -> domish.Element:
+        """Generate the data element for user avatar
+
+        @raise StanzaError("item-not-found"): no avatar cached with requested ID
+        """
+        if not itemIdentifiers:
+            avatar_data = await self.get_avatar_data(client, ap_account)
+            if "base64" not in avatar_data:
+                await threads.deferToThread(self._blocking_b_6_4_encode_avatar, avatar_data)
+        else:
+            if len(itemIdentifiers) > 1:
+                # only a single item ID is supported
+                raise error.StanzaError("item-not-found")
+            item_id = itemIdentifiers[0]
+            # just to be sure that that we don't have an empty string
+            assert item_id
+            cache_data = self.apg.host.common_cache.get_metadata(item_id)
+            if cache_data is None:
+                raise error.StanzaError("item-not-found")
+            avatar_data = {
+                "cache_uid": item_id,
+                "path": cache_data["path"]
+            }
+            await threads.deferToThread(self._blocking_b_6_4_encode_avatar, avatar_data)
+
+        return self.apg._a.build_item_data_elt(avatar_data)
+
+    @ensure_deferred
+    async def items(
+        self,
+        requestor: jid.JID,
+        service: jid.JID,
+        node: str,
+        maxItems: Optional[int],
+        itemIdentifiers: Optional[List[str]],
+        rsm_req: Optional[rsm.RSMRequest]
+    ) -> Tuple[List[domish.Element], Optional[rsm.RSMResponse]]:
+        if not service.user:
+            return [], None
+        ap_account = self.host.plugins["XEP-0106"].unescape(service.user)
+        if ap_account.count("@") != 1:
+            log.warning(f"Invalid AP account used by {requestor}: {ap_account!r}")
+            return [], None
+
+        # cached_node may be pre-filled with some nodes (e.g. attachments nodes),
+        # otherwise it is filled when suitable
+        cached_node = None
+        client = self.apg.client
+        kwargs = {}
+
+        if node == self.apg._pps.subscriptions_node:
+            collection_name = "following"
+            parser = self.ap_following_2_elt
+            kwargs["only_ids"] = True
+            use_cache = False
+        elif node.startswith(self.apg._pps.subscribers_node_prefix):
+            collection_name = "followers"
+            parser = self.ap_follower_2_elt
+            kwargs["only_ids"] = True
+            use_cache = False
+        elif node == self.apg._v.node:
+            # vCard4 request
+            item_elt = await self.generate_v_card(ap_account)
+            return [item_elt], None
+        elif node == self.apg._a.namespace_metadata:
+            item_elt = await self.generate_avatar_metadata(self.apg.client, ap_account)
+            return [item_elt], None
+        elif node == self.apg._a.namespace_data:
+            item_elt = await self.generate_avatar_data(
+                self.apg.client, ap_account, itemIdentifiers
+            )
+            return [item_elt], None
+        elif self.apg._pa.is_attachment_node(node):
+            use_cache = True
+            # we check cache here because we emit an item-not-found error if the node is
+            # not in cache, as we are not dealing with real AP items
+            cached_node = await self.host.memory.storage.get_pubsub_node(
+                client, service, node
+            )
+            if cached_node is None:
+                raise error.StanzaError("item-not-found")
+        else:
+            if node.startswith(self.apg._m.namespace):
+                parser = self.apg.ap_item_2_mb_elt
+            elif node.startswith(self.apg._events.namespace):
+                parser = self.apg.ap_events.ap_item_2_event_elt
+            else:
+                raise error.StanzaError(
+                    "feature-not-implemented",
+                    text=f"AP Gateway {C.APP_VERSION} only supports "
+                    f"{self.apg._m.namespace} node for now"
+                )
+            collection_name = "outbox"
+            use_cache = True
+
+        if use_cache:
+            if cached_node is None:
+                cached_node = await self.host.memory.storage.get_pubsub_node(
+                    client, service, node
+                )
+            # TODO: check if node is synchronised
+            if cached_node is not None:
+                # the node is cached, we return items from cache
+                log.debug(f"node {node!r} from {service} is in cache")
+                pubsub_items, metadata = await self.apg._c.get_items_from_cache(
+                    client, cached_node, maxItems, itemIdentifiers, rsm_request=rsm_req
+                )
+                try:
+                    rsm_resp = rsm.RSMResponse(**metadata["rsm"])
+                except KeyError:
+                    rsm_resp = None
+                return [i.data for i in pubsub_items], rsm_resp
+
+        if itemIdentifiers:
+            items = []
+            for item_id in itemIdentifiers:
+                item_data = await self.apg.ap_get(item_id)
+                item_elt = await parser(item_data)
+                items.append(item_elt)
+            return items, None
+        else:
+            if rsm_req is None:
+                if maxItems is None:
+                    maxItems = 20
+                kwargs.update({
+                    "max_items": maxItems,
+                    "chronological_pagination": False,
+                })
+            else:
+                if len(
+                    [v for v in (rsm_req.after, rsm_req.before, rsm_req.index)
+                     if v is not None]
+                ) > 1:
+                    raise error.StanzaError(
+                        "bad-request",
+                        text="You can't use after, before and index at the same time"
+                    )
+                kwargs.update({"max_items": rsm_req.max})
+                if rsm_req.after is not None:
+                    kwargs["after_id"] = rsm_req.after
+                elif rsm_req.before is not None:
+                    kwargs["chronological_pagination"] = False
+                    if rsm_req.before != "":
+                        kwargs["after_id"] = rsm_req.before
+                elif rsm_req.index is not None:
+                    kwargs["start_index"] = rsm_req.index
+
+            log.info(
+                f"No cache found for node {node} at {service} (AP account {ap_account}), "
+                "using Collection Paging to RSM translation"
+            )
+            if self.apg._m.is_comment_node(node):
+                parent_item = self.apg._m.get_parent_item(node)
+                try:
+                    parent_data = await self.apg.ap_get(parent_item)
+                    collection = await self.apg.ap_get_object(
+                        parent_data.get("object", {}),
+                        "replies"
+                    )
+                except Exception as e:
+                    raise error.StanzaError(
+                        "item-not-found",
+                        text=e
+                    )
+            else:
+                actor_data = await self.apg.get_ap_actor_data_from_account(ap_account)
+                collection = await self.apg.ap_get_object(actor_data, collection_name)
+            if not collection:
+                raise error.StanzaError(
+                    "item-not-found",
+                    text=f"No collection found for node {node!r} (account: {ap_account})"
+                )
+
+            kwargs["parser"] = parser
+            return await self.apg.get_ap_items(collection, **kwargs)
+
+    @ensure_deferred
+    async def retract(self, requestor, service, nodeIdentifier, itemIdentifiers):
+        raise error.StanzaError("forbidden")
+
+    @ensure_deferred
+    async def subscribe(self, requestor, service, nodeIdentifier, subscriber):
+        # TODO: handle comments nodes
+        client = self.apg.client
+        # we use PENDING state for microblog, it will be set to SUBSCRIBED once the Follow
+        # is accepted. Other nodes are directly set to subscribed, their subscriptions
+        # being internal.
+        if nodeIdentifier == self.apg._m.namespace:
+            sub_state = SubscriptionState.PENDING
+        else:
+            sub_state = SubscriptionState.SUBSCRIBED
+        node = await self.host.memory.storage.get_pubsub_node(
+            client, service, nodeIdentifier, with_subscriptions=True
+        )
+        if node is None:
+            node = await self.host.memory.storage.set_pubsub_node(
+                client,
+                service,
+                nodeIdentifier,
+            )
+            subscription = None
+        else:
+            try:
+                subscription = next(
+                    s for s in node.subscriptions
+                    if s.subscriber == requestor.userhostJID()
+                )
+            except StopIteration:
+                subscription = None
+
+        if subscription is None:
+            subscription = PubsubSub(
+                subscriber=requestor.userhostJID(),
+                state=sub_state
+            )
+            node.subscriptions.append(subscription)
+            await self.host.memory.storage.add(node)
+        else:
+            if subscription.state is None:
+                subscription.state = sub_state
+                await self.host.memory.storage.add(node)
+            elif subscription.state == SubscriptionState.SUBSCRIBED:
+                log.info(
+                    f"{requestor.userhostJID()} has already a subscription to {node!r} "
+                    f"at {service}. Doing the request anyway."
+                )
+            elif subscription.state == SubscriptionState.PENDING:
+                log.info(
+                    f"{requestor.userhostJID()} has already a pending subscription to "
+                    f"{node!r} at {service}. Doing the request anyway."
+                )
+                if sub_state != SubscriptionState.PENDING:
+                    subscription.state = sub_state
+                    await self.host.memory.storage.add(node)
+            else:
+                raise exceptions.InternalError(
+                    f"unmanaged subscription state: {subscription.state}"
+                )
+
+        if nodeIdentifier in (self.apg._m.namespace, self.apg._events.namespace):
+            # if we subscribe to microblog or events node, we follow the corresponding
+            # account
+            req_actor_id, recip_actor_id, inbox = await self.get_ap_actor_ids_and_inbox(
+                requestor, service
+            )
+
+            data = self.apg.create_activity("Follow", req_actor_id, recip_actor_id)
+
+            resp = await self.apg.sign_and_post(inbox, req_actor_id, data)
+            if resp.code >= 300:
+                text = await resp.text()
+                raise error.StanzaError("service-unavailable", text=text)
+        return pubsub.Subscription(nodeIdentifier, requestor, "subscribed")
+
+    @ensure_deferred
+    async def unsubscribe(self, requestor, service, nodeIdentifier, subscriber):
+        req_actor_id, recip_actor_id, inbox = await self.get_ap_actor_ids_and_inbox(
+            requestor, service
+        )
+        data = self.apg.create_activity(
+            "Undo",
+            req_actor_id,
+            self.apg.create_activity(
+                "Follow",
+                req_actor_id,
+                recip_actor_id
+            )
+        )
+
+        resp = await self.apg.sign_and_post(inbox, req_actor_id, data)
+        if resp.code >= 300:
+            text = await resp.text()
+            raise error.StanzaError("service-unavailable", text=text)
+
+    def getConfigurationOptions(self):
+        return NODE_OPTIONS
+
+    def getConfiguration(
+        self,
+        requestor: jid.JID,
+        service: jid.JID,
+        nodeIdentifier: str
+    ) -> defer.Deferred:
+        return defer.succeed(NODE_CONFIG_VALUES)
+
+    def getNodeInfo(
+        self,
+        requestor: jid.JID,
+        service: jid.JID,
+        nodeIdentifier: str,
+        pep: bool = False,
+        recipient: Optional[jid.JID] = None
+    ) -> Optional[dict]:
+        if not nodeIdentifier:
+            return None
+        info = {
+            "type": "leaf",
+            "meta-data": NODE_CONFIG
+        }
+        return info
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_comp_ap_gateway/regex.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+
+# Libervia ActivityPub Gateway
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Various Regular Expression for AP gateway"""
+
+import re
+
+## "Signature" header parsing
+
+# those expression have been generated with abnf-to-regex
+# (https://github.com/aas-core-works/abnf-to-regexp)
+
+# the base RFC 7320 ABNF rules come from https://github.com/EricGT/ABNF
+
+# here is the ABNF file used:
+# ---
+# BWS = OWS
+# OWS = *( SP / HTAB )
+# tchar = "!" / "#" / "$" / "%" / "&" / "`" / "*" / "+" / "-" / "." / "^" / "_" / "\'" / "|" / "~" / DIGIT / ALPHA
+# token = 1*tchar
+# sig-param = token BWS "=" BWS ( token / quoted-string )
+# quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
+# qdtext = HTAB / SP / "!" / %x23-5B ; '#'-'['
+#  / %x5D-7E ; ']'-'~'
+#  / obs-text
+# quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
+# obs-text = %x80-FF
+# ---
+
+ows = '[ \t]*'
+bws = f'{ows}'
+obs_text = '[\\x80-\\xff]'
+qdtext = f'([\t !#-\\[\\]-~]|{obs_text})'
+quoted_pair = f'\\\\([\t !-~]|{obs_text})'
+quoted_string = f'"({qdtext}|{quoted_pair})*"'
+tchar = "([!#$%&`*+\\-.^_]|\\\\'|[|~0-9a-zA-Z])"
+token = f'({tchar})+'
+RE_SIG_PARAM = re.compile(
+    f'(?P<key>{token}{bws})={bws}'
+    f'((?P<uq_value>{token})|(?P<quoted_value>{quoted_string}))'
+)
+
+
+## Account/Mention
+
+# FIXME: naive regex, should be approved following webfinger, but popular implementations
+#   such as Mastodon use a very restricted subset
+RE_ACCOUNT = re.compile(r"[a-zA-Z0-9._-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-]+")
+RE_MENTION = re.compile(rf"(?<!\w)@{RE_ACCOUNT.pattern}\b")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_comp_file_sharing.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,884 @@
+#!/usr/bin/env python3
+
+# Libervia File Sharing component
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import os.path
+import mimetypes
+import tempfile
+from functools import partial
+import shortuuid
+import unicodedata
+from urllib.parse import urljoin, urlparse, quote, unquote
+from pathlib import Path
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import stream
+from libervia.backend.tools import video
+from libervia.backend.tools.utils import ensure_deferred
+from libervia.backend.tools.common import regex
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common import files_utils
+from libervia.backend.tools.common import utils
+from libervia.backend.tools.common import tls
+from twisted.internet import defer, reactor
+from twisted.words.protocols.jabber import error
+from twisted.web import server, resource, static, http
+from wokkel import pubsub
+from wokkel import generic
+
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "File sharing component",
+    C.PI_IMPORT_NAME: "file-sharing",
+    C.PI_MODES: [C.PLUG_MODE_COMPONENT],
+    C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT,
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [
+        "FILE",
+        "FILE_SHARING_MANAGEMENT",
+        "XEP-0106",
+        "XEP-0234",
+        "XEP-0260",
+        "XEP-0261",
+        "XEP-0264",
+        "XEP-0329",
+        "XEP-0363",
+    ],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "FileSharing",
+    C.PI_HANDLER: C.BOOL_TRUE,
+    C.PI_DESCRIPTION: _("""Component hosting and sharing files"""),
+}
+
+HASH_ALGO = "sha-256"
+NS_COMMENTS = "org.salut-a-toi.comments"
+NS_FS_AFFILIATION = "org.salut-a-toi.file-sharing-affiliation"
+COMMENT_NODE_PREFIX = "org.salut-a-toi.file_comments/"
+# Directory used to buffer request body (i.e. file in case of PUT) we use more than one @
+# there, to be sure than it's not conflicting with a JID
+TMP_BUFFER_DIR = "@@tmp@@"
+OVER_QUOTA_TXT = D_(
+    "You are over quota, your maximum allowed size is {quota} and you are already using "
+    "{used_space}, you can't upload {file_size} more."
+)
+
+HTTP_VERSION = unicodedata.normalize(
+    'NFKD',
+    f"{C.APP_NAME} file sharing {C.APP_VERSION}"
+)
+
+
+class HTTPFileServer(resource.Resource):
+    isLeaf = True
+
+    def errorPage(self, request, code):
+        request.setResponseCode(code)
+        if code == http.BAD_REQUEST:
+            brief = 'Bad Request'
+            details = "Your request is invalid"
+        elif code == http.FORBIDDEN:
+            brief = 'Forbidden'
+            details = "You're not allowed to use this resource"
+        elif code == http.NOT_FOUND:
+            brief = 'Not Found'
+            details = "No resource found at this URL"
+        else:
+            brief = 'Error'
+            details = "This resource can't be used"
+            log.error(f"Unexpected return code used: {code}")
+        log.warning(
+            f'Error returned while trying to access url {request.uri.decode()}: '
+            f'"{brief}" ({code}): {details}'
+        )
+
+        return resource.ErrorPage(code, brief, details).render(request)
+
+    def get_disposition_type(self, media_type, media_subtype):
+        if media_type in ('image', 'video'):
+            return 'inline'
+        elif media_type == 'application' and media_subtype == 'pdf':
+            return 'inline'
+        else:
+            return 'attachment'
+
+    def render(self, request):
+        request.setHeader("server", HTTP_VERSION)
+        request.setHeader("Access-Control-Allow-Origin", "*")
+        request.setHeader("Access-Control-Allow-Methods", "OPTIONS, HEAD, GET, PUT")
+        request.setHeader(
+            "Access-Control-Allow-Headers",
+            "Content-Type, Range, Xmpp-File-Path, Xmpp-File-No-Http")
+        request.setHeader("Access-Control-Allow-Credentials", "true")
+        request.setHeader("Accept-Ranges", "bytes")
+
+        request.setHeader(
+            "Access-Control-Expose-Headers",
+            "Date, Content-Length, Content-Range")
+        return super().render(request)
+
+    def render_options(self, request):
+        request.setResponseCode(http.OK)
+        return b""
+
+    def render_GET(self, request):
+        try:
+            request.upload_data
+        except exceptions.DataError:
+            return self.errorPage(request, http.NOT_FOUND)
+
+        defer.ensureDeferred(self.render_get(request))
+        return server.NOT_DONE_YET
+
+    async def render_get(self, request):
+        try:
+            upload_id, filename = request.upload_data
+        except exceptions.DataError:
+            request.write(self.errorPage(request, http.FORBIDDEN))
+            request.finish()
+            return
+        found_files = await request.file_sharing.host.memory.get_files(
+            client=None, peer_jid=None, perms_to_check=None, public_id=upload_id)
+        if not found_files:
+            request.write(self.errorPage(request, http.NOT_FOUND))
+            request.finish()
+            return
+        if len(found_files) > 1:
+            log.error(f"more that one files found for public id {upload_id!r}")
+
+        found_file = found_files[0]
+        file_path = request.file_sharing.files_path/found_file['file_hash']
+        file_res = static.File(file_path)
+        file_res.type = f'{found_file["media_type"]}/{found_file["media_subtype"]}'
+        file_res.encoding = file_res.contentEncodings.get(Path(found_file['name']).suffix)
+        disp_type = self.get_disposition_type(
+            found_file['media_type'], found_file['media_subtype'])
+        # the URL is percent encoded, and not all browsers/tools unquote the file name,
+        # thus we add a content disposition header
+        request.setHeader(
+            'Content-Disposition',
+            f"{disp_type}; filename*=UTF-8''{quote(found_file['name'])}"
+        )
+        # cf. https://xmpp.org/extensions/xep-0363.html#server
+        request.setHeader(
+            'Content-Security-Policy',
+            "default-src 'none'; frame-ancestors 'none';"
+        )
+        ret = file_res.render(request)
+        if ret != server.NOT_DONE_YET:
+            # HEAD returns directly the result (while GET use a produced)
+            request.write(ret)
+            request.finish()
+
+    def render_PUT(self, request):
+        defer.ensureDeferred(self.render_put(request))
+        return server.NOT_DONE_YET
+
+    async def render_put(self, request):
+        try:
+            client, upload_request = request.upload_request_data
+            upload_id, filename = request.upload_data
+        except AttributeError:
+            request.write(self.errorPage(request, http.BAD_REQUEST))
+            request.finish()
+            return
+
+        # at this point request is checked and file is buffered, we can store it
+        # we close the content here, before registering the file
+        request.content.close()
+        tmp_file_path = Path(request.content.name)
+        request.content = None
+
+        # the 2 following headers are not standard, but useful in the context of file
+        # sharing with HTTP Upload: first one allow uploader to specify the path
+        # and second one will disable public exposure of the file through HTTP
+        path = request.getHeader("Xmpp-File-Path")
+        if path:
+            path = unquote(path)
+        else:
+            path =  "/uploads"
+        if request.getHeader("Xmpp-File-No-Http") is not None:
+            public_id = None
+        else:
+            public_id = upload_id
+
+        file_data = {
+            "name": unquote(upload_request.filename),
+            "mime_type": upload_request.content_type,
+            "size": upload_request.size,
+            "path": path
+        }
+
+        await request.file_sharing.register_received_file(
+            client, upload_request.from_, file_data, tmp_file_path,
+            public_id=public_id,
+        )
+
+        request.setResponseCode(http.CREATED)
+        request.finish()
+
+
+class FileSharingRequest(server.Request):
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._upload_data = None
+
+    @property
+    def upload_data(self):
+        """A tuple with upload_id and filename retrieved from requested path"""
+        if self._upload_data is not None:
+            return self._upload_data
+
+        # self.path is not available if we are early in the request (e.g. when gotLength
+        # is called), in which case channel._path must be used. On the other hand, when
+        # render_[VERB] is called, only self.path is available
+        path = self.channel._path if self.path is None else self.path
+        # we normalise the path
+        path = urlparse(path.decode()).path
+        try:
+            __, upload_id, filename = path.split('/')
+        except ValueError:
+            raise exceptions.DataError("no enought path elements")
+        if len(upload_id) < 10:
+            raise exceptions.DataError(f"invalid upload ID received for a PUT: {upload_id!r}")
+
+        self._upload_data = (upload_id, filename)
+        return self._upload_data
+
+    @property
+    def file_sharing(self):
+        return self.channel.site.file_sharing
+
+    @property
+    def file_tmp_dir(self):
+        return self.channel.site.file_tmp_dir
+
+    def refuse_request(self):
+        if self.content is not None:
+            self.content.close()
+        self.content = open(os.devnull, 'w+b')
+        self.channel._respondToBadRequestAndDisconnect()
+
+    def gotLength(self, length):
+        if self.channel._command.decode().upper() == 'PUT':
+            # for PUT we check early if upload_id is fine, to avoid buffering a file we'll refuse
+            # we buffer the file in component's TMP_BUFFER_DIR, so we just have to rename it at the end
+            try:
+                upload_id, filename = self.upload_data
+            except exceptions.DataError as e:
+                log.warning(f"Invalid PUT request, we stop here: {e}")
+                return self.refuse_request()
+            try:
+                client, upload_request, timer = self.file_sharing.expected_uploads.pop(upload_id)
+            except KeyError:
+                log.warning(f"unknown (expired?) upload ID received for a PUT: {upload_id!r}")
+                return self.refuse_request()
+
+            if not timer.active:
+                log.warning(f"upload id {upload_id!r} used for a PUT, but it is expired")
+                return self.refuse_request()
+
+            timer.cancel()
+
+            if upload_request.filename != filename:
+                log.warning(
+                    f"invalid filename for PUT (upload id: {upload_id!r}, URL: {self.channel._path.decode()}). Original "
+                    f"{upload_request.filename!r} doesn't match {filename!r}"
+                )
+                return self.refuse_request()
+
+            self.upload_request_data = (client, upload_request)
+
+            file_tmp_path = files_utils.get_unique_name(
+                self.file_tmp_dir/upload_id)
+
+            self.content = open(file_tmp_path, 'w+b')
+        else:
+            return super().gotLength(length)
+
+
+class FileSharingSite(server.Site):
+    requestFactory = FileSharingRequest
+
+    def __init__(self, file_sharing):
+        self.file_sharing = file_sharing
+        self.file_tmp_dir = file_sharing.host.get_local_path(
+            None, C.FILES_TMP_DIR, TMP_BUFFER_DIR, component=True
+        )
+        for old_file in self.file_tmp_dir.iterdir():
+            log.debug(f"purging old buffer file at {old_file}")
+            old_file.unlink()
+        super().__init__(HTTPFileServer())
+
+    def getContentFile(self, length):
+        file_tmp_path = self.file_tmp_dir/shortuuid.uuid()
+        return open(file_tmp_path, 'w+b')
+
+
+class FileSharing:
+
+    def __init__(self, host):
+        self.host = host
+        self.initialised = False
+
+    def init(self):
+        # we init once on first component connection,
+        # there is not need to init this plugin if not component use it
+        # TODO: this plugin should not be loaded at all if no component uses it
+        #   and should be loaded dynamically as soon as a suitable profile is created
+        if self.initialised:
+            return
+        self.initialised = True
+        log.info(_("File Sharing initialization"))
+        self._f = self.host.plugins["FILE"]
+        self._jf = self.host.plugins["XEP-0234"]
+        self._h = self.host.plugins["XEP-0300"]
+        self._t = self.host.plugins["XEP-0264"]
+        self._hu = self.host.plugins["XEP-0363"]
+        self._hu.register_handler(self._on_http_upload)
+        self.host.trigger.add("FILE_getDestDir", self._get_dest_dir_trigger)
+        self.host.trigger.add(
+            "XEP-0234_fileSendingRequest", self._file_sending_request_trigger, priority=1000
+        )
+        self.host.trigger.add("XEP-0234_buildFileElement", self._add_file_metadata_elts)
+        self.host.trigger.add("XEP-0234_parseFileElement", self._get_file_metadata_elts)
+        self.host.trigger.add("XEP-0329_compGetFilesFromNode", self._add_file_metadata)
+        self.host.trigger.add(
+            "XEP-0329_compGetFilesFromNode_build_directory",
+            self._add_directory_metadata_elts)
+        self.host.trigger.add(
+            "XEP-0329_parseResult_directory",
+            self._get_directory_metadata_elts)
+        self.files_path = self.host.get_local_path(None, C.FILES_DIR)
+        self.http_port = int(self.host.memory.config_get(
+            'component file-sharing', 'http_upload_port', 8888))
+        connection_type = self.host.memory.config_get(
+            'component file-sharing', 'http_upload_connection_type', 'https')
+        if connection_type not in ('http', 'https'):
+            raise exceptions.ConfigError(
+                'bad http_upload_connection_type, you must use one of "http" or "https"'
+            )
+        self.server = FileSharingSite(self)
+        self.expected_uploads = {}
+        if connection_type == 'http':
+            reactor.listenTCP(self.http_port, self.server)
+        else:
+            options = tls.get_options_from_config(
+                self.host.memory.config, "component file-sharing")
+            tls.tls_options_check(options)
+            context_factory = tls.get_tls_context_factory(options)
+            reactor.listenSSL(self.http_port, self.server, context_factory)
+
+    def get_handler(self, client):
+        return Comments_handler(self)
+
+    def profile_connecting(self, client):
+        # we activate HTTP upload
+        client.enabled_features.add("XEP-0363")
+
+        self.init()
+        public_base_url = self.host.memory.config_get(
+            'component file-sharing', 'http_upload_public_facing_url')
+        if public_base_url is None:
+            client._file_sharing_base_url = f"https://{client.host}:{self.http_port}"
+        else:
+            client._file_sharing_base_url = public_base_url
+        path = client.file_tmp_dir = os.path.join(
+            self.host.memory.config_get("", "local_dir"),
+            C.FILES_TMP_DIR,
+            regex.path_escape(client.profile),
+        )
+        if not os.path.exists(path):
+            os.makedirs(path)
+
+    def get_quota(self, client, entity):
+        """Return maximum size allowed for all files for entity"""
+        quotas = self.host.memory.config_get("component file-sharing", "quotas_json", {})
+        if self.host.memory.is_admin_jid(entity):
+            quota = quotas.get("admins")
+        else:
+            try:
+                quota = quotas["jids"][entity.userhost()]
+            except KeyError:
+                quota = quotas.get("users")
+        return None if quota is None else utils.parse_size(quota)
+
+    async def generate_thumbnails(self, extra: dict, image_path: Path):
+        thumbnails = extra.setdefault(C.KEY_THUMBNAILS, [])
+        for max_thumb_size in self._t.SIZES:
+            try:
+                thumb_size, thumb_id = await self._t.generate_thumbnail(
+                    image_path,
+                    max_thumb_size,
+                    #  we keep thumbnails for 6 months
+                    60 * 60 * 24 * 31 * 6,
+                )
+            except Exception as e:
+                log.warning(_("Can't create thumbnail: {reason}").format(reason=e))
+                break
+            thumbnails.append({"id": thumb_id, "size": thumb_size})
+
+    async def register_received_file(
+            self, client, peer_jid, file_data, file_path, public_id=None, extra=None):
+        """Post file reception tasks
+
+        once file is received, this method create hash/thumbnails if necessary
+        move the file to the right location, and create metadata entry in database
+        """
+        name = file_data["name"]
+        if extra is None:
+            extra = {}
+
+        mime_type = file_data.get("mime_type")
+        if not mime_type or mime_type == "application/octet-stream":
+            mime_type = mimetypes.guess_type(name)[0]
+
+        is_image = mime_type is not None and mime_type.startswith("image")
+        is_video = mime_type is not None and mime_type.startswith("video")
+
+        if file_data.get("hash_algo") == HASH_ALGO:
+            log.debug(_("Reusing already generated hash"))
+            file_hash = file_data["hash_hasher"].hexdigest()
+        else:
+            hasher = self._h.get_hasher(HASH_ALGO)
+            with file_path.open('rb') as f:
+                file_hash = await self._h.calculate_hash(f, hasher)
+        final_path = self.files_path/file_hash
+
+        if final_path.is_file():
+            log.debug(
+                "file [{file_hash}] already exists, we can remove temporary one".format(
+                    file_hash=file_hash
+                )
+            )
+            file_path.unlink()
+        else:
+            file_path.rename(final_path)
+            log.debug(
+                "file [{file_hash}] moved to {files_path}".format(
+                    file_hash=file_hash, files_path=self.files_path
+                )
+            )
+
+        if is_image:
+            await self.generate_thumbnails(extra, final_path)
+        elif is_video:
+            with tempfile.TemporaryDirectory() as tmp_dir:
+                thumb_path = Path(tmp_dir) / "thumbnail.jpg"
+                try:
+                    await video.get_thumbnail(final_path, thumb_path)
+                except Exception as e:
+                    log.warning(_("Can't get thumbnail for {final_path}: {e}").format(
+                        final_path=final_path, e=e))
+                else:
+                    await self.generate_thumbnails(extra, thumb_path)
+
+        await self.host.memory.set_file(
+            client,
+            name=name,
+            version="",
+            file_hash=file_hash,
+            hash_algo=HASH_ALGO,
+            size=file_data["size"],
+            path=file_data.get("path"),
+            namespace=file_data.get("namespace"),
+            mime_type=mime_type,
+            public_id=public_id,
+            owner=peer_jid,
+            extra=extra,
+        )
+
+    async def _get_dest_dir_trigger(
+        self, client, peer_jid, transfer_data, file_data, stream_object
+    ):
+        """This trigger accept file sending request, and store file locally"""
+        if not client.is_component:
+            return True, None
+        # client._file_sharing_allowed_hosts is set in plugin XEP-0329
+        if peer_jid.host not in client._file_sharing_allowed_hosts:
+            raise error.StanzaError("forbidden")
+        assert stream_object
+        assert "stream_object" not in transfer_data
+        assert C.KEY_PROGRESS_ID in file_data
+        filename = file_data["name"]
+        assert filename and not "/" in filename
+        quota = self.get_quota(client, peer_jid)
+        if quota is not None:
+            used_space = await self.host.memory.file_get_used_space(client, peer_jid)
+
+            if (used_space + file_data["size"]) > quota:
+                raise error.StanzaError(
+                    "not-acceptable",
+                    text=OVER_QUOTA_TXT.format(
+                        quota=utils.get_human_size(quota),
+                        used_space=utils.get_human_size(used_space),
+                        file_size=utils.get_human_size(file_data['size'])
+                    )
+                )
+        file_tmp_dir = self.host.get_local_path(
+            None, C.FILES_TMP_DIR, peer_jid.userhost(), component=True
+        )
+        file_tmp_path = file_data['file_path'] = files_utils.get_unique_name(
+            file_tmp_dir/filename)
+
+        transfer_data["finished_d"].addCallback(
+            lambda __: defer.ensureDeferred(
+                self.register_received_file(client, peer_jid, file_data, file_tmp_path)
+            )
+        )
+
+        self._f.open_file_write(
+            client, file_tmp_path, transfer_data, file_data, stream_object
+        )
+        return False, True
+
+    async def _retrieve_files(
+        self, client, session, content_data, content_name, file_data, file_elt
+    ):
+        """This method retrieve a file on request, and send if after checking permissions"""
+        peer_jid = session["peer_jid"]
+        if session['local_jid'].user:
+            owner = client.get_owner_from_jid(session['local_jid'])
+        else:
+            owner = peer_jid
+        try:
+            found_files = await self.host.memory.get_files(
+                client,
+                peer_jid=peer_jid,
+                name=file_data.get("name"),
+                file_hash=file_data.get("file_hash"),
+                hash_algo=file_data.get("hash_algo"),
+                path=file_data.get("path"),
+                namespace=file_data.get("namespace"),
+                owner=owner,
+            )
+        except exceptions.NotFound:
+            found_files = None
+        except exceptions.PermissionError:
+            log.warning(
+                _("{peer_jid} is trying to access an unauthorized file: {name}").format(
+                    peer_jid=peer_jid, name=file_data.get("name")
+                )
+            )
+            return False
+
+        if not found_files:
+            log.warning(
+                _("no matching file found ({file_data})").format(file_data=file_data)
+            )
+            return False
+
+        # we only use the first found file
+        found_file = found_files[0]
+        if found_file['type'] != C.FILE_TYPE_FILE:
+            raise TypeError("a file was expected, type is {type_}".format(
+                type_=found_file['type']))
+        file_hash = found_file["file_hash"]
+        file_path = self.files_path / file_hash
+        file_data["hash_hasher"] = hasher = self._h.get_hasher(found_file["hash_algo"])
+        size = file_data["size"] = found_file["size"]
+        file_data["file_hash"] = file_hash
+        file_data["hash_algo"] = found_file["hash_algo"]
+
+        # we complete file_elt so peer can have some details on the file
+        if "name" not in file_data:
+            file_elt.addElement("name", content=found_file["name"])
+        file_elt.addElement("size", content=str(size))
+        content_data["stream_object"] = stream.FileStreamObject(
+            self.host,
+            client,
+            file_path,
+            uid=self._jf.get_progress_id(session, content_name),
+            size=size,
+            data_cb=lambda data: hasher.update(data),
+        )
+        return True
+
+    def _file_sending_request_trigger(
+        self, client, session, content_data, content_name, file_data, file_elt
+    ):
+        if not client.is_component:
+            return True, None
+        else:
+            return (
+                False,
+                defer.ensureDeferred(self._retrieve_files(
+                    client, session, content_data, content_name, file_data, file_elt
+                )),
+            )
+
+    ## HTTP Upload ##
+
+    def _purge_slot(self, upload_id):
+        try:
+            del self.expected_uploads[upload_id]
+        except KeyError:
+            log.error(f"trying to purge an inexisting upload slot ({upload_id})")
+
+    async def _on_http_upload(self, client, request):
+        # filename should be already cleaned, but it's better to double check
+        assert '/' not in request.filename
+        # client._file_sharing_allowed_hosts is set in plugin XEP-0329
+        if request.from_.host not in client._file_sharing_allowed_hosts:
+            raise error.StanzaError("forbidden")
+
+        quota = self.get_quota(client, request.from_)
+        if quota is not None:
+            used_space = await self.host.memory.file_get_used_space(client, request.from_)
+
+            if (used_space + request.size) > quota:
+                raise error.StanzaError(
+                    "not-acceptable",
+                    text=OVER_QUOTA_TXT.format(
+                        quota=utils.get_human_size(quota),
+                        used_space=utils.get_human_size(used_space),
+                        file_size=utils.get_human_size(request.size)
+                    ),
+                    appCondition = self._hu.get_file_too_large_elt(max(quota - used_space, 0))
+                )
+
+        upload_id = shortuuid.ShortUUID().random(length=30)
+        assert '/' not in upload_id
+        timer = reactor.callLater(30, self._purge_slot, upload_id)
+        self.expected_uploads[upload_id] = (client, request, timer)
+        url = urljoin(client._file_sharing_base_url, f"{upload_id}/{request.filename}")
+        slot = self._hu.Slot(
+            put=url,
+            get=url,
+            headers=[],
+        )
+        return slot
+
+    ## metadata triggers ##
+
+    def _add_file_metadata_elts(self, client, file_elt, extra_args):
+        # affiliation
+        affiliation = extra_args.get('affiliation')
+        if affiliation is not None:
+            file_elt.addElement((NS_FS_AFFILIATION, "affiliation"), content=affiliation)
+
+        # comments
+        try:
+            comments_url = extra_args.pop("comments_url")
+        except KeyError:
+            return
+
+        comment_elt = file_elt.addElement((NS_COMMENTS, "comments"), content=comments_url)
+
+        try:
+            count = len(extra_args["extra"]["comments"])
+        except KeyError:
+            count = 0
+
+        comment_elt["count"] = str(count)
+        return True
+
+    def _get_file_metadata_elts(self, client, file_elt, file_data):
+        # affiliation
+        try:
+            affiliation_elt = next(file_elt.elements(NS_FS_AFFILIATION, "affiliation"))
+        except StopIteration:
+            pass
+        else:
+            file_data["affiliation"] = str(affiliation_elt)
+
+        # comments
+        try:
+            comments_elt = next(file_elt.elements(NS_COMMENTS, "comments"))
+        except StopIteration:
+            pass
+        else:
+            file_data["comments_url"] = str(comments_elt)
+            file_data["comments_count"] = comments_elt["count"]
+        return True
+
+    def _add_file_metadata(
+            self, client, iq_elt, iq_result_elt, owner, node_path, files_data):
+        for file_data in files_data:
+            file_data["comments_url"] = uri.build_xmpp_uri(
+                "pubsub",
+                path=client.jid.full(),
+                node=COMMENT_NODE_PREFIX + file_data["id"],
+            )
+        return True
+
+    def _add_directory_metadata_elts(
+            self, client, file_data, directory_elt, owner, node_path):
+        affiliation = file_data.get('affiliation')
+        if affiliation is not None:
+            directory_elt.addElement(
+                (NS_FS_AFFILIATION, "affiliation"),
+                content=affiliation
+            )
+
+    def _get_directory_metadata_elts(
+            self, client, elt, file_data):
+        try:
+            affiliation_elt = next(elt.elements(NS_FS_AFFILIATION, "affiliation"))
+        except StopIteration:
+            pass
+        else:
+            file_data['affiliation'] = str(affiliation_elt)
+
+
+class Comments_handler(pubsub.PubSubService):
+    """This class is a minimal Pubsub service handling virtual nodes for comments"""
+
+    def __init__(self, plugin_parent):
+        super(Comments_handler, self).__init__()
+        self.host = plugin_parent.host
+        self.plugin_parent = plugin_parent
+        self.discoIdentity = {
+            "category": "pubsub",
+            "type": "virtual",  # FIXME: non standard, here to avoid this service being considered as main pubsub one
+            "name": "files commenting service",
+        }
+
+    def _get_file_id(self, nodeIdentifier):
+        if not nodeIdentifier.startswith(COMMENT_NODE_PREFIX):
+            raise error.StanzaError("item-not-found")
+        file_id = nodeIdentifier[len(COMMENT_NODE_PREFIX) :]
+        if not file_id:
+            raise error.StanzaError("item-not-found")
+        return file_id
+
+    async def get_file_data(self, requestor, nodeIdentifier):
+        file_id = self._get_file_id(nodeIdentifier)
+        try:
+            files = await self.host.memory.get_files(self.parent, requestor, file_id)
+        except (exceptions.NotFound, exceptions.PermissionError):
+            # we don't differenciate between NotFound and PermissionError
+            # to avoid leaking information on existing files
+            raise error.StanzaError("item-not-found")
+        if not files:
+            raise error.StanzaError("item-not-found")
+        if len(files) > 1:
+            raise error.InternalError("there should be only one file")
+        return files[0]
+
+    def comments_update(self, extra, new_comments, peer_jid):
+        """update comments (replace or insert new_comments)
+
+        @param extra(dict): extra data to update
+        @param new_comments(list[tuple(unicode, unicode, unicode)]): comments to update or insert
+        @param peer_jid(unicode, None): bare jid of the requestor, or None if request is done by owner
+        """
+        current_comments = extra.setdefault("comments", [])
+        new_comments_by_id = {c[0]: c for c in new_comments}
+        updated = []
+        # we now check every current comment, to see if one id in new ones
+        # exist, in which case we must update
+        for idx, comment in enumerate(current_comments):
+            comment_id = comment[0]
+            if comment_id in new_comments_by_id:
+                # a new comment has an existing id, update is requested
+                if peer_jid and comment[1] != peer_jid:
+                    # requestor has not the right to modify the comment
+                    raise exceptions.PermissionError
+                # we replace old_comment with updated one
+                new_comment = new_comments_by_id[comment_id]
+                current_comments[idx] = new_comment
+                updated.append(new_comment)
+
+        # we now remove every updated comments, to only keep
+        # the ones to insert
+        for comment in updated:
+            new_comments.remove(comment)
+
+        current_comments.extend(new_comments)
+
+    def comments_delete(self, extra, comments):
+        try:
+            comments_dict = extra["comments"]
+        except KeyError:
+            return
+        for comment in comments:
+            try:
+                comments_dict.remove(comment)
+            except ValueError:
+                continue
+
+    def _get_from(self, item_elt):
+        """retrieve publisher of an item
+
+        @param item_elt(domish.element): <item> element
+        @return (unicode): full jid as string
+        """
+        iq_elt = item_elt
+        while iq_elt.parent != None:
+            iq_elt = iq_elt.parent
+        return iq_elt["from"]
+
+    @ensure_deferred
+    async def publish(self, requestor, service, nodeIdentifier, items):
+        #  we retrieve file a first time to check authorisations
+        file_data = await self.get_file_data(requestor, nodeIdentifier)
+        file_id = file_data["id"]
+        comments = [(item["id"], self._get_from(item), item.toXml()) for item in items]
+        if requestor.userhostJID() == file_data["owner"]:
+            peer_jid = None
+        else:
+            peer_jid = requestor.userhost()
+        update_cb = partial(self.comments_update, new_comments=comments, peer_jid=peer_jid)
+        try:
+            await self.host.memory.file_update(file_id, "extra", update_cb)
+        except exceptions.PermissionError:
+            raise error.StanzaError("not-authorized")
+
+    @ensure_deferred
+    async def items(self, requestor, service, nodeIdentifier, maxItems, itemIdentifiers):
+        file_data = await self.get_file_data(requestor, nodeIdentifier)
+        comments = file_data["extra"].get("comments", [])
+        if itemIdentifiers:
+            return [generic.parseXml(c[2]) for c in comments if c[0] in itemIdentifiers]
+        else:
+            return [generic.parseXml(c[2]) for c in comments]
+
+    @ensure_deferred
+    async def retract(self, requestor, service, nodeIdentifier, itemIdentifiers):
+        file_data = await self.get_file_data(requestor, nodeIdentifier)
+        file_id = file_data["id"]
+        try:
+            comments = file_data["extra"]["comments"]
+        except KeyError:
+            raise error.StanzaError("item-not-found")
+
+        to_remove = []
+        for comment in comments:
+            comment_id = comment[0]
+            if comment_id in itemIdentifiers:
+                to_remove.append(comment)
+                itemIdentifiers.remove(comment_id)
+                if not itemIdentifiers:
+                    break
+
+        if itemIdentifiers:
+            # not all items have been to_remove, we can't continue
+            raise error.StanzaError("item-not-found")
+
+        if requestor.userhostJID() != file_data["owner"]:
+            if not all([c[1] == requestor.userhost() for c in to_remove]):
+                raise error.StanzaError("not-authorized")
+
+        remove_cb = partial(self.comments_delete, comments=to_remove)
+        await self.host.memory.file_update(file_id, "extra", remove_cb)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_comp_file_sharing_management.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,483 @@
+#!/usr/bin/env python3
+
+# Libervia plugin to manage file sharing component through ad-hoc commands
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import os.path
+from functools import partial
+from wokkel import data_form
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import utils
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "File Sharing Management",
+    C.PI_IMPORT_NAME: "FILE_SHARING_MANAGEMENT",
+    C.PI_MODES: [C.PLUG_MODE_COMPONENT],
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0050", "XEP-0264"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "FileSharingManagement",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _(
+        "Experimental handling of file management for file sharing. This plugins allows "
+        "to change permissions of stored files/directories or remove them."
+    ),
+}
+
+NS_FILE_MANAGEMENT = "https://salut-a-toi.org/protocol/file-management:0"
+NS_FILE_MANAGEMENT_PERM = "https://salut-a-toi.org/protocol/file-management:0#perm"
+NS_FILE_MANAGEMENT_DELETE = "https://salut-a-toi.org/protocol/file-management:0#delete"
+NS_FILE_MANAGEMENT_THUMB = "https://salut-a-toi.org/protocol/file-management:0#thumb"
+NS_FILE_MANAGEMENT_QUOTA = "https://salut-a-toi.org/protocol/file-management:0#quota"
+
+
+class WorkflowError(Exception):
+    """Raised when workflow can't be completed"""
+
+    def __init__(self, err_args):
+        """
+        @param err_args(tuple): arguments to return to finish the command workflow
+        """
+        Exception.__init__(self)
+        self.err_args = err_args
+
+
+class FileSharingManagement(object):
+    # This is a temporary way (Q&D) to handle stored files, a better way (using pubsub
+    # syntax?) should be elaborated and proposed as a standard.
+
+    def __init__(self, host):
+        log.info(_("File Sharing Management plugin initialization"))
+        self.host = host
+        self._c = host.plugins["XEP-0050"]
+        self._t = host.plugins["XEP-0264"]
+        self.files_path = host.get_local_path(None, C.FILES_DIR)
+        host.bridge.add_method(
+            "file_sharing_delete",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="",
+            method=self._delete,
+            async_=True,
+        )
+
+    def profile_connected(self, client):
+        self._c.add_ad_hoc_command(
+            client, self._on_change_file, "Change Permissions of File(s)",
+            node=NS_FILE_MANAGEMENT_PERM,
+            allowed_magics=C.ENTITY_ALL,
+        )
+        self._c.add_ad_hoc_command(
+            client, self._on_delete_file, "Delete File(s)",
+            node=NS_FILE_MANAGEMENT_DELETE,
+            allowed_magics=C.ENTITY_ALL,
+        )
+        self._c.add_ad_hoc_command(
+            client, self._on_gen_thumbnails, "Generate Thumbnails",
+            node=NS_FILE_MANAGEMENT_THUMB,
+            allowed_magics=C.ENTITY_ALL,
+        )
+        self._c.add_ad_hoc_command(
+            client, self._on_quota, "Get Quota",
+            node=NS_FILE_MANAGEMENT_QUOTA,
+            allowed_magics=C.ENTITY_ALL,
+        )
+
+    def _delete(self, service_jid_s, path, namespace, profile):
+        client = self.host.get_client(profile)
+        service_jid = jid.JID(service_jid_s) if service_jid_s else None
+        return defer.ensureDeferred(self._c.sequence(
+            client,
+            [{"path": path, "namespace": namespace}, {"confirm": True}],
+            NS_FILE_MANAGEMENT_DELETE,
+            service_jid,
+        ))
+
+    def _err(self, reason):
+        """Helper method to get argument to return for error
+
+        workflow will be interrupted with an error note
+        @param reason(unicode): reason of the error
+        @return (tuple): arguments to use in defer.returnValue
+        """
+        status = self._c.STATUS.COMPLETED
+        payload = None
+        note = (self._c.NOTE.ERROR, reason)
+        return payload, status, None, note
+
+    def _get_root_args(self):
+        """Create the form to select the file to use
+
+        @return (tuple): arguments to use in defer.returnValue
+        """
+        status = self._c.STATUS.EXECUTING
+        form = data_form.Form("form", title="File Management",
+                              formNamespace=NS_FILE_MANAGEMENT)
+
+        field = data_form.Field(
+            "text-single", "path", required=True
+        )
+        form.addField(field)
+
+        field = data_form.Field(
+            "text-single", "namespace", required=False
+        )
+        form.addField(field)
+
+        payload = form.toElement()
+        return payload, status, None, None
+
+    async def _get_file_data(self, client, session_data, command_form):
+        """Retrieve field requested in root form
+
+        "found_file" will also be set in session_data
+        @param command_form(data_form.Form): response to root form
+        @return (D(dict)): found file data
+        @raise WorkflowError: something is wrong
+        """
+        fields = command_form.fields
+        try:
+            path = fields['path'].value.strip()
+            namespace = fields['namespace'].value or None
+        except KeyError:
+            self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
+
+        if not path:
+            self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
+
+        requestor = session_data['requestor']
+        requestor_bare = requestor.userhostJID()
+        path = path.rstrip('/')
+        parent_path, basename = os.path.split(path)
+
+        # TODO: if parent_path and basename are empty, we ask for root directory
+        #       this must be managed
+
+        try:
+            found_files = await self.host.memory.get_files(
+                client, requestor_bare, path=parent_path, name=basename,
+                namespace=namespace)
+            found_file = found_files[0]
+        except (exceptions.NotFound, IndexError):
+            raise WorkflowError(self._err(_("file not found")))
+        except exceptions.PermissionError:
+            raise WorkflowError(self._err(_("forbidden")))
+
+        if found_file['owner'] != requestor_bare:
+            # only owner can manage files
+            log.warning(_("Only owner can manage files"))
+            raise WorkflowError(self._err(_("forbidden")))
+
+        session_data['found_file'] = found_file
+        session_data['namespace'] = namespace
+        return found_file
+
+    def _update_read_permission(self, access, allowed_jids):
+        if not allowed_jids:
+            if C.ACCESS_PERM_READ in access:
+                del access[C.ACCESS_PERM_READ]
+        elif allowed_jids == 'PUBLIC':
+            access[C.ACCESS_PERM_READ] = {
+                "type": C.ACCESS_TYPE_PUBLIC
+            }
+        else:
+            access[C.ACCESS_PERM_READ] = {
+                "type": C.ACCESS_TYPE_WHITELIST,
+                "jids": [j.full() for j in allowed_jids]
+            }
+
+    async def _update_dir(self, client, requestor, namespace, file_data, allowed_jids):
+        """Recursively update permission of a directory and all subdirectories
+
+        @param file_data(dict): metadata of the file
+        @param allowed_jids(list[jid.JID]): list of entities allowed to read the file
+        """
+        assert file_data['type'] == C.FILE_TYPE_DIRECTORY
+        files_data = await self.host.memory.get_files(
+            client, requestor, parent=file_data['id'], namespace=namespace)
+
+        for file_data in files_data:
+            if not file_data['access'].get(C.ACCESS_PERM_READ, {}):
+                log.debug("setting {perm} read permission for {name}".format(
+                    perm=allowed_jids, name=file_data['name']))
+                await self.host.memory.file_update(
+                    file_data['id'], 'access',
+                    partial(self._update_read_permission, allowed_jids=allowed_jids))
+            if file_data['type'] == C.FILE_TYPE_DIRECTORY:
+                await self._update_dir(client, requestor, namespace, file_data, 'PUBLIC')
+
+    async def _on_change_file(self, client, command_elt, session_data, action, node):
+        try:
+            x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
+            command_form = data_form.Form.fromElement(x_elt)
+        except StopIteration:
+            command_form = None
+
+        found_file = session_data.get('found_file')
+        requestor = session_data['requestor']
+        requestor_bare = requestor.userhostJID()
+
+        if command_form is None or len(command_form.fields) == 0:
+            # root request
+            return self._get_root_args()
+
+        elif found_file is None:
+            # file selected, we retrieve it and ask for permissions
+            try:
+                found_file = await self._get_file_data(client, session_data, command_form)
+            except WorkflowError as e:
+                return e.err_args
+
+            # management request
+            if found_file['type'] == C.FILE_TYPE_DIRECTORY:
+                instructions = D_("Please select permissions for this directory")
+            else:
+                instructions = D_("Please select permissions for this file")
+
+            form = data_form.Form("form", title="File Management",
+                                  instructions=[instructions],
+                                  formNamespace=NS_FILE_MANAGEMENT)
+            field = data_form.Field(
+                "text-multi", "read_allowed", required=False,
+                desc='list of jids allowed to read this file (beside yourself), or '
+                     '"PUBLIC" to let a public access'
+            )
+            read_access = found_file["access"].get(C.ACCESS_PERM_READ, {})
+            access_type = read_access.get('type', C.ACCESS_TYPE_WHITELIST)
+            if access_type == C.ACCESS_TYPE_PUBLIC:
+                field.values = ['PUBLIC']
+            else:
+                field.values = read_access.get('jids', [])
+            form.addField(field)
+            if found_file['type'] == C.FILE_TYPE_DIRECTORY:
+                field = data_form.Field(
+                    "boolean", "recursive", value=False, required=False,
+                    desc="Files under it will be made public to follow this dir "
+                         "permission (only if they don't have already a permission set)."
+                )
+                form.addField(field)
+
+            status = self._c.STATUS.EXECUTING
+            payload = form.toElement()
+            return (payload, status, None, None)
+
+        else:
+            # final phase, we'll do permission change here
+            try:
+                read_allowed = command_form.fields['read_allowed']
+            except KeyError:
+                self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
+
+            if read_allowed.value == 'PUBLIC':
+                allowed_jids = 'PUBLIC'
+            elif read_allowed.value.strip() == '':
+                allowed_jids = None
+            else:
+                try:
+                    allowed_jids = [jid.JID(v.strip()) for v in read_allowed.values
+                                    if v.strip()]
+                except RuntimeError as e:
+                    log.warning(_("Can't use read_allowed values: {reason}").format(
+                        reason=e))
+                    self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
+
+            if found_file['type'] == C.FILE_TYPE_FILE:
+                await self.host.memory.file_update(
+                    found_file['id'], 'access',
+                    partial(self._update_read_permission, allowed_jids=allowed_jids))
+            else:
+                try:
+                    recursive = command_form.fields['recursive']
+                except KeyError:
+                    self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
+                await self.host.memory.file_update(
+                    found_file['id'], 'access',
+                    partial(self._update_read_permission, allowed_jids=allowed_jids))
+                if recursive:
+                    # we set all file under the directory as public (if they haven't
+                    # already a permission set), so allowed entities of root directory
+                    # can read them.
+                    namespace = session_data['namespace']
+                    await self._update_dir(
+                        client, requestor_bare, namespace, found_file, 'PUBLIC')
+
+            # job done, we can end the session
+            status = self._c.STATUS.COMPLETED
+            payload = None
+            note = (self._c.NOTE.INFO, _("management session done"))
+            return (payload, status, None, note)
+
+    async def _on_delete_file(self, client, command_elt, session_data, action, node):
+        try:
+            x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
+            command_form = data_form.Form.fromElement(x_elt)
+        except StopIteration:
+            command_form = None
+
+        found_file = session_data.get('found_file')
+        requestor = session_data['requestor']
+        requestor_bare = requestor.userhostJID()
+
+        if command_form is None or len(command_form.fields) == 0:
+            # root request
+            return self._get_root_args()
+
+        elif found_file is None:
+            # file selected, we need confirmation before actually deleting
+            try:
+                found_file = await self._get_file_data(client, session_data, command_form)
+            except WorkflowError as e:
+                return e.err_args
+            if found_file['type'] == C.FILE_TYPE_DIRECTORY:
+                msg = D_("Are you sure to delete directory {name} and all files and "
+                         "directories under it?").format(name=found_file['name'])
+            else:
+                msg = D_("Are you sure to delete file {name}?"
+                    .format(name=found_file['name']))
+            form = data_form.Form("form", title="File Management",
+                                  instructions = [msg],
+                                  formNamespace=NS_FILE_MANAGEMENT)
+            field = data_form.Field(
+                "boolean", "confirm", value=False, required=True,
+                desc="check this box to confirm"
+            )
+            form.addField(field)
+            status = self._c.STATUS.EXECUTING
+            payload = form.toElement()
+            return (payload, status, None, None)
+
+        else:
+            # final phase, we'll do deletion here
+            try:
+                confirmed = C.bool(command_form.fields['confirm'].value)
+            except KeyError:
+                self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
+            if not confirmed:
+                note = None
+            else:
+                recursive = found_file['type'] == C.FILE_TYPE_DIRECTORY
+                await self.host.memory.file_delete(
+                    client, requestor_bare, found_file['id'], recursive)
+                note = (self._c.NOTE.INFO, _("file deleted"))
+            status = self._c.STATUS.COMPLETED
+            payload = None
+            return (payload, status, None, note)
+
+    def _update_thumbs(self, extra, thumbnails):
+        extra[C.KEY_THUMBNAILS] = thumbnails
+
+    async def _gen_thumbs(self, client, requestor, namespace, file_data):
+        """Recursively generate thumbnails
+
+        @param file_data(dict): metadata of the file
+        """
+        if file_data['type'] == C.FILE_TYPE_DIRECTORY:
+            sub_files_data = await self.host.memory.get_files(
+                client, requestor, parent=file_data['id'], namespace=namespace)
+            for sub_file_data in sub_files_data:
+                await self._gen_thumbs(client, requestor, namespace, sub_file_data)
+
+        elif file_data['type'] == C.FILE_TYPE_FILE:
+            media_type = file_data['media_type']
+            file_path = os.path.join(self.files_path, file_data['file_hash'])
+            if media_type == 'image':
+                thumbnails = []
+
+                for max_thumb_size in self._t.SIZES:
+                    try:
+                        thumb_size, thumb_id = await self._t.generate_thumbnail(
+                            file_path,
+                            max_thumb_size,
+                            #  we keep thumbnails for 6 months
+                            60 * 60 * 24 * 31 * 6,
+                        )
+                    except Exception as e:
+                        log.warning(_("Can't create thumbnail: {reason}")
+                            .format(reason=e))
+                        break
+                    thumbnails.append({"id": thumb_id, "size": thumb_size})
+
+                await self.host.memory.file_update(
+                    file_data['id'], 'extra',
+                    partial(self._update_thumbs, thumbnails=thumbnails))
+
+                log.info("thumbnails for [{file_name}] generated"
+                    .format(file_name=file_data['name']))
+
+        else:
+            log.warning("unmanaged file type: {type_}".format(type_=file_data['type']))
+
+    async def _on_gen_thumbnails(self, client, command_elt, session_data, action, node):
+        try:
+            x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
+            command_form = data_form.Form.fromElement(x_elt)
+        except StopIteration:
+            command_form = None
+
+        found_file = session_data.get('found_file')
+        requestor = session_data['requestor']
+
+        if command_form is None or len(command_form.fields) == 0:
+            # root request
+            return self._get_root_args()
+
+        elif found_file is None:
+            # file selected, we retrieve it and ask for permissions
+            try:
+                found_file = await self._get_file_data(client, session_data, command_form)
+            except WorkflowError as e:
+                return e.err_args
+
+            log.info("Generating thumbnails as requested")
+            await self._gen_thumbs(client, requestor, found_file['namespace'], found_file)
+
+            # job done, we can end the session
+            status = self._c.STATUS.COMPLETED
+            payload = None
+            note = (self._c.NOTE.INFO, _("thumbnails generated"))
+            return (payload, status, None, note)
+
+    async def _on_quota(self, client, command_elt, session_data, action, node):
+        requestor = session_data['requestor']
+        quota = self.host.plugins["file_sharing"].get_quota(client, requestor)
+        try:
+            size_used = await self.host.memory.file_get_used_space(client, requestor)
+        except exceptions.PermissionError:
+            raise WorkflowError(self._err(_("forbidden")))
+        status = self._c.STATUS.COMPLETED
+        form = data_form.Form("result")
+        form.makeFields({"quota": quota, "user": size_used})
+        payload = form.toElement()
+        note = (
+            self._c.NOTE.INFO,
+            _("You are currently using {size_used} on {size_quota}").format(
+                size_used = utils.get_human_size(size_used),
+                size_quota = (
+                    _("unlimited quota") if quota is None
+                    else utils.get_human_size(quota)
+                )
+            )
+        )
+        return (payload, status, None, note)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_dbg_manhole.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for debugging, using a manhole
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from twisted.conch.insults import insults
+from twisted.conch.telnet import TelnetTransport, TelnetBootstrapProtocol
+from twisted.internet import reactor, protocol, defer
+from twisted.words.protocols.jabber import jid
+from twisted.conch.manhole import ColoredManhole
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Manhole debug plugin",
+    C.PI_IMPORT_NAME: "manhole",
+    C.PI_TYPE: "DEBUG",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "Manhole",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Debug plugin to have a telnet server"""),
+}
+
+
+
+class Manhole(object):
+
+    def __init__(self, host):
+        self.host = host
+        port = int(host.memory.config_get(None, "manhole_debug_dangerous_port_int", 0))
+        if port:
+            self.start_manhole(port)
+
+    def start_manhole(self, port):
+        log.warning(_("/!\\ Manhole debug server activated, be sure to not use it in "
+                      "production, this is dangerous /!\\"))
+        log.info(_("You can connect to manhole server using telnet on port {port}")
+            .format(port=port))
+        f = protocol.ServerFactory()
+        namespace = {
+            "host": self.host,
+            "C": C,
+            "jid": jid,
+            "d": defer.ensureDeferred,
+        }
+        f.protocol = lambda: TelnetTransport(TelnetBootstrapProtocol,
+                                             insults.ServerProtocol,
+                                             ColoredManhole,
+                                             namespace=namespace,
+                                             )
+        reactor.listenTCP(port, f)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_exp_command_export.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin to export commands (experimental)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.words.protocols.jabber import jid
+from twisted.internet import reactor, protocol
+
+from libervia.backend.tools import trigger
+from libervia.backend.tools.utils import clean_ustr
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Command export plugin",
+    C.PI_IMPORT_NAME: "EXP-COMMANS-EXPORT",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "CommandExport",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of command export"""),
+}
+
+
+class ExportCommandProtocol(protocol.ProcessProtocol):
+    """ Try to register an account with prosody """
+
+    def __init__(self, parent, client, target, options):
+        self.parent = parent
+        self.target = target
+        self.options = options
+        self.client = client
+
+    def _clean(self, data):
+        if not data:
+            log.error("data should not be empty !")
+            return ""
+        decoded = data.decode("utf-8", "ignore")[: -1 if data[-1] == "\n" else None]
+        return clean_ustr(decoded)
+
+    def connectionMade(self):
+        log.info("connectionMade :)")
+
+    def outReceived(self, data):
+        self.client.sendMessage(self.target, {"": self._clean(data)}, no_trigger=True)
+
+    def errReceived(self, data):
+        self.client.sendMessage(self.target, {"": self._clean(data)}, no_trigger=True)
+
+    def processEnded(self, reason):
+        log.info("process finished: %d" % (reason.value.exitCode,))
+        self.parent.removeProcess(self.target, self)
+
+    def write(self, message):
+        self.transport.write(message.encode("utf-8"))
+
+    def bool_option(self, key):
+        """ Get boolean value from options
+        @param key: name of the option
+        @return: True if key exists and set to "true" (case insensitive),
+                 False in all other cases """
+        value = self.options.get(key, "")
+        return value.lower() == "true"
+
+
+class CommandExport(object):
+    """Command export plugin: export a command to an entity"""
+
+    # XXX: This plugin can be potentially dangerous if we don't trust entities linked
+    #      this is specially true if we have other triggers.
+    # FIXME: spawned should be a client attribute, not a class one
+
+    def __init__(self, host):
+        log.info(_("Plugin command export initialization"))
+        self.host = host
+        self.spawned = {}  # key = entity
+        host.trigger.add("message_received", self.message_received_trigger, priority=10000)
+        host.bridge.add_method(
+            "command_export",
+            ".plugin",
+            in_sign="sasasa{ss}s",
+            out_sign="",
+            method=self._export_command,
+        )
+
+    def removeProcess(self, entity, process):
+        """ Called when the process is finished
+        @param entity: jid.JID attached to the process
+        @param process: process to remove"""
+        try:
+            processes_set = self.spawned[(entity, process.client.profile)]
+            processes_set.discard(process)
+            if not processes_set:
+                del (self.spawned[(entity, process.client.profile)])
+        except ValueError:
+            pass
+
+    def message_received_trigger(self, client, message_elt, post_treat):
+        """ Check if source is linked and repeat message, else do nothing  """
+        from_jid = jid.JID(message_elt["from"])
+        spawned_key = (from_jid.userhostJID(), client.profile)
+
+        if spawned_key in self.spawned:
+            try:
+                body = next(message_elt.elements(C.NS_CLIENT, "body"))
+            except StopIteration:
+                # do not block message without body (chat state notification...)
+                return True
+
+            mess_data = str(body) + "\n"
+            processes_set = self.spawned[spawned_key]
+            _continue = False
+            exclusive = False
+            for process in processes_set:
+                process.write(mess_data)
+                _continue &= process.bool_option("continue")
+                exclusive |= process.bool_option("exclusive")
+            if exclusive:
+                raise trigger.SkipOtherTriggers
+            return _continue
+
+        return True
+
+    def _export_command(self, command, args, targets, options, profile_key):
+        """ Export a commands to authorised targets
+        @param command: full path of the command to execute
+        @param args: list of arguments, with command name as first one
+        @param targets: list of allowed entities
+        @param options: export options, a dict which can have the following keys ("true" to set booleans):
+                        - exclusive: if set, skip all other triggers
+                        - loop: if set, restart the command once terminated #TODO
+                        - pty: if set, launch in a pseudo terminal
+                        - continue: continue normal message_received handling
+        """
+        client = self.host.get_client(profile_key)
+        for target in targets:
+            try:
+                _jid = jid.JID(target)
+                if not _jid.user or not _jid.host:
+                    raise jid.InvalidFormat
+                _jid = _jid.userhostJID()
+            except (RuntimeError, jid.InvalidFormat, AttributeError):
+                log.info("invalid target ignored: %s" % (target,))
+                continue
+            process_prot = ExportCommandProtocol(self, client, _jid, options)
+            self.spawned.setdefault((_jid, client.profile), set()).add(process_prot)
+            reactor.spawnProcess(
+                process_prot, command, args, usePTY=process_prot.bool_option("pty")
+            )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_exp_invitation.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,350 @@
+#!/usr/bin/env python3
+
+# SàT plugin to manage invitations
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional
+from zope.interface import implementer
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from wokkel import disco, iwokkel
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.tools import utils
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Invitation",
+    C.PI_IMPORT_NAME: "INVITATION",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0329", "XEP-0334", "LIST_INTEREST"],
+    C.PI_RECOMMENDATIONS: ["EMAIL_INVITATION"],
+    C.PI_MAIN: "Invitation",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("Experimental handling of invitations"),
+}
+
+NS_INVITATION = "https://salut-a-toi/protocol/invitation:0"
+INVITATION = '/message/invitation[@xmlns="{ns_invit}"]'.format(
+    ns_invit=NS_INVITATION
+)
+NS_INVITATION_LIST = NS_INVITATION + "#list"
+
+
+class Invitation(object):
+
+    def __init__(self, host):
+        log.info(_("Invitation plugin initialization"))
+        self.host = host
+        self._p = self.host.plugins["XEP-0060"]
+        self._h = self.host.plugins["XEP-0334"]
+        # map from namespace of the invitation to callback handling it
+        self._ns_cb = {}
+
+    def get_handler(self, client):
+        return PubsubInvitationHandler(self)
+
+    def register_namespace(self, namespace, callback):
+        """Register a callback for a namespace
+
+        @param namespace(unicode): namespace handled
+        @param callback(callbable): method handling the invitation
+            For pubsub invitation, it will be called with following arguments:
+                - client
+                - name(unicode, None): name of the event
+                - extra(dict): extra data
+                - service(jid.JID): pubsub service jid
+                - node(unicode): pubsub node
+                - item_id(unicode, None): pubsub item id
+                - item_elt(domish.Element): item of the invitation
+            For file sharing invitation, it will be called with following arguments:
+                - client
+                - name(unicode, None): name of the repository
+                - extra(dict): extra data
+                - service(jid.JID): service jid of the file repository
+                - repos_type(unicode): type of the repository, can be:
+                    - files: generic file sharing
+                    - photos: photos album
+                - namespace(unicode, None): namespace of the repository
+                - path(unicode, None): path of the repository
+        @raise exceptions.ConflictError: this namespace is already registered
+        """
+        if namespace in self._ns_cb:
+            raise exceptions.ConflictError(
+                "invitation namespace {namespace} is already register with {callback}"
+                .format(namespace=namespace, callback=self._ns_cb[namespace]))
+        self._ns_cb[namespace] = callback
+
+    def _generate_base_invitation(self, client, invitee_jid, name, extra):
+        """Generate common mess_data end invitation_elt
+
+        @param invitee_jid(jid.JID): entitee to send invitation to
+        @param name(unicode, None): name of the shared repository
+        @param extra(dict, None): extra data, where key can be:
+            - thumb_url: URL of a thumbnail
+        @return (tuple[dict, domish.Element): mess_data and invitation_elt
+        """
+        mess_data = {
+            "from": client.jid,
+            "to": invitee_jid,
+            "uid": "",
+            "message": {},
+            "type": C.MESS_TYPE_CHAT,
+            "subject": {},
+            "extra": {},
+        }
+        client.generate_message_xml(mess_data)
+        self._h.add_hint_elements(mess_data["xml"], [self._h.HINT_STORE])
+        invitation_elt = mess_data["xml"].addElement("invitation", NS_INVITATION)
+        if name is not None:
+            invitation_elt["name"] = name
+        thumb_url = extra.get('thumb_url')
+        if thumb_url:
+            if not thumb_url.startswith('http'):
+                log.warning(
+                    "only http URLs are allowed for thumbnails, got {url}, ignoring"
+                    .format(url=thumb_url))
+            else:
+                invitation_elt['thumb_url'] = thumb_url
+        return mess_data, invitation_elt
+
+    def send_pubsub_invitation(
+        self,
+        client: SatXMPPEntity,
+        invitee_jid: jid.JID,
+        service: jid.JID,
+        node: str,
+        item_id: Optional[str],
+        name: Optional[str],
+        extra: Optional[dict]
+    ) -> None:
+        """Send an pubsub invitation in a <message> stanza
+
+        @param invitee_jid: entitee to send invitation to
+        @param service: pubsub service
+        @param node: pubsub node
+        @param item_id: pubsub id
+            None when the invitation is for a whole node
+        @param name: see [_generate_base_invitation]
+        @param extra: see [_generate_base_invitation]
+        """
+        if extra is None:
+            extra = {}
+        mess_data, invitation_elt = self._generate_base_invitation(
+            client, invitee_jid, name, extra)
+        pubsub_elt = invitation_elt.addElement("pubsub")
+        pubsub_elt["service"] = service.full()
+        pubsub_elt["node"] = node
+        if item_id is None:
+            try:
+                namespace = extra.pop("namespace")
+            except KeyError:
+                raise exceptions.DataError('"namespace" key is missing in "extra" data')
+            node_data_elt = pubsub_elt.addElement("node_data")
+            node_data_elt["namespace"] = namespace
+            try:
+                node_data_elt.addChild(extra["element"])
+            except KeyError:
+                pass
+        else:
+            pubsub_elt["item"] = item_id
+        if "element" in extra:
+            invitation_elt.addChild(extra.pop("element"))
+        client.send(mess_data["xml"])
+
+    async def send_file_sharing_invitation(
+        self, client, invitee_jid, service, repos_type=None, namespace=None, path=None,
+        name=None, extra=None
+    ):
+        """Send a file sharing invitation in a <message> stanza
+
+        @param invitee_jid(jid.JID): entitee to send invitation to
+        @param service(jid.JID): file sharing service
+        @param repos_type(unicode, None): type of files repository, can be:
+            - None, "files": files sharing
+            - "photos": photos album
+        @param namespace(unicode, None): namespace of the shared repository
+        @param path(unicode, None): path of the shared repository
+        @param name(unicode, None): see [_generate_base_invitation]
+        @param extra(dict, None): see [_generate_base_invitation]
+        """
+        if extra is None:
+            extra = {}
+        li_plg = self.host.plugins["LIST_INTEREST"]
+        li_plg.normalise_file_sharing_service(client, service)
+
+        # FIXME: not the best place to adapt permission, but it's necessary to check them
+        #   for UX
+        try:
+            await self.host.plugins['XEP-0329'].affiliationsSet(
+                client, service, namespace, path, {invitee_jid: "member"}
+            )
+        except Exception as e:
+            log.warning(f"Can't set affiliation: {e}")
+
+        if "thumb_url" not in extra:
+            # we have no thumbnail, we check in our own list of interests if there is one
+            try:
+                item_id = li_plg.get_file_sharing_id(service, namespace, path)
+                own_interest = await li_plg.get(client, item_id)
+            except exceptions.NotFound:
+                log.debug(
+                    "no thumbnail found for file sharing interest at "
+                    f"[{service}/{namespace}]{path}"
+                )
+            else:
+                try:
+                    extra['thumb_url'] = own_interest['thumb_url']
+                except KeyError:
+                    pass
+
+        mess_data, invitation_elt = self._generate_base_invitation(
+            client, invitee_jid, name, extra)
+        file_sharing_elt = invitation_elt.addElement("file_sharing")
+        file_sharing_elt["service"] = service.full()
+        if repos_type is not None:
+            if repos_type not in ("files", "photos"):
+                msg = "unknown repository type: {repos_type}".format(
+                    repos_type=repos_type)
+                log.warning(msg)
+                raise exceptions.DateError(msg)
+            file_sharing_elt["type"] = repos_type
+        if namespace is not None:
+            file_sharing_elt["namespace"] = namespace
+        if path is not None:
+            file_sharing_elt["path"] = path
+        client.send(mess_data["xml"])
+
+    async def _parse_pubsub_elt(self, client, pubsub_elt):
+        try:
+            service = jid.JID(pubsub_elt["service"])
+            node = pubsub_elt["node"]
+        except (RuntimeError, KeyError):
+            raise exceptions.DataError("Bad invitation, ignoring")
+
+        item_id = pubsub_elt.getAttribute("item")
+
+        if item_id is not None:
+            try:
+                items, metadata = await self._p.get_items(
+                    client, service, node, item_ids=[item_id]
+                )
+            except Exception as e:
+                log.warning(_("Can't get item linked with invitation: {reason}").format(
+                            reason=e))
+            try:
+                item_elt = items[0]
+            except IndexError:
+                log.warning(_("Invitation was linking to a non existing item"))
+                raise exceptions.DataError
+
+            try:
+                namespace = item_elt.firstChildElement().uri
+            except Exception as e:
+                log.warning(_("Can't retrieve namespace of invitation: {reason}").format(
+                    reason = e))
+                raise exceptions.DataError
+
+            args = [service, node, item_id, item_elt]
+        else:
+            try:
+                node_data_elt = next(pubsub_elt.elements(NS_INVITATION, "node_data"))
+            except StopIteration:
+                raise exceptions.DataError("Bad invitation, ignoring")
+            namespace = node_data_elt['namespace']
+            args = [service, node, None, node_data_elt]
+
+        return namespace, args
+
+    async def _parse_file_sharing_elt(self, client, file_sharing_elt):
+        try:
+            service = jid.JID(file_sharing_elt["service"])
+        except (RuntimeError, KeyError):
+            log.warning(_("Bad invitation, ignoring"))
+            raise exceptions.DataError
+        repos_type = file_sharing_elt.getAttribute("type", "files")
+        sharing_ns = file_sharing_elt.getAttribute("namespace")
+        path = file_sharing_elt.getAttribute("path")
+        args = [service, repos_type, sharing_ns, path]
+        ns_fis = self.host.get_namespace("fis")
+        return ns_fis, args
+
+    async def on_invitation(self, message_elt, client):
+        log.debug("invitation received [{profile}]".format(profile=client.profile))
+        invitation_elt = message_elt.invitation
+
+        name = invitation_elt.getAttribute("name")
+        extra = {}
+        if invitation_elt.hasAttribute("thumb_url"):
+            extra['thumb_url'] = invitation_elt['thumb_url']
+
+        for elt in invitation_elt.elements():
+            if elt.uri != NS_INVITATION:
+                log.warning("unexpected element: {xml}".format(xml=elt.toXml()))
+                continue
+            if elt.name == "pubsub":
+                method = self._parse_pubsub_elt
+            elif elt.name == "file_sharing":
+                method = self._parse_file_sharing_elt
+            else:
+                log.warning("not implemented invitation element: {xml}".format(
+                    xml = elt.toXml()))
+                continue
+            try:
+                namespace, args = await method(client, elt)
+            except exceptions.DataError:
+                log.warning("Can't parse invitation element: {xml}".format(
+                            xml = elt.toXml()))
+                continue
+
+            try:
+                cb = self._ns_cb[namespace]
+            except KeyError:
+                log.warning(_(
+                    'No handler for namespace "{namespace}", invitation ignored')
+                    .format(namespace=namespace))
+            else:
+                await utils.as_deferred(cb, client, namespace, name, extra, *args)
+
+
+@implementer(iwokkel.IDisco)
+class PubsubInvitationHandler(XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            INVITATION,
+            lambda message_elt: defer.ensureDeferred(
+                self.plugin_parent.on_invitation(message_elt, client=self.parent)
+            ),
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [
+            disco.DiscoFeature(NS_INVITATION),
+        ]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_exp_invitation_file.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+
+# SàT plugin to send invitations for file sharing
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.tools.common import data_format
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "File Sharing Invitation",
+    C.PI_IMPORT_NAME: "FILE_SHARING_INVITATION",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0329", "INVITATION"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "FileSharingInvitation",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("Experimental handling of invitations for file sharing"),
+}
+
+
+class FileSharingInvitation:
+
+    def __init__(self, host):
+        log.info(_("File Sharing Invitation plugin initialization"))
+        self.host = host
+        ns_fis = host.get_namespace("fis")
+        host.plugins["INVITATION"].register_namespace(ns_fis, self.on_invitation)
+        host.bridge.add_method(
+            "fis_invite",
+            ".plugin",
+            in_sign="ssssssss",
+            out_sign="",
+            method=self._send_file_sharing_invitation,
+            async_=True
+        )
+
+    def _send_file_sharing_invitation(
+            self, invitee_jid_s, service_s, repos_type=None, namespace=None, path=None,
+            name=None, extra_s='', profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        invitee_jid = jid.JID(invitee_jid_s)
+        service = jid.JID(service_s)
+        extra = data_format.deserialise(extra_s)
+        return defer.ensureDeferred(
+            self.host.plugins["INVITATION"].send_file_sharing_invitation(
+                client, invitee_jid, service, repos_type=repos_type or None,
+                namespace=namespace or None, path=path or None, name=name or None,
+                extra=extra)
+        )
+
+    def on_invitation(
+        self,
+        client: SatXMPPEntity,
+        namespace: str,
+        name: str,
+        extra: dict,
+        service: jid.JID,
+        repos_type: str,
+        sharing_ns: str,
+        path: str
+    ):
+        if repos_type == "files":
+            type_human = _("file sharing")
+        elif repos_type == "photos":
+            type_human = _("photo album")
+        else:
+            log.warning("Unknown repository type: {repos_type}".format(
+                repos_type=repos_type))
+            repos_type = "file"
+            type_human = _("file sharing")
+        log.info(_(
+            '{profile} has received an invitation for a files repository ({type_human}) '
+            'with namespace {sharing_ns!r} at path [{path}]').format(
+            profile=client.profile, type_human=type_human, sharing_ns=sharing_ns,
+                path=path)
+            )
+        return defer.ensureDeferred(
+            self.host.plugins['LIST_INTEREST'].register_file_sharing(
+                client, service, repos_type, sharing_ns, path, name, extra
+            )
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_exp_invitation_pubsub.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+
+# SàT plugin to send invitations for Pubsub
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.tools import utils
+from libervia.backend.tools.common import data_format
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Pubsub Invitation",
+    C.PI_IMPORT_NAME: "PUBSUB_INVITATION",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "INVITATION"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "PubsubInvitation",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("Invitations for pubsub based features"),
+}
+
+
+class PubsubInvitation:
+
+    def __init__(self, host):
+        log.info(_("Pubsub Invitation plugin initialization"))
+        self.host = host
+        self._p = host.plugins["XEP-0060"]
+        # namespace to handler map
+        self._ns_handler = {}
+        host.bridge.add_method(
+            "ps_invite",
+            ".plugin",
+            in_sign="sssssss",
+            out_sign="",
+            method=self._send_pubsub_invitation,
+            async_=True
+        )
+
+    def register(
+        self,
+        namespace: str,
+        handler
+    ) -> None:
+        self._ns_handler[namespace] = handler
+        self.host.plugins["INVITATION"].register_namespace(namespace, self.on_invitation)
+
+    def _send_pubsub_invitation(
+            self, invitee_jid_s, service_s, node, item_id=None,
+            name=None, extra_s='', profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        invitee_jid = jid.JID(invitee_jid_s)
+        service = jid.JID(service_s)
+        extra = data_format.deserialise(extra_s)
+        return defer.ensureDeferred(
+            self.invite(
+                client,
+                invitee_jid,
+                service,
+                node,
+                item_id or None,
+                name=name or None,
+                extra=extra
+            )
+        )
+
+    async def invite(
+        self,
+        client: SatXMPPEntity,
+        invitee_jid: jid.JID,
+        service: jid.JID,
+        node: str,
+        item_id: Optional[str] = None,
+        name: str = '',
+        extra: Optional[dict] = None,
+    ) -> None:
+        if extra is None:
+            extra = {}
+        else:
+            namespace = extra.get("namespace")
+            if namespace:
+                try:
+                    handler = self._ns_handler[namespace]
+                    preflight = handler.invite_preflight
+                except KeyError:
+                    pass
+                except AttributeError:
+                    log.debug(f"no invite_preflight method found for {namespace!r}")
+                else:
+                    await utils.as_deferred(
+                        preflight,
+                        client, invitee_jid, service, node, item_id, name, extra
+                    )
+            if item_id is None:
+                item_id = extra.pop("default_item_id", None)
+
+        # we authorize our invitee to see the nodes of interest
+        await self._p.set_node_affiliations(client, service, node, {invitee_jid: "member"})
+        log.debug(f"affiliation set on {service}'s {node!r} node")
+
+        # now we send the invitation
+        self.host.plugins["INVITATION"].send_pubsub_invitation(
+            client,
+            invitee_jid,
+            service,
+            node,
+            item_id,
+            name=name or None,
+            extra=extra
+        )
+
+    async def on_invitation(
+        self,
+        client: SatXMPPEntity,
+        namespace: str,
+        name: str,
+        extra: dict,
+        service: jid.JID,
+        node: str,
+        item_id: Optional[str],
+        item_elt: domish.Element
+    ) -> None:
+        if extra is None:
+            extra = {}
+        try:
+            handler = self._ns_handler[namespace]
+            preflight = handler.on_invitation_preflight
+        except KeyError:
+            pass
+        except AttributeError:
+            log.debug(f"no on_invitation_preflight method found for {namespace!r}")
+        else:
+            await utils.as_deferred(
+                preflight,
+                client, namespace, name, extra, service, node, item_id, item_elt
+            )
+            if item_id is None:
+                item_id = extra.pop("default_item_id", None)
+        creator = extra.pop("creator", False)
+        element = extra.pop("element", None)
+        if not name:
+            name = extra.pop("name", "")
+
+        return await self.host.plugins['LIST_INTEREST'].register_pubsub(
+            client, namespace, service, node, item_id, creator,
+            name, element, extra)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_exp_jingle_stream.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,305 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing pipes (experimental)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import errno
+from zope import interface
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber import jid
+from twisted.internet import defer
+from twisted.internet import protocol
+from twisted.internet import endpoints
+from twisted.internet import reactor
+from twisted.internet import error
+from twisted.internet import interfaces
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools import stream
+
+
+log = getLogger(__name__)
+
+NS_STREAM = "http://salut-a-toi.org/protocol/stream"
+SECURITY_LIMIT = 30
+START_PORT = 8888
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Jingle Stream Plugin",
+    C.PI_IMPORT_NAME: "STREAM",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0166"],
+    C.PI_MAIN: "JingleStream",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Jingle Stream plugin"""),
+}
+
+CONFIRM = D_("{peer} wants to send you a stream, do you accept ?")
+CONFIRM_TITLE = D_("Stream Request")
+
+
+class StreamProtocol(protocol.Protocol):
+    def __init__(self):
+        self.pause = False
+
+    def set_pause(self, paused):
+        # in Python 2.x, Twisted classes are old style
+        # so we can use property and setter
+        if paused:
+            if not self.pause:
+                self.transport.pauseProducing()
+                self.pause = True
+        else:
+            if self.pause:
+                self.transport.resumeProducing()
+                self.pause = False
+
+    def disconnect(self):
+        self.transport.loseConnection()
+
+    def connectionMade(self):
+        if self.factory.client_conn is not None:
+            self.transport.loseConnection()
+        self.factory.set_client_conn(self)
+
+    def dataReceived(self, data):
+        self.factory.write_to_consumer(data)
+
+    def sendData(self, data):
+        self.transport.write(data)
+
+    def connectionLost(self, reason):
+        if self.factory.client_conn != self:
+            # only the first connected client_conn is relevant
+            return
+
+        if reason.type == error.ConnectionDone:
+            self.factory.stream_finished()
+        else:
+            self.factory.stream_failed(reason)
+
+
+@interface.implementer(stream.IStreamProducer)
+@interface.implementer(interfaces.IPushProducer)
+@interface.implementer(interfaces.IConsumer)
+class StreamFactory(protocol.Factory):
+    protocol = StreamProtocol
+    consumer = None
+    producer = None
+    deferred = None
+
+    def __init__(self):
+        self.client_conn = None
+
+    def set_client_conn(self, stream_protocol):
+        # in Python 2.x, Twisted classes are old style
+        # so we can use property and setter
+        assert self.client_conn is None
+        self.client_conn = stream_protocol
+        if self.consumer is None:
+            self.client_conn.set_pause(True)
+
+    def start_stream(self, consumer):
+        if self.consumer is not None:
+            raise exceptions.InternalError(
+                _("stream can't be used with multiple consumers")
+            )
+        assert self.deferred is None
+        self.consumer = consumer
+        consumer.registerProducer(self, True)
+        self.deferred = defer.Deferred()
+        if self.client_conn is not None:
+            self.client_conn.set_pause(False)
+        return self.deferred
+
+    def stream_finished(self):
+        self.client_conn = None
+        if self.consumer:
+            self.consumer.unregisterProducer()
+            self.port_listening.stopListening()
+        self.deferred.callback(None)
+
+    def stream_failed(self, failure_):
+        self.client_conn = None
+        if self.consumer:
+            self.consumer.unregisterProducer()
+            self.port_listening.stopListening()
+            self.deferred.errback(failure_)
+        elif self.producer:
+            self.producer.stopProducing()
+
+    def stop_stream(self):
+        if self.client_conn is not None:
+            self.client_conn.disconnect()
+
+    def registerProducer(self, producer, streaming):
+        self.producer = producer
+
+    def pauseProducing(self):
+        self.client_conn.set_pause(True)
+
+    def resumeProducing(self):
+        self.client_conn.set_pause(False)
+
+    def stopProducing(self):
+        if self.client_conn:
+            self.client_conn.disconnect()
+
+    def write(self, data):
+        try:
+            self.client_conn.sendData(data)
+        except AttributeError:
+            log.warning(_("No client connected, can't send data"))
+
+    def write_to_consumer(self, data):
+        self.consumer.write(data)
+
+
+class JingleStream(object):
+    """This non standard jingle application send byte stream"""
+
+    def __init__(self, host):
+        log.info(_("Plugin Stream initialization"))
+        self.host = host
+        self._j = host.plugins["XEP-0166"]  # shortcut to access jingle
+        self._j.register_application(NS_STREAM, self)
+        host.bridge.add_method(
+            "stream_out",
+            ".plugin",
+            in_sign="ss",
+            out_sign="s",
+            method=self._stream_out,
+            async_=True,
+        )
+
+    # jingle callbacks
+
+    def _stream_out(self, to_jid_s, profile_key):
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(self.stream_out(client, jid.JID(to_jid_s)))
+
+    async def stream_out(self, client, to_jid):
+        """send a stream
+
+        @param peer_jid(jid.JID): recipient
+        @return: an unique id to identify the transfer
+        """
+        port = START_PORT
+        factory = StreamFactory()
+        while True:
+            endpoint = endpoints.TCP4ServerEndpoint(reactor, port)
+            try:
+                port_listening = await endpoint.listen(factory)
+            except error.CannotListenError as e:
+                if e.socketError.errno == errno.EADDRINUSE:
+                    port += 1
+                else:
+                    raise e
+            else:
+                factory.port_listening = port_listening
+                break
+        # we don't want to wait for IQ result of initiate
+        defer.ensureDeferred(self._j.initiate(
+            client,
+            to_jid,
+            [
+                {
+                    "app_ns": NS_STREAM,
+                    "senders": self._j.ROLE_INITIATOR,
+                    "app_kwargs": {"stream_object": factory},
+                }
+            ],
+        ))
+        return str(port)
+
+    def jingle_session_init(self, client, session, content_name, stream_object):
+        content_data = session["contents"][content_name]
+        application_data = content_data["application_data"]
+        assert "stream_object" not in application_data
+        application_data["stream_object"] = stream_object
+        desc_elt = domish.Element((NS_STREAM, "description"))
+        return desc_elt
+
+    @defer.inlineCallbacks
+    def jingle_request_confirmation(self, client, action, session, content_name, desc_elt):
+        """This method request confirmation for a jingle session"""
+        content_data = session["contents"][content_name]
+        if content_data["senders"] not in (
+            self._j.ROLE_INITIATOR,
+            self._j.ROLE_RESPONDER,
+        ):
+            log.warning("Bad sender, assuming initiator")
+            content_data["senders"] = self._j.ROLE_INITIATOR
+
+        confirm_data = yield xml_tools.defer_dialog(
+            self.host,
+            _(CONFIRM).format(peer=session["peer_jid"].full()),
+            _(CONFIRM_TITLE),
+            type_=C.XMLUI_DIALOG_CONFIRM,
+            action_extra={
+                "from_jid": session["peer_jid"].full(),
+                "type": "STREAM",
+            },
+            security_limit=SECURITY_LIMIT,
+            profile=client.profile,
+        )
+
+        if not C.bool(confirm_data["answer"]):
+            defer.returnValue(False)
+        try:
+            port = int(confirm_data["port"])
+        except (ValueError, KeyError):
+            raise exceptions.DataError(_("given port is invalid"))
+        endpoint = endpoints.TCP4ClientEndpoint(reactor, "localhost", port)
+        factory = StreamFactory()
+        yield endpoint.connect(factory)
+        content_data["stream_object"] = factory
+        finished_d = content_data["finished_d"] = defer.Deferred()
+        args = [client, session, content_name, content_data]
+        finished_d.addCallbacks(self._finished_cb, self._finished_eb, args, None, args)
+        defer.returnValue(True)
+
+    def jingle_handler(self, client, action, session, content_name, desc_elt):
+        content_data = session["contents"][content_name]
+        application_data = content_data["application_data"]
+        if action in (self._j.A_ACCEPTED_ACK, self._j.A_SESSION_INITIATE):
+            pass
+        elif action == self._j.A_SESSION_ACCEPT:
+            assert not "stream_object" in content_data
+            content_data["stream_object"] = application_data["stream_object"]
+            finished_d = content_data["finished_d"] = defer.Deferred()
+            args = [client, session, content_name, content_data]
+            finished_d.addCallbacks(self._finished_cb, self._finished_eb, args, None, args)
+        else:
+            log.warning("FIXME: unmanaged action {}".format(action))
+        return desc_elt
+
+    def _finished_cb(self, __, client, session, content_name, content_data):
+        log.info("Pipe transfer completed")
+        self._j.content_terminate(client, session, content_name)
+        content_data["stream_object"].stop_stream()
+
+    def _finished_eb(self, failure, client, session, content_name, content_data):
+        log.warning("Error while streaming pipe: {}".format(failure))
+        self._j.content_terminate(
+            client, session, content_name, reason=self._j.REASON_FAILED_TRANSPORT
+        )
+        content_data["stream_object"].stop_stream()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_exp_lang_detect.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin to detect language (experimental)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+
+try:
+    from langid.langid import LanguageIdentifier, model
+except ImportError:
+    raise exceptions.MissingModule(
+        'Missing module langid, please download/install it with "pip install langid")'
+    )
+
+identifier = LanguageIdentifier.from_modelstring(model, norm_probs=False)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Language detection plugin",
+    C.PI_IMPORT_NAME: "EXP-LANG-DETECT",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "LangDetect",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Detect and set message language when unknown"""),
+}
+
+CATEGORY = D_("Misc")
+NAME = "lang_detect"
+LABEL = D_("language detection")
+PARAMS = """
+    <params>
+    <individual>
+    <category name="{category_name}">
+        <param name="{name}" label="{label}" type="bool" value="true" />
+    </category>
+    </individual>
+    </params>
+    """.format(
+    category_name=CATEGORY, name=NAME, label=_(LABEL)
+)
+
+
+class LangDetect(object):
+    def __init__(self, host):
+        log.info(_("Language detection plugin initialization"))
+        self.host = host
+        host.memory.update_params(PARAMS)
+        host.trigger.add("message_received", self.message_received_trigger)
+        host.trigger.add("sendMessage", self.message_send_trigger)
+
+    def add_language(self, mess_data):
+        message = mess_data["message"]
+        if len(message) == 1 and list(message.keys())[0] == "":
+            msg = list(message.values())[0].strip()
+            if msg:
+                lang = identifier.classify(msg)[0]
+                mess_data["message"] = {lang: msg}
+        return mess_data
+
+    def message_received_trigger(self, client, message_elt, post_treat):
+        """ Check if source is linked and repeat message, else do nothing  """
+
+        lang_detect = self.host.memory.param_get_a(
+            NAME, CATEGORY, profile_key=client.profile
+        )
+        if lang_detect:
+            post_treat.addCallback(self.add_language)
+        return True
+
+    def message_send_trigger(self, client, data, pre_xml_treatments, post_xml_treatments):
+        lang_detect = self.host.memory.param_get_a(
+            NAME, CATEGORY, profile_key=client.profile
+        )
+        if lang_detect:
+            self.add_language(data)
+        return True
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_exp_list_of_interest.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,321 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin to detect language (experimental)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import uri
+from wokkel import disco, iwokkel, pubsub
+from zope.interface import implementer
+from twisted.internet import defer
+from twisted.words.protocols.jabber import error as jabber_error, jid
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "List of Interest",
+    C.PI_IMPORT_NAME: "LIST_INTEREST",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0329", "XEP-0106"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "ListInterest",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("Experimental handling of interesting XMPP locations"),
+}
+
+NS_LIST_INTEREST = "https://salut-a-toi/protocol/list-interest:0"
+
+
+class ListInterest(object):
+    namespace = NS_LIST_INTEREST
+
+    def __init__(self, host):
+        log.info(_("List of Interest plugin initialization"))
+        self.host = host
+        self._p = self.host.plugins["XEP-0060"]
+        host.bridge.add_method(
+            "interests_list",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="aa{ss}",
+            method=self._list_interests,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "interests_file_sharing_register",
+            ".plugin",
+            in_sign="sssssss",
+            out_sign="",
+            method=self._register_file_sharing,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "interest_retract",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._interest_retract,
+            async_=True,
+        )
+
+    def get_handler(self, client):
+        return ListInterestHandler(self)
+
+    @defer.inlineCallbacks
+    def createNode(self, client):
+        try:
+            # TODO: check auto-create, no need to create node first if available
+            options = {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST}
+            yield self._p.createNode(
+                client,
+                client.jid.userhostJID(),
+                nodeIdentifier=NS_LIST_INTEREST,
+                options=options,
+            )
+        except jabber_error.StanzaError as e:
+            if e.condition == "conflict":
+                log.debug(_("requested node already exists"))
+
+    async def register_pubsub(self, client, namespace, service, node, item_id=None,
+                       creator=False, name=None, element=None, extra=None):
+        """Register an interesting element in personal list
+
+        @param namespace(unicode): namespace of the interest
+            this is used as a cache, to avoid the need to retrieve the item only to get
+            its namespace
+        @param service(jid.JID): target pubsub service
+        @param node(unicode): target pubsub node
+        @param item_id(unicode, None): target pubsub id
+        @param creator(bool): True if client's profile is the creator of the node
+            This is used a cache, to avoid the need to retrieve affiliations
+        @param name(unicode, None): name of the interest
+        @param element(domish.Element, None): element to attach
+            may be used to cache some extra data
+        @param extra(dict, None): extra data, key can be:
+            - thumb_url: http(s) URL of a thumbnail
+        """
+        if extra is None:
+            extra = {}
+        await self.createNode(client)
+        interest_elt = domish.Element((NS_LIST_INTEREST, "interest"))
+        interest_elt["namespace"] = namespace
+        if name is not None:
+            interest_elt['name'] = name
+        thumb_url = extra.get('thumb_url')
+        if thumb_url:
+            interest_elt['thumb_url'] = thumb_url
+        pubsub_elt = interest_elt.addElement("pubsub")
+        pubsub_elt["service"] = service.full()
+        pubsub_elt["node"] = node
+        if item_id is not None:
+            pubsub_elt["item"] = item_id
+        if creator:
+            pubsub_elt["creator"] = C.BOOL_TRUE
+        if element is not None:
+            pubsub_elt.addChild(element)
+        uri_kwargs = {
+            "path": service.full(),
+            "node": node
+        }
+        if item_id:
+            uri_kwargs['id'] = item_id
+        interest_uri = uri.build_xmpp_uri("pubsub", **uri_kwargs)
+        # we use URI of the interest as item id to avoid duplicates
+        item_elt = pubsub.Item(interest_uri, payload=interest_elt)
+        await self._p.publish(
+            client, client.jid.userhostJID(), NS_LIST_INTEREST, items=[item_elt]
+        )
+
+    def _register_file_sharing(
+        self, service, repos_type, namespace, path, name, extra_raw,
+        profile
+    ):
+        client = self.host.get_client(profile)
+        extra = data_format.deserialise(extra_raw)
+
+        return defer.ensureDeferred(self.register_file_sharing(
+            client, jid.JID(service), repos_type or None, namespace or None, path or None,
+            name or None, extra
+        ))
+
+    def normalise_file_sharing_service(self, client, service):
+        # FIXME: Q&D fix as the bare file sharing service JID will lead to user own
+        #   repository, which thus would not be the same for the host and the guest.
+        #   By specifying the user part, we for the use of the host repository.
+        #   A cleaner way should be implemented
+        if service.user is None:
+            service.user = self.host.plugins['XEP-0106'].escape(client.jid.user)
+
+    def get_file_sharing_id(self, service, namespace, path):
+        return f"{service}_{namespace or ''}_{path or ''}"
+
+    async def register_file_sharing(
+            self, client, service, repos_type=None, namespace=None, path=None, name=None,
+            extra=None):
+        """Register an interesting file repository in personal list
+
+        @param service(jid.JID): service of the file repository
+        @param repos_type(unicode): type of the repository
+        @param namespace(unicode, None): namespace of the repository
+        @param path(unicode, None): path of the repository
+        @param name(unicode, None): name of the repository
+        @param extra(dict, None): same as [register_pubsub]
+        """
+        if extra is None:
+            extra = {}
+        self.normalise_file_sharing_service(client, service)
+        await self.createNode(client)
+        item_id = self.get_file_sharing_id(service, namespace, path)
+        interest_elt = domish.Element((NS_LIST_INTEREST, "interest"))
+        interest_elt["namespace"] = self.host.get_namespace("fis")
+        if name is not None:
+            interest_elt['name'] = name
+        thumb_url = extra.get('thumb_url')
+        if thumb_url:
+            interest_elt['thumb_url'] = thumb_url
+
+        file_sharing_elt = interest_elt.addElement("file_sharing")
+        file_sharing_elt["service"] = service.full()
+        if repos_type is not None:
+            file_sharing_elt["type"] = repos_type
+        if namespace is not None:
+            file_sharing_elt["namespace"] = namespace
+        if path is not None:
+            file_sharing_elt["path"] = path
+        item_elt = pubsub.Item(item_id, payload=interest_elt)
+        await self._p.publish(
+            client, client.jid.userhostJID(), NS_LIST_INTEREST, items=[item_elt]
+        )
+
+    def _list_interests_serialise(self, interests_data):
+        interests = []
+        for item_elt in interests_data[0]:
+            interest_data = {"id": item_elt['id']}
+            interest_elt = item_elt.interest
+            if interest_elt.hasAttribute('namespace'):
+                interest_data['namespace'] = interest_elt.getAttribute('namespace')
+            if interest_elt.hasAttribute('name'):
+                interest_data['name'] = interest_elt.getAttribute('name')
+            if interest_elt.hasAttribute('thumb_url'):
+                interest_data['thumb_url'] = interest_elt.getAttribute('thumb_url')
+            elt = interest_elt.firstChildElement()
+            if elt.uri != NS_LIST_INTEREST:
+                log.warning("unexpected child element, ignoring: {xml}".format(
+                    xml = elt.toXml()))
+                continue
+            if elt.name == 'pubsub':
+                interest_data.update({
+                    "type": "pubsub",
+                    "service": elt['service'],
+                    "node": elt['node'],
+                })
+                for attr in ('item', 'creator'):
+                    if elt.hasAttribute(attr):
+                        interest_data[attr] = elt[attr]
+            elif elt.name == 'file_sharing':
+                interest_data.update({
+                    "type": "file_sharing",
+                    "service": elt['service'],
+                })
+                if elt.hasAttribute('type'):
+                    interest_data['subtype'] = elt['type']
+                for attr in ('files_namespace', 'path'):
+                    if elt.hasAttribute(attr):
+                        interest_data[attr] = elt[attr]
+            else:
+                log.warning("unknown element, ignoring: {xml}".format(xml=elt.toXml()))
+                continue
+            interests.append(interest_data)
+
+        return interests
+
+    def _list_interests(self, service, node, namespace, profile):
+        service = jid.JID(service) if service else None
+        node = node or None
+        namespace = namespace or None
+        client = self.host.get_client(profile)
+        d = defer.ensureDeferred(self.list_interests(client, service, node, namespace))
+        d.addCallback(self._list_interests_serialise)
+        return d
+
+    async def list_interests(self, client, service=None, node=None, namespace=None):
+        """Retrieve list of interests
+
+        @param service(jid.JID, None): service to use
+            None to use own PEP
+        @param node(unicode, None): node to use
+            None to use default node
+        @param namespace(unicode, None): filter interests of this namespace
+            None to retrieve all interests
+        @return: same as [XEP_0060.get_items]
+        """
+        # TODO: if a MAM filter were available, it would improve performances
+        if not node:
+            node = NS_LIST_INTEREST
+        items, metadata = await self._p.get_items(client, service, node)
+        if namespace is not None:
+            filtered_items = []
+            for item in items:
+                try:
+                    interest_elt = next(item.elements(NS_LIST_INTEREST, "interest"))
+                except StopIteration:
+                    log.warning(_("Missing interest element: {xml}").format(
+                        xml=item.toXml()))
+                    continue
+                if interest_elt.getAttribute("namespace") == namespace:
+                    filtered_items.append(item)
+            items = filtered_items
+
+        return (items, metadata)
+
+    def _interest_retract(self, service_s, item_id, profile_key):
+        d = self._p._retract_item(
+            service_s, NS_LIST_INTEREST, item_id, True, profile_key)
+        d.addCallback(lambda __: None)
+        return d
+
+    async def get(self, client: SatXMPPEntity, item_id: str) -> dict:
+        """Retrieve a specific interest in profile's list"""
+        items_data = await self._p.get_items(client, None, NS_LIST_INTEREST, item_ids=[item_id])
+        try:
+            return self._list_interests_serialise(items_data)[0]
+        except IndexError:
+            raise exceptions.NotFound
+
+
+@implementer(iwokkel.IDisco)
+class ListInterestHandler(XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [
+            disco.DiscoFeature(NS_LIST_INTEREST),
+        ]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_exp_parrot.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,201 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for parrot mode (experimental)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.words.protocols.jabber import jid
+
+from libervia.backend.core.exceptions import UnknownEntityError
+
+# from sat.tools import trigger
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Parrot Plugin",
+    C.PI_IMPORT_NAME: "EXP-PARROT",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0045"],
+    C.PI_RECOMMENDATIONS: [C.TEXT_CMDS],
+    C.PI_MAIN: "Exp_Parrot",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _(
+        """Implementation of parrot mode (repeat messages between 2 entities)"""
+    ),
+}
+
+
+class Exp_Parrot(object):
+    """Parrot mode plugin: repeat messages from one entity or MUC room to another one"""
+
+    # XXX: This plugin can be potentially dangerous if we don't trust entities linked
+    #      this is specially true if we have other triggers.
+    #      send_message_trigger avoid other triggers execution, it's deactivated to allow
+    #      /unparrot command in text commands plugin.
+    # FIXME: potentially unsecure, specially with e2e encryption
+
+    def __init__(self, host):
+        log.info(_("Plugin Parrot initialization"))
+        self.host = host
+        host.trigger.add("message_received", self.message_received_trigger, priority=100)
+        # host.trigger.add("sendMessage", self.send_message_trigger, priority=100)
+        try:
+            self.host.plugins[C.TEXT_CMDS].register_text_commands(self)
+        except KeyError:
+            log.info(_("Text commands not available"))
+
+    # def send_message_trigger(self, client, mess_data, treatments):
+    #    """ Deactivate other triggers if recipient is in parrot links """
+    #    try:
+    #        _links = client.parrot_links
+    #    except AttributeError:
+    #        return True
+    #
+    #    if mess_data['to'].userhostJID() in _links.values():
+    #        log.debug("Parrot link detected, skipping other triggers")
+    #        raise trigger.SkipOtherTriggers
+
+    def message_received_trigger(self, client, message_elt, post_treat):
+        """ Check if source is linked and repeat message, else do nothing  """
+        # TODO: many things are not repeated (subject, thread, etc)
+        from_jid = message_elt["from"]
+
+        try:
+            _links = client.parrot_links
+        except AttributeError:
+            return True
+
+        if not from_jid.userhostJID() in _links:
+            return True
+
+        message = {}
+        for e in message_elt.elements(C.NS_CLIENT, "body"):
+            body = str(e)
+            lang = e.getAttribute("lang") or ""
+
+            try:
+                entity_type = self.host.memory.entity_data_get(
+                    client, from_jid, [C.ENTITY_TYPE])[C.ENTITY_TYPE]
+            except (UnknownEntityError, KeyError):
+                entity_type = "contact"
+            if entity_type == C.ENTITY_TYPE_MUC:
+                src_txt = from_jid.resource
+                if src_txt == self.host.plugins["XEP-0045"].get_room_nick(
+                    client, from_jid.userhostJID()
+                ):
+                    # we won't repeat our own messages
+                    return True
+            else:
+                src_txt = from_jid.user
+            message[lang] = "[{}] {}".format(src_txt, body)
+
+            linked = _links[from_jid.userhostJID()]
+
+            client.sendMessage(
+                jid.JID(str(linked)), message, None, "auto", no_trigger=True
+            )
+
+        return True
+
+    def add_parrot(self, client, source_jid, dest_jid):
+        """Add a parrot link from one entity to another one
+
+        @param source_jid: entity from who messages will be repeated
+        @param dest_jid: entity where the messages will be repeated
+        """
+        try:
+            _links = client.parrot_links
+        except AttributeError:
+            _links = client.parrot_links = {}
+
+        _links[source_jid.userhostJID()] = dest_jid
+        log.info(
+            "Parrot mode: %s will be repeated to %s"
+            % (source_jid.userhost(), str(dest_jid))
+        )
+
+    def remove_parrot(self, client, source_jid):
+        """Remove parrot link
+
+        @param source_jid: this entity will no more be repeated
+        """
+        try:
+            del client.parrot_links[source_jid.userhostJID()]
+        except (AttributeError, KeyError):
+            pass
+
+    def cmd_parrot(self, client, mess_data):
+        """activate Parrot mode between 2 entities, in both directions."""
+        log.debug("Catched parrot command")
+        txt_cmd = self.host.plugins[C.TEXT_CMDS]
+
+        try:
+            link_left_jid = jid.JID(mess_data["unparsed"].strip())
+            if not link_left_jid.user or not link_left_jid.host:
+                raise jid.InvalidFormat
+        except (RuntimeError, jid.InvalidFormat, AttributeError):
+            txt_cmd.feed_back(
+                client, "Can't activate Parrot mode for invalid jid", mess_data
+            )
+            return False
+
+        link_right_jid = mess_data["to"]
+
+        self.add_parrot(client, link_left_jid, link_right_jid)
+        self.add_parrot(client, link_right_jid, link_left_jid)
+
+        txt_cmd.feed_back(
+            client,
+            "Parrot mode activated for {}".format(str(link_left_jid)),
+            mess_data,
+        )
+
+        return False
+
+    def cmd_unparrot(self, client, mess_data):
+        """remove Parrot mode between 2 entities, in both directions."""
+        log.debug("Catched unparrot command")
+        txt_cmd = self.host.plugins[C.TEXT_CMDS]
+
+        try:
+            link_left_jid = jid.JID(mess_data["unparsed"].strip())
+            if not link_left_jid.user or not link_left_jid.host:
+                raise jid.InvalidFormat
+        except jid.InvalidFormat:
+            txt_cmd.feed_back(
+                client, "Can't deactivate Parrot mode for invalid jid", mess_data
+            )
+            return False
+
+        link_right_jid = mess_data["to"]
+
+        self.remove_parrot(client, link_left_jid)
+        self.remove_parrot(client, link_right_jid)
+
+        txt_cmd.feed_back(
+            client,
+            "Parrot mode deactivated for {} and {}".format(
+                str(link_left_jid), str(link_right_jid)
+            ),
+            mess_data,
+        )
+
+        return False
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_exp_pubsub_admin.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin to send pubsub requests with administrator privilege
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+from twisted.words.protocols.jabber import jid
+from wokkel import pubsub
+from wokkel import generic
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Pubsub Administrator",
+    C.PI_IMPORT_NAME: "PUBSUB_ADMIN",
+    C.PI_TYPE: C.PLUG_TYPE_EXP,
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "PubsubAdmin",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""\Implementation of Pubsub Administrator
+This allows a pubsub administrator to overwrite completly items, including publisher.
+Specially useful when importing a node."""),
+}
+
+NS_PUBSUB_ADMIN = "https://salut-a-toi.org/spec/pubsub_admin:0"
+
+
+class PubsubAdmin(object):
+
+    def __init__(self, host):
+        self.host = host
+        host.bridge.add_method(
+            "ps_admin_items_send",
+            ".plugin",
+            in_sign="ssasss",
+            out_sign="as",
+            method=self._publish,
+            async_=True,
+        )
+
+    def _publish(self, service, nodeIdentifier, items, extra=None,
+                 profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        service = None if not service else jid.JID(service)
+        extra = data_format.deserialise(extra)
+        items = [generic.parseXml(i.encode('utf-8')) for i in items]
+        return self.publish(
+            client, service, nodeIdentifier, items, extra
+        )
+
+    def _send_cb(self, iq_result):
+        publish_elt = iq_result.admin.pubsub.publish
+        ids = []
+        for item_elt in publish_elt.elements(pubsub.NS_PUBSUB, 'item'):
+            ids.append(item_elt['id'])
+        return ids
+
+    def publish(self, client, service, nodeIdentifier, items, extra=None):
+        for item in items:
+            if item.name != 'item' or item.uri != pubsub.NS_PUBSUB:
+                raise exceptions.DataError(
+                    'Invalid element, a pubsub item is expected: {xml}'.format(
+                    xml=item.toXml()))
+        iq_elt = client.IQ()
+        iq_elt['to'] = service.full() if service else client.jid.userhost()
+        admin_elt = iq_elt.addElement((NS_PUBSUB_ADMIN, 'admin'))
+        pubsub_elt = admin_elt.addElement((pubsub.NS_PUBSUB, 'pubsub'))
+        publish_elt = pubsub_elt.addElement('publish')
+        publish_elt['node'] = nodeIdentifier
+        for item in items:
+            publish_elt.addChild(item)
+        d = iq_elt.send()
+        d.addCallback(self._send_cb)
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_exp_pubsub_hook.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,286 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Pubsub Hooks
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from libervia.backend.memory import persistent
+from twisted.words.protocols.jabber import jid
+from twisted.internet import defer
+
+log = getLogger(__name__)
+
+NS_PUBSUB_HOOK = "PUBSUB_HOOK"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "PubSub Hook",
+    C.PI_IMPORT_NAME: NS_PUBSUB_HOOK,
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060"],
+    C.PI_MAIN: "PubsubHook",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _(
+        """Experimental plugin to launch on action on Pubsub notifications"""
+    ),
+}
+
+#  python module
+HOOK_TYPE_PYTHON = "python"
+# python file path
+HOOK_TYPE_PYTHON_FILE = "python_file"
+# python code directly
+HOOK_TYPE_PYTHON_CODE = "python_code"
+HOOK_TYPES = (HOOK_TYPE_PYTHON, HOOK_TYPE_PYTHON_FILE, HOOK_TYPE_PYTHON_CODE)
+
+
+class PubsubHook(object):
+    def __init__(self, host):
+        log.info(_("PubSub Hook initialization"))
+        self.host = host
+        self.node_hooks = {}  # keep track of the number of hooks per node (for all profiles)
+        host.bridge.add_method(
+            "ps_hook_add", ".plugin", in_sign="ssssbs", out_sign="", method=self._addHook
+        )
+        host.bridge.add_method(
+            "ps_hook_remove",
+            ".plugin",
+            in_sign="sssss",
+            out_sign="i",
+            method=self._removeHook,
+        )
+        host.bridge.add_method(
+            "ps_hook_list",
+            ".plugin",
+            in_sign="s",
+            out_sign="aa{ss}",
+            method=self._list_hooks,
+        )
+
+    @defer.inlineCallbacks
+    def profile_connected(self, client):
+        hooks = client._hooks = persistent.PersistentBinaryDict(
+            NS_PUBSUB_HOOK, client.profile
+        )
+        client._hooks_temporary = {}
+        yield hooks.load()
+        for node in hooks:
+            self._install_node_manager(client, node)
+
+    def profile_disconnected(self, client):
+        for node in client._hooks:
+            self._remove_node_manager(client, node)
+
+    def _install_node_manager(self, client, node):
+        if node in self.node_hooks:
+            log.debug(_("node manager already set for {node}").format(node=node))
+            self.node_hooks[node] += 1
+        else:
+            # first hook on this node
+            self.host.plugins["XEP-0060"].add_managed_node(
+                node, items_cb=self._items_received
+            )
+            self.node_hooks[node] = 0
+            log.info(_("node manager installed on {node}").format(node=node))
+
+    def _remove_node_manager(self, client, node):
+        try:
+            self.node_hooks[node] -= 1
+        except KeyError:
+            log.error(_("trying to remove a {node} without hook").format(node=node))
+        else:
+            if self.node_hooks[node] == 0:
+                del self.node_hooks[node]
+                self.host.plugins["XEP-0060"].remove_managed_node(node, self._items_received)
+                log.debug(_("hook removed"))
+            else:
+                log.debug(_("node still needed for an other hook"))
+
+    def install_hook(self, client, service, node, hook_type, hook_arg, persistent):
+        if hook_type not in HOOK_TYPES:
+            raise exceptions.DataError(
+                _("{hook_type} is not handled").format(hook_type=hook_type)
+            )
+        if hook_type != HOOK_TYPE_PYTHON_FILE:
+            raise NotImplementedError(
+                _("{hook_type} hook type not implemented yet").format(
+                    hook_type=hook_type
+                )
+            )
+        self._install_node_manager(client, node)
+        hook_data = {"service": service, "type": hook_type, "arg": hook_arg}
+
+        if persistent:
+            hooks_list = client._hooks.setdefault(node, [])
+            hooks_list.append(hook_data)
+            client._hooks.force(node)
+        else:
+            hooks_list = client._hooks_temporary.setdefault(node, [])
+            hooks_list.append(hook_data)
+
+        log.info(
+            _("{persistent} hook installed on {node} for {profile}").format(
+                persistent=_("persistent") if persistent else _("temporary"),
+                node=node,
+                profile=client.profile,
+            )
+        )
+
+    def _items_received(self, client, itemsEvent):
+        node = itemsEvent.nodeIdentifier
+        for hooks in (client._hooks, client._hooks_temporary):
+            if node not in hooks:
+                continue
+            hooks_list = hooks[node]
+            for hook_data in hooks_list[:]:
+                if hook_data["service"] != itemsEvent.sender.userhostJID():
+                    continue
+                try:
+                    callback = hook_data["callback"]
+                except KeyError:
+                    # first time we get this hook, we create the callback
+                    hook_type = hook_data["type"]
+                    try:
+                        if hook_type == HOOK_TYPE_PYTHON_FILE:
+                            hook_globals = {}
+                            exec(compile(open(hook_data["arg"], "rb").read(), hook_data["arg"], 'exec'), hook_globals)
+                            callback = hook_globals["hook"]
+                        else:
+                            raise NotImplementedError(
+                                _("{hook_type} hook type not implemented yet").format(
+                                    hook_type=hook_type
+                                )
+                            )
+                    except Exception as e:
+                        log.warning(
+                            _(
+                                "Can't load Pubsub hook at node {node}, it will be removed: {reason}"
+                            ).format(node=node, reason=e)
+                        )
+                        hooks_list.remove(hook_data)
+                        continue
+
+                for item in itemsEvent.items:
+                    try:
+                        callback(self.host, client, item)
+                    except Exception as e:
+                        log.warning(
+                            _(
+                                "Error while running Pubsub hook for node {node}: {msg}"
+                            ).format(node=node, msg=e)
+                        )
+
+    def _addHook(self, service, node, hook_type, hook_arg, persistent, profile):
+        client = self.host.get_client(profile)
+        service = jid.JID(service) if service else client.jid.userhostJID()
+        return self.add_hook(
+            client,
+            service,
+            str(node),
+            str(hook_type),
+            str(hook_arg),
+            persistent,
+        )
+
+    def add_hook(self, client, service, node, hook_type, hook_arg, persistent):
+        r"""Add a hook which will be triggered on a pubsub notification
+
+        @param service(jid.JID): service of the node
+        @param node(unicode): Pubsub node
+        @param hook_type(unicode): type of the hook, one of:
+            - HOOK_TYPE_PYTHON: a python module (must be in path)
+                module must have a "hook" method which will be called
+            - HOOK_TYPE_PYTHON_FILE: a python file
+                file must have a "hook" method which will be called
+            - HOOK_TYPE_PYTHON_CODE: direct python code
+                /!\ Python hooks will be executed in SàT context,
+                with host, client and item as arguments, it means that:
+                    - they can do whatever they wants, so don't run untrusted hooks
+                    - they MUST NOT BLOCK, they are run in Twisted async environment and blocking would block whole SàT process
+                    - item are domish.Element
+        @param hook_arg(unicode): argument of the hook, depending on the hook_type
+            can be a module path, file path, python code
+        """
+        assert service is not None
+        return self.install_hook(client, service, node, hook_type, hook_arg, persistent)
+
+    def _removeHook(self, service, node, hook_type, hook_arg, profile):
+        client = self.host.get_client(profile)
+        service = jid.JID(service) if service else client.jid.userhostJID()
+        return self.remove_hook(client, service, node, hook_type or None, hook_arg or None)
+
+    def remove_hook(self, client, service, node, hook_type=None, hook_arg=None):
+        """Remove a persistent or temporaty root
+
+        @param service(jid.JID): service of the node
+        @param node(unicode): Pubsub node
+        @param hook_type(unicode, None): same as for [add_hook]
+            match all if None
+        @param hook_arg(unicode, None): same as for [add_hook]
+            match all if None
+        @return(int): number of hooks removed
+        """
+        removed = 0
+        for hooks in (client._hooks, client._hooks_temporary):
+            if node in hooks:
+                for hook_data in hooks[node]:
+                    if (
+                        service != hook_data["service"]
+                        or hook_type is not None
+                        and hook_type != hook_data["type"]
+                        or hook_arg is not None
+                        and hook_arg != hook_data["arg"]
+                    ):
+                        continue
+                    hooks[node].remove(hook_data)
+                    removed += 1
+                    if not hooks[node]:
+                        #  no more hooks, we can remove the node
+                        del hooks[node]
+                        self._remove_node_manager(client, node)
+                    else:
+                        if hooks == client._hooks:
+                            hooks.force(node)
+        return removed
+
+    def _list_hooks(self, profile):
+        hooks_list = self.list_hooks(self.host.get_client(profile))
+        for hook in hooks_list:
+            hook["service"] = hook["service"].full()
+            hook["persistent"] = C.bool_const(hook["persistent"])
+        return hooks_list
+
+    def list_hooks(self, client):
+        """return list of registered hooks"""
+        hooks_list = []
+        for hooks in (client._hooks, client._hooks_temporary):
+            persistent = hooks is client._hooks
+            for node, hooks_data in hooks.items():
+                for hook_data in hooks_data:
+                    hooks_list.append(
+                        {
+                            "service": hook_data["service"],
+                            "node": node,
+                            "type": hook_data["type"],
+                            "arg": hook_data["arg"],
+                            "persistent": persistent,
+                        }
+                    )
+        return hooks_list
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_import.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,334 @@
+#!/usr/bin/env python3
+
+
+# SàT plugin for generic data import handling
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.internet import defer
+from libervia.backend.core import exceptions
+from twisted.words.protocols.jabber import jid
+from functools import partial
+import collections
+import uuid
+import json
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "import",
+    C.PI_IMPORT_NAME: "IMPORT",
+    C.PI_TYPE: C.PLUG_TYPE_IMPORT,
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "ImportPlugin",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Generic import plugin, base for specialized importers"""),
+}
+
+Importer = collections.namedtuple("Importer", ("callback", "short_desc", "long_desc"))
+
+
+class ImportPlugin(object):
+    def __init__(self, host):
+        log.info(_("plugin import initialization"))
+        self.host = host
+
+    def initialize(self, import_handler, name):
+        """Initialize a specialized import handler
+
+        @param import_handler(object): specialized import handler instance
+            must have the following methods:
+                - import_item: import a single main item (i.e. prepare data for publishing)
+                - importSubitems: import sub items (i.e. items linked to main item, e.g. comments).
+                    Must return a dict with kwargs for recursive_import if items are to be imported recursively.
+                    At least "items_import_data", "service" and "node" keys must be provided.
+                    if None is returned, no recursion will be done to import subitems, but import can still be done directly by the method.
+                - publish_item: actualy publish an item
+                - item_filters: modify item according to options
+        @param name(unicode): import handler name
+        """
+        assert name == name.lower().strip()
+        log.info(_("initializing {name} import handler").format(name=name))
+        import_handler.name = name
+        import_handler.register = partial(self.register, import_handler)
+        import_handler.unregister = partial(self.unregister, import_handler)
+        import_handler.importers = {}
+
+        def _import(name, location, options, pubsub_service, pubsub_node, profile):
+            return self._do_import(
+                import_handler,
+                name,
+                location,
+                options,
+                pubsub_service,
+                pubsub_node,
+                profile,
+            )
+
+        def _import_list():
+            return self.list_importers(import_handler)
+
+        def _import_desc(name):
+            return self.getDescription(import_handler, name)
+
+        self.host.bridge.add_method(
+            name + "import",
+            ".plugin",
+            in_sign="ssa{ss}sss",
+            out_sign="s",
+            method=_import,
+            async_=True,
+        )
+        self.host.bridge.add_method(
+            name + "ImportList",
+            ".plugin",
+            in_sign="",
+            out_sign="a(ss)",
+            method=_import_list,
+        )
+        self.host.bridge.add_method(
+            name + "ImportDesc",
+            ".plugin",
+            in_sign="s",
+            out_sign="(ss)",
+            method=_import_desc,
+        )
+
+    def get_progress(self, import_handler, progress_id, profile):
+        client = self.host.get_client(profile)
+        return client._import[import_handler.name][progress_id]
+
+    def list_importers(self, import_handler):
+        importers = list(import_handler.importers.keys())
+        importers.sort()
+        return [
+            (name, import_handler.importers[name].short_desc)
+            for name in import_handler.importers
+        ]
+
+    def getDescription(self, import_handler, name):
+        """Return import short and long descriptions
+
+        @param name(unicode): importer name
+        @return (tuple[unicode,unicode]): short and long description
+        """
+        try:
+            importer = import_handler.importers[name]
+        except KeyError:
+            raise exceptions.NotFound(
+                "{handler_name} importer not found [{name}]".format(
+                    handler_name=import_handler.name, name=name
+                )
+            )
+        else:
+            return importer.short_desc, importer.long_desc
+
+    def _do_import(self, import_handler, name, location, options, pubsub_service="",
+                  pubsub_node="", profile=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile)
+        options = {key: str(value) for key, value in options.items()}
+        for option in import_handler.BOOL_OPTIONS:
+            try:
+                options[option] = C.bool(options[option])
+            except KeyError:
+                pass
+        for option in import_handler.JSON_OPTIONS:
+            try:
+                options[option] = json.loads(options[option])
+            except KeyError:
+                pass
+            except ValueError:
+                raise exceptions.DataError(
+                    _("invalid json option: {option}").format(option=option)
+                )
+        pubsub_service = jid.JID(pubsub_service) if pubsub_service else None
+        return self.do_import(
+            client,
+            import_handler,
+            str(name),
+            str(location),
+            options,
+            pubsub_service,
+            pubsub_node or None,
+        )
+
+    @defer.inlineCallbacks
+    def do_import(self, client, import_handler, name, location, options=None,
+                 pubsub_service=None, pubsub_node=None,):
+        """import data
+
+        @param import_handler(object): instance of the import handler
+        @param name(unicode): name of the importer
+        @param location(unicode): location of the data to import
+            can be an url, a file path, or anything which make sense
+            check importer description for more details
+        @param options(dict, None): extra options.
+        @param pubsub_service(jid.JID, None): jid of the PubSub service where data must be
+            imported.
+            None to use profile's server
+        @param pubsub_node(unicode, None): PubSub node to use
+            None to use importer's default node
+        @return (unicode): progress id
+        """
+        if options is None:
+            options = {}
+        else:
+            for opt_name, opt_default in import_handler.OPT_DEFAULTS.items():
+                # we want a filled options dict, with all empty or False values removed
+                try:
+                    value = options[opt_name]
+                except KeyError:
+                    if opt_default:
+                        options[opt_name] = opt_default
+                else:
+                    if not value:
+                        del options[opt_name]
+
+        try:
+            importer = import_handler.importers[name]
+        except KeyError:
+            raise exceptions.NotFound("Importer [{}] not found".format(name))
+        items_import_data, items_count = yield importer.callback(
+            client, location, options
+        )
+        progress_id = str(uuid.uuid4())
+        try:
+            _import = client._import
+        except AttributeError:
+            _import = client._import = {}
+        progress_data = _import.setdefault(import_handler.name, {})
+        progress_data[progress_id] = {"position": "0"}
+        if items_count is not None:
+            progress_data[progress_id]["size"] = str(items_count)
+        metadata = {
+            "name": "{}: {}".format(name, location),
+            "direction": "out",
+            "type": import_handler.name.upper() + "_IMPORT",
+        }
+        self.host.register_progress_cb(
+            progress_id,
+            partial(self.get_progress, import_handler),
+            metadata,
+            profile=client.profile,
+        )
+        self.host.bridge.progress_started(progress_id, metadata, client.profile)
+        session = {  #  session data, can be used by importers
+            "root_service": pubsub_service,
+            "root_node": pubsub_node,
+        }
+        self.recursive_import(
+            client,
+            import_handler,
+            items_import_data,
+            progress_id,
+            session,
+            options,
+            None,
+            pubsub_service,
+            pubsub_node,
+        )
+        defer.returnValue(progress_id)
+
+    @defer.inlineCallbacks
+    def recursive_import(
+        self,
+        client,
+        import_handler,
+        items_import_data,
+        progress_id,
+        session,
+        options,
+        return_data=None,
+        service=None,
+        node=None,
+        depth=0,
+    ):
+        """Do the import recursively
+
+        @param import_handler(object): instance of the import handler
+        @param items_import_data(iterable): iterable of data as specified in [register]
+        @param progress_id(unicode): id of progression
+        @param session(dict): data for this import session
+            can be used by importer so store any useful data
+            "root_service" and "root_node" are set to the main pubsub service and node of the import
+        @param options(dict): import options
+        @param return_data(dict): data to return on progress_finished
+        @param service(jid.JID, None): PubSub service to use
+        @param node(unicode, None): PubSub node to use
+        @param depth(int): level of recursion
+        """
+        if return_data is None:
+            return_data = {}
+        for idx, item_import_data in enumerate(items_import_data):
+            item_data = yield import_handler.import_item(
+                client, item_import_data, session, options, return_data, service, node
+            )
+            yield import_handler.item_filters(client, item_data, session, options)
+            recurse_kwargs = yield import_handler.import_sub_items(
+                client, item_import_data, item_data, session, options
+            )
+            yield import_handler.publish_item(client, item_data, service, node, session)
+
+            if recurse_kwargs is not None:
+                recurse_kwargs["client"] = client
+                recurse_kwargs["import_handler"] = import_handler
+                recurse_kwargs["progress_id"] = progress_id
+                recurse_kwargs["session"] = session
+                recurse_kwargs.setdefault("options", options)
+                recurse_kwargs["return_data"] = return_data
+                recurse_kwargs["depth"] = depth + 1
+                log.debug(_("uploading subitems"))
+                yield self.recursive_import(**recurse_kwargs)
+
+            if depth == 0:
+                client._import[import_handler.name][progress_id]["position"] = str(
+                    idx + 1
+                )
+
+        if depth == 0:
+            self.host.bridge.progress_finished(progress_id, return_data, client.profile)
+            self.host.remove_progress_cb(progress_id, client.profile)
+            del client._import[import_handler.name][progress_id]
+
+    def register(self, import_handler, name, callback, short_desc="", long_desc=""):
+        """Register an Importer method
+
+        @param name(unicode): unique importer name, should indicate the software it can import and always lowercase
+        @param callback(callable): method to call:
+            the signature must be (client, location, options) (cf. [do_import])
+            the importer must return a tuple with (items_import_data, items_count)
+            items_import_data(iterable[dict]) data specific to specialized importer
+                cf. import_item docstring of specialized importer for details
+            items_count (int, None) indicate the total number of items (without subitems)
+                useful to display a progress indicator when the iterator is a generator
+                use None if you can't guess the total number of items
+        @param short_desc(unicode): one line description of the importer
+        @param long_desc(unicode): long description of the importer, its options, etc.
+        """
+        name = name.lower()
+        if name in import_handler.importers:
+            raise exceptions.ConflictError(
+                _(
+                    "An {handler_name} importer with the name {name} already exist"
+                ).format(handler_name=import_handler.name, name=name)
+            )
+        import_handler.importers[name] = Importer(callback, short_desc, long_desc)
+
+    def unregister(self, import_handler, name):
+        del import_handler.importers[name]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_merge_req_mercurial.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+
+# SàT plugin managing Mercurial VCS
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import re
+from twisted.python.procutils import which
+from libervia.backend.tools.common import async_process
+from libervia.backend.tools import utils
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Mercurial Merge Request handler",
+    C.PI_IMPORT_NAME: "MERGE_REQUEST_MERCURIAL",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_DEPENDENCIES: ["MERGE_REQUESTS"],
+    C.PI_MAIN: "MercurialHandler",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Merge request handler for Mercurial""")
+}
+
+SHORT_DESC = D_("handle Mercurial repository")
+CLEAN_RE = re.compile(r'[^\w -._]', flags=re.UNICODE)
+
+
+class MercurialProtocol(async_process.CommandProtocol):
+    """handle hg commands"""
+    name = "Mercurial"
+    command = None
+
+    @classmethod
+    def run(cls, path, command, *args, **kwargs):
+        """Create a new MercurialRegisterProtocol and execute the given mercurial command.
+
+        @param path(unicode): path to the repository
+        @param command(unicode): hg command to run
+        @return D(bytes): stdout of the command
+        """
+        assert "path" not in kwargs
+        kwargs["path"] = path
+        # FIXME: we have to use this workaround because Twisted's protocol.ProcessProtocol
+        #        is not using new style classes. This can be removed once moved to
+        #        Python 3 (super can be used normally then).
+        d = async_process.CommandProtocol.run.__func__(cls, command, *args, **kwargs)
+        d.addErrback(utils.logError)
+        return d
+
+
+class MercurialHandler(object):
+    data_types = ('mercurial_changeset',)
+
+    def __init__(self, host):
+        log.info(_("Mercurial merge request handler initialization"))
+        try:
+            MercurialProtocol.command = which('hg')[0]
+        except IndexError:
+            raise exceptions.NotFound(_("Mercurial executable (hg) not found, "
+                                        "can't use Mercurial handler"))
+        self.host = host
+        self._m = host.plugins['MERGE_REQUESTS']
+        self._m.register('mercurial', self, self.data_types, SHORT_DESC)
+
+
+    def check(self, repository):
+        d = MercurialProtocol.run(repository, 'identify')
+        d.addCallback(lambda __: True)
+        d.addErrback(lambda __: False)
+        return d
+
+    def export(self, repository):
+        d = MercurialProtocol.run(
+            repository, 'export', '-g', '-r', 'outgoing() and ancestors(.)',
+            '--encoding=utf-8'
+        )
+        d.addCallback(lambda data: data.decode('utf-8'))
+        return d
+
+    def import_(self, repository, data, data_type, item_id, service, node, extra):
+        parsed_data = self.parse(data)
+        try:
+            parsed_name = parsed_data[0]['commit_msg'].split('\n')[0]
+            parsed_name = CLEAN_RE.sub('', parsed_name)[:40]
+        except Exception:
+            parsed_name = ''
+        name = 'mr_{item_id}_{parsed_name}'.format(item_id=CLEAN_RE.sub('', item_id),
+                                                   parsed_name=parsed_name)
+        return MercurialProtocol.run(repository, 'qimport', '-g', '--name', name,
+                                     '--encoding=utf-8', '-', stdin=data)
+
+    def parse(self, data, data_type=None):
+        lines = data.splitlines()
+        total_lines = len(lines)
+        patches = []
+        while lines:
+            patch = {}
+            commit_msg = []
+            diff = []
+            state = 'init'
+            if lines[0] != '# HG changeset patch':
+                raise exceptions.DataError(_('invalid changeset signature'))
+            # line index of this patch in the whole data
+            patch_idx = total_lines - len(lines)
+            del lines[0]
+
+            for idx, line in enumerate(lines):
+                if state == 'init':
+                    if line.startswith('# '):
+                        if line.startswith('# User '):
+                            elems = line[7:].split()
+                            if not elems:
+                                continue
+                            last = elems[-1]
+                            if (last.startswith('<') and last.endswith('>')
+                                and '@' in last):
+                                patch[self._m.META_EMAIL] = elems.pop()[1:-1]
+                            patch[self._m.META_AUTHOR] = ' '.join(elems)
+                        elif line.startswith('# Date '):
+                            time_data = line[7:].split()
+                            if len(time_data) != 2:
+                                log.warning(_('unexpected time data: {data}')
+                                            .format(data=line[7:]))
+                                continue
+                            patch[self._m.META_TIMESTAMP] = (int(time_data[0])
+                                                             + int(time_data[1]))
+                        elif line.startswith('# Node ID '):
+                            patch[self._m.META_HASH] = line[10:]
+                        elif line.startswith('# Parent  '):
+                            patch[self._m.META_PARENT_HASH] = line[10:]
+                    else:
+                        state = 'commit_msg'
+                if state == 'commit_msg':
+                    if line.startswith('diff --git a/'):
+                        state = 'diff'
+                        patch[self._m.META_DIFF_IDX] = patch_idx + idx + 1
+                    else:
+                        commit_msg.append(line)
+                if state == 'diff':
+                    if line.startswith('# ') or idx == len(lines)-1:
+                        # a new patch is starting or we have reached end of patches
+                        if idx == len(lines)-1:
+                            # end of patches, we need to keep the line
+                            diff.append(line)
+                        patch[self._m.META_COMMIT_MSG] = '\n'.join(commit_msg)
+                        patch[self._m.META_DIFF] = '\n'.join(diff)
+                        patches.append(patch)
+                        if idx == len(lines)-1:
+                            del lines[:]
+                        else:
+                            del lines[:idx]
+                        break
+                    else:
+                        diff.append(line)
+        return patches
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_account.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,766 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for account creation
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger
+
+from libervia.backend.core import exceptions
+from libervia.backend.tools import xml_tools
+from libervia.backend.memory.memory import Sessions
+from libervia.backend.memory.crypto import PasswordHasher
+from libervia.backend.core.constants import Const as C
+import configparser
+from twisted.internet import defer
+from twisted.python.failure import Failure
+from twisted.words.protocols.jabber import jid
+from libervia.backend.tools.common import email as sat_email
+
+
+log = getLogger(__name__)
+
+
+#  FIXME: this plugin code is old and need a cleaning
+# TODO: account deletion/password change need testing
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Account Plugin",
+    C.PI_IMPORT_NAME: "MISC-ACCOUNT",
+    C.PI_TYPE: "MISC",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0077"],
+    C.PI_RECOMMENDATIONS: ["GROUPBLOG"],
+    C.PI_MAIN: "MiscAccount",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Libervia account creation"""),
+}
+
+CONFIG_SECTION = "plugin account"
+
+# You need do adapt the following consts to your server
+# all theses values (key=option name, value=default) can (and should) be overriden
+# in libervia.conf in section CONFIG_SECTION
+
+default_conf = {
+    "email_from": "NOREPLY@example.net",
+    "email_server": "localhost",
+    "email_sender_domain": "",
+    "email_port": 25,
+    "email_username": "",
+    "email_password": "",
+    "email_starttls": "false",
+    "email_auth": "false",
+    "email_admins_list": [],
+    "admin_email": "",
+    "new_account_server": "localhost",
+    "new_account_domain": "",  #  use xmpp_domain if not found
+    "reserved_list": ["libervia"],  # profiles which can't be used
+}
+
+WELCOME_MSG = D_(
+    """Welcome to Libervia, the web interface of Salut à Toi.
+
+Your account on {domain} has been successfully created.
+This is a demonstration version to show you the current status of the project.
+It is still under development, please keep it in mind!
+
+Here is your connection information:
+
+Login on {domain}: {profile}
+Jabber ID (JID): {jid}
+Your password has been chosen by yourself during registration.
+
+In the beginning, you have nobody to talk to. To find some contacts, you may use the users' directory:
+    - make yourself visible in "Service / Directory subscription".
+    - search for people with "Contacts" / Search directory".
+
+Any feedback welcome. Thank you!
+
+Salut à Toi association
+https://www.salut-a-toi.org
+"""
+)
+
+DEFAULT_DOMAIN = "example.net"
+
+
+class MiscAccount(object):
+    """Account plugin: create a SàT + XMPP account, used by Libervia"""
+
+    # XXX: This plugin was initialy a Q&D one used for the demo.
+    # TODO: cleaning, separate email handling, more configuration/tests, fixes
+
+    def __init__(self, host):
+        log.info(_("Plugin Account initialization"))
+        self.host = host
+        host.bridge.add_method(
+            "libervia_account_register",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._register_account,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "account_domain_new_get",
+            ".plugin",
+            in_sign="",
+            out_sign="s",
+            method=self.account_domain_new_get,
+            async_=False,
+        )
+        host.bridge.add_method(
+            "account_dialog_ui_get",
+            ".plugin",
+            in_sign="s",
+            out_sign="s",
+            method=self._get_account_dialog_ui,
+            async_=False,
+        )
+        host.bridge.add_method(
+            "credentials_xmpp_connect",
+            ".plugin",
+            in_sign="ss",
+            out_sign="b",
+            method=self.credentials_xmpp_connect,
+            async_=True,
+        )
+
+        self.fix_email_admins()
+        self._sessions = Sessions()
+
+        self.__account_cb_id = host.register_callback(
+            self._account_dialog_cb, with_data=True
+        )
+        self.__change_password_id = host.register_callback(
+            self.__change_password_cb, with_data=True
+        )
+
+        def delete_blog_callback(posts, comments):
+            return lambda data, profile: self.__delete_blog_posts_cb(
+                posts, comments, data, profile
+            )
+
+        self.__delete_posts_id = host.register_callback(
+            delete_blog_callback(True, False), with_data=True
+        )
+        self.__delete_comments_id = host.register_callback(
+            delete_blog_callback(False, True), with_data=True
+        )
+        self.__delete_posts_comments_id = host.register_callback(
+            delete_blog_callback(True, True), with_data=True
+        )
+
+        self.__delete_account_id = host.register_callback(
+            self.__delete_account_cb, with_data=True
+        )
+
+    # FIXME: remove this after some time, when the deprecated parameter is really abandoned
+    def fix_email_admins(self):
+        """Handle deprecated config option "admin_email" to fix the admin emails list"""
+        admin_email = self.config_get("admin_email")
+        if not admin_email:
+            return
+        log.warning(
+            "admin_email parameter is deprecated, please use email_admins_list instead"
+        )
+        param_name = "email_admins_list"
+        try:
+            section = ""
+            value = self.host.memory.config_get(section, param_name, Exception)
+        except (configparser.NoOptionError, configparser.NoSectionError):
+            section = CONFIG_SECTION
+            value = self.host.memory.config_get(
+                section, param_name, default_conf[param_name]
+            )
+
+        value = set(value)
+        value.add(admin_email)
+        self.host.memory.config.set(section, param_name, ",".join(value))
+
+    def config_get(self, name, section=CONFIG_SECTION):
+        if name.startswith("email_"):
+            # XXX: email_ parameters were first in [plugin account] section
+            #      but as it make more sense to have them in common with other plugins,
+            #      they can now be in [DEFAULT] section
+            try:
+                value = self.host.memory.config_get(None, name, Exception)
+            except (configparser.NoOptionError, configparser.NoSectionError):
+                pass
+            else:
+                return value
+
+        if section == CONFIG_SECTION:
+            default = default_conf[name]
+        else:
+            default = None
+        return self.host.memory.config_get(section, name, default)
+
+    def _register_account(self, email, password, profile):
+        return self.registerAccount(email, password, None, profile)
+
+    def registerAccount(self, email, password, jid_s, profile):
+        """Register a new profile, its associated XMPP account, send the confirmation emails.
+
+        @param email (unicode): where to send to confirmation email to
+        @param password (unicode): password chosen by the user
+            while be used for profile *and* XMPP account
+        @param jid_s (unicode): JID to re-use or to register:
+            - non empty value: bind this JID to the new sat profile
+            - None or "": register a new JID on the local XMPP server
+        @param profile
+        @return Deferred
+        """
+        d = self.create_profile(password, jid_s, profile)
+        d.addCallback(lambda __: self.send_emails(email, profile))
+        return d
+
+    def create_profile(self, password, jid_s, profile):
+        """Register a new profile and its associated XMPP account.
+
+        @param password (unicode): password chosen by the user
+            while be used for profile *and* XMPP account
+        @param jid_s (unicode): JID to re-use or to register:
+            - non empty value: bind this JID to the new sat profile
+            - None or "": register a new JID on the local XMPP server
+        @param profile
+        @return Deferred
+        """
+        if not password or not profile:
+            raise exceptions.DataError
+
+        if profile.lower() in self.config_get("reserved_list"):
+            return defer.fail(Failure(exceptions.ConflictError))
+
+        d = self.host.memory.create_profile(profile, password)
+        d.addCallback(lambda __: self.profile_created(password, jid_s, profile))
+        return d
+
+    def profile_created(self, password, jid_s, profile):
+        """Create the XMPP account and set the profile connection parameters.
+
+        @param password (unicode): password chosen by the user
+        @param jid_s (unicode): JID to re-use or to register:
+            - non empty value: bind this JID to the new sat profile
+            - None or empty: register a new JID on the local XMPP server
+        @param profile
+        @return: Deferred
+        """
+        if jid_s:
+            d = defer.succeed(None)
+            jid_ = jid.JID(jid_s)
+        else:
+            jid_s = profile + "@" + self.account_domain_new_get()
+            jid_ = jid.JID(jid_s)
+            d = self.host.plugins["XEP-0077"].register_new_account(jid_, password)
+
+        def setParams(__):
+            self.host.memory.param_set(
+                "JabberID", jid_s, "Connection", profile_key=profile
+            )
+            d = self.host.memory.param_set(
+                "Password", password, "Connection", profile_key=profile
+            )
+            return d
+
+        def remove_profile(failure):
+            self.host.memory.profile_delete_async(profile)
+            return failure
+
+        d.addCallback(lambda __: self.host.memory.start_session(password, profile))
+        d.addCallback(setParams)
+        d.addCallback(lambda __: self.host.memory.stop_session(profile))
+        d.addErrback(remove_profile)
+        return d
+
+    def _send_email_eb(self, failure_, email):
+        # TODO: return error code to user
+        log.error(
+            _("Failed to send account creation confirmation to {email}: {msg}").format(
+                email=email, msg=failure_
+            )
+        )
+
+    def send_emails(self, email, profile):
+        # time to send the email
+
+        domain = self.account_domain_new_get()
+
+        # email to the administrators
+        admins_emails = self.config_get("email_admins_list")
+        if not admins_emails:
+            log.warning(
+                "No known admin email, we can't send email to administrator(s).\n"
+                "Please fill email_admins_list parameter"
+            )
+            d_admin = defer.fail(exceptions.DataError("no admin email"))
+        else:
+            subject = _("New Libervia account created")
+            # there is no email when an existing XMPP account is used
+            body = f"New account created on {domain}: {profile} [{email or '<no email>'}]"
+            d_admin = sat_email.send_email(
+                self.host.memory.config, admins_emails, subject, body)
+
+        admins_emails_txt = ", ".join(["<" + addr + ">" for addr in admins_emails])
+        d_admin.addCallbacks(
+            lambda __: log.debug(
+                "Account creation notification sent to admin(s) {}".format(
+                    admins_emails_txt
+                )
+            ),
+            lambda __: log.error(
+                "Failed to send account creation notification to admin {}".format(
+                    admins_emails_txt
+                )
+            ),
+        )
+        if not email:
+            # TODO: if use register with an existing account, an XMPP message should be sent
+            return d_admin
+
+        jid_s = self.host.memory.param_get_a(
+            "JabberID", "Connection", profile_key=profile
+        )
+        subject = _("Your Libervia account has been created")
+        body = _(WELCOME_MSG).format(profile=profile, jid=jid_s, domain=domain)
+
+        # XXX: this will not fail when the email address doesn't exist
+        # FIXME: check email reception to validate email given by the user
+        # FIXME: delete the profile if the email could not been sent?
+        d_user = sat_email.send_email(self.host.memory.config, [email], subject, body)
+        d_user.addCallbacks(
+            lambda __: log.debug(
+                "Account creation confirmation sent to <{}>".format(email)
+            ),
+            self._send_email_eb,
+            errbackArgs=[email]
+        )
+        return defer.DeferredList([d_user, d_admin])
+
+    def account_domain_new_get(self):
+        """get the domain that will be set to new account"""
+
+        domain = self.config_get("new_account_domain") or self.config_get(
+            "xmpp_domain", None
+        )
+        if not domain:
+            log.warning(
+                _(
+                    'xmpp_domain needs to be set in sat.conf. Using "{default}" meanwhile'
+                ).format(default=DEFAULT_DOMAIN)
+            )
+            return DEFAULT_DOMAIN
+        return domain
+
+    def _get_account_dialog_ui(self, profile):
+        """Get the main dialog to manage your account
+        @param menu_data
+        @param profile: %(doc_profile)s
+        @return: XML of the dialog
+        """
+        form_ui = xml_tools.XMLUI(
+            "form",
+            "tabs",
+            title=D_("Manage your account"),
+            submit_id=self.__account_cb_id,
+        )
+        tab_container = form_ui.current_container
+
+        tab_container.add_tab(
+            "update", D_("Change your password"), container=xml_tools.PairsContainer
+        )
+        form_ui.addLabel(D_("Current profile password"))
+        form_ui.addPassword("current_passwd", value="")
+        form_ui.addLabel(D_("New password"))
+        form_ui.addPassword("new_passwd1", value="")
+        form_ui.addLabel(D_("New password (again)"))
+        form_ui.addPassword("new_passwd2", value="")
+
+        # FIXME: uncomment and fix these features
+        """
+        if 'GROUPBLOG' in self.host.plugins:
+            tab_container.add_tab("delete_posts", D_("Delete your posts"), container=xml_tools.PairsContainer)
+            form_ui.addLabel(D_("Current profile password"))
+            form_ui.addPassword("delete_posts_passwd", value="")
+            form_ui.addLabel(D_("Delete all your posts and their comments"))
+            form_ui.addBool("delete_posts_checkbox", "false")
+            form_ui.addLabel(D_("Delete all your comments on other's posts"))
+            form_ui.addBool("delete_comments_checkbox", "false")
+
+        tab_container.add_tab("delete", D_("Delete your account"), container=xml_tools.PairsContainer)
+        form_ui.addLabel(D_("Current profile password"))
+        form_ui.addPassword("delete_passwd", value="")
+        form_ui.addLabel(D_("Delete your account"))
+        form_ui.addBool("delete_checkbox", "false")
+        """
+
+        return form_ui.toXml()
+
+    @defer.inlineCallbacks
+    def _account_dialog_cb(self, data, profile):
+        """Called when the user submits the main account dialog
+        @param data
+        @param profile
+        """
+        sat_cipher = yield self.host.memory.param_get_a_async(
+            C.PROFILE_PASS_PATH[1], C.PROFILE_PASS_PATH[0], profile_key=profile
+        )
+
+        @defer.inlineCallbacks
+        def verify(attempt):
+            auth = yield PasswordHasher.verify(attempt, sat_cipher)
+            defer.returnValue(auth)
+
+        def error_ui(message=None):
+            if not message:
+                message = D_("The provided profile password doesn't match.")
+            error_ui = xml_tools.XMLUI("popup", title=D_("Attempt failure"))
+            error_ui.addText(message)
+            return {"xmlui": error_ui.toXml()}
+
+        # check for account deletion
+        # FIXME: uncomment and fix these features
+        """
+        delete_passwd = data[xml_tools.SAT_FORM_PREFIX + 'delete_passwd']
+        delete_checkbox = data[xml_tools.SAT_FORM_PREFIX + 'delete_checkbox']
+        if delete_checkbox == 'true':
+            verified = yield verify(delete_passwd)
+            assert isinstance(verified, bool)
+            if verified:
+                defer.returnValue(self.__delete_account(profile))
+            defer.returnValue(error_ui())
+
+        # check for blog posts deletion
+        if 'GROUPBLOG' in self.host.plugins:
+            delete_posts_passwd = data[xml_tools.SAT_FORM_PREFIX + 'delete_posts_passwd']
+            delete_posts_checkbox = data[xml_tools.SAT_FORM_PREFIX + 'delete_posts_checkbox']
+            delete_comments_checkbox = data[xml_tools.SAT_FORM_PREFIX + 'delete_comments_checkbox']
+            posts = delete_posts_checkbox == 'true'
+            comments = delete_comments_checkbox == 'true'
+            if posts or comments:
+                verified = yield verify(delete_posts_passwd)
+                assert isinstance(verified, bool)
+                if verified:
+                    defer.returnValue(self.__delete_blog_posts(posts, comments, profile))
+                defer.returnValue(error_ui())
+        """
+
+        # check for password modification
+        current_passwd = data[xml_tools.SAT_FORM_PREFIX + "current_passwd"]
+        new_passwd1 = data[xml_tools.SAT_FORM_PREFIX + "new_passwd1"]
+        new_passwd2 = data[xml_tools.SAT_FORM_PREFIX + "new_passwd2"]
+        if new_passwd1 or new_passwd2:
+            verified = yield verify(current_passwd)
+            assert isinstance(verified, bool)
+            if verified:
+                if new_passwd1 == new_passwd2:
+                    data = yield self.__change_password(new_passwd1, profile=profile)
+                    defer.returnValue(data)
+                else:
+                    defer.returnValue(
+                        error_ui(
+                            D_("The values entered for the new password are not equal.")
+                        )
+                    )
+            defer.returnValue(error_ui())
+
+        defer.returnValue({})
+
+    def __change_password(self, password, profile):
+        """Ask for a confirmation before changing the XMPP account and SàT profile passwords.
+
+        @param password (str): the new password
+        @param profile (str): %(doc_profile)s
+        """
+        session_id, __ = self._sessions.new_session(
+            {"new_password": password}, profile=profile
+        )
+        form_ui = xml_tools.XMLUI(
+            "form",
+            title=D_("Change your password?"),
+            submit_id=self.__change_password_id,
+            session_id=session_id,
+        )
+        form_ui.addText(
+            D_(
+                "Note for advanced users: this will actually change both your SàT profile password AND your XMPP account password."
+            )
+        )
+        form_ui.addText(D_("Continue with changing the password?"))
+        return {"xmlui": form_ui.toXml()}
+
+    def __change_password_cb(self, data, profile):
+        """Actually change the user XMPP account and SàT profile password
+        @param data (dict)
+        @profile (str): %(doc_profile)s
+        """
+        client = self.host.get_client(profile)
+        password = self._sessions.profile_get(data["session_id"], profile)["new_password"]
+        del self._sessions[data["session_id"]]
+
+        def password_changed(__):
+            d = self.host.memory.param_set(
+                C.PROFILE_PASS_PATH[1],
+                password,
+                C.PROFILE_PASS_PATH[0],
+                profile_key=profile,
+            )
+            d.addCallback(
+                lambda __: self.host.memory.param_set(
+                    "Password", password, "Connection", profile_key=profile
+                )
+            )
+            confirm_ui = xml_tools.XMLUI("popup", title=D_("Confirmation"))
+            confirm_ui.addText(D_("Your password has been changed."))
+            return defer.succeed({"xmlui": confirm_ui.toXml()})
+
+        def errback(failure):
+            error_ui = xml_tools.XMLUI("popup", title=D_("Error"))
+            error_ui.addText(
+                D_("Your password could not be changed: %s") % failure.getErrorMessage()
+            )
+            return defer.succeed({"xmlui": error_ui.toXml()})
+
+        d = self.host.plugins["XEP-0077"].change_password(client, password)
+        d.addCallbacks(password_changed, errback)
+        return d
+
+    def __delete_account(self, profile):
+        """Ask for a confirmation before deleting the XMPP account and SàT profile
+        @param profile
+        """
+        form_ui = xml_tools.XMLUI(
+            "form", title=D_("Delete your account?"), submit_id=self.__delete_account_id
+        )
+        form_ui.addText(
+            D_(
+                "If you confirm this dialog, you will be disconnected and then your XMPP account AND your SàT profile will both be DELETED."
+            )
+        )
+        target = D_(
+            "contact list, messages history, blog posts and comments"
+            if "GROUPBLOG" in self.host.plugins
+            else D_("contact list and messages history")
+        )
+        form_ui.addText(
+            D_(
+                "All your data stored on %(server)s, including your %(target)s will be erased."
+            )
+            % {"server": self.account_domain_new_get(), "target": target}
+        )
+        form_ui.addText(
+            D_(
+                "There is no other confirmation dialog, this is the very last one! Are you sure?"
+            )
+        )
+        return {"xmlui": form_ui.toXml()}
+
+    def __delete_account_cb(self, data, profile):
+        """Actually delete the XMPP account and SàT profile
+
+        @param data
+        @param profile
+        """
+        client = self.host.get_client(profile)
+
+        def user_deleted(__):
+
+            # FIXME: client should be disconnected at this point, so 2 next loop should be removed (to be confirmed)
+            for jid_ in client.roster._jids:  # empty roster
+                client.presence.unsubscribe(jid_)
+
+            for jid_ in self.host.memory.sub_waiting_get(
+                profile
+            ):  # delete waiting subscriptions
+                self.host.memory.del_waiting_sub(jid_)
+
+            delete_profile = lambda: self.host.memory.profile_delete_async(
+                profile, force=True
+            )
+            if "GROUPBLOG" in self.host.plugins:
+                d = self.host.plugins["GROUPBLOG"].deleteAllGroupBlogsAndComments(
+                    profile_key=profile
+                )
+                d.addCallback(lambda __: delete_profile())
+            else:
+                delete_profile()
+
+            return defer.succeed({})
+
+        def errback(failure):
+            error_ui = xml_tools.XMLUI("popup", title=D_("Error"))
+            error_ui.addText(
+                D_("Your XMPP account could not be deleted: %s")
+                % failure.getErrorMessage()
+            )
+            return defer.succeed({"xmlui": error_ui.toXml()})
+
+        d = self.host.plugins["XEP-0077"].unregister(client, jid.JID(client.jid.host))
+        d.addCallbacks(user_deleted, errback)
+        return d
+
+    def __delete_blog_posts(self, posts, comments, profile):
+        """Ask for a confirmation before deleting the blog posts
+        @param posts: delete all posts of the user (and their comments)
+        @param comments: delete all the comments of the user on other's posts
+        @param data
+        @param profile
+        """
+        if posts:
+            if comments:  # delete everything
+                form_ui = xml_tools.XMLUI(
+                    "form",
+                    title=D_("Delete all your (micro-)blog posts and comments?"),
+                    submit_id=self.__delete_posts_comments_id,
+                )
+                form_ui.addText(
+                    D_(
+                        "If you confirm this dialog, all the (micro-)blog data you submitted will be erased."
+                    )
+                )
+                form_ui.addText(
+                    D_(
+                        "These are the public and private posts and comments you sent to any group."
+                    )
+                )
+                form_ui.addText(
+                    D_(
+                        "There is no other confirmation dialog, this is the very last one! Are you sure?"
+                    )
+                )
+            else:  # delete only the posts
+                form_ui = xml_tools.XMLUI(
+                    "form",
+                    title=D_("Delete all your (micro-)blog posts?"),
+                    submit_id=self.__delete_posts_id,
+                )
+                form_ui.addText(
+                    D_(
+                        "If you confirm this dialog, all the public and private posts you sent to any group will be erased."
+                    )
+                )
+                form_ui.addText(
+                    D_(
+                        "There is no other confirmation dialog, this is the very last one! Are you sure?"
+                    )
+                )
+        elif comments:  # delete only the comments
+            form_ui = xml_tools.XMLUI(
+                "form",
+                title=D_("Delete all your (micro-)blog comments?"),
+                submit_id=self.__delete_comments_id,
+            )
+            form_ui.addText(
+                D_(
+                    "If you confirm this dialog, all the public and private comments you made on other people's posts will be erased."
+                )
+            )
+            form_ui.addText(
+                D_(
+                    "There is no other confirmation dialog, this is the very last one! Are you sure?"
+                )
+            )
+
+        return {"xmlui": form_ui.toXml()}
+
+    def __delete_blog_posts_cb(self, posts, comments, data, profile):
+        """Actually delete the XMPP account and SàT profile
+        @param posts: delete all posts of the user (and their comments)
+        @param comments: delete all the comments of the user on other's posts
+        @param profile
+        """
+        if posts:
+            if comments:
+                target = D_("blog posts and comments")
+                d = self.host.plugins["GROUPBLOG"].deleteAllGroupBlogsAndComments(
+                    profile_key=profile
+                )
+            else:
+                target = D_("blog posts")
+                d = self.host.plugins["GROUPBLOG"].deleteAllGroupBlogs(
+                    profile_key=profile
+                )
+        elif comments:
+            target = D_("comments")
+            d = self.host.plugins["GROUPBLOG"].deleteAllGroupBlogsComments(
+                profile_key=profile
+            )
+
+        def deleted(result):
+            ui = xml_tools.XMLUI("popup", title=D_("Deletion confirmation"))
+            # TODO: change the message when delete/retract notifications are done with XEP-0060
+            ui.addText(D_("Your %(target)s have been deleted.") % {"target": target})
+            ui.addText(
+                D_(
+                    "Known issue of the demo version: you need to refresh the page to make the deleted posts actually disappear."
+                )
+            )
+            return defer.succeed({"xmlui": ui.toXml()})
+
+        def errback(failure):
+            error_ui = xml_tools.XMLUI("popup", title=D_("Error"))
+            error_ui.addText(
+                D_("Your %(target)s could not be deleted: %(message)s")
+                % {"target": target, "message": failure.getErrorMessage()}
+            )
+            return defer.succeed({"xmlui": error_ui.toXml()})
+
+        d.addCallbacks(deleted, errback)
+        return d
+
+    def credentials_xmpp_connect(self, jid_s, password):
+        """Create and connect a new SàT profile using the given XMPP credentials.
+
+        Re-use given JID and XMPP password for the profile name and profile password.
+        @param jid_s (unicode): JID
+        @param password (unicode): XMPP password
+        @return Deferred(bool)
+        @raise exceptions.PasswordError, exceptions.ConflictError
+        """
+        try:  # be sure that the profile doesn't exist yet
+            self.host.memory.get_profile_name(jid_s)
+        except exceptions.ProfileUnknownError:
+            pass
+        else:
+            raise exceptions.ConflictError
+
+        d = self.create_profile(password, jid_s, jid_s)
+        d.addCallback(
+            lambda __: self.host.memory.get_profile_name(jid_s)
+        )  # checks if the profile has been successfuly created
+        d.addCallback(lambda profile: defer.ensureDeferred(
+            self.host.connect(profile, password, {}, 0)))
+
+        def connected(result):
+            self.send_emails(None, profile=jid_s)
+            return result
+
+        def remove_profile(
+            failure
+        ):  # profile has been successfully created but the XMPP credentials are wrong!
+            log.debug(
+                "Removing previously auto-created profile: %s" % failure.getErrorMessage()
+            )
+            self.host.memory.profile_delete_async(jid_s)
+            raise failure
+
+        # FIXME: we don't catch the case where the JID host is not an XMPP server, and the user
+        # has to wait until the DBUS timeout ; as a consequence, emails are sent to the admins
+        # and the profile is not deleted. When the host exists, remove_profile is well called.
+        d.addCallbacks(connected, remove_profile)
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_android.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,566 @@
+#!/usr/bin/env python3
+
+# SAT plugin for file tansfer
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+import os
+import os.path
+import json
+from pathlib import Path
+from zope.interface import implementer
+from twisted.names import client as dns_client
+from twisted.python.procutils import which
+from twisted.internet import defer
+from twisted.internet import reactor
+from twisted.internet import protocol
+from twisted.internet import abstract
+from twisted.internet import error as int_error
+from twisted.internet import _sslverify
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.tools.common import async_process
+from libervia.backend.memory import params
+
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Android",
+    C.PI_IMPORT_NAME: "android",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_RECOMMENDATIONS: ["XEP-0352"],
+    C.PI_MAIN: "AndroidPlugin",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: D_(
+        """Manage Android platform specificities, like pause or notifications"""
+    ),
+}
+
+if sys.platform != "android":
+    raise exceptions.CancelError("this module is not needed on this platform")
+
+
+import re
+import certifi
+from plyer import vibrator
+from android import api_version
+from plyer.platforms.android import activity
+from plyer.platforms.android.notification import AndroidNotification
+from jnius import autoclass
+from android.broadcast import BroadcastReceiver
+from android import python_act
+
+
+Context = autoclass('android.content.Context')
+ConnectivityManager = autoclass('android.net.ConnectivityManager')
+MediaPlayer = autoclass('android.media.MediaPlayer')
+AudioManager = autoclass('android.media.AudioManager')
+
+# notifications
+AndroidString = autoclass('java.lang.String')
+PendingIntent = autoclass('android.app.PendingIntent')
+Intent = autoclass('android.content.Intent')
+
+# DNS
+# regex to find dns server prop with "getprop"
+RE_DNS = re.compile(r"^\[net\.[a-z0-9]+\.dns[0-4]\]: \[(.*)\]$", re.MULTILINE)
+SystemProperties = autoclass('android.os.SystemProperties')
+
+#: delay between a pause event and sending the inactive indication to server, in seconds
+#: we don't send the indication immediately because user can be just checking something
+#: quickly on an other app.
+CSI_DELAY = 30
+
+PARAM_RING_CATEGORY = "Notifications"
+PARAM_RING_NAME = "sound"
+PARAM_RING_LABEL = D_("sound on notifications")
+RING_OPTS = {
+    "normal": D_("Normal"),
+    "never": D_("Never"),
+}
+PARAM_VIBRATE_CATEGORY = "Notifications"
+PARAM_VIBRATE_NAME = "vibrate"
+PARAM_VIBRATE_LABEL = D_("Vibrate on notifications")
+VIBRATION_OPTS = {
+    "always": D_("Always"),
+    "vibrate": D_("In vibrate mode"),
+    "never": D_("Never"),
+}
+SOCKET_DIR = "/data/data/org.libervia.cagou/"
+SOCKET_FILE = ".socket"
+STATE_RUNNING = b"running"
+STATE_PAUSED = b"paused"
+STATE_STOPPED = b"stopped"
+STATES = (STATE_RUNNING, STATE_PAUSED, STATE_STOPPED)
+NET_TYPE_NONE = "no network"
+NET_TYPE_WIFI = "wifi"
+NET_TYPE_MOBILE = "mobile"
+NET_TYPE_OTHER = "other"
+INTENT_EXTRA_ACTION = AndroidString("org.salut-a-toi.IntentAction")
+
+
+@implementer(_sslverify.IOpenSSLTrustRoot)
+class AndroidTrustPaths:
+
+    def _addCACertsToContext(self, context):
+        # twisted doesn't have access to Android root certificates
+        # we use certifi to work around that (same thing is done in Kivy)
+        context.load_verify_locations(certifi.where())
+
+
+def platformTrust():
+    return AndroidTrustPaths()
+
+
+class Notification(AndroidNotification):
+    # We extend plyer's AndroidNotification instead of creating directly with jnius
+    # because it already handles issues like backward compatibility, and we just want to
+    # slightly modify the behaviour.
+
+    @staticmethod
+    def _set_open_behavior(notification, sat_action):
+        # we reproduce plyer's AndroidNotification._set_open_behavior
+        # bu we add SàT specific extra action data
+
+        app_context = activity.getApplication().getApplicationContext()
+        notification_intent = Intent(app_context, python_act)
+
+        notification_intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+        notification_intent.setAction(Intent.ACTION_MAIN)
+        notification_intent.add_category(Intent.CATEGORY_LAUNCHER)
+        if sat_action is not None:
+            action_data = AndroidString(json.dumps(sat_action).encode())
+            log.debug(f"adding extra {INTENT_EXTRA_ACTION} ==> {action_data}")
+            notification_intent = notification_intent.putExtra(
+                INTENT_EXTRA_ACTION, action_data)
+
+        # we use PendingIntent.FLAG_UPDATE_CURRENT here, otherwise extra won't be set
+        # in the new intent (the old ACTION_MAIN intent will be reused). This differs
+        # from plyers original behaviour which set no flag here
+        pending_intent = PendingIntent.getActivity(
+            app_context, 0, notification_intent, PendingIntent.FLAG_UPDATE_CURRENT
+        )
+
+        notification.setContentIntent(pending_intent)
+        notification.setAutoCancel(True)
+
+    def _notify(self, **kwargs):
+        # we reproduce plyer's AndroidNotification._notify behaviour here
+        # and we add handling of "sat_action" attribute (SàT specific).
+        # we also set, where suitable, default values to empty string instead of
+        # original None, as a string is expected (in plyer the empty string is used
+        # in the generic "notify" method).
+        sat_action = kwargs.pop("sat_action", None)
+        noti = None
+        message = kwargs.get('message', '').encode('utf-8')
+        ticker = kwargs.get('ticker', '').encode('utf-8')
+        title = AndroidString(
+            kwargs.get('title', '').encode('utf-8')
+        )
+        icon = kwargs.get('app_icon', '')
+
+        if kwargs.get('toast', False):
+            self._toast(message)
+            return
+        else:
+            noti = self._build_notification(title)
+
+        noti.setContentTitle(title)
+        noti.setContentText(AndroidString(message))
+        noti.setTicker(AndroidString(ticker))
+
+        self._set_icons(noti, icon=icon)
+        self._set_open_behavior(noti, sat_action)
+
+        self._open_notification(noti)
+
+
+class FrontendStateProtocol(protocol.Protocol):
+
+    def __init__(self, android_plugin):
+        self.android_plugin = android_plugin
+
+    def dataReceived(self, data):
+        if data in STATES:
+            self.android_plugin.state = data
+        else:
+            log.warning("Unexpected data: {data}".format(data=data))
+
+
+class FrontendStateFactory(protocol.Factory):
+
+    def __init__(self, android_plugin):
+        self.android_plugin = android_plugin
+
+    def buildProtocol(self, addr):
+        return FrontendStateProtocol(self.android_plugin)
+
+
+
+class AndroidPlugin(object):
+
+    params = """
+    <params>
+    <individual>
+    <category name="{category_name}" label="{category_label}">
+        <param name="{ring_param_name}" label="{ring_param_label}" type="list" security="0">
+            {ring_options}
+        </param>
+        <param name="{vibrate_param_name}" label="{vibrate_param_label}" type="list" security="0">
+            {vibrate_options}
+        </param>
+     </category>
+    </individual>
+    </params>
+    """.format(
+        category_name=PARAM_VIBRATE_CATEGORY,
+        category_label=D_(PARAM_VIBRATE_CATEGORY),
+        vibrate_param_name=PARAM_VIBRATE_NAME,
+        vibrate_param_label=PARAM_VIBRATE_LABEL,
+        vibrate_options=params.make_options(VIBRATION_OPTS, "always"),
+        ring_param_name=PARAM_RING_NAME,
+        ring_param_label=PARAM_RING_LABEL,
+        ring_options=params.make_options(RING_OPTS, "normal"),
+    )
+
+    def __init__(self, host):
+        log.info(_("plugin Android initialization"))
+        log.info(f"using Android API {api_version}")
+        self.host = host
+        self._csi = host.plugins.get('XEP-0352')
+        self._csi_timer = None
+        host.memory.update_params(self.params)
+        try:
+            os.mkdir(SOCKET_DIR, 0o700)
+        except OSError as e:
+            if e.errno == 17:
+                # dir already exists
+                pass
+            else:
+                raise e
+        self._state = None
+        factory = FrontendStateFactory(self)
+        socket_path = os.path.join(SOCKET_DIR, SOCKET_FILE)
+        try:
+            reactor.listenUNIX(socket_path, factory)
+        except int_error.CannotListenError as e:
+            if e.socketError.errno == 98:
+                # the address is already in use, we need to remove it
+                os.unlink(socket_path)
+                reactor.listenUNIX(socket_path, factory)
+            else:
+                raise e
+        # we set a low priority because we want the notification to be sent after all
+        # plugins have done their job
+        host.trigger.add("message_received", self.message_received_trigger, priority=-1000)
+
+        # profiles autoconnection
+        host.bridge.add_method(
+            "profile_autoconnect_get",
+            ".plugin",
+            in_sign="",
+            out_sign="s",
+            method=self._profile_autoconnect_get,
+            async_=True,
+        )
+
+        # audio manager, to get ring status
+        self.am = activity.getSystemService(Context.AUDIO_SERVICE)
+
+        # sound notification
+        media_dir = Path(host.memory.config_get("", "media_dir"))
+        assert media_dir is not None
+        notif_path = media_dir / "sounds" / "notifications" / "music-box.mp3"
+        self.notif_player = MediaPlayer()
+        self.notif_player.setDataSource(str(notif_path))
+        self.notif_player.setAudioStreamType(AudioManager.STREAM_NOTIFICATION)
+        self.notif_player.prepare()
+
+        # SSL fix
+        _sslverify.platformTrust = platformTrust
+        log.info("SSL Android patch applied")
+
+        # DNS fix
+        defer.ensureDeferred(self.update_resolver())
+
+        # Connectivity handling
+        self.cm = activity.getSystemService(Context.CONNECTIVITY_SERVICE)
+        self._net_type = None
+        d = defer.ensureDeferred(self._check_connectivity())
+        d.addErrback(host.log_errback)
+
+        # XXX: we need to keep a reference to BroadcastReceiver to avoid
+        #     "XXX has no attribute 'invoke'" error (looks like the same issue as
+        #     https://github.com/kivy/pyjnius/issues/59)
+        self.br = BroadcastReceiver(
+            callback=lambda *args, **kwargs: reactor.callFromThread(
+                self.on_connectivity_change
+            ),
+            actions=["android.net.conn.CONNECTIVITY_CHANGE"]
+        )
+        self.br.start()
+
+    @property
+    def state(self):
+        return self._state
+
+    @state.setter
+    def state(self, new_state):
+        log.debug(f"frontend state has changed: {new_state.decode()}")
+        previous_state = self._state
+        self._state = new_state
+        if new_state == STATE_RUNNING:
+            self._on_running(previous_state)
+        elif new_state == STATE_PAUSED:
+            self._on_paused(previous_state)
+        elif new_state == STATE_STOPPED:
+            self._on_stopped(previous_state)
+
+    @property
+    def cagou_active(self):
+        return self._state == STATE_RUNNING
+
+    def _on_running(self, previous_state):
+        if previous_state is not None:
+            self.host.bridge.bridge_reactivate_signals()
+        self.set_active()
+
+    def _on_paused(self, previous_state):
+        self.host.bridge.bridge_deactivate_signals()
+        self.set_inactive()
+
+    def _on_stopped(self, previous_state):
+        self.set_inactive()
+
+    def _notify_message(self, mess_data, client):
+        """Send notification when suitable
+
+        notification is sent if:
+            - there is a message and it is not a groupchat
+            - message is not coming from ourself
+        """
+        if (mess_data["message"] and mess_data["type"] != C.MESS_TYPE_GROUPCHAT
+            and not mess_data["from"].userhostJID() == client.jid.userhostJID()):
+            message = next(iter(mess_data["message"].values()))
+            try:
+                subject = next(iter(mess_data["subject"].values()))
+            except StopIteration:
+                subject = D_("new message from {contact}").format(
+                    contact = mess_data['from'])
+
+            notification = Notification()
+            notification._notify(
+                title=subject,
+                message=message,
+                sat_action={
+                    "type": "open",
+                    "widget": "chat",
+                    "target": mess_data["from"].userhost(),
+                },
+            )
+
+            ringer_mode = self.am.getRingerMode()
+            vibrate_mode = ringer_mode == AudioManager.RINGER_MODE_VIBRATE
+
+            ring_setting = self.host.memory.param_get_a(
+                PARAM_RING_NAME,
+                PARAM_RING_CATEGORY,
+                profile_key=client.profile
+            )
+
+            if ring_setting != 'never' and ringer_mode == AudioManager.RINGER_MODE_NORMAL:
+                self.notif_player.start()
+
+            vibration_setting = self.host.memory.param_get_a(
+                PARAM_VIBRATE_NAME,
+                PARAM_VIBRATE_CATEGORY,
+                profile_key=client.profile
+            )
+            if (vibration_setting == 'always'
+                or vibration_setting == 'vibrate' and vibrate_mode):
+                    try:
+                        vibrator.vibrate()
+                    except Exception as e:
+                        log.warning("Can't use vibrator: {e}".format(e=e))
+        return mess_data
+
+    def message_received_trigger(self, client, message_elt, post_treat):
+        if not self.cagou_active:
+            # we only send notification is the frontend is not displayed
+            post_treat.addCallback(self._notify_message, client)
+
+        return True
+
+    # Profile autoconnection
+
+    def _profile_autoconnect_get(self):
+        return defer.ensureDeferred(self.profile_autoconnect_get())
+
+    async def _get_profiles_autoconnect(self):
+        autoconnect_dict = await self.host.memory.storage.get_ind_param_values(
+            category='Connection', name='autoconnect_backend',
+        )
+        return [p for p, v in autoconnect_dict.items() if C.bool(v)]
+
+    async def profile_autoconnect_get(self):
+        """Return profile to connect automatically by frontend, if any"""
+        profiles_autoconnect = await self._get_profiles_autoconnect()
+        if not profiles_autoconnect:
+            return None
+        if len(profiles_autoconnect) > 1:
+            log.warning(
+                f"More that one profiles with backend autoconnection set found, picking "
+                f"up first one (full list: {profiles_autoconnect!r})")
+        return profiles_autoconnect[0]
+
+    # CSI
+
+    def _set_inactive(self):
+        self._csi_timer = None
+        for client in self.host.get_clients(C.PROF_KEY_ALL):
+            self._csi.set_inactive(client)
+
+    def set_inactive(self):
+        if self._csi is None or self._csi_timer is not None:
+            return
+        self._csi_timer = reactor.callLater(CSI_DELAY, self._set_inactive)
+
+    def set_active(self):
+        if self._csi is None:
+            return
+        if self._csi_timer is not None:
+            self._csi_timer.cancel()
+            self._csi_timer = None
+        for client in self.host.get_clients(C.PROF_KEY_ALL):
+            self._csi.set_active(client)
+
+    # Connectivity
+
+    async def _handle_network_change(self, net_type):
+        """Notify the clients about network changes.
+
+        This way the client can disconnect/reconnect transport, or change delays
+        """
+        log.debug(f"handling network change ({net_type})")
+        if net_type == NET_TYPE_NONE:
+            for client in self.host.get_clients(C.PROF_KEY_ALL):
+                client.network_disabled()
+        else:
+            # DNS servers may have changed
+            await self.update_resolver()
+            # client may be there but disabled (e.g. with stream management)
+            for client in self.host.get_clients(C.PROF_KEY_ALL):
+                log.debug(f"enabling network for {client.profile}")
+                client.network_enabled()
+
+            # profiles may have been disconnected and then purged, we try
+            # to reconnect them in case
+            profiles_autoconnect = await self._get_profiles_autoconnect()
+            for profile in profiles_autoconnect:
+                if not self.host.is_connected(profile):
+                    log.info(f"{profile} is not connected, reconnecting it")
+                    try:
+                        await self.host.connect(profile)
+                    except Exception as e:
+                        log.error(f"Can't connect profile {profile}: {e}")
+
+    async def _check_connectivity(self):
+        active_network = self.cm.getActiveNetworkInfo()
+        if active_network is None:
+            net_type = NET_TYPE_NONE
+        else:
+            net_type_android = active_network.getType()
+            if net_type_android == ConnectivityManager.TYPE_WIFI:
+                net_type = NET_TYPE_WIFI
+            elif net_type_android == ConnectivityManager.TYPE_MOBILE:
+                net_type = NET_TYPE_MOBILE
+            else:
+                net_type = NET_TYPE_OTHER
+
+        if net_type != self._net_type:
+            log.info("connectivity has changed")
+            self._net_type = net_type
+            if net_type == NET_TYPE_NONE:
+                log.info("no network active")
+            elif net_type == NET_TYPE_WIFI:
+                log.info("WIFI activated")
+            elif net_type == NET_TYPE_MOBILE:
+                log.info("mobile data activated")
+            else:
+                log.info("network activated (type={net_type_android})"
+                    .format(net_type_android=net_type_android))
+        else:
+            log.debug("_check_connectivity called without network change ({net_type})"
+                .format(net_type = net_type))
+
+        # we always call _handle_network_change even if there is not connectivity change
+        # to be sure to reconnect when necessary
+        await self._handle_network_change(net_type)
+
+
+    def on_connectivity_change(self):
+        log.debug("on_connectivity_change called")
+        d = defer.ensureDeferred(self._check_connectivity())
+        d.addErrback(self.host.log_errback)
+
+    async def update_resolver(self):
+        # There is no "/etc/resolv.conf" on Android, which confuse Twisted and makes
+        # SRV record checking unusable. We fixe that by checking DNS server used, and
+        # updating Twisted's resolver accordingly
+        dns_servers = await self.get_dns_servers()
+
+        log.info(
+            "Patching Twisted to use Android DNS resolver ({dns_servers})".format(
+            dns_servers=', '.join([s[0] for s in dns_servers]))
+        )
+        dns_client.theResolver = dns_client.createResolver(servers=dns_servers)
+
+    async def get_dns_servers(self):
+        servers = []
+
+        if api_version < 26:
+            # thanks to A-IV at https://stackoverflow.com/a/11362271 for the way to go
+            log.debug("Old API, using SystemProperties to find DNS")
+            for idx in range(1, 5):
+                addr = SystemProperties.get(f'net.dns{idx}')
+                if abstract.isIPAddress(addr):
+                    servers.append((addr, 53))
+        else:
+            log.debug(f"API {api_version} >= 26, using getprop to find DNS")
+            # use of getprop inspired by various solutions at
+            # https://stackoverflow.com/q/3070144
+            # it's the most simple option, and it fit wells with async_process
+            getprop_paths = which('getprop')
+            if getprop_paths:
+                try:
+                    getprop_path = getprop_paths[0]
+                    props = await async_process.run(getprop_path)
+                    servers = [(ip, 53) for ip in RE_DNS.findall(props.decode())
+                               if abstract.isIPAddress(ip)]
+                except Exception as e:
+                    log.warning(f"Can't use \"getprop\" to find DNS server: {e}")
+        if not servers:
+            # FIXME: Cloudflare's 1.1.1.1 seems to have a better privacy policy, to be
+            #   checked.
+            log.warning(
+                "no server found, we have to use factory Google DNS, this is not ideal "
+                "for privacy"
+            )
+            servers.append(('8.8.8.8', 53), ('8.8.4.4', 53))
+        return servers
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_app_manager.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,636 @@
+#!/usr/bin/env python3
+
+# Libervia plugin to manage external applications
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from pathlib import Path
+from typing import Optional, List, Callable
+from functools import partial, reduce
+import tempfile
+import secrets
+import string
+import shortuuid
+from twisted.internet import defer
+from twisted.python.procutils import which
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import async_process
+
+log = getLogger(__name__)
+
+try:
+    import yaml
+except ImportError:
+    raise exceptions.MissingModule(
+        'Missing module PyYAML, please download/install it. You can use '
+        '"pip install pyyaml"'
+    )
+
+try:
+    from yaml import CLoader as Loader, CDumper as Dumper
+except ImportError:
+    log.warning(
+        "Can't use LibYAML binding (is libyaml installed?), pure Python version will be "
+        "used, but it is slower"
+    )
+    from yaml import Loader, Dumper
+
+from yaml.constructor import ConstructorError
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Applications Manager",
+    C.PI_IMPORT_NAME: "APP_MANAGER",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_MAIN: "AppManager",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _(
+        """Applications Manager
+
+Manage external applications using packagers, OS virtualization/containers or other
+software management tools.
+"""),
+}
+
+APP_FILE_PREFIX = "sat_app_"
+
+
+class AppManager:
+    load = partial(yaml.load, Loader=Loader)
+    dump = partial(yaml.dump, Dumper=Dumper)
+
+    def __init__(self, host):
+        log.info(_("plugin Applications Manager initialization"))
+        self.host = host
+        self._managers = {}
+        self._apps = {}
+        self._started = {}
+        # instance id to app data map
+        self._instances = {}
+        host.bridge.add_method(
+            "applications_list",
+            ".plugin",
+            in_sign="as",
+            out_sign="as",
+            method=self.list_applications,
+        )
+        host.bridge.add_method(
+            "application_start",
+            ".plugin",
+            in_sign="ss",
+            out_sign="s",
+            method=self._start,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "application_stop",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._stop,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "application_exposed_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._get_exposed,
+            async_=True,
+        )
+        # application has been started succeesfully,
+        # args: name, instance_id, extra
+        host.bridge.add_signal(
+            "application_started", ".plugin", signature="sss"
+        )
+        # application went wrong with the application
+        # args: name, instance_id, extra
+        host.bridge.add_signal(
+            "application_error", ".plugin", signature="sss"
+        )
+        yaml.add_constructor(
+            "!sat_conf", self._sat_conf_constr, Loader=Loader)
+        yaml.add_constructor(
+            "!sat_generate_pwd", self._sat_generate_pwd_constr, Loader=Loader)
+        yaml.add_constructor(
+            "!sat_param", self._sat_param_constr, Loader=Loader)
+
+    def unload(self):
+        log.debug("unloading applications manager")
+        for instances in self._started.values():
+            for instance in instances:
+                data = instance['data']
+                if not data['single_instance']:
+                    log.debug(
+                        f"cleaning temporary directory at {data['_instance_dir_path']}")
+                    data['_instance_dir_obj'].cleanup()
+
+    def _sat_conf_constr(self, loader, node):
+        """Get a value from Libervia configuration
+
+        A list is expected with either "name" of a config parameter, a one or more of
+        those parameters:
+            - section
+            - name
+            - default value
+            - filter
+        filter can be:
+            - "first": get the first item of the value
+        """
+        config_data = loader.construct_sequence(node)
+        if len(config_data) == 1:
+            section, name, default, filter_ = "", config_data[0], None, None
+        if len(config_data) == 2:
+            (section, name), default, filter_ = config_data, None, None
+        elif len(config_data) == 3:
+            (section, name, default), filter_ = config_data, None
+        elif len(config_data) == 4:
+            section, name, default, filter_ = config_data
+        else:
+            raise ValueError(
+                f"invalid !sat_conf value ({config_data!r}), a list of 1 to 4 items is "
+                "expected"
+            )
+
+        value = self.host.memory.config_get(section, name, default)
+        # FIXME: "public_url" is used only here and doesn't take multi-sites into account
+        if name == "public_url" and (not value or value.startswith('http')):
+            if not value:
+                log.warning(_(
+                    'No value found for "public_url", using "example.org" for '
+                    'now, please set the proper value in libervia.conf'))
+            else:
+                log.warning(_(
+                    'invalid value for "public_url" ({value}), it musts not start with '
+                    'schema ("http"), ignoring it and using "example.org" '
+                    'instead')
+                        .format(value=value))
+            value = "example.org"
+
+        if filter_ is None:
+            pass
+        elif filter_ == 'first':
+            value = value[0]
+        else:
+            raise ValueError(f"unmanaged filter: {filter_}")
+
+        return value
+
+    def _sat_generate_pwd_constr(self, loader, node):
+        alphabet = string.ascii_letters + string.digits
+        return ''.join(secrets.choice(alphabet) for i in range(30))
+
+    def _sat_param_constr(self, loader, node):
+        """Get a parameter specified when starting the application
+
+        The value can be either the name of the parameter to get, or a list as
+        [name, default_value]
+        """
+        try:
+            name, default = loader.construct_sequence(node)
+        except ConstructorError:
+            name, default = loader.construct_scalar(node), None
+        return self._params.get(name, default)
+
+    def register(self, manager):
+        name = manager.name
+        if name in self._managers:
+            raise exceptions.ConflictError(
+                f"There is already a manager with the name {name}")
+        self._managers[manager.name] = manager
+        if hasattr(manager, "discover_path"):
+            self.discover(manager.discover_path, manager)
+
+    def get_manager(self, app_data: dict) -> object:
+        """Get manager instance needed for this app
+
+        @raise exceptions.DataError: something is wrong with the type
+        @raise exceptions.NotFound: manager is not registered
+        """
+        try:
+            app_type = app_data["type"]
+        except KeyError:
+            raise exceptions.DataError(
+                "app file doesn't have the mandatory \"type\" key"
+            )
+        if not isinstance(app_type, str):
+            raise exceptions.DataError(
+                f"invalid app data type: {app_type!r}"
+            )
+        app_type = app_type.strip()
+        try:
+            return self._managers[app_type]
+        except KeyError:
+            raise exceptions.NotFound(
+                f"No manager found to manage app of type {app_type!r}")
+
+    def get_app_data(
+        self,
+        id_type: Optional[str],
+        identifier: str
+    ) -> dict:
+        """Retrieve instance's app_data from identifier
+
+        @param id_type: type of the identifier, can be:
+            - "name": identifier is a canonical application name
+                the first found instance of this application is returned
+            - "instance": identifier is an instance id
+        @param identifier: identifier according to id_type
+        @return: instance application data
+        @raise exceptions.NotFound: no instance with this id can be found
+        @raise ValueError: id_type is invalid
+        """
+        if not id_type:
+            id_type = 'name'
+        if id_type == 'name':
+            identifier = identifier.lower().strip()
+            try:
+                return next(iter(self._started[identifier]))
+            except (KeyError, StopIteration):
+                raise exceptions.NotFound(
+                    f"No instance of {identifier!r} is currently running"
+                )
+        elif id_type == 'instance':
+            instance_id = identifier
+            try:
+                return self._instances[instance_id]
+            except KeyError:
+                raise exceptions.NotFound(
+                    f"There is no application instance running with id {instance_id!r}"
+                )
+        else:
+            raise ValueError(f"invalid id_type: {id_type!r}")
+
+    def discover(
+            self,
+            dir_path: Path,
+            manager: Optional = None
+    ) -> None:
+        for file_path in dir_path.glob(f"{APP_FILE_PREFIX}*.yaml"):
+            if manager is None:
+                try:
+                    app_data = self.parse(file_path)
+                    manager = self.get_manager(app_data)
+                except (exceptions.DataError, exceptions.NotFound) as e:
+                    log.warning(
+                        f"Can't parse {file_path}, skipping: {e}")
+            app_name = file_path.stem[len(APP_FILE_PREFIX):].strip().lower()
+            if not app_name:
+                log.warning(
+                    f"invalid app file name at {file_path}")
+                continue
+            app_dict = self._apps.setdefault(app_name, {})
+            manager_set = app_dict.setdefault(manager, set())
+            manager_set.add(file_path)
+            log.debug(
+                f"{app_name!r} {manager.name} application found"
+            )
+
+    def parse(self, file_path: Path, params: Optional[dict] = None) -> dict:
+        """Parse Libervia application file
+
+        @param params: parameters for running this instance
+        @raise exceptions.DataError: something is wrong in the file
+        """
+        if params is None:
+            params = {}
+        with file_path.open() as f:
+            # we set parameters to be used only with this instance
+            # no async method must used between this assignation and `load`
+            self._params = params
+            app_data = self.load(f)
+            self._params = None
+        if "name" not in app_data:
+            # note that we don't use lower() here as we want human readable name and
+            # uppercase may be set on purpose
+            app_data['name'] = file_path.stem[len(APP_FILE_PREFIX):].strip()
+        single_instance = app_data.setdefault("single_instance", True)
+        if not isinstance(single_instance, bool):
+            raise ValueError(
+                f'"single_instance" must be a boolean, but it is {type(single_instance)}'
+            )
+        return app_data
+
+    def list_applications(self, filters: Optional[List[str]]) -> List[str]:
+        """List available application
+
+        @param filters: only show applications matching those filters.
+            using None will list all known applications
+            a filter can be:
+                - available: applications available locally
+                - running: only show launched applications
+        """
+        if not filters:
+            return list(self.apps)
+        found = set()
+        for filter_ in filters:
+            if filter_ == "available":
+                found.update(self._apps)
+            elif filter_ == "running":
+                found.update(self._started)
+            else:
+                raise ValueError(f"Unknown filter: {filter_}")
+        return list(found)
+
+    def _start(self, app_name, extra):
+        extra = data_format.deserialise(extra)
+        d = defer.ensureDeferred(self.start(str(app_name), extra))
+        d.addCallback(data_format.serialise)
+        return d
+
+    async def start(
+        self,
+        app_name: str,
+        extra: Optional[dict] = None,
+    ) -> dict:
+        """Start an application
+
+        @param app_name: name of the application to start
+        @param extra: extra parameters
+        @return: data with following keys:
+            - name (str): canonical application name
+            - instance (str): instance ID
+            - started (bool): True if the application is already started
+                if False, the "application_started" signal should be used to get notificed
+                when the application is actually started
+            - expose (dict): exposed data as given by [self.get_exposed]
+                exposed data which need to be computed are NOT returned, they will
+                available when the app will be fully started, throught the
+                [self.get_exposed] method.
+        """
+        # FIXME: for now we use the first app manager available for the requested app_name
+        # TODO: implement running multiple instance of the same app if some metadata
+        #   to be defined in app_data allows explicitly it.
+        app_name = app_name.lower().strip()
+        try:
+            app_file_path = next(iter(next(iter(self._apps[app_name].values()))))
+        except KeyError:
+            raise exceptions.NotFound(
+                f"No application found with the name {app_name!r}"
+            )
+        log.info(f"starting {app_name!r}")
+        started_data = self._started.setdefault(app_name, [])
+        app_data = self.parse(app_file_path, extra)
+        app_data["_started"] = False
+        app_data['_file_path'] = app_file_path
+        app_data['_name_canonical'] = app_name
+        single_instance = app_data['single_instance']
+        ret_data = {
+            "name": app_name,
+            "started": False
+        }
+        if single_instance:
+            if started_data:
+                instance_data = started_data[0]
+                instance_id = instance_data["_instance_id"]
+                ret_data["instance"] = instance_id
+                ret_data["started"] = instance_data["_started"]
+                ret_data["expose"] = await self.get_exposed(
+                    instance_id, "instance", {"skip_compute": True}
+                )
+                log.info(f"{app_name!r} is already started or being started")
+                return ret_data
+            else:
+                cache_path = self.host.memory.get_cache_path(
+                    PLUGIN_INFO[C.PI_IMPORT_NAME], app_name
+                )
+                cache_path.mkdir(0o700, parents=True, exist_ok=True)
+                app_data['_instance_dir_path'] = cache_path
+        else:
+            dest_dir_obj = tempfile.TemporaryDirectory(prefix="sat_app_")
+            app_data['_instance_dir_obj'] = dest_dir_obj
+            app_data['_instance_dir_path'] = Path(dest_dir_obj.name)
+        instance_id = ret_data["instance"] = app_data['_instance_id'] = shortuuid.uuid()
+        manager = self.get_manager(app_data)
+        app_data['_manager'] = manager
+        started_data.append(app_data)
+        self._instances[instance_id] = app_data
+        # we retrieve exposed data such as url_prefix which can be useful computed exposed
+        # data must wait for the app to be started, so we skip them for now
+        ret_data["expose"] = await self.get_exposed(
+            instance_id, "instance", {"skip_compute": True}
+        )
+
+        try:
+            start = manager.start
+        except AttributeError:
+            raise exceptions.InternalError(
+                f"{manager.name} doesn't have the mandatory \"start\" method"
+            )
+        else:
+            defer.ensureDeferred(self.start_app(start, app_data))
+        return ret_data
+
+    async def start_app(self, start_cb: Callable, app_data: dict) -> None:
+        app_name = app_data["_name_canonical"]
+        instance_id = app_data["_instance_id"]
+        try:
+            await start_cb(app_data)
+        except Exception as e:
+            log.exception(f"Can't start libervia app {app_name!r}")
+            self.host.bridge.application_error(
+                app_name,
+                instance_id,
+                data_format.serialise(
+                    {
+                        "class": str(type(e)),
+                        "msg": str(e)
+                    }
+                ))
+        else:
+            app_data["_started"] = True
+            self.host.bridge.application_started(app_name, instance_id, "")
+            log.info(f"{app_name!r} started")
+
+    def _stop(self, identifier, id_type, extra):
+        extra = data_format.deserialise(extra)
+        return defer.ensureDeferred(
+            self.stop(str(identifier), str(id_type) or None, extra))
+
+    async def stop(
+        self,
+        identifier: str,
+        id_type: Optional[str] = None,
+        extra: Optional[dict] = None,
+    ) -> None:
+        if extra is None:
+            extra = {}
+
+        app_data = self.get_app_data(id_type, identifier)
+
+        log.info(f"stopping {app_data['name']!r}")
+
+        app_name = app_data['_name_canonical']
+        instance_id = app_data['_instance_id']
+        manager = app_data['_manager']
+
+        try:
+            stop = manager.stop
+        except AttributeError:
+            raise exceptions.InternalError(
+                f"{manager.name} doesn't have the mandatory \"stop\" method"
+            )
+        else:
+            try:
+                await stop(app_data)
+            except Exception as e:
+                log.warning(
+                    f"Instance {instance_id} of application {app_name} can't be stopped "
+                    f"properly: {e}"
+                )
+                return
+
+        try:
+            del self._instances[instance_id]
+        except KeyError:
+            log.error(
+                f"INTERNAL ERROR: {instance_id!r} is not present in self._instances")
+
+        try:
+            self._started[app_name].remove(app_data)
+        except ValueError:
+            log.error(
+                "INTERNAL ERROR: there is no app data in self._started with id "
+                f"{instance_id!r}"
+            )
+
+        log.info(f"{app_name!r} stopped")
+
+    def _get_exposed(self, identifier, id_type, extra):
+        extra = data_format.deserialise(extra)
+        d = defer.ensureDeferred(self.get_exposed(identifier, id_type, extra))
+        d.addCallback(lambda d: data_format.serialise(d))
+        return d
+
+    async def get_exposed(
+        self,
+        identifier: str,
+        id_type: Optional[str] = None,
+        extra: Optional[dict] = None,
+    ) -> dict:
+        """Get data exposed by the application
+
+        The manager's "compute_expose" method will be called if it exists. It can be used
+        to handle manager specific conventions.
+        """
+        app_data = self.get_app_data(id_type, identifier)
+        if app_data.get('_exposed_computed', False):
+            return app_data['expose']
+        if extra is None:
+            extra = {}
+        expose = app_data.setdefault("expose", {})
+        if "passwords" in expose:
+            passwords = expose['passwords']
+            for name, value in list(passwords.items()):
+                if isinstance(value, list):
+                    # if we have a list, is the sequence of keys leading to the value
+                    # to expose. We use "reduce" to retrieve the desired value
+                    try:
+                        passwords[name] = reduce(lambda l, k: l[k], value, app_data)
+                    except Exception as e:
+                        log.warning(
+                            f"Can't retrieve exposed value for password {name!r}: {e}")
+                        del passwords[name]
+
+        url_prefix = expose.get("url_prefix")
+        if isinstance(url_prefix, list):
+            try:
+                expose["url_prefix"] = reduce(lambda l, k: l[k], url_prefix, app_data)
+            except Exception as e:
+                log.warning(
+                    f"Can't retrieve exposed value for url_prefix: {e}")
+                del expose["url_prefix"]
+
+        if extra.get("skip_compute", False):
+            return expose
+
+        try:
+            compute_expose = app_data['_manager'].compute_expose
+        except AttributeError:
+            pass
+        else:
+            await compute_expose(app_data)
+
+        app_data['_exposed_computed'] = True
+        return expose
+
+    async def _do_prepare(
+        self,
+        app_data: dict,
+    ) -> None:
+        name = app_data['name']
+        dest_path = app_data['_instance_dir_path']
+        if next(dest_path.iterdir(), None) != None:
+            log.debug(f"There is already a prepared dir at {dest_path}, nothing to do")
+            return
+        try:
+            prepare = app_data['prepare'].copy()
+        except KeyError:
+            prepare = {}
+
+        if not prepare:
+            log.debug("Nothing to prepare for {name!r}")
+            return
+
+        for action, value in list(prepare.items()):
+            log.debug(f"[{name}] [prepare] running {action!r} action")
+            if action == "git":
+                try:
+                    git_path = which('git')[0]
+                except IndexError:
+                    raise exceptions.NotFound(
+                        "Can't find \"git\" executable, {name} can't be started without it"
+                    )
+                await async_process.run(git_path, "clone", value, str(dest_path))
+                log.debug(f"{value!r} git repository cloned at {dest_path}")
+            else:
+                raise NotImplementedError(
+                    f"{action!r} is not managed, can't start {name}"
+                )
+            del prepare[action]
+
+        if prepare:
+            raise exceptions.InternalError('"prepare" should be empty')
+
+    async def _do_create_files(
+        self,
+        app_data: dict,
+    ) -> None:
+        dest_path = app_data['_instance_dir_path']
+        files = app_data.get('files')
+        if not files:
+            return
+        if not isinstance(files, dict):
+            raise ValueError('"files" must be a dictionary')
+        for filename, data in files.items():
+            path = dest_path / filename
+            if path.is_file():
+                log.info(f"{path} already exists, skipping")
+            with path.open("w") as f:
+                f.write(data.get("content", ""))
+            log.debug(f"{path} created")
+
+    async def start_common(self, app_data: dict) -> None:
+        """Method running common action when starting a manager
+
+        It should be called by managers in "start" method.
+        """
+        await self._do_prepare(app_data)
+        await self._do_create_files(app_data)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_attach.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,278 @@
+#!/usr/bin/env python3
+
+# SàT plugin for attaching files
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from collections import namedtuple
+import mimetypes
+from pathlib import Path
+import shutil
+import tempfile
+from typing import Callable, Optional
+
+from twisted.internet import defer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import utils
+from libervia.backend.tools import image
+
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "File Attach",
+    C.PI_IMPORT_NAME: "ATTACH",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_DEPENDENCIES: ["UPLOAD"],
+    C.PI_MAIN: "AttachPlugin",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Attachments handler"""),
+}
+
+
+AttachmentHandler = namedtuple('AttachmentHandler', ['can_handle', 'attach', 'priority'])
+
+
+class AttachPlugin:
+
+    def __init__(self, host):
+        log.info(_("plugin Attach initialization"))
+        self.host = host
+        self._u = host.plugins["UPLOAD"]
+        host.trigger.add("sendMessage", self._send_message_trigger)
+        host.trigger.add("sendMessageComponent", self._send_message_trigger)
+        self._attachments_handlers = {'clear': [], 'encrypted': []}
+        self.register(self.default_can_handle, self.default_attach, False, -1000)
+
+    def register(self, can_handle, attach, encrypted=False, priority=0):
+        """Register an attachments handler
+
+        @param can_handle(callable, coroutine, Deferred): a method which must return True
+            if this plugin can handle the upload, otherwise next ones will be tried.
+            This method will get client and mess_data as arguments, before the XML is
+            generated
+        @param attach(callable, coroutine, Deferred): attach the file
+            this method will get client and mess_data as arguments, after XML is
+            generated. Upload operation must be handled
+            hint: "UPLOAD" plugin can be used
+        @param encrypted(bool): True if the handler manages encrypted files
+            A handler can be registered twice if it handle both encrypted and clear
+            attachments
+        @param priority(int): priority of this handler, handler with higher priority will
+            be tried first
+        """
+        handler = AttachmentHandler(can_handle, attach, priority)
+        handlers = (
+            self._attachments_handlers['encrypted']
+            if encrypted else self._attachments_handlers['clear']
+        )
+        if handler in handlers:
+            raise exceptions.InternalError(
+                'Attachment handler has been registered twice, this should never happen'
+            )
+
+        handlers.append(handler)
+        handlers.sort(key=lambda h: h.priority, reverse=True)
+        log.debug(f"new attachments handler: {handler}")
+
+    async def attach_files(self, client, data):
+        """Main method to attach file
+
+        It will do generic pre-treatment, and call the suitable attachments handler
+        """
+        # we check attachment for pre-treatment like large image resizing
+        # media_type will be added if missing (and if it can be guessed from path)
+        attachments = data["extra"][C.KEY_ATTACHMENTS]
+        tmp_dirs_to_clean = []
+        for attachment in attachments:
+            if attachment.get(C.KEY_ATTACHMENTS_RESIZE, False):
+                path = Path(attachment["path"])
+                try:
+                    media_type = attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE]
+                except KeyError:
+                    media_type = mimetypes.guess_type(path, strict=False)[0]
+                    if media_type is None:
+                        log.warning(
+                            _("Can't resize attachment of unknown type: {attachment}")
+                            .format(attachment=attachment))
+                        continue
+                    attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE] = media_type
+
+                main_type = media_type.split('/')[0]
+                if main_type == "image":
+                    report = image.check(self.host, path)
+                    if report['too_large']:
+                        tmp_dir = Path(tempfile.mkdtemp())
+                        tmp_dirs_to_clean.append(tmp_dir)
+                        new_path = tmp_dir / path.name
+                        await image.resize(
+                            path, report["recommended_size"], dest=new_path)
+                        attachment["path"] = new_path
+                        log.info(
+                            _("Attachment {path!r} has been resized at {new_path!r}")
+                            .format(path=str(path), new_path=str(new_path)))
+                else:
+                    log.warning(
+                        _("Can't resize attachment of type {main_type!r}: {attachment}")
+                        .format(main_type=main_type, attachment=attachment))
+
+        if client.encryption.is_encryption_requested(data):
+            handlers = self._attachments_handlers['encrypted']
+        else:
+            handlers = self._attachments_handlers['clear']
+
+        for handler in handlers:
+            can_handle = await utils.as_deferred(handler.can_handle, client, data)
+            if can_handle:
+                break
+        else:
+            raise exceptions.NotFound(
+                _("No plugin can handle attachment with {destinee}").format(
+                destinee = data['to']
+            ))
+
+        await utils.as_deferred(handler.attach, client, data)
+
+        for dir_path in tmp_dirs_to_clean:
+            log.debug(f"Cleaning temporary directory at {dir_path}")
+            shutil.rmtree(dir_path)
+
+        return data
+
+    async def upload_files(
+        self,
+        client: SatXMPPEntity,
+        data: dict,
+        upload_cb: Optional[Callable] = None
+    ):
+        """Upload file, and update attachments
+
+        invalid attachments will be removed
+        @param client:
+        @param data(dict): message data
+        @param upload_cb(coroutine, Deferred, None): method to use for upload
+            if None, upload method from UPLOAD plugin will be used.
+            Otherwise, following kwargs will be used with the cb:
+                - client
+                - filepath
+                - filename
+                - options
+            the method must return a tuple similar to UPLOAD plugin's upload method,
+            it must contain:
+                - progress_id
+                - a deferred which fire download URL
+        """
+        if upload_cb is None:
+            upload_cb = self._u.upload
+
+        uploads_d = []
+        to_delete = []
+        attachments = data["extra"]["attachments"]
+
+        for attachment in attachments:
+            if "url" in attachment and not "path" in attachment:
+                log.debug(f"attachment is external, we don't upload it: {attachment}")
+                continue
+            try:
+                # we pop path because we don't want it to be stored, as the file can be
+                # only in a temporary location
+                path = Path(attachment.pop("path"))
+            except KeyError:
+                log.warning("no path in attachment: {attachment}")
+                to_delete.append(attachment)
+                continue
+
+            if "url" in attachment:
+                url = attachment.pop('url')
+                log.warning(
+                    f"unexpected URL in attachment: {url!r}\nattachment: {attachment}"
+                )
+
+            try:
+                name = attachment["name"]
+            except KeyError:
+                name = attachment["name"] = path.name
+
+            attachment["size"] = path.stat().st_size
+
+            extra = {
+                "attachment": attachment
+            }
+            progress_id = attachment.pop("progress_id", None)
+            if progress_id:
+                extra["progress_id"] = progress_id
+            check_certificate = self.host.memory.param_get_a(
+                "check_certificate", "Connection", profile_key=client.profile)
+            if not check_certificate:
+                extra['ignore_tls_errors'] = True
+                log.warning(
+                    _("certificate check disabled for upload, this is dangerous!"))
+
+            __, upload_d = await upload_cb(
+                client=client,
+                filepath=path,
+                filename=name,
+                extra=extra,
+            )
+            uploads_d.append(upload_d)
+
+        for attachment in to_delete:
+            attachments.remove(attachment)
+
+        upload_results = await defer.DeferredList(uploads_d)
+        for idx, (success, ret) in enumerate(upload_results):
+            attachment = attachments[idx]
+
+            if not success:
+                # ret is a failure here
+                log.warning(f"error while uploading {attachment}: {ret}")
+                continue
+
+            attachment["url"] = ret
+
+        return data
+
+    def _attach_files(self, data, client):
+        return defer.ensureDeferred(self.attach_files(client, data))
+
+    def _send_message_trigger(
+        self, client, mess_data, pre_xml_treatments, post_xml_treatments):
+        if mess_data['extra'].get(C.KEY_ATTACHMENTS):
+            post_xml_treatments.addCallback(self._attach_files, client=client)
+        return True
+
+    async def default_can_handle(self, client, data):
+        return True
+
+    async def default_attach(self, client, data):
+        await self.upload_files(client, data)
+        # TODO: handle xhtml-im
+        body_elt = data["xml"].body
+        if body_elt is None:
+            body_elt = data["xml"].addElement("body")
+        attachments = data["extra"][C.KEY_ATTACHMENTS]
+        if attachments:
+            body_links = '\n'.join(a['url'] for a in attachments)
+            if str(body_elt).strip():
+                # if there is already a body, we add a line feed before the first link
+                body_elt.addContent('\n')
+            body_elt.addContent(body_links)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_debug.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+
+# SàT plugin for managing raw XML log
+# Copyright (C) 2009-2016  Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import json
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.constants import Const as C
+
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Debug Plugin",
+    C.PI_IMPORT_NAME: "DEBUG",
+    C.PI_TYPE: "Misc",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "Debug",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Set of method to make development and debugging easier"""),
+}
+
+
+class Debug(object):
+    def __init__(self, host):
+        log.info(_("Plugin Debug initialization"))
+        self.host = host
+        host.bridge.add_method(
+            "debug_signal_fake",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._fake_signal,
+        )
+
+    def _fake_signal(self, signal, arguments, profile_key):
+        """send a signal from backend
+
+        @param signal(str): name of the signal
+        @param arguments(unicode): json encoded list of arguments
+        @parm profile_key(unicode): profile_key to use or C.PROF_KEY_NONE if profile is not needed
+        """
+        args = json.loads(arguments)
+        method = getattr(self.host.bridge, signal)
+        if profile_key != C.PROF_KEY_NONE:
+            profile = self.host.memory.get_profile_name(profile_key)
+            args.append(profile)
+        method(*args)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_download.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,368 @@
+#!/usr/bin/env python3
+
+# SAT plugin for downloading files
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import hashlib
+from pathlib import Path
+from typing import Any, Dict, Optional, Union, Tuple, Callable
+from urllib.parse import unquote, urlparse
+
+import treq
+from twisted.internet import defer
+from twisted.words.protocols.jabber import error as jabber_error
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools import stream
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.web import treq_client_no_ssl
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "File Download",
+    C.PI_IMPORT_NAME: "DOWNLOAD",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_MAIN: "DownloadPlugin",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""File download management"""),
+}
+
+
+class DownloadPlugin(object):
+
+    def __init__(self, host):
+        log.info(_("plugin Download initialization"))
+        self.host = host
+        host.bridge.add_method(
+            "file_download",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="s",
+            method=self._file_download,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "file_download_complete",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="s",
+            method=self._file_download_complete,
+            async_=True,
+        )
+        self._download_callbacks = {}
+        self._scheme_callbacks = {}
+        self.register_scheme('http', self.download_http)
+        self.register_scheme('https', self.download_http)
+
+    def _file_download(
+            self, attachment_s: str, dest_path: str, extra_s: str, profile: str
+    ) -> defer.Deferred:
+        d = defer.ensureDeferred(self.file_download(
+            self.host.get_client(profile),
+            data_format.deserialise(attachment_s),
+            Path(dest_path),
+            data_format.deserialise(extra_s)
+        ))
+        d.addCallback(lambda ret: data_format.serialise(ret))
+        return d
+
+    async def file_download(
+        self,
+        client: SatXMPPEntity,
+        attachment: Dict[str, Any],
+        dest_path: Path,
+        extra: Optional[Dict[str, Any]] = None
+    ) -> Dict[str, Any]:
+        """Download a file using best available method
+
+        parameters are the same as for [download]
+        @return (dict): action dictionary, with progress id in case of success, else xmlui
+            message
+        """
+        try:
+            progress_id, __ = await self.download(client, attachment, dest_path, extra)
+        except Exception as e:
+            if (isinstance(e, jabber_error.StanzaError)
+                and e.condition == 'not-acceptable'):
+                reason = e.text
+            else:
+                reason = str(e)
+            msg = D_("Can't download file: {reason}").format(reason=reason)
+            log.warning(msg)
+            return {
+                "xmlui": xml_tools.note(
+                    msg, D_("Can't download file"), C.XMLUI_DATA_LVL_WARNING
+                ).toXml()
+            }
+        else:
+            return {"progress": progress_id}
+
+    def _file_download_complete(
+            self, attachment_s: str, dest_path: str, extra_s: str, profile: str
+    ) -> defer.Deferred:
+        d = defer.ensureDeferred(self.file_download_complete(
+            self.host.get_client(profile),
+            data_format.deserialise(attachment_s),
+            Path(dest_path),
+            data_format.deserialise(extra_s)
+        ))
+        d.addCallback(lambda path: str(path))
+        return d
+
+    async def file_download_complete(
+        self,
+        client: SatXMPPEntity,
+        attachment: Dict[str, Any],
+        dest_path: Path,
+        extra: Optional[Dict[str, Any]] = None
+    ) -> str:
+        """Helper method to fully download a file and return its path
+
+        parameters are the same as for [download]
+        @return (str): path to the downloaded file
+            use empty string to store the file in cache
+        """
+        __, download_d = await self.download(client, attachment, dest_path, extra)
+        dest_path = await download_d
+        return dest_path
+
+    async def download_uri(
+        self,
+        client: SatXMPPEntity,
+        uri: str,
+        dest_path: Union[Path, str],
+        extra: Optional[Dict[str, Any]] = None
+    ) -> Tuple[str, defer.Deferred]:
+        if extra is None:
+            extra = {}
+        uri_parsed = urlparse(uri, 'http')
+        if dest_path:
+            dest_path = Path(dest_path)
+            cache_uid = None
+        else:
+            filename = Path(unquote(uri_parsed.path)).name.strip() or C.FILE_DEFAULT_NAME
+            # we don't use Path.suffixes because we don't want to have more than 2
+            # suffixes, but we still want to handle suffixes like "tar.gz".
+            stem, *suffixes = filename.rsplit('.', 2)
+            # we hash the URL to have an unique identifier, and avoid double download
+            url_hash = hashlib.sha256(uri_parsed.geturl().encode()).hexdigest()
+            cache_uid = f"{stem}_{url_hash}"
+            cache_data = client.cache.get_metadata(cache_uid)
+            if cache_data is not None:
+                # file is already in cache, we return it
+                download_d = defer.succeed(cache_data['path'])
+                return '', download_d
+            else:
+                # the file is not in cache
+                unique_name = '.'.join([cache_uid] + suffixes)
+                with client.cache.cache_data(
+                    "DOWNLOAD", cache_uid, filename=unique_name) as f:
+                    # we close the file and only use its name, the file will be opened
+                    # by the registered callback
+                    dest_path = Path(f.name)
+
+        # should we check certificates?
+        check_certificate = self.host.memory.param_get_a(
+            "check_certificate", "Connection", profile_key=client.profile)
+        if not check_certificate:
+            extra['ignore_tls_errors'] = True
+            log.warning(
+                _("certificate check disabled for download, this is dangerous!"))
+
+        try:
+            callback = self._scheme_callbacks[uri_parsed.scheme]
+        except KeyError:
+            raise exceptions.NotFound(f"Can't find any handler for uri {uri}")
+        else:
+            try:
+                progress_id, download_d = await callback(
+                    client, uri_parsed, dest_path, extra)
+            except Exception as e:
+                log.warning(_(
+                    "Can't download URI {uri}: {reason}").format(
+                    uri=uri, reason=e))
+                if cache_uid is not None:
+                    client.cache.remove_from_cache(cache_uid)
+                elif dest_path.exists():
+                    dest_path.unlink()
+                raise e
+            download_d.addCallback(lambda __: dest_path)
+            return progress_id, download_d
+
+
+    async def download(
+        self,
+        client: SatXMPPEntity,
+        attachment: Dict[str, Any],
+        dest_path: Union[Path, str],
+        extra: Optional[Dict[str, Any]] = None
+    ) -> Tuple[str, defer.Deferred]:
+        """Download a file from URI using suitable method
+
+        @param uri: URI to the file to download
+        @param dest_path: where the file must be downloaded
+            if empty string, the file will be stored in local path
+        @param extra: options depending on scheme handler
+            Some common options:
+                - ignore_tls_errors(bool): True to ignore SSL/TLS certificate verification
+                  used only if HTTPS transport is needed
+        @return: ``progress_id`` and a Deferred which fire download URL when download is
+            finished.
+            ``progress_id`` can be empty string if the file already exist and is not
+            downloaded again (can happen if cache is used with empty ``dest_path``).
+        """
+        uri = attachment.get("uri")
+        if uri:
+            return await self.download_uri(client, uri, dest_path, extra)
+        else:
+            for source in attachment.get("sources", []):
+                source_type = source.get("type")
+                if not source_type:
+                    log.warning(
+                        "source type is missing for source: {source}\nattachment: "
+                        f"{attachment}"
+                    )
+                    continue
+                try:
+                    cb = self._download_callbacks[source_type]
+                except KeyError:
+                    log.warning(
+                        f"no source handler registered for {source_type!r}"
+                    )
+                else:
+                    try:
+                        return await cb(client, attachment, source, dest_path, extra)
+                    except exceptions.CancelError as e:
+                        # the handler can't or doesn't want to handle this source
+                        log.debug(
+                            f"Following source handling by {cb} has been cancelled ({e}):"
+                            f"{source}"
+                        )
+
+        log.warning(
+            "no source could be handled, we can't download the attachment:\n"
+            f"{attachment}"
+        )
+        raise exceptions.FeatureNotFound("no handler could manage the attachment")
+
+    def register_download_handler(
+        self,
+        source_type: str,
+        callback: Callable[
+            [
+                SatXMPPEntity, Dict[str, Any], Dict[str, Any], Union[str, Path],
+                Dict[str, Any]
+            ],
+            Tuple[str, defer.Deferred]
+        ]
+    ) -> None:
+        """Register a handler to manage a type of attachment source
+
+        @param source_type: ``type`` of source handled
+            This is usually the namespace of the protocol used
+        @param callback: method to call to manage the source.
+            Call arguments are the same as for [download], with an extra ``source`` dict
+            which is used just after ``attachment`` to give a quick reference to the
+            source used.
+            The callabke must return a tuple with:
+                - progress ID
+                - a Deferred which fire whant the file is fully downloaded
+        """
+        if source_type is self._download_callbacks:
+            raise exceptions.ConflictError(
+                f"The is already a callback registered for source type {source_type!r}"
+            )
+        self._download_callbacks[source_type] = callback
+
+    def register_scheme(self, scheme: str, download_cb: Callable) -> None:
+        """Register an URI scheme handler
+
+        @param scheme: URI scheme this callback is handling
+        @param download_cb: callback to download a file
+            arguments are:
+                - (SatXMPPClient) client
+                - (urllib.parse.SplitResult) parsed URI
+                - (Path) destination path where the file must be downloaded
+                - (dict) options
+            must return a tuple with progress_id and a Deferred which fire when download
+            is finished
+        """
+        if scheme in self._scheme_callbacks:
+            raise exceptions.ConflictError(
+                f"A method with scheme {scheme!r} is already registered"
+            )
+        self._scheme_callbacks[scheme] = download_cb
+
+    def unregister(self, scheme):
+        try:
+            del self._scheme_callbacks[scheme]
+        except KeyError:
+            raise exceptions.NotFound(f"No callback registered for scheme {scheme!r}")
+
+    def errback_download(self, file_obj, download_d, resp):
+        """Set file_obj and download deferred appropriatly after a network error
+
+        @param file_obj(SatFile): file where the download must be done
+        @param download_d(Deferred): deffered which must be fired on complete download
+        @param resp(treq.response.IResponse): treq response
+        """
+        msg = f"HTTP error ({resp.code}): {resp.phrase.decode()}"
+        file_obj.close(error=msg)
+        download_d.errback(exceptions.NetworkError(msg))
+
+    async def download_http(self, client, uri_parsed, dest_path, options):
+        url = uri_parsed.geturl()
+
+        if options.get('ignore_tls_errors', False):
+            log.warning(
+                "TLS certificate check disabled, this is highly insecure"
+            )
+            treq_client = treq_client_no_ssl
+        else:
+            treq_client = treq
+
+        head_data = await treq_client.head(url)
+        try:
+            content_length = int(head_data.headers.getRawHeaders('content-length')[0])
+        except (KeyError, TypeError, IndexError):
+            content_length = None
+            log.debug(f"No content lenght found at {url}")
+        file_obj = stream.SatFile(
+            self.host,
+            client,
+            dest_path,
+            mode="wb",
+            size = content_length,
+        )
+
+        progress_id = file_obj.uid
+
+        resp = await treq_client.get(url, unbuffered=True)
+        if resp.code == 200:
+            d = treq.collect(resp, file_obj.write)
+            d.addBoth(lambda _: file_obj.close())
+        else:
+            d = defer.Deferred()
+            self.errback_download(file_obj, d, resp)
+        return progress_id, d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_email_invitation.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,521 @@
+#!/usr/bin/env python3
+
+# SàT plugin for sending invitations by email
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import shortuuid
+from typing import Optional
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import error
+from twisted.words.protocols.jabber import sasl
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import utils
+from libervia.backend.tools.common import data_format
+from libervia.backend.memory import persistent
+from libervia.backend.tools.common import email as sat_email
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Email Invitations",
+    C.PI_IMPORT_NAME: "EMAIL_INVITATION",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_DEPENDENCIES: ['XEP-0077'],
+    C.PI_RECOMMENDATIONS: ["IDENTITY"],
+    C.PI_MAIN: "InvitationsPlugin",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""invitation of people without XMPP account""")
+}
+
+
+SUFFIX_MAX = 5
+INVITEE_PROFILE_TPL = "guest@@{uuid}"
+KEY_ID = 'id'
+KEY_JID = 'jid'
+KEY_CREATED = 'created'
+KEY_LAST_CONNECTION = 'last_connection'
+KEY_GUEST_PROFILE = 'guest_profile'
+KEY_PASSWORD = 'password'
+KEY_EMAILS_EXTRA = 'emails_extra'
+EXTRA_RESERVED = {KEY_ID, KEY_JID, KEY_CREATED, 'jid_', 'jid', KEY_LAST_CONNECTION,
+                  KEY_GUEST_PROFILE, KEY_PASSWORD, KEY_EMAILS_EXTRA}
+DEFAULT_SUBJECT = D_("You have been invited by {host_name} to {app_name}")
+DEFAULT_BODY = D_("""Hello {name}!
+
+You have received an invitation from {host_name} to participate to "{app_name}".
+To join, you just have to click on the following URL:
+{url}
+
+Please note that this URL should not be shared with anybody!
+If you want more details on {app_name}, you can check {app_url}.
+
+Welcome!
+""")
+
+
+class InvitationsPlugin(object):
+
+    def __init__(self, host):
+        log.info(_("plugin Invitations initialization"))
+        self.host = host
+        self.invitations = persistent.LazyPersistentBinaryDict('invitations')
+        host.bridge.add_method("invitation_create", ".plugin", in_sign='sasssssssssa{ss}s',
+                              out_sign='a{ss}',
+                              method=self._create,
+                              async_=True)
+        host.bridge.add_method("invitation_get", ".plugin", in_sign='s', out_sign='a{ss}',
+                              method=self.get,
+                              async_=True)
+        host.bridge.add_method("invitation_delete", ".plugin", in_sign='s', out_sign='',
+                              method=self._delete,
+                              async_=True)
+        host.bridge.add_method("invitation_modify", ".plugin", in_sign='sa{ss}b',
+                              out_sign='',
+                              method=self._modify,
+                              async_=True)
+        host.bridge.add_method("invitation_list", ".plugin", in_sign='s',
+                              out_sign='a{sa{ss}}',
+                              method=self._list,
+                              async_=True)
+        host.bridge.add_method("invitation_simple_create", ".plugin", in_sign='sssss',
+                              out_sign='a{ss}',
+                              method=self._simple_create,
+                              async_=True)
+
+    def check_extra(self, extra):
+        if EXTRA_RESERVED.intersection(extra):
+            raise ValueError(
+                _("You can't use following key(s) in extra, they are reserved: {}")
+                .format(', '.join(EXTRA_RESERVED.intersection(extra))))
+
+    def _create(self, email='', emails_extra=None, jid_='', password='', name='',
+                host_name='', language='', url_template='', message_subject='',
+                message_body='', extra=None, profile=''):
+        # XXX: we don't use **kwargs here to keep arguments name for introspection with
+        #      D-Bus bridge
+        if emails_extra is None:
+            emails_extra = []
+
+        if extra is None:
+            extra = {}
+        else:
+            extra = {str(k): str(v) for k,v in extra.items()}
+
+        kwargs = {"extra": extra,
+                  KEY_EMAILS_EXTRA: [str(e) for e in emails_extra]
+                  }
+
+        # we need to be sure that values are unicode, else they won't be pickled correctly
+        # with D-Bus
+        for key in ("jid_", "password", "name", "host_name", "email", "language",
+                    "url_template", "message_subject", "message_body", "profile"):
+            value = locals()[key]
+            if value:
+                kwargs[key] = str(value)
+        return defer.ensureDeferred(self.create(**kwargs))
+
+    async def get_existing_invitation(self, email: Optional[str]) -> Optional[dict]:
+        """Retrieve existing invitation with given email
+
+        @param email: check if any invitation exist with this email
+        @return: first found invitation, or None if nothing found
+        """
+        # FIXME: This method is highly inefficient, it get all invitations and check them
+        # one by one, this is just a temporary way to avoid creating creating new accounts
+        # for an existing email. A better way will be available with Libervia 0.9.
+        # TODO: use a better way to check existing invitations
+
+        if email is None:
+            return None
+        all_invitations = await self.invitations.all()
+        for id_, invitation in all_invitations.items():
+            if invitation.get("email") == email:
+                invitation[KEY_ID] = id_
+                return invitation
+
+    async def _create_account_and_profile(
+        self,
+        id_: str,
+        kwargs: dict,
+        extra: dict
+    ) -> None:
+        """Create XMPP account and Libervia profile for guest"""
+        ## XMPP account creation
+        password = kwargs.pop('password', None)
+        if password is None:
+           password = utils.generate_password()
+        assert password
+        # XXX: password is here saved in clear in database
+        #      it is needed for invitation as the same password is used for profile
+        #      and SàT need to be able to automatically open the profile with the uuid
+        # FIXME: we could add an extra encryption key which would be used with the
+        #        uuid when the invitee is connecting (e.g. with URL). This key would
+        #        not be saved and could be used to encrypt profile password.
+        extra[KEY_PASSWORD] = password
+
+        jid_ = kwargs.pop('jid_', None)
+        if not jid_:
+            domain = self.host.memory.config_get(None, 'xmpp_domain')
+            if not domain:
+                # TODO: fallback to profile's domain
+                raise ValueError(_("You need to specify xmpp_domain in sat.conf"))
+            jid_ = "invitation-{uuid}@{domain}".format(uuid=shortuuid.uuid(),
+                                                        domain=domain)
+        jid_ = jid.JID(jid_)
+        extra[KEY_JID] = jid_.full()
+
+        if jid_.user:
+            # we don't register account if there is no user as anonymous login is then
+            # used
+            try:
+                await self.host.plugins['XEP-0077'].register_new_account(jid_, password)
+            except error.StanzaError as e:
+                prefix = jid_.user
+                idx = 0
+                while e.condition == 'conflict':
+                    if idx >= SUFFIX_MAX:
+                        raise exceptions.ConflictError(_("Can't create XMPP account"))
+                    jid_.user = prefix + '_' + str(idx)
+                    log.info(_("requested jid already exists, trying with {}".format(
+                        jid_.full())))
+                    try:
+                        await self.host.plugins['XEP-0077'].register_new_account(
+                            jid_,
+                            password
+                        )
+                    except error.StanzaError:
+                        idx += 1
+                    else:
+                        break
+                if e.condition != 'conflict':
+                    raise e
+
+            log.info(_("account {jid_} created").format(jid_=jid_.full()))
+
+        ## profile creation
+
+        extra[KEY_GUEST_PROFILE] = guest_profile = INVITEE_PROFILE_TPL.format(
+            uuid=id_
+        )
+        # profile creation should not fail as we generate unique name ourselves
+        await self.host.memory.create_profile(guest_profile, password)
+        await self.host.memory.start_session(password, guest_profile)
+        await self.host.memory.param_set("JabberID", jid_.full(), "Connection",
+                                        profile_key=guest_profile)
+        await self.host.memory.param_set("Password", password, "Connection",
+                                        profile_key=guest_profile)
+
+    async def create(self, **kwargs):
+        r"""Create an invitation
+
+        This will create an XMPP account and a profile, and use a UUID to retrieve them.
+        The profile is automatically generated in the form guest@@[UUID], this way they
+            can be retrieved easily
+        **kwargs: keywords arguments which can have the following keys, unset values are
+                  equivalent to None:
+            jid_(jid.JID, None): jid to use for invitation, the jid will be created using
+                                 XEP-0077
+                if the jid has no user part, an anonymous account will be used (no XMPP
+                    account created in this case)
+                if None, automatically generate an account name (in the form
+                    "invitation-[random UUID]@domain.tld") (note that this UUID is not the
+                    same as the invitation one, as jid can be used publicly (leaking the
+                    UUID), and invitation UUID give access to account.
+                in case of conflict, a suffix number is added to the account until a free
+                    one if found (with a failure if SUFFIX_MAX is reached)
+            password(unicode, None): password to use (will be used for XMPP account and
+                                     profile)
+                None to automatically generate one
+            name(unicode, None): name of the invitee
+                will be set as profile identity if present
+            host_name(unicode, None): name of the host
+            email(unicode, None): email to send the invitation to
+                if None, no invitation email is sent, you can still associate email using
+                    extra
+                if email is used, extra can't have "email" key
+            language(unicode): language of the invitee (used notabily to translate the
+                               invitation)
+                TODO: not used yet
+            url_template(unicode, None): template to use to construct the invitation URL
+                use {uuid} as a placeholder for identifier
+                use None if you don't want to include URL (or if it is already specified
+                    in custom message)
+                /!\ you must put full URL, don't forget https://
+                /!\ the URL will give access to the invitee account, you should warn in
+                    message to not publish it publicly
+            message_subject(unicode, None): customised message body for the invitation
+                                            email
+                None to use default subject
+                uses the same substitution as for message_body
+            message_body(unicode, None): customised message body for the invitation email
+                None to use default body
+                use {name} as a place holder for invitee name
+                use {url} as a placeholder for the invitation url
+                use {uuid} as a placeholder for the identifier
+                use {app_name} as a placeholder for this software name
+                use {app_url} as a placeholder for this software official website
+                use {profile} as a placeholder for host's profile
+                use {host_name} as a placeholder for host's name
+            extra(dict, None): extra data to associate with the invitee
+                some keys are reserved:
+                    - created (creation date)
+                if email argument is used, "email" key can't be used
+            profile(unicode, None): profile of the host (person who is inviting)
+        @return (dict[unicode, unicode]): dictionary with:
+            - UUID associated with the invitee (key: id)
+            - filled extra dictionary, as saved in the databae
+        """
+        ## initial checks
+        extra = kwargs.pop('extra', {})
+        if set(kwargs).intersection(extra):
+            raise ValueError(
+                _("You can't use following key(s) in both args and extra: {}").format(
+                ', '.join(set(kwargs).intersection(extra))))
+
+        self.check_extra(extra)
+
+        email = kwargs.pop('email', None)
+
+        existing = await self.get_existing_invitation(email)
+        if existing is not None:
+            log.info(f"There is already an invitation for {email!r}")
+            extra.update(existing)
+            del extra[KEY_ID]
+
+        emails_extra = kwargs.pop('emails_extra', [])
+        if not email and emails_extra:
+            raise ValueError(
+                _('You need to provide a main email address before using emails_extra'))
+
+        if (email is not None
+            and not 'url_template' in kwargs
+            and not 'message_body' in kwargs):
+            raise ValueError(
+                _("You need to provide url_template if you use default message body"))
+
+        ## uuid
+        log.info(_("creating an invitation"))
+        id_ = existing[KEY_ID] if existing else str(shortuuid.uuid())
+
+        if existing is None:
+            await self._create_account_and_profile(id_, kwargs, extra)
+
+        profile = kwargs.pop('profile', None)
+        guest_profile = extra[KEY_GUEST_PROFILE]
+        jid_ = jid.JID(extra[KEY_JID])
+
+        ## identity
+        name = kwargs.pop('name', None)
+        password = extra[KEY_PASSWORD]
+        if name is not None:
+            extra['name'] = name
+            try:
+                id_plugin = self.host.plugins['IDENTITY']
+            except KeyError:
+                pass
+            else:
+                await self.host.connect(guest_profile, password)
+                guest_client = self.host.get_client(guest_profile)
+                await id_plugin.set_identity(guest_client, {'nicknames': [name]})
+                await self.host.disconnect(guest_profile)
+
+        ## email
+        language = kwargs.pop('language', None)
+        if language is not None:
+            extra['language'] = language.strip()
+
+        if email is not None:
+            extra['email'] = email
+            data_format.iter2dict(KEY_EMAILS_EXTRA, extra)
+            url_template = kwargs.pop('url_template', '')
+            format_args = {
+                'uuid': id_,
+                'app_name': C.APP_NAME,
+                'app_url': C.APP_URL}
+
+            if name is None:
+                format_args['name'] = email
+            else:
+                format_args['name'] = name
+
+            if profile is None:
+                format_args['profile'] = ''
+            else:
+                format_args['profile'] = extra['profile'] = profile
+
+            host_name = kwargs.pop('host_name', None)
+            if host_name is None:
+                format_args['host_name'] = profile or _("somebody")
+            else:
+                format_args['host_name'] = extra['host_name'] = host_name
+
+            invite_url = url_template.format(**format_args)
+            format_args['url'] = invite_url
+
+            await sat_email.send_email(
+                self.host.memory.config,
+                [email] + emails_extra,
+                (kwargs.pop('message_subject', None) or DEFAULT_SUBJECT).format(
+                    **format_args),
+                (kwargs.pop('message_body', None) or DEFAULT_BODY).format(**format_args),
+            )
+
+        ## roster
+
+        # we automatically add guest to host roster (if host is specified)
+        # FIXME: a parameter to disable auto roster adding would be nice
+        if profile is not None:
+            try:
+                client = self.host.get_client(profile)
+            except Exception as e:
+                log.error(f"Can't get host profile: {profile}: {e}")
+            else:
+                await self.host.contact_update(client, jid_, name, ['guests'])
+
+        if kwargs:
+            log.warning(_("Not all arguments have been consumed: {}").format(kwargs))
+
+        ## extra data saving
+        self.invitations[id_] = extra
+
+        extra[KEY_ID] = id_
+
+        return extra
+
+    def _simple_create(self, invitee_email, invitee_name, url_template, extra_s, profile):
+        client = self.host.get_client(profile)
+        # FIXME: needed because python-dbus use a specific string class
+        invitee_email = str(invitee_email)
+        invitee_name = str(invitee_name)
+        url_template = str(url_template)
+        extra = data_format.deserialise(extra_s)
+        d = defer.ensureDeferred(
+            self.simple_create(client, invitee_email, invitee_name, url_template, extra)
+        )
+        d.addCallback(lambda data: {k: str(v) for k,v in data.items()})
+        return d
+
+    async def simple_create(
+        self, client, invitee_email, invitee_name, url_template, extra):
+        """Simplified method to invite somebody by email"""
+        return await self.create(
+            name=invitee_name,
+            email=invitee_email,
+            url_template=url_template,
+            profile=client.profile,
+        )
+
+    def get(self, id_):
+        """Retrieve invitation linked to uuid if it exists
+
+        @param id_(unicode): UUID linked to an invitation
+        @return (dict[unicode, unicode]): data associated to the invitation
+        @raise KeyError: there is not invitation with this id_
+        """
+        return self.invitations[id_]
+
+    def _delete(self, id_):
+        return defer.ensureDeferred(self.delete(id_))
+
+    async def delete(self, id_):
+        """Delete an invitation data and associated XMPP account"""
+        log.info(f"deleting invitation {id_}")
+        data = await self.get(id_)
+        guest_profile = data['guest_profile']
+        password = data['password']
+        try:
+            await self.host.connect(guest_profile, password)
+            guest_client = self.host.get_client(guest_profile)
+            # XXX: be extra careful to use guest_client and not client below, as this will
+            #   delete the associated XMPP account
+            log.debug("deleting XMPP account")
+            await self.host.plugins['XEP-0077'].unregister(guest_client, None)
+        except (error.StanzaError, sasl.SASLAuthError) as e:
+            log.warning(
+                f"Can't delete {guest_profile}'s XMPP account, maybe it as already been "
+                f"deleted: {e}")
+        try:
+            await self.host.memory.profile_delete_async(guest_profile, True)
+        except Exception as e:
+            log.warning(f"Can't delete guest profile {guest_profile}: {e}")
+        log.debug("removing guest data")
+        await self.invitations.adel(id_)
+        log.info(f"{id_} invitation has been deleted")
+
+    def _modify(self, id_, new_extra, replace):
+        return self.modify(id_, {str(k): str(v) for k,v in new_extra.items()},
+                           replace)
+
+    def modify(self, id_, new_extra, replace=False):
+        """Modify invitation data
+
+        @param id_(unicode): UUID linked to an invitation
+        @param new_extra(dict[unicode, unicode]): data to update
+            empty values will be deleted if replace is True
+        @param replace(bool): if True replace the data
+            else update them
+        @raise KeyError: there is not invitation with this id_
+        """
+        self.check_extra(new_extra)
+        def got_current_data(current_data):
+            if replace:
+                new_data = new_extra
+                for k in EXTRA_RESERVED:
+                    try:
+                        new_data[k] = current_data[k]
+                    except KeyError:
+                        continue
+            else:
+                new_data = current_data
+                for k,v in new_extra.items():
+                    if k in EXTRA_RESERVED:
+                        log.warning(_("Skipping reserved key {key}").format(key=k))
+                        continue
+                    if v:
+                        new_data[k] = v
+                    else:
+                        try:
+                            del new_data[k]
+                        except KeyError:
+                            pass
+
+            self.invitations[id_] = new_data
+
+        d = self.invitations[id_]
+        d.addCallback(got_current_data)
+        return d
+
+    def _list(self, profile=C.PROF_KEY_NONE):
+        return defer.ensureDeferred(self.list(profile))
+
+    async def list(self, profile=C.PROF_KEY_NONE):
+        """List invitations
+
+        @param profile(unicode): return invitation linked to this profile only
+            C.PROF_KEY_NONE: don't filter invitations
+        @return list(unicode): invitations uids
+        """
+        invitations = await self.invitations.all()
+        if profile != C.PROF_KEY_NONE:
+            invitations = {id_:data for id_, data in invitations.items()
+                           if data.get('profile') == profile}
+
+        return invitations
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_extra_pep.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for displaying messages from extra PEP services
+# Copyright (C) 2015 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.memory import params
+from twisted.words.protocols.jabber import jid
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Extra PEP",
+    C.PI_IMPORT_NAME: "EXTRA-PEP",
+    C.PI_TYPE: "MISC",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "ExtraPEP",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Display messages from extra PEP services"""),
+}
+
+
+PARAM_KEY = "Misc"
+PARAM_NAME = "blogs"
+PARAM_LABEL = "Blog authors following list"
+PARAM_DEFAULT = (jid.JID("salut-a-toi@libervia.org"),)
+
+
+class ExtraPEP(object):
+
+    params = """
+    <params>
+    <individual>
+    <category name="%(category_name)s" label="%(category_label)s">
+        <param name="%(param_name)s" label="%(param_label)s" type="jids_list" security="0">
+            %(jids)s
+        </param>
+     </category>
+    </individual>
+    </params>
+    """ % {
+        "category_name": PARAM_KEY,
+        "category_label": D_(PARAM_KEY),
+        "param_name": PARAM_NAME,
+        "param_label": D_(PARAM_LABEL),
+        "jids": "\n".join({elt.toXml() for elt in params.create_jid_elts(PARAM_DEFAULT)}),
+    }
+
+    def __init__(self, host):
+        log.info(_("Plugin Extra PEP initialization"))
+        self.host = host
+        host.memory.update_params(self.params)
+
+    def get_followed_entities(self, profile_key):
+        return self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile_key)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_file.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,350 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for file tansfer
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import os.path
+from functools import partial
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools import stream
+from libervia.backend.tools import utils
+from libervia.backend.tools.common import data_format, utils as common_utils
+
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "File Tansfer",
+    C.PI_IMPORT_NAME: "FILE",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_MAIN: "FilePlugin",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _(
+        """File Tansfer Management:
+This plugin manage the various ways of sending a file, and choose the best one."""
+    ),
+}
+
+
+SENDING = D_("Please select a file to send to {peer}")
+SENDING_TITLE = D_("File sending")
+CONFIRM = D_(
+    '{peer} wants to send the file "{name}" to you:\n{desc}\n\nThe file has a size of '
+    '{size_human}\n\nDo you accept ?'
+)
+CONFIRM_TITLE = D_("Confirm file transfer")
+CONFIRM_OVERWRITE = D_("File {} already exists, are you sure you want to overwrite ?")
+CONFIRM_OVERWRITE_TITLE = D_("File exists")
+SECURITY_LIMIT = 30
+
+PROGRESS_ID_KEY = "progress_id"
+
+
+class FilePlugin:
+    File = stream.SatFile
+
+    def __init__(self, host):
+        log.info(_("plugin File initialization"))
+        self.host = host
+        host.bridge.add_method(
+            "file_send",
+            ".plugin",
+            in_sign="ssssss",
+            out_sign="a{ss}",
+            method=self._file_send,
+            async_=True,
+        )
+        self._file_managers = []
+        host.import_menu(
+            (D_("Action"), D_("send file")),
+            self._file_send_menu,
+            security_limit=10,
+            help_string=D_("Send a file"),
+            type_=C.MENU_SINGLE,
+        )
+
+    def _file_send(
+        self,
+        peer_jid_s: str,
+        filepath: str,
+        name: str,
+        file_desc: str,
+        extra_s: str,
+        profile: str = C.PROF_KEY_NONE
+    ) -> defer.Deferred:
+        client = self.host.get_client(profile)
+        return defer.ensureDeferred(self.file_send(
+            client, jid.JID(peer_jid_s), filepath, name or None, file_desc or None,
+            data_format.deserialise(extra_s)
+        ))
+
+    async def file_send(
+        self, client, peer_jid, filepath, filename=None, file_desc=None, extra=None
+    ):
+        """Send a file using best available method
+
+        @param peer_jid(jid.JID): jid of the destinee
+        @param filepath(str): absolute path to the file
+        @param filename(unicode, None): name to use, or None to find it from filepath
+        @param file_desc(unicode, None): description of the file
+        @param profile: %(doc_profile)s
+        @return (dict): action dictionary, with progress id in case of success, else
+            xmlui message
+        """
+        if not os.path.isfile(filepath):
+            raise exceptions.DataError("The given path doesn't link to a file")
+        if not filename:
+            filename = os.path.basename(filepath) or "_"
+        for manager, priority in self._file_managers:
+            if await utils.as_deferred(manager.can_handle_file_send,
+                                      client, peer_jid, filepath):
+                try:
+                    method_name = manager.name
+                except AttributeError:
+                    method_name = manager.__class__.__name__
+                log.info(
+                    _("{name} method will be used to send the file").format(
+                        name=method_name
+                    )
+                )
+                try:
+                    progress_id = await utils.as_deferred(
+                        manager.file_send, client, peer_jid, filepath, filename, file_desc,
+                        extra
+                    )
+                except Exception as e:
+                    log.warning(
+                        _("Can't send {filepath} to {peer_jid} with {method_name}: "
+                          "{reason}").format(
+                              filepath=filepath,
+                              peer_jid=peer_jid,
+                              method_name=method_name,
+                              reason=e
+                          )
+                    )
+                    continue
+                return {"progress": progress_id}
+        msg = "Can't find any method to send file to {jid}".format(jid=peer_jid.full())
+        log.warning(msg)
+        return {
+            "xmlui": xml_tools.note(
+                "Can't transfer file", msg, C.XMLUI_DATA_LVL_WARNING
+            ).toXml()
+        }
+
+    def _on_file_choosed(self, peer_jid, data, profile):
+        client = self.host.get_client(profile)
+        cancelled = C.bool(data.get("cancelled", C.BOOL_FALSE))
+        if cancelled:
+            return
+        path = data["path"]
+        return self.file_send(client, peer_jid, path)
+
+    def _file_send_menu(self, data, profile):
+        """ XMLUI activated by menu: return file sending UI
+
+        @param profile: %(doc_profile)s
+        """
+        try:
+            jid_ = jid.JID(data["jid"])
+        except RuntimeError:
+            raise exceptions.DataError(_("Invalid JID"))
+
+        file_choosed_id = self.host.register_callback(
+            partial(self._on_file_choosed, jid_),
+            with_data=True,
+            one_shot=True,
+        )
+        xml_ui = xml_tools.XMLUI(
+            C.XMLUI_DIALOG,
+            dialog_opt={
+                C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_FILE,
+                C.XMLUI_DATA_MESS: _(SENDING).format(peer=jid_.full()),
+            },
+            title=_(SENDING_TITLE),
+            submit_id=file_choosed_id,
+        )
+
+        return {"xmlui": xml_ui.toXml()}
+
+    def register(self, manager, priority: int = 0) -> None:
+        """Register a fileSending manager
+
+        @param manager: object implementing can_handle_file_send, and file_send methods
+        @param priority: pririoty of this manager, the higher available will be used
+        """
+        m_data = (manager, priority)
+        if m_data in self._file_managers:
+            raise exceptions.ConflictError(
+                f"Manager {manager} is already registered"
+            )
+        if not hasattr(manager, "can_handle_file_send") or not hasattr(manager, "file_send"):
+            raise ValueError(
+                f'{manager} must have both "can_handle_file_send" and "file_send" methods to '
+                'be registered')
+        self._file_managers.append(m_data)
+        self._file_managers.sort(key=lambda m: m[1], reverse=True)
+
+    def unregister(self, manager):
+        for idx, data in enumerate(self._file_managers):
+            if data[0] == manager:
+                break
+        else:
+            raise exceptions.NotFound("The file manager {manager} is not registered")
+        del self._file_managers[idx]
+
+    # Dialogs with user
+    # the overwrite check is done here
+
+    def open_file_write(self, client, file_path, transfer_data, file_data, stream_object):
+        """create SatFile or FileStremaObject for the requested file and fill suitable data
+        """
+        if stream_object:
+            assert "stream_object" not in transfer_data
+            transfer_data["stream_object"] = stream.FileStreamObject(
+                self.host,
+                client,
+                file_path,
+                mode="wb",
+                uid=file_data[PROGRESS_ID_KEY],
+                size=file_data["size"],
+                data_cb=file_data.get("data_cb"),
+            )
+        else:
+            assert "file_obj" not in transfer_data
+            transfer_data["file_obj"] = stream.SatFile(
+                self.host,
+                client,
+                file_path,
+                mode="wb",
+                uid=file_data[PROGRESS_ID_KEY],
+                size=file_data["size"],
+                data_cb=file_data.get("data_cb"),
+            )
+
+    async def _got_confirmation(
+        self, client, data, peer_jid, transfer_data, file_data, stream_object
+    ):
+        """Called when the permission and dest path have been received
+
+        @param peer_jid(jid.JID): jid of the file sender
+        @param transfer_data(dict): same as for [self.get_dest_dir]
+        @param file_data(dict): same as for [self.get_dest_dir]
+        @param stream_object(bool): same as for [self.get_dest_dir]
+        return (bool): True if copy is wanted and OK
+            False if user wants to cancel
+            if file exists ask confirmation and call again self._getDestDir if needed
+        """
+        if data.get("cancelled", False):
+            return False
+        path = data["path"]
+        file_data["file_path"] = file_path = os.path.join(path, file_data["name"])
+        log.debug("destination file path set to {}".format(file_path))
+
+        # we manage case where file already exists
+        if os.path.exists(file_path):
+            overwrite = await xml_tools.defer_confirm(
+                self.host,
+                _(CONFIRM_OVERWRITE).format(file_path),
+                _(CONFIRM_OVERWRITE_TITLE),
+                action_extra={
+                    "from_jid": peer_jid.full(),
+                    "type": C.META_TYPE_OVERWRITE,
+                    "progress_id": file_data[PROGRESS_ID_KEY],
+                },
+                security_limit=SECURITY_LIMIT,
+                profile=client.profile,
+            )
+
+            if not overwrite:
+                return await self.get_dest_dir(client, peer_jid, transfer_data, file_data)
+
+        self.open_file_write(client, file_path, transfer_data, file_data, stream_object)
+        return True
+
+    async def get_dest_dir(
+        self, client, peer_jid, transfer_data, file_data, stream_object=False
+    ):
+        """Request confirmation and destination dir to user
+
+        Overwrite confirmation is managed.
+        if transfer is confirmed, 'file_obj' is added to transfer_data
+        @param peer_jid(jid.JID): jid of the file sender
+        @param filename(unicode): name of the file
+        @param transfer_data(dict): data of the transfer session,
+            it will be only used to store the file_obj.
+            "file_obj" (or "stream_object") key *MUST NOT* exist before using get_dest_dir
+        @param file_data(dict): information about the file to be transfered
+            It MUST contain the following keys:
+                - peer_jid (jid.JID): other peer jid
+                - name (unicode): name of the file to trasnsfer
+                    the name must not be empty or contain a "/" character
+                - size (int): size of the file
+                - desc (unicode): description of the file
+                - progress_id (unicode): id to use for progression
+            It *MUST NOT* contain the "peer" key
+            It may contain:
+                - data_cb (callable): method called on each data read/write
+            "file_path" will be added to this dict once destination selected
+            "size_human" will also be added with human readable file size
+        @param stream_object(bool): if True, a stream_object will be used instead of file_obj
+            a stream.FileStreamObject will be used
+        return: True if transfer is accepted
+        """
+        cont, ret_value = await self.host.trigger.async_return_point(
+            "FILE_getDestDir", client, peer_jid, transfer_data, file_data, stream_object
+        )
+        if not cont:
+            return ret_value
+        filename = file_data["name"]
+        assert filename and not "/" in filename
+        assert PROGRESS_ID_KEY in file_data
+        # human readable size
+        file_data["size_human"] = common_utils.get_human_size(file_data["size"])
+        resp_data = await xml_tools.defer_dialog(
+            self.host,
+            _(CONFIRM).format(peer=peer_jid.full(), **file_data),
+            _(CONFIRM_TITLE),
+            type_=C.XMLUI_DIALOG_FILE,
+            options={C.XMLUI_DATA_FILETYPE: C.XMLUI_DATA_FILETYPE_DIR},
+            action_extra={
+                "from_jid": peer_jid.full(),
+                "type": C.META_TYPE_FILE,
+                "progress_id": file_data[PROGRESS_ID_KEY],
+            },
+            security_limit=SECURITY_LIMIT,
+            profile=client.profile,
+        )
+
+        accepted = await self._got_confirmation(
+            client,
+            resp_data,
+            peer_jid,
+            transfer_data,
+            file_data,
+            stream_object,
+        )
+        return accepted
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_forums.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,307 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for pubsub forums
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import uri, data_format
+from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
+from twisted.internet import defer
+import shortuuid
+import json
+log = getLogger(__name__)
+
+NS_FORUMS = 'org.salut-a-toi.forums:0'
+NS_FORUMS_TOPICS = NS_FORUMS + '#topics'
+
+PLUGIN_INFO = {
+    C.PI_NAME: _("forums management"),
+    C.PI_IMPORT_NAME: "forums",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0277"],
+    C.PI_MAIN: "forums",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""forums management plugin""")
+}
+FORUM_ATTR = {'title', 'name', 'main-language', 'uri'}
+FORUM_SUB_ELTS = ('short-desc', 'desc')
+FORUM_TOPICS_NODE_TPL = '{node}#topics_{uuid}'
+FORUM_TOPIC_NODE_TPL = '{node}_{uuid}'
+
+
+class forums(object):
+
+    def __init__(self, host):
+        log.info(_("forums plugin initialization"))
+        self.host = host
+        self._m = self.host.plugins['XEP-0277']
+        self._p = self.host.plugins['XEP-0060']
+        self._node_options = {
+            self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
+            self._p.OPT_PERSIST_ITEMS: 1,
+            self._p.OPT_DELIVER_PAYLOADS: 1,
+            self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
+            self._p.OPT_PUBLISH_MODEL: self._p.ACCESS_OPEN,
+            }
+        host.register_namespace('forums', NS_FORUMS)
+        host.bridge.add_method("forums_get", ".plugin",
+                              in_sign='ssss', out_sign='s',
+                              method=self._get,
+                              async_=True)
+        host.bridge.add_method("forums_set", ".plugin",
+                              in_sign='sssss', out_sign='',
+                              method=self._set,
+                              async_=True)
+        host.bridge.add_method("forum_topics_get", ".plugin",
+                              in_sign='ssa{ss}s', out_sign='(aa{ss}s)',
+                              method=self._get_topics,
+                              async_=True)
+        host.bridge.add_method("forum_topic_create", ".plugin",
+                              in_sign='ssa{ss}s', out_sign='',
+                              method=self._create_topic,
+                              async_=True)
+
+    @defer.inlineCallbacks
+    def _create_forums(self, client, forums, service, node, forums_elt=None, names=None):
+        """Recursively create <forums> element(s)
+
+        @param forums(list): forums which may have subforums
+        @param service(jid.JID): service where the new nodes will be created
+        @param node(unicode): node of the forums
+            will be used as basis for the newly created nodes
+        @param parent_elt(domish.Element, None): element where the forum must be added
+            if None, the root <forums> element will be created
+        @return (domish.Element): created forums
+        """
+        if not isinstance(forums, list):
+            raise ValueError(_("forums arguments must be a list of forums"))
+        if forums_elt is None:
+            forums_elt = domish.Element((NS_FORUMS, 'forums'))
+            assert names is None
+            names = set()
+        else:
+            if names is None or forums_elt.name != 'forums':
+                raise exceptions.InternalError('invalid forums or names')
+            assert names is not None
+
+        for forum in forums:
+            if not isinstance(forum, dict):
+                raise ValueError(_("A forum item must be a dictionary"))
+            forum_elt = forums_elt.addElement('forum')
+
+            for key, value in forum.items():
+                if key == 'name' and key in names:
+                    raise exceptions.ConflictError(_("following forum name is not unique: {name}").format(name=key))
+                if key == 'uri' and not value.strip():
+                    log.info(_("creating missing forum node"))
+                    forum_node = FORUM_TOPICS_NODE_TPL.format(node=node, uuid=shortuuid.uuid())
+                    yield self._p.createNode(client, service, forum_node, self._node_options)
+                    value = uri.build_xmpp_uri('pubsub',
+                                             path=service.full(),
+                                             node=forum_node)
+                if key in FORUM_ATTR:
+                    forum_elt[key] = value.strip()
+                elif key in FORUM_SUB_ELTS:
+                    forum_elt.addElement(key, content=value)
+                elif key == 'sub-forums':
+                    sub_forums_elt = forum_elt.addElement('forums')
+                    yield self._create_forums(client, value, service, node, sub_forums_elt, names=names)
+                else:
+                    log.warning(_("Unknown forum attribute: {key}").format(key=key))
+            if not forum_elt.getAttribute('title'):
+                name = forum_elt.getAttribute('name')
+                if name:
+                    forum_elt['title'] = name
+                else:
+                    raise ValueError(_("forum need a title or a name"))
+            if not forum_elt.getAttribute('uri') and not forum_elt.children:
+                raise ValueError(_("forum need uri or sub-forums"))
+        defer.returnValue(forums_elt)
+
+    def _parse_forums(self, parent_elt=None, forums=None):
+        """Recursivly parse a <forums> elements and return corresponding forums data
+
+        @param item(domish.Element): item with <forums> element
+        @param parent_elt(domish.Element, None): element to parse
+        @return (list): parsed data
+        @raise ValueError: item is invalid
+        """
+        if parent_elt.name == 'item':
+            forums = []
+            try:
+                forums_elt = next(parent_elt.elements(NS_FORUMS, 'forums'))
+            except StopIteration:
+                raise ValueError(_("missing <forums> element"))
+        else:
+            forums_elt = parent_elt
+            if forums is None:
+                raise exceptions.InternalError('expected forums')
+            if forums_elt.name != 'forums':
+                raise ValueError(_('Unexpected element: {xml}').format(xml=forums_elt.toXml()))
+        for forum_elt in forums_elt.elements():
+            if forum_elt.name == 'forum':
+                data = {}
+                for attrib in FORUM_ATTR.intersection(forum_elt.attributes):
+                    data[attrib] = forum_elt[attrib]
+                unknown = set(forum_elt.attributes).difference(FORUM_ATTR)
+                if unknown:
+                    log.warning(_("Following attributes are unknown: {unknown}").format(unknown=unknown))
+                for elt in forum_elt.elements():
+                    if elt.name in FORUM_SUB_ELTS:
+                        data[elt.name] = str(elt)
+                    elif elt.name == 'forums':
+                        sub_forums = data['sub-forums'] = []
+                        self._parse_forums(elt, sub_forums)
+                if not 'title' in data or not {'uri', 'sub-forums'}.intersection(data):
+                    log.warning(_("invalid forum, ignoring: {xml}").format(xml=forum_elt.toXml()))
+                else:
+                    forums.append(data)
+            else:
+                log.warning(_("unkown forums sub element: {xml}").format(xml=forum_elt))
+
+        return forums
+
+    def _get(self, service=None, node=None, forums_key=None, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        if service.strip():
+            service = jid.JID(service)
+        else:
+            service = None
+        if not node.strip():
+            node = None
+        d = defer.ensureDeferred(self.get(client, service, node, forums_key or None))
+        d.addCallback(lambda data: json.dumps(data))
+        return d
+
+    async def get(self, client, service=None, node=None, forums_key=None):
+        if service is None:
+            service = client.pubsub_service
+        if node is None:
+            node = NS_FORUMS
+        if forums_key is None:
+            forums_key = 'default'
+        items_data = await self._p.get_items(client, service, node, item_ids=[forums_key])
+        item = items_data[0][0]
+        # we have the item and need to convert it to json
+        forums = self._parse_forums(item)
+        return forums
+
+    def _set(self, forums, service=None, node=None, forums_key=None, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        forums = json.loads(forums)
+        if service.strip():
+            service = jid.JID(service)
+        else:
+            service = None
+        if not node.strip():
+            node = None
+        return defer.ensureDeferred(
+            self.set(client, forums, service, node, forums_key or None)
+        )
+
+    async def set(self, client, forums, service=None, node=None, forums_key=None):
+        """Create or replace forums structure
+
+        @param forums(list): list of dictionary as follow:
+            a dictionary represent a forum metadata, with the following keys:
+                - title: title of the forum
+                - name: short name (unique in those forums) for the forum
+                - main-language: main language to be use in the forums
+                - uri: XMPP uri to the microblog node hosting the forum
+                - short-desc: short description of the forum (in main-language)
+                - desc: long description of the forum (in main-language)
+                - sub-forums: a list of sub-forums with the same structure
+            title or name is needed, and uri or sub-forums
+        @param forums_key(unicode, None): key (i.e. item id) of the forums
+            may be used to store different forums structures for different languages
+            None to use "default"
+        """
+        if service is None:
+             service = client.pubsub_service
+        if node is None:
+            node = NS_FORUMS
+        if forums_key is None:
+            forums_key = 'default'
+        forums_elt = await self._create_forums(client, forums, service, node)
+        return await self._p.send_item(
+            client, service, node, forums_elt, item_id=forums_key
+        )
+
+    def _get_topics(self, service, node, extra=None, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        extra = self._p.parse_extra(extra)
+        d = defer.ensureDeferred(
+            self.get_topics(
+                client, jid.JID(service), node, rsm_request=extra.rsm_request,
+                extra=extra.extra
+            )
+        )
+        d.addCallback(
+            lambda topics_data: (topics_data[0], data_format.serialise(topics_data[1]))
+        )
+        return d
+
+    async def get_topics(self, client, service, node, rsm_request=None, extra=None):
+        """Retrieve topics data
+
+        Topics are simple microblog URIs with some metadata duplicated from first post
+        """
+        topics_data = await self._p.get_items(
+            client, service, node, rsm_request=rsm_request, extra=extra
+        )
+        topics = []
+        item_elts, metadata = topics_data
+        for item_elt in item_elts:
+            topic_elt = next(item_elt.elements(NS_FORUMS, 'topic'))
+            title_elt = next(topic_elt.elements(NS_FORUMS, 'title'))
+            topic = {'uri': topic_elt['uri'],
+                     'author': topic_elt['author'],
+                     'title': str(title_elt)}
+            topics.append(topic)
+        return (topics, metadata)
+
+    def _create_topic(self, service, node, mb_data, profile_key):
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(
+            self.create_topic(client, jid.JID(service), node, mb_data)
+        )
+
+    async def create_topic(self, client, service, node, mb_data):
+        try:
+            title = mb_data['title']
+            content = mb_data.pop('content')
+        except KeyError as e:
+            raise exceptions.DataError("missing mandatory data: {key}".format(key=e.args[0]))
+        else:
+            mb_data["content_rich"] = content
+        topic_node = FORUM_TOPIC_NODE_TPL.format(node=node, uuid=shortuuid.uuid())
+        await self._p.createNode(client, service, topic_node, self._node_options)
+        await self._m.send(client, mb_data, service, topic_node)
+        topic_uri = uri.build_xmpp_uri('pubsub',
+                                     subtype='microblog',
+                                     path=service.full(),
+                                     node=topic_node)
+        topic_elt = domish.Element((NS_FORUMS, 'topic'))
+        topic_elt['uri'] = topic_uri
+        topic_elt['author'] = client.jid.userhost()
+        topic_elt.addElement('title', content = title)
+        await self._p.send_item(client, service, node, topic_elt)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_groupblog.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for microbloging with roster access
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.internet import defer
+from libervia.backend.core import exceptions
+from wokkel import disco, data_form, iwokkel
+from zope.interface import implementer
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+NS_PUBSUB = "http://jabber.org/protocol/pubsub"
+NS_GROUPBLOG = "http://salut-a-toi.org/protocol/groupblog"
+# NS_PUBSUB_EXP = 'http://goffi.org/protocol/pubsub' #for non official features
+NS_PUBSUB_EXP = (
+    NS_PUBSUB
+)  # XXX: we can't use custom namespace as Wokkel's PubSubService use official NS
+NS_PUBSUB_GROUPBLOG = NS_PUBSUB_EXP + "#groupblog"
+NS_PUBSUB_ITEM_CONFIG = NS_PUBSUB_EXP + "#item-config"
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Group blogging through collections",
+    C.PI_IMPORT_NAME: "GROUPBLOG",
+    C.PI_TYPE: "MISC",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0277"],
+    C.PI_MAIN: "GroupBlog",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of microblogging fine permissions"""),
+}
+
+
+class GroupBlog(object):
+    """This class use a SàT PubSub Service to manage access on microblog"""
+
+    def __init__(self, host):
+        log.info(_("Group blog plugin initialization"))
+        self.host = host
+        self._p = self.host.plugins["XEP-0060"]
+        host.trigger.add("XEP-0277_item2data", self._item_2_data_trigger)
+        host.trigger.add("XEP-0277_data2entry", self._data_2_entry_trigger)
+        host.trigger.add("XEP-0277_comments", self._comments_trigger)
+
+    ## plugin management methods ##
+
+    def get_handler(self, client):
+        return GroupBlog_handler()
+
+    @defer.inlineCallbacks
+    def profile_connected(self, client):
+        try:
+            yield self.host.check_features(client, (NS_PUBSUB_GROUPBLOG,))
+        except exceptions.FeatureNotFound:
+            client.server_groupblog_available = False
+            log.warning(
+                _(
+                    "Server is not able to manage item-access pubsub, we can't use group blog"
+                )
+            )
+        else:
+            client.server_groupblog_available = True
+            log.info(_("Server can manage group blogs"))
+
+    def features_get(self, profile):
+        try:
+            client = self.host.get_client(profile)
+        except exceptions.ProfileNotSetError:
+            return {}
+        try:
+            return {"available": C.bool_const(client.server_groupblog_available)}
+        except AttributeError:
+            if self.host.is_connected(profile):
+                log.debug("Profile is not connected, service is not checked yet")
+            else:
+                log.error("client.server_groupblog_available should be available !")
+            return {}
+
+    def _item_2_data_trigger(self, item_elt, entry_elt, microblog_data):
+        """Parse item to find group permission elements"""
+        config_form = data_form.findForm(item_elt, NS_PUBSUB_ITEM_CONFIG)
+        if config_form is None:
+            return
+        access_model = config_form.get(self._p.OPT_ACCESS_MODEL, self._p.ACCESS_OPEN)
+        if access_model == self._p.ACCESS_PUBLISHER_ROSTER:
+            opt = self._p.OPT_ROSTER_GROUPS_ALLOWED
+            microblog_data['groups'] = config_form.fields[opt].values
+
+    def _data_2_entry_trigger(self, client, mb_data, entry_elt, item_elt):
+        """Build fine access permission if needed
+
+        This trigger check if "group*" key are present,
+        and create a fine item config to restrict view to these groups
+        """
+        groups = mb_data.get('groups', [])
+        if not groups:
+            return
+        if not client.server_groupblog_available:
+            raise exceptions.CancelError("GroupBlog is not available")
+        log.debug("This entry use group blog")
+        form = data_form.Form("submit", formNamespace=NS_PUBSUB_ITEM_CONFIG)
+        access = data_form.Field(
+            None, self._p.OPT_ACCESS_MODEL, value=self._p.ACCESS_PUBLISHER_ROSTER
+        )
+        allowed = data_form.Field(None, self._p.OPT_ROSTER_GROUPS_ALLOWED, values=groups)
+        form.addField(access)
+        form.addField(allowed)
+        item_elt.addChild(form.toElement())
+
+    def _comments_trigger(self, client, mb_data, options):
+        """This method is called when a comments node is about to be created
+
+        It changes the access mode to roster if needed, and give the authorized groups
+        """
+        if "group" in mb_data:
+            options[self._p.OPT_ACCESS_MODEL] = self._p.ACCESS_PUBLISHER_ROSTER
+            options[self._p.OPT_ROSTER_GROUPS_ALLOWED] = mb_data['groups']
+
+@implementer(iwokkel.IDisco)
+class GroupBlog_handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_GROUPBLOG)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_identity.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,809 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from collections import namedtuple
+import io
+from pathlib import Path
+from base64 import b64encode
+import hashlib
+from typing import Any, Coroutine, Dict, List, Optional, Union
+
+from twisted.internet import defer, threads
+from twisted.words.protocols.jabber import jid
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.memory import persistent
+from libervia.backend.tools import image
+from libervia.backend.tools import utils
+from libervia.backend.tools.common import data_format
+
+try:
+    from PIL import Image
+except:
+    raise exceptions.MissingModule(
+        "Missing module pillow, please download/install it from https://python-pillow.github.io"
+    )
+
+
+
+log = getLogger(__name__)
+
+
+IMPORT_NAME = "IDENTITY"
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Identity Plugin",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [],
+    C.PI_RECOMMENDATIONS: ["XEP-0045"],
+    C.PI_MAIN: "Identity",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Identity manager"""),
+}
+
+Callback = namedtuple("Callback", ("origin", "get", "set", "priority"))
+AVATAR_DIM = (128, 128)
+
+
+class Identity:
+
+    def __init__(self, host):
+        log.info(_("Plugin Identity initialization"))
+        self.host = host
+        self._m = host.plugins.get("XEP-0045")
+        self.metadata = {
+            "avatar": {
+                "type": dict,
+                # convert avatar path to avatar metadata (and check validity)
+                "set_data_filter": self.avatar_set_data_filter,
+                # update profile avatar, so all frontends are aware
+                "set_post_treatment": self.avatar_set_post_treatment,
+                "update_is_new_data": self.avatar_update_is_new_data,
+                "update_data_filter": self.avatar_update_data_filter,
+                # we store the metadata in database, to restore it on next connection
+                # (it is stored only for roster entities)
+                "store": True,
+            },
+            "nicknames": {
+                "type": list,
+                # accumulate all nicknames from all callbacks in a list instead
+                # of returning only the data from the first successful callback
+                "get_all": True,
+                # append nicknames from roster, resource, etc.
+                "get_post_treatment": self.nicknames_get_post_treatment,
+                "update_is_new_data": self.nicknames_update_is_new_data,
+                "store": True,
+            },
+            "description": {
+                "type": str,
+                "get_all": True,
+                "get_post_treatment": self.description_get_post_treatment,
+                "store": True,
+            }
+        }
+        host.trigger.add("roster_update", self._roster_update_trigger)
+        host.memory.set_signal_on_update("avatar")
+        host.memory.set_signal_on_update("nicknames")
+        host.bridge.add_method(
+            "identity_get",
+            ".plugin",
+            in_sign="sasbs",
+            out_sign="s",
+            method=self._get_identity,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "identities_get",
+            ".plugin",
+            in_sign="asass",
+            out_sign="s",
+            method=self._get_identities,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "identities_base_get",
+            ".plugin",
+            in_sign="s",
+            out_sign="s",
+            method=self._get_base_identities,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "identity_set",
+            ".plugin",
+            in_sign="ss",
+            out_sign="",
+            method=self._set_identity,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "avatar_get",
+            ".plugin",
+            in_sign="sbs",
+            out_sign="s",
+            method=self._getAvatar,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "avatar_set",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._set_avatar,
+            async_=True,
+        )
+
+    async def profile_connecting(self, client):
+        client._identity_update_lock = []
+        # we restore known identities from database
+        client._identity_storage = persistent.LazyPersistentBinaryDict(
+            "identity", client.profile)
+
+        stored_data = await client._identity_storage.all()
+
+        to_delete = []
+
+        for key, value in stored_data.items():
+            entity_s, name = key.split('\n')
+            if name not in self.metadata.keys():
+                log.debug(f"removing {key} from storage: not an allowed metadata name")
+                to_delete.append(key)
+                continue
+            entity = jid.JID(entity_s)
+
+            if name == 'avatar':
+                if value is not None:
+                    try:
+                        cache_uid = value['cache_uid']
+                        if not cache_uid:
+                            raise ValueError
+                        filename = value['filename']
+                        if not filename:
+                            raise ValueError
+                    except (ValueError, KeyError):
+                        log.warning(
+                            f"invalid data for {entity} avatar, it will be deleted: "
+                            f"{value}")
+                        to_delete.append(key)
+                        continue
+                    cache = self.host.common_cache.get_metadata(cache_uid)
+                    if cache is None:
+                        log.debug(
+                            f"purging avatar for {entity}: it is not in cache anymore")
+                        to_delete.append(key)
+                        continue
+
+            self.host.memory.update_entity_data(
+                client, entity, name, value, silent=True
+            )
+
+        for key in to_delete:
+            await client._identity_storage.adel(key)
+
+    def _roster_update_trigger(self, client, roster_item):
+        old_item = client.roster.get_item(roster_item.jid)
+        if old_item is None or old_item.name != roster_item.name:
+            log.debug(
+                f"roster nickname has been updated to {roster_item.name!r} for "
+                f"{roster_item.jid}"
+            )
+            defer.ensureDeferred(
+                self.update(
+                    client,
+                    IMPORT_NAME,
+                    "nicknames",
+                    [roster_item.name],
+                    roster_item.jid
+                )
+            )
+        return True
+
+    def register(
+            self,
+            origin: str,
+            metadata_name: str,
+            cb_get: Union[Coroutine, defer.Deferred],
+            cb_set: Union[Coroutine, defer.Deferred],
+            priority: int=0):
+        """Register callbacks to handle identity metadata
+
+        @param origin: namespace of the plugin managing this metadata
+        @param metadata_name: name of metadata can be:
+            - avatar
+            - nicknames
+        @param cb_get: method to retrieve a metadata
+            the method will get client and metadata names to retrieve as arguments.
+        @param cb_set: method to set a metadata
+            the method will get client, metadata name to set, and value as argument.
+        @param priority: priority of this method for the given metadata.
+            methods with bigger priorities will be called first
+        """
+        if not metadata_name in self.metadata.keys():
+            raise ValueError(f"Invalid metadata_name: {metadata_name!r}")
+        callback = Callback(origin=origin, get=cb_get, set=cb_set, priority=priority)
+        cb_list = self.metadata[metadata_name].setdefault('callbacks', [])
+        cb_list.append(callback)
+        cb_list.sort(key=lambda c: c.priority, reverse=True)
+
+    def get_identity_jid(self, client, peer_jid):
+        """Return jid to use to set identity metadata
+
+        if it's a jid of a room occupant, full jid will be used
+        otherwise bare jid will be used
+        if None, bare jid of profile will be used
+        @return (jid.JID): jid to use for avatar
+        """
+        if peer_jid is None:
+            return client.jid.userhostJID()
+        if self._m is None:
+            return peer_jid.userhostJID()
+        else:
+            return self._m.get_bare_or_full(client, peer_jid)
+
+    def check_type(self, metadata_name, value):
+        """Check that type used for a metadata is the one declared in self.metadata"""
+        value_type = self.metadata[metadata_name]["type"]
+        if not isinstance(value, value_type):
+            raise ValueError(
+                f"{value} has wrong type: it is {type(value)} while {value_type} was "
+                f"expected")
+
+    def get_field_type(self, metadata_name: str) -> str:
+        """Return the type the requested field
+
+        @param metadata_name: name of the field to check
+        @raise KeyError: the request field doesn't exist
+        """
+        return self.metadata[metadata_name]["type"]
+
+    async def get(
+            self,
+            client: SatXMPPEntity,
+            metadata_name: str,
+            entity: Optional[jid.JID],
+            use_cache: bool=True,
+            prefilled_values: Optional[Dict[str, Any]]=None
+        ):
+        """Retrieve identity metadata of an entity
+
+        if metadata is already in cache, it is returned. Otherwise, registered callbacks
+        will be tried in priority order (bigger to lower)
+        @param metadata_name: name of the metadata
+            must be one of self.metadata key
+            the name will also be used as entity data name in host.memory
+        @param entity: entity for which avatar is requested
+            None to use profile's jid
+        @param use_cache: if False, cache won't be checked
+        @param prefilled_values: map of origin => value to use when `get_all` is set
+        """
+        entity = self.get_identity_jid(client, entity)
+        try:
+            metadata = self.metadata[metadata_name]
+        except KeyError:
+            raise ValueError(f"Invalid metadata name: {metadata_name!r}")
+        get_all = metadata.get('get_all', False)
+        if use_cache:
+            try:
+                data = self.host.memory.get_entity_datum(
+                    client, entity, metadata_name)
+            except (KeyError, exceptions.UnknownEntityError):
+                pass
+            else:
+                return data
+
+        try:
+            callbacks = metadata['callbacks']
+        except KeyError:
+            log.warning(_("No callback registered for {metadata_name}")
+                        .format(metadata_name=metadata_name))
+            return [] if get_all else None
+
+        if get_all:
+            all_data = []
+        elif prefilled_values is not None:
+            raise exceptions.InternalError(
+                "prefilled_values can only be used when `get_all` is set")
+
+        for callback in callbacks:
+            try:
+                if prefilled_values is not None and callback.origin in prefilled_values:
+                    data = prefilled_values[callback.origin]
+                    log.debug(
+                        f"using prefilled values {data!r} for {metadata_name} with "
+                        f"{callback.origin}")
+                else:
+                    data = await defer.ensureDeferred(callback.get(client, entity))
+            except exceptions.CancelError:
+                continue
+            except Exception as e:
+                log.warning(
+                    _("Error while trying to get {metadata_name} with {callback}: {e}")
+                    .format(callback=callback.get, metadata_name=metadata_name, e=e))
+            else:
+                if data:
+                    self.check_type(metadata_name, data)
+                    if get_all:
+                        if isinstance(data, list):
+                            all_data.extend(data)
+                        else:
+                            all_data.append(data)
+                    else:
+                        break
+        else:
+            data = None
+
+        if get_all:
+            data = all_data
+
+        post_treatment = metadata.get("get_post_treatment")
+        if post_treatment is not None:
+            data = await utils.as_deferred(post_treatment, client, entity, data)
+
+        self.host.memory.update_entity_data(
+            client, entity, metadata_name, data)
+
+        if metadata.get('store', False):
+            key = f"{entity}\n{metadata_name}"
+            await client._identity_storage.aset(key, data)
+
+        return data
+
+    async def set(self, client, metadata_name, data, entity=None):
+        """Set identity metadata for an entity
+
+        Registered callbacks will be tried in priority order (bigger to lower)
+        @param metadata_name(str): name of the metadata
+            must be one of self.metadata key
+            the name will also be used to set entity data in host.memory
+        @param data(object): value to set
+        @param entity(jid.JID, None): entity for which avatar is requested
+            None to use profile's jid
+        """
+        entity = self.get_identity_jid(client, entity)
+        metadata = self.metadata[metadata_name]
+        data_filter = metadata.get("set_data_filter")
+        if data_filter is not None:
+            data = await utils.as_deferred(data_filter, client, entity, data)
+        self.check_type(metadata_name, data)
+
+        try:
+            callbacks = metadata['callbacks']
+        except KeyError:
+            log.warning(_("No callback registered for {metadata_name}")
+                        .format(metadata_name=metadata_name))
+            return exceptions.FeatureNotFound(f"Can't set {metadata_name} for {entity}")
+
+        for callback in callbacks:
+            try:
+                await defer.ensureDeferred(callback.set(client, data, entity))
+            except exceptions.CancelError:
+                continue
+            except Exception as e:
+                log.warning(
+                    _("Error while trying to set {metadata_name} with {callback}: {e}")
+                    .format(callback=callback.set, metadata_name=metadata_name, e=e))
+            else:
+                break
+        else:
+            raise exceptions.FeatureNotFound(f"Can't set {metadata_name} for {entity}")
+
+        post_treatment = metadata.get("set_post_treatment")
+        if post_treatment is not None:
+            await utils.as_deferred(post_treatment, client, entity, data)
+
+    async def update(
+        self,
+        client: SatXMPPEntity,
+        origin: str,
+        metadata_name: str,
+        data: Any,
+        entity: Optional[jid.JID]
+    ):
+        """Update a metadata in cache
+
+        This method may be called by plugins when an identity metadata is available.
+        @param origin: namespace of the plugin which is source of the metadata
+        """
+        entity = self.get_identity_jid(client, entity)
+        if (entity, metadata_name) in client._identity_update_lock:
+            log.debug(f"update is locked for {entity}'s {metadata_name}")
+            return
+        metadata = self.metadata[metadata_name]
+
+        try:
+            cached_data = self.host.memory.get_entity_datum(
+                client, entity, metadata_name)
+        except (KeyError, exceptions.UnknownEntityError):
+            # metadata is not cached, we do the update
+            pass
+        else:
+            # metadata is cached, we check if the new value differs from the cached one
+            try:
+                update_is_new_data = metadata["update_is_new_data"]
+            except KeyError:
+                update_is_new_data = self.default_update_is_new_data
+
+            if data is None:
+                if cached_data is None:
+                    log.debug(
+                        f"{metadata_name} for {entity} is already disabled, nothing to "
+                        f"do")
+                    return
+            elif cached_data is None:
+                pass
+            elif not update_is_new_data(client, entity, cached_data, data):
+                log.debug(
+                    f"{metadata_name} for {entity} is already in cache, nothing to "
+                    f"do")
+                return
+
+        # we can't use the cache, so we do the update
+
+        log.debug(f"updating {metadata_name} for {entity}")
+
+        if metadata.get('get_all', False):
+            # get_all is set, meaning that we have to check all plugins
+            # so we first delete current cache
+            try:
+                self.host.memory.del_entity_datum(client, entity, metadata_name)
+            except (KeyError, exceptions.UnknownEntityError):
+                pass
+            # then fill it again by calling get, which will retrieve all values
+            # we lock update to avoid infinite recursions (update can be called during
+            # get callbacks)
+            client._identity_update_lock.append((entity, metadata_name))
+            await self.get(client, metadata_name, entity, prefilled_values={origin: data})
+            client._identity_update_lock.remove((entity, metadata_name))
+            return
+
+        if data is not None:
+            data_filter = metadata['update_data_filter']
+            if data_filter is not None:
+                data = await utils.as_deferred(data_filter, client, entity, data)
+            self.check_type(metadata_name, data)
+
+        self.host.memory.update_entity_data(client, entity, metadata_name, data)
+
+        if metadata.get('store', False):
+            key = f"{entity}\n{metadata_name}"
+            await client._identity_storage.aset(key, data)
+
+    def default_update_is_new_data(self, client, entity, cached_data, new_data):
+        return new_data != cached_data
+
+    def _getAvatar(self, entity, use_cache, profile):
+        client = self.host.get_client(profile)
+        entity = jid.JID(entity) if entity else None
+        d = defer.ensureDeferred(self.get(client, "avatar", entity, use_cache))
+        d.addCallback(lambda data: data_format.serialise(data))
+        return d
+
+    def _set_avatar(self, file_path, entity, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        entity = jid.JID(entity) if entity else None
+        return defer.ensureDeferred(
+            self.set(client, "avatar", file_path, entity))
+
+    def _blocking_cache_avatar(
+        self,
+        source: str,
+        avatar_data: dict[str, Any]
+    ):
+        """This method is executed in a separated thread"""
+        if avatar_data["media_type"] == "image/svg+xml":
+            # for vector image, we save directly
+            img_buf = open(avatar_data["path"], "rb")
+        else:
+            # for bitmap image, we check size and resize if necessary
+            try:
+                img = Image.open(avatar_data["path"])
+            except IOError as e:
+                raise exceptions.DataError(f"Can't open image: {e}")
+
+            if img.size != AVATAR_DIM:
+                img.thumbnail(AVATAR_DIM)
+                if img.size[0] != img.size[1]:  # we need to crop first
+                    left, upper = (0, 0)
+                    right, lower = img.size
+                    offset = abs(right - lower) / 2
+                    if right == min(img.size):
+                        upper += offset
+                        lower -= offset
+                    else:
+                        left += offset
+                        right -= offset
+                    img = img.crop((left, upper, right, lower))
+            img_buf = io.BytesIO()
+            # PNG is well supported among clients, so we convert to this format
+            img.save(img_buf, "PNG")
+            img_buf.seek(0)
+            avatar_data["media_type"] = "image/png"
+
+        media_type = avatar_data["media_type"]
+        avatar_data["base64"] = image_b64 = b64encode(img_buf.read()).decode()
+        img_buf.seek(0)
+        image_hash = hashlib.sha1(img_buf.read()).hexdigest()
+        img_buf.seek(0)
+        with self.host.common_cache.cache_data(
+            source, image_hash, media_type
+        ) as f:
+            f.write(img_buf.read())
+            avatar_data['path'] = Path(f.name)
+            avatar_data['filename'] = avatar_data['path'].name
+        avatar_data['cache_uid'] = image_hash
+
+    async def cache_avatar(self, source: str, avatar_data: Dict[str, Any]) -> None:
+        """Resize if necessary and cache avatar
+
+        @param source: source importing the avatar (usually it is plugin's import name),
+            will be used in cache metadata
+        @param avatar_data: avatar metadata as build by [avatar_set_data_filter]
+            will be updated with following keys:
+                path: updated path using cached file
+                filename: updated filename using cached file
+                base64: resized and base64 encoded avatar
+                cache_uid: SHA1 hash used as cache unique ID
+        """
+        await threads.deferToThread(self._blocking_cache_avatar, source, avatar_data)
+
+    async def avatar_set_data_filter(self, client, entity, file_path):
+        """Convert avatar file path to dict data"""
+        file_path = Path(file_path)
+        if not file_path.is_file():
+            raise ValueError(f"There is no file at {file_path} to use as avatar")
+        avatar_data = {
+            'path': file_path,
+            'filename': file_path.name,
+            'media_type': image.guess_type(file_path),
+        }
+        media_type = avatar_data['media_type']
+        if media_type is None:
+            raise ValueError(f"Can't identify type of image at {file_path}")
+        if not media_type.startswith('image/'):
+            raise ValueError(f"File at {file_path} doesn't appear to be an image")
+        await self.cache_avatar(IMPORT_NAME, avatar_data)
+        return avatar_data
+
+    async def avatar_set_post_treatment(self, client, entity, avatar_data):
+        """Update our own avatar"""
+        await self.update(client, IMPORT_NAME, "avatar", avatar_data, entity)
+
+    def avatar_build_metadata(
+            self,
+            path: Path,
+            media_type: Optional[str] = None,
+            cache_uid: Optional[str] = None
+    ) -> Optional[Dict[str, Union[str, Path, None]]]:
+        """Helper method to generate avatar metadata
+
+        @param path(str, Path, None): path to avatar file
+            avatar file must be in cache
+            None if avatar is explicitely not set
+        @param media_type(str, None): type of the avatar file (MIME type)
+        @param cache_uid(str, None): UID of avatar in cache
+        @return (dict, None): avatar metadata
+            None if avatar is not set
+        """
+        if path is None:
+            return None
+        else:
+            if cache_uid is None:
+                raise ValueError("cache_uid must be set if path is set")
+            path = Path(path)
+            if media_type is None:
+                media_type = image.guess_type(path)
+
+            return {
+                "path": path,
+                "filename": path.name,
+                "media_type": media_type,
+                "cache_uid": cache_uid,
+            }
+
+    def avatar_update_is_new_data(self, client, entity, cached_data, new_data):
+        return new_data['path'] != cached_data['path']
+
+    async def avatar_update_data_filter(self, client, entity, data):
+        if not isinstance(data, dict):
+            raise ValueError(f"Invalid data type ({type(data)}), a dict is expected")
+        mandatory_keys = {'path', 'filename', 'cache_uid'}
+        if not data.keys() >= mandatory_keys:
+            raise ValueError(f"missing avatar data keys: {mandatory_keys - data.keys()}")
+        return data
+
+    async def nicknames_get_post_treatment(self, client, entity, plugin_nicknames):
+        """Prepend nicknames from core locations + set default nickname
+
+        nicknames are checked from many locations, there is always at least
+        one nickname. First nickname of the list can be used in priority.
+        Nicknames are appended in this order:
+            - roster, plugins set nicknames
+            - if no nickname is found, user part of jid is then used, or bare jid
+              if there is no user part.
+        For MUC, room nick is always put first
+        """
+        nicknames = []
+
+        # for MUC we add resource
+        if entity.resource:
+            # get_identity_jid let the resource only if the entity is a MUC room
+            # occupant jid
+            nicknames.append(entity.resource)
+
+        # we first check roster (if we are not in a component)
+        if not client.is_component:
+            roster_item = client.roster.get_item(entity.userhostJID())
+            if roster_item is not None and roster_item.name:
+                # user set name has priority over entity set name
+                nicknames.append(roster_item.name)
+
+        nicknames.extend(plugin_nicknames)
+
+        if not nicknames:
+            if entity.user:
+                nicknames.append(entity.user.capitalize())
+            else:
+                nicknames.append(entity.userhost())
+
+        # we remove duplicates while preserving order with dict
+        return list(dict.fromkeys(nicknames))
+
+    def nicknames_update_is_new_data(self, client, entity, cached_data, new_nicknames):
+        return not set(new_nicknames).issubset(cached_data)
+
+    async def description_get_post_treatment(
+        self,
+        client: SatXMPPEntity,
+        entity: jid.JID,
+        plugin_description: List[str]
+    ) -> str:
+        """Join all descriptions in a unique string"""
+        return '\n'.join(plugin_description)
+
+    def _get_identity(self, entity_s, metadata_filter, use_cache, profile):
+        entity = jid.JID(entity_s)
+        client = self.host.get_client(profile)
+        d = defer.ensureDeferred(
+            self.get_identity(client, entity, metadata_filter, use_cache))
+        d.addCallback(data_format.serialise)
+        return d
+
+    async def get_identity(
+        self,
+        client: SatXMPPEntity,
+        entity: Optional[jid.JID] = None,
+        metadata_filter: Optional[List[str]] = None,
+        use_cache: bool = True
+    ) -> Dict[str, Any]:
+        """Retrieve identity of an entity
+
+        @param entity: entity to check
+        @param metadata_filter: if not None or empty, only return
+            metadata in this filter
+        @param use_cache: if False, cache won't be checked
+            should be True most of time, to avoid useless network requests
+        @return: identity data
+        """
+        id_data = {}
+
+        if not metadata_filter:
+            metadata_names = self.metadata.keys()
+        else:
+            metadata_names = metadata_filter
+
+        for metadata_name in metadata_names:
+            id_data[metadata_name] = await self.get(
+                client, metadata_name, entity, use_cache)
+
+        return id_data
+
+    def _get_identities(self, entities_s, metadata_filter, profile):
+        entities = [jid.JID(e) for e in entities_s]
+        client = self.host.get_client(profile)
+        d = defer.ensureDeferred(self.get_identities(client, entities, metadata_filter))
+        d.addCallback(lambda d: data_format.serialise({str(j):i for j, i in d.items()}))
+        return d
+
+    async def get_identities(
+        self,
+        client: SatXMPPEntity,
+        entities: List[jid.JID],
+        metadata_filter: Optional[List[str]] = None,
+    ) -> dict:
+        """Retrieve several identities at once
+
+        @param entities: entities from which identities must be retrieved
+        @param metadata_filter: same as for [get_identity]
+        @return: identities metadata where key is jid
+            if an error happens while retrieve a jid entity, it won't be present in the
+            result (and a warning will be logged)
+        """
+        identities = {}
+        get_identity_list = []
+        for entity_jid in entities:
+            get_identity_list.append(
+                defer.ensureDeferred(
+                    self.get_identity(
+                        client,
+                        entity=entity_jid,
+                        metadata_filter=metadata_filter,
+                    )
+                )
+            )
+        identities_result = await defer.DeferredList(get_identity_list)
+        for idx, (success, identity) in enumerate(identities_result):
+            entity_jid = entities[idx]
+            if not success:
+                log.warning(f"Can't get identity for {entity_jid}")
+            else:
+                identities[entity_jid] = identity
+        return identities
+
+    def _get_base_identities(self, profile_key):
+        client = self.host.get_client(profile_key)
+        d = defer.ensureDeferred(self.get_base_identities(client))
+        d.addCallback(lambda d: data_format.serialise({str(j):i for j, i in d.items()}))
+        return d
+
+    async def get_base_identities(
+        self,
+        client: SatXMPPEntity,
+    ) -> dict:
+        """Retrieve identities for entities in roster + own identity + invitations
+
+        @param with_guests: if True, get affiliations of people invited by email
+
+        """
+        if client.is_component:
+            entities = [client.jid.userhostJID()]
+        else:
+            entities = client.roster.get_jids() + [client.jid.userhostJID()]
+
+        return await self.get_identities(
+            client,
+            entities,
+            ['avatar', 'nicknames']
+        )
+
+    def _set_identity(self, id_data_s, profile):
+        client = self.host.get_client(profile)
+        id_data = data_format.deserialise(id_data_s)
+        return defer.ensureDeferred(self.set_identity(client, id_data))
+
+    async def set_identity(self, client, id_data):
+        """Update profile's identity
+
+        @param id_data(dict): data to update, key can be one of self.metadata keys
+        """
+        if not id_data.keys() <= self.metadata.keys():
+            raise ValueError(
+                f"Invalid metadata names: {id_data.keys() - self.metadata.keys()}")
+        for metadata_name, data in id_data.items():
+            try:
+                await self.set(client, metadata_name, data)
+            except Exception as e:
+                log.warning(
+                    _("Can't set metadata {metadata_name!r}: {reason}")
+                    .format(metadata_name=metadata_name, reason=e))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_ip.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,330 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for IP address discovery
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import urllib.parse
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xml_tools
+from wokkel import disco, iwokkel
+from twisted.web import client as webclient
+from twisted.web import error as web_error
+from twisted.internet import defer
+from twisted.internet import reactor
+from twisted.internet import protocol
+from twisted.internet import endpoints
+from twisted.internet import error as internet_error
+from zope.interface import implementer
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.protocols.jabber.error import StanzaError
+
+log = getLogger(__name__)
+
+try:
+    import netifaces
+except ImportError:
+    log.warning(
+        "netifaces is not available, it help discovering IPs, you can install it on https://pypi.python.org/pypi/netifaces"
+    )
+    netifaces = None
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "IP discovery",
+    C.PI_IMPORT_NAME: "IP",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0279"],
+    C.PI_RECOMMENDATIONS: ["NAT-PORT"],
+    C.PI_MAIN: "IPPlugin",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""This plugin help to discover our external IP address."""),
+}
+
+# TODO: GET_IP_PAGE should be configurable in sat.conf
+GET_IP_PAGE = (
+    "http://salut-a-toi.org/whereami/"
+)  # This page must only return external IP of the requester
+GET_IP_LABEL = D_("Allow external get IP")
+GET_IP_CATEGORY = "General"
+GET_IP_NAME = "allow_get_ip"
+GET_IP_CONFIRM_TITLE = D_("Confirm external site request")
+GET_IP_CONFIRM = D_(
+    """To facilitate data transfer, we need to contact a website.
+A request will be done on {page}
+That means that administrators of {domain} can know that you use "{app_name}" and your IP Address.
+
+IP address is an identifier to locate you on Internet (similar to a phone number).
+
+Do you agree to do this request ?
+"""
+).format(
+    page=GET_IP_PAGE, domain=urllib.parse.urlparse(GET_IP_PAGE).netloc, app_name=C.APP_NAME
+)
+NS_IP_CHECK = "urn:xmpp:sic:1"
+
+PARAMS = """
+    <params>
+    <general>
+    <category name="{category}">
+        <param name="{name}" label="{label}" type="bool" />
+    </category>
+    </general>
+    </params>
+    """.format(
+    category=GET_IP_CATEGORY, name=GET_IP_NAME, label=GET_IP_LABEL
+)
+
+
+class IPPlugin(object):
+    # TODO: refresh IP if a new connection is detected
+    # TODO: manage IPv6 when implemented in SàT
+
+    def __init__(self, host):
+        log.info(_("plugin IP discovery initialization"))
+        self.host = host
+        host.memory.update_params(PARAMS)
+
+        # NAT-Port
+        try:
+            self._nat = host.plugins["NAT-PORT"]
+        except KeyError:
+            log.debug("NAT port plugin not available")
+            self._nat = None
+
+        # XXX: cache is kept until SàT is restarted
+        #      if IP may have changed, use self.refresh_ip
+        self._external_ip_cache = None
+        self._local_ip_cache = None
+
+    def get_handler(self, client):
+        return IPPlugin_handler()
+
+    def refresh_ip(self):
+        # FIXME: use a trigger instead ?
+        self._external_ip_cache = None
+        self._local_ip_cache = None
+
+    def _external_allowed(self, client):
+        """Return value of parameter with autorisation of user to do external requests
+
+        if parameter is not set, a dialog is shown to use to get its confirmation, and parameted is set according to answer
+        @return (defer.Deferred[bool]): True if external request is autorised
+        """
+        allow_get_ip = self.host.memory.params.param_get_a(
+            GET_IP_NAME, GET_IP_CATEGORY, use_default=False
+        )
+
+        if allow_get_ip is None:
+            # we don't have autorisation from user yet to use get_ip, we ask him
+            def param_set(allowed):
+                # FIXME: we need to use bool_const as param_set only manage str/unicode
+                #        need to be fixed when params will be refactored
+                self.host.memory.param_set(
+                    GET_IP_NAME, C.bool_const(allowed), GET_IP_CATEGORY
+                )
+                return allowed
+
+            d = xml_tools.defer_confirm(
+                self.host,
+                _(GET_IP_CONFIRM),
+                _(GET_IP_CONFIRM_TITLE),
+                profile=client.profile,
+            )
+            d.addCallback(param_set)
+            return d
+
+        return defer.succeed(allow_get_ip)
+
+    def _filter_addresse(self, ip_addr):
+        """Filter acceptable addresses
+
+        For now, just remove IPv4 local addresses
+        @param ip_addr(str): IP addresse
+        @return (bool): True if addresse is acceptable
+        """
+        return not ip_addr.startswith("127.")
+
+    def _insert_first(self, addresses, ip_addr):
+        """Insert ip_addr as first item in addresses
+
+        @param addresses(list): list of IP addresses
+        @param ip_addr(str): IP addresse
+        """
+        if ip_addr in addresses:
+            if addresses[0] != ip_addr:
+                addresses.remove(ip_addr)
+                addresses.insert(0, ip_addr)
+        else:
+            addresses.insert(0, ip_addr)
+
+    async def _get_ip_from_external(self, ext_url):
+        """Get local IP by doing a connection on an external url
+
+        @param ext_utl(str): url to connect to
+        @return (str, None): return local IP, or None if it's not possible
+        """
+        url = urllib.parse.urlparse(ext_url)
+        port = url.port
+        if port is None:
+            if url.scheme == "http":
+                port = 80
+            elif url.scheme == "https":
+                port = 443
+            else:
+                log.error("Unknown url scheme: {}".format(url.scheme))
+                return None
+        if url.hostname is None:
+            log.error("Can't find url hostname for {}".format(GET_IP_PAGE))
+
+        point = endpoints.TCP4ClientEndpoint(reactor, url.hostname, port)
+
+        p = await endpoints.connectProtocol(point, protocol.Protocol())
+        local_ip = p.transport.getHost().host
+        p.transport.loseConnection()
+        return local_ip
+
+    @defer.inlineCallbacks
+    def get_local_i_ps(self, client):
+        """Try do discover local area network IPs
+
+        @return (deferred): list of lan IP addresses
+            if there are several addresses, the one used with the server is put first
+            if no address is found, localhost IP will be in the list
+        """
+        # TODO: manage permission requesting (e.g. for UMTS link)
+        if self._local_ip_cache is not None:
+            defer.returnValue(self._local_ip_cache)
+        addresses = []
+        localhost = ["127.0.0.1"]
+
+        # we first try our luck with netifaces
+        if netifaces is not None:
+            addresses = []
+            for interface in netifaces.interfaces():
+                if_addresses = netifaces.ifaddresses(interface)
+                try:
+                    inet_list = if_addresses[netifaces.AF_INET]
+                except KeyError:
+                    continue
+                for data in inet_list:
+                    addresse = data["addr"]
+                    if self._filter_addresse(addresse):
+                        addresses.append(addresse)
+
+        # then we use our connection to server
+        ip = client.xmlstream.transport.getHost().host
+        if self._filter_addresse(ip):
+            self._insert_first(addresses, ip)
+            defer.returnValue(addresses)
+
+        # if server is local, we try with NAT-Port
+        if self._nat is not None:
+            nat_ip = yield self._nat.get_ip(local=True)
+            if nat_ip is not None:
+                self._insert_first(addresses, nat_ip)
+                defer.returnValue(addresses)
+
+            if addresses:
+                defer.returnValue(addresses)
+
+        # still not luck, we need to contact external website
+        allow_get_ip = yield self._external_allowed(client)
+
+        if not allow_get_ip:
+            defer.returnValue(addresses or localhost)
+
+        try:
+            local_ip = yield defer.ensureDeferred(self._get_ip_from_external(GET_IP_PAGE))
+        except (internet_error.DNSLookupError, internet_error.TimeoutError):
+            log.warning("Can't access Domain Name System")
+        else:
+            if local_ip is not None:
+                self._insert_first(addresses, local_ip)
+
+        defer.returnValue(addresses or localhost)
+
+    @defer.inlineCallbacks
+    def get_external_ip(self, client):
+        """Try to discover external IP
+
+        @return (deferred): external IP address or None if it can't be discovered
+        """
+        if self._external_ip_cache is not None:
+            defer.returnValue(self._external_ip_cache)
+
+        # we first try with XEP-0279
+        ip_check = yield self.host.hasFeature(client, NS_IP_CHECK)
+        if ip_check:
+            log.debug("Server IP Check available, we use it to retrieve our IP")
+            iq_elt = client.IQ("get")
+            iq_elt.addElement((NS_IP_CHECK, "address"))
+            try:
+                result_elt = yield iq_elt.send()
+                address_elt = next(result_elt.elements(NS_IP_CHECK, "address"))
+                ip_elt = next(address_elt.elements(NS_IP_CHECK, "ip"))
+            except StopIteration:
+                log.warning(
+                    "Server returned invalid result on XEP-0279 request, we ignore it"
+                )
+            except StanzaError as e:
+                log.warning("error while requesting ip to server: {}".format(e))
+            else:
+                # FIXME: server IP may not be the same as external IP (server can be on local machine or network)
+                #        IP should be checked to see if we have a local one, and rejected in this case
+                external_ip = str(ip_elt)
+                log.debug("External IP found: {}".format(external_ip))
+                self._external_ip_cache = external_ip
+                defer.returnValue(self._external_ip_cache)
+
+        # then with NAT-Port
+        if self._nat is not None:
+            nat_ip = yield self._nat.get_ip()
+            if nat_ip is not None:
+                self._external_ip_cache = nat_ip
+                defer.returnValue(nat_ip)
+
+        # and finally by requesting external website
+        allow_get_ip = yield self._external_allowed(client)
+        try:
+            ip = ((yield webclient.getPage(GET_IP_PAGE.encode('utf-8')))
+                  if allow_get_ip else None)
+        except (internet_error.DNSLookupError, internet_error.TimeoutError):
+            log.warning("Can't access Domain Name System")
+            ip = None
+        except web_error.Error as e:
+            log.warning(
+                "Error while retrieving IP on {url}: {message}".format(
+                    url=GET_IP_PAGE, message=e
+                )
+            )
+            ip = None
+        else:
+            self._external_ip_cache = ip
+        defer.returnValue(ip)
+
+
+@implementer(iwokkel.IDisco)
+class IPPlugin_handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_IP_CHECK)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_lists.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,519 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import shortuuid
+from typing import List, Tuple, Optional
+from twisted.internet import defer
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber import jid
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.core.constants import Const as C
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common import data_format
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+# XXX: this plugin was formely named "tickets", thus the namespace keeps this
+# name
+APP_NS_TICKETS = "org.salut-a-toi.tickets:0"
+NS_TICKETS_TYPE = "org.salut-a-toi.tickets#type:0"
+
+PLUGIN_INFO = {
+    C.PI_NAME: _("Pubsub Lists"),
+    C.PI_IMPORT_NAME: "LISTS",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0346", "XEP-0277", "IDENTITY",
+                        "PUBSUB_INVITATION"],
+    C.PI_MAIN: "PubsubLists",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Pubsub lists management plugin"""),
+}
+
+TEMPLATES = {
+    "todo": {
+        "name": D_("TODO List"),
+        "icon": "check",
+        "fields": [
+            {"name": "title"},
+            {"name": "author"},
+            {"name": "created"},
+            {"name": "updated"},
+            {"name": "time_limit"},
+            {"name": "labels", "type": "text-multi"},
+            {
+                "name": "status",
+                "label": D_("status"),
+                "type": "list-single",
+                "options": [
+                    {
+                        "label": D_("to do"),
+                        "value": "todo"
+                    },
+                    {
+                        "label": D_("in progress"),
+                        "value": "in_progress"
+                    },
+                    {
+                        "label": D_("done"),
+                        "value": "done"
+                    },
+                ],
+                "value": "todo"
+            },
+            {
+                "name": "priority",
+                "label": D_("priority"),
+                "type": "list-single",
+                "options": [
+                    {
+                        "label": D_("major"),
+                        "value": "major"
+                    },
+                    {
+                        "label": D_("normal"),
+                        "value": "normal"
+                    },
+                    {
+                        "label": D_("minor"),
+                        "value": "minor"
+                    },
+                ],
+                "value": "normal"
+            },
+            {"name": "body", "type": "xhtml"},
+            {"name": "comments_uri"},
+        ]
+    },
+    "grocery": {
+        "name": D_("Grocery List"),
+        "icon": "basket",
+        "fields": [
+            {"name": "name", "label": D_("name")},
+            {"name": "quantity", "label": D_("quantity")},
+            {
+                "name": "status",
+                "label": D_("status"),
+                "type": "list-single",
+                "options": [
+                    {
+                        "label": D_("to buy"),
+                        "value": "to_buy"
+                    },
+                    {
+                        "label": D_("bought"),
+                        "value": "bought"
+                    },
+                ],
+                "value": "to_buy"
+            },
+        ]
+    },
+    "tickets": {
+        "name": D_("Tickets"),
+        "icon": "clipboard",
+        "fields": [
+            {"name": "title"},
+            {"name": "author"},
+            {"name": "created"},
+            {"name": "updated"},
+            {"name": "labels", "type": "text-multi"},
+            {
+                "name": "type",
+                "label": D_("type"),
+                "type": "list-single",
+                "options": [
+                    {
+                        "label": D_("bug"),
+                        "value": "bug"
+                    },
+                    {
+                        "label": D_("feature request"),
+                        "value": "feature"
+                    },
+                ],
+                "value": "bug"
+            },
+            {
+                "name": "status",
+                "label": D_("status"),
+                "type": "list-single",
+                "options": [
+                    {
+                        "label": D_("queued"),
+                        "value": "queued"
+                    },
+                    {
+                        "label": D_("started"),
+                        "value": "started"
+                    },
+                    {
+                        "label": D_("review"),
+                        "value": "review"
+                    },
+                    {
+                        "label": D_("closed"),
+                        "value": "closed"
+                    },
+                ],
+                "value": "queued"
+            },
+            {
+                "name": "priority",
+                "label": D_("priority"),
+                "type": "list-single",
+                "options": [
+                    {
+                        "label": D_("major"),
+                        "value": "major"
+                    },
+                    {
+                        "label": D_("normal"),
+                        "value": "normal"
+                    },
+                    {
+                        "label": D_("minor"),
+                        "value": "minor"
+                    },
+                ],
+                "value": "normal"
+            },
+            {"name": "body", "type": "xhtml"},
+            {"name": "comments_uri"},
+        ]
+    }
+}
+
+
+class PubsubLists:
+
+    def __init__(self, host):
+        log.info(_("Pubsub lists plugin initialization"))
+        self.host = host
+        self._s = self.host.plugins["XEP-0346"]
+        self.namespace = self._s.get_submitted_ns(APP_NS_TICKETS)
+        host.register_namespace("tickets", APP_NS_TICKETS)
+        host.register_namespace("tickets_type", NS_TICKETS_TYPE)
+        self.host.plugins["PUBSUB_INVITATION"].register(
+            APP_NS_TICKETS, self
+        )
+        self._p = self.host.plugins["XEP-0060"]
+        self._m = self.host.plugins["XEP-0277"]
+        host.bridge.add_method(
+            "list_get",
+            ".plugin",
+            in_sign="ssiassss",
+            out_sign="s",
+            method=lambda service, node, max_items, items_ids, sub_id, extra, profile_key:
+                self._s._get(
+                service,
+                node,
+                max_items,
+                items_ids,
+                sub_id,
+                extra,
+                default_node=self.namespace,
+                form_ns=APP_NS_TICKETS,
+                filters={
+                    "author": self._s.value_or_publisher_filter,
+                    "created": self._s.date_filter,
+                    "updated": self._s.date_filter,
+                    "time_limit": self._s.date_filter,
+                },
+                profile_key=profile_key),
+            async_=True,
+        )
+        host.bridge.add_method(
+            "list_set",
+            ".plugin",
+            in_sign="ssa{sas}ssss",
+            out_sign="s",
+            method=self._set,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "list_delete_item",
+            ".plugin",
+            in_sign="sssbs",
+            out_sign="",
+            method=self._delete,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "list_schema_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=lambda service, nodeIdentifier, profile_key: self._s._get_ui_schema(
+                service, nodeIdentifier, default_node=self.namespace,
+                profile_key=profile_key),
+            async_=True,
+        )
+        host.bridge.add_method(
+            "lists_list",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._lists_list,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "list_templates_names_get",
+            ".plugin",
+            in_sign="ss",
+            out_sign="s",
+            method=self._get_templates_names,
+        )
+        host.bridge.add_method(
+            "list_template_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._get_template,
+        )
+        host.bridge.add_method(
+            "list_template_create",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="(ss)",
+            method=self._create_template,
+            async_=True,
+        )
+
+    async def on_invitation_preflight(
+        self,
+        client: SatXMPPEntity,
+        namespace: str,
+        name: str,
+        extra: dict,
+        service: jid.JID,
+        node: str,
+        item_id: Optional[str],
+        item_elt: domish.Element
+    ) -> None:
+        try:
+            schema = await self._s.get_schema_form(client, service, node)
+        except Exception as e:
+            log.warning(f"Can't retrive node schema as {node!r} [{service}]: {e}")
+        else:
+            try:
+                field_type = schema[NS_TICKETS_TYPE]
+            except KeyError:
+                log.debug("no type found in list schema")
+            else:
+                list_elt = extra["element"] = domish.Element((APP_NS_TICKETS, "list"))
+                list_elt["type"] = field_type
+
+    def _set(self, service, node, values, schema=None, item_id=None, extra_s='',
+             profile_key=C.PROF_KEY_NONE):
+        client, service, node, schema, item_id, extra = self._s.prepare_bridge_set(
+            service, node, schema, item_id, extra_s, profile_key
+        )
+        d = defer.ensureDeferred(self.set(
+            client, service, node, values, schema, item_id, extra, deserialise=True
+        ))
+        d.addCallback(lambda ret: ret or "")
+        return d
+
+    async def set(
+        self, client, service, node, values, schema=None, item_id=None, extra=None,
+        deserialise=False, form_ns=APP_NS_TICKETS
+    ):
+        """Publish a tickets
+
+        @param node(unicode, None): Pubsub node to use
+            None to use default tickets node
+        @param values(dict[key(unicode), [iterable[object]|object]]): values of the ticket
+
+            if value is not iterable, it will be put in a list
+            'created' and 'updated' will be forced to current time:
+                - 'created' is set if item_id is None, i.e. if it's a new ticket
+                - 'updated' is set everytime
+        @param extra(dict, None): same as for [XEP-0060.send_item] with additional keys:
+            - update(bool): if True, get previous item data to merge with current one
+                if True, item_id must be set
+        other arguments are same as for [self._s.send_data_form_item]
+        @return (unicode): id of the created item
+        """
+        if not node:
+            node = self.namespace
+
+        if not item_id:
+            comments_service = await self._m.get_comments_service(client, service)
+
+            # we need to use uuid for comments node, because we don't know item id in
+            # advance (we don't want to set it ourselves to let the server choose, so we
+            # can have a nicer id if serial ids is activated)
+            comments_node = self._m.get_comments_node(
+                node + "_" + str(shortuuid.uuid())
+            )
+            options = {
+                self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
+                self._p.OPT_PERSIST_ITEMS: 1,
+                self._p.OPT_DELIVER_PAYLOADS: 1,
+                self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
+                self._p.OPT_PUBLISH_MODEL: self._p.ACCESS_OPEN,
+            }
+            await self._p.createNode(client, comments_service, comments_node, options)
+            values["comments_uri"] = uri.build_xmpp_uri(
+                "pubsub",
+                subtype="microblog",
+                path=comments_service.full(),
+                node=comments_node,
+            )
+
+        return await self._s.set(
+            client, service, node, values, schema, item_id, extra, deserialise, form_ns
+        )
+
+    def _delete(
+        self, service_s, nodeIdentifier, itemIdentifier, notify, profile_key
+    ):
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(self.delete(
+            client,
+            jid.JID(service_s) if service_s else None,
+            nodeIdentifier,
+            itemIdentifier,
+            notify
+        ))
+
+    async def delete(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: Optional[str],
+        itemIdentifier: str,
+        notify: Optional[bool] = None
+    ) -> None:
+        if not node:
+            node = self.namespace
+        return await self._p.retract_items(
+            service, node, (itemIdentifier,), notify, client.profile
+        )
+
+    def _lists_list(self, service, node, profile):
+        service = jid.JID(service) if service else None
+        node = node or None
+        client = self.host.get_client(profile)
+        d = defer.ensureDeferred(self.lists_list(client, service, node))
+        d.addCallback(data_format.serialise)
+        return d
+
+    async def lists_list(
+        self, client, service: Optional[jid.JID], node: Optional[str]=None
+    ) -> List[dict]:
+        """Retrieve list of pubsub lists registered in personal interests
+
+        @return list: list of lists metadata
+        """
+        items, metadata = await self.host.plugins['LIST_INTEREST'].list_interests(
+            client, service, node, namespace=APP_NS_TICKETS)
+        lists = []
+        for item in items:
+            interest_elt = item.interest
+            if interest_elt is None:
+                log.warning(f"invalid interest for {client.profile}: {item.toXml}")
+                continue
+            if interest_elt.getAttribute("namespace") != APP_NS_TICKETS:
+                continue
+            pubsub_elt = interest_elt.pubsub
+            list_data = {
+                "id": item["id"],
+                "name": interest_elt["name"],
+                "service": pubsub_elt["service"],
+                "node": pubsub_elt["node"],
+                "creator": C.bool(pubsub_elt.getAttribute("creator", C.BOOL_FALSE)),
+            }
+            try:
+                list_elt = next(pubsub_elt.elements(APP_NS_TICKETS, "list"))
+            except StopIteration:
+                pass
+            else:
+                list_type = list_data["type"] = list_elt["type"]
+                if list_type in TEMPLATES:
+                    list_data["icon_name"] = TEMPLATES[list_type]["icon"]
+            lists.append(list_data)
+
+        return lists
+
+    def _get_templates_names(self, language, profile):
+        client = self.host.get_client(profile)
+        return data_format.serialise(self.get_templates_names(client, language))
+
+    def get_templates_names(self, client, language: str) -> list:
+        """Retrieve well known list templates"""
+
+        templates = [{"id": tpl_id, "name": d["name"], "icon": d["icon"]}
+                     for tpl_id, d in TEMPLATES.items()]
+        return templates
+
+    def _get_template(self, name, language, profile):
+        client = self.host.get_client(profile)
+        return data_format.serialise(self.get_template(client, name, language))
+
+    def get_template(self, client, name: str, language: str) -> dict:
+        """Retrieve a well known template"""
+        return TEMPLATES[name]
+
+    def _create_template(self, template_id, name, access_model, profile):
+        client = self.host.get_client(profile)
+        d = defer.ensureDeferred(self.create_template(
+            client, template_id, name, access_model
+        ))
+        d.addCallback(lambda node_data: (node_data[0].full(), node_data[1]))
+        return d
+
+    async def create_template(
+        self, client, template_id: str, name: str, access_model: str
+    ) -> Tuple[jid.JID, str]:
+        """Create a list from a template"""
+        name = name.strip()
+        if not name:
+            name = shortuuid.uuid()
+        fields = TEMPLATES[template_id]["fields"].copy()
+        fields.insert(
+            0,
+            {"type": "hidden", "name": NS_TICKETS_TYPE, "value": template_id}
+        )
+        schema = xml_tools.data_dict_2_data_form(
+            {"namespace": APP_NS_TICKETS, "fields": fields}
+        ).toElement()
+
+        service = client.jid.userhostJID()
+        node = self._s.get_submitted_ns(f"{APP_NS_TICKETS}_{name}")
+        options = {
+            self._p.OPT_ACCESS_MODEL: access_model,
+        }
+        if template_id == "grocery":
+            # for grocery list, we want all publishers to be able to set all items
+            # XXX: should node options be in TEMPLATE?
+            options[self._p.OPT_OVERWRITE_POLICY] = self._p.OWPOL_ANY_PUB
+        await self._p.createNode(client, service, node, options)
+        await self._s.set_schema(client, service, node, schema)
+        list_elt = domish.Element((APP_NS_TICKETS, "list"))
+        list_elt["type"] = template_id
+        try:
+            await self.host.plugins['LIST_INTEREST'].register_pubsub(
+                client, APP_NS_TICKETS, service, node, creator=True,
+                name=name, element=list_elt)
+        except Exception as e:
+            log.warning(f"Can't add list to interests: {e}")
+        return service, node
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_merge_requests.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,353 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Pubsub Schemas
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from collections import namedtuple
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.tools.common import data_format
+from libervia.backend.core.log import getLogger
+
+
+log = getLogger(__name__)
+
+APP_NS_MERGE_REQUESTS = 'org.salut-a-toi.merge_requests:0'
+
+PLUGIN_INFO = {
+    C.PI_NAME: _("Merge requests management"),
+    C.PI_IMPORT_NAME: "MERGE_REQUESTS",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0346", "LISTS", "TEXT_SYNTAXES"],
+    C.PI_MAIN: "MergeRequests",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Merge requests management plugin""")
+}
+
+FIELD_DATA_TYPE = 'type'
+FIELD_DATA = 'request_data'
+
+
+MergeRequestHandler = namedtuple("MergeRequestHandler", ['name',
+                                                         'handler',
+                                                         'data_types',
+                                                         'short_desc',
+                                                         'priority'])
+
+
+class MergeRequests(object):
+    META_AUTHOR = 'author'
+    META_EMAIL = 'email'
+    META_TIMESTAMP = 'timestamp'
+    META_HASH = 'hash'
+    META_PARENT_HASH = 'parent_hash'
+    META_COMMIT_MSG = 'commit_msg'
+    META_DIFF = 'diff'
+    # index of the diff in the whole data
+    # needed to retrieve comments location
+    META_DIFF_IDX = 'diff_idx'
+
+    def __init__(self, host):
+        log.info(_("Merge requests plugin initialization"))
+        self.host = host
+        self._s = self.host.plugins["XEP-0346"]
+        self.namespace = self._s.get_submitted_ns(APP_NS_MERGE_REQUESTS)
+        host.register_namespace('merge_requests', self.namespace)
+        self._p = self.host.plugins["XEP-0060"]
+        self._t = self.host.plugins["LISTS"]
+        self._handlers = {}
+        self._handlers_list = []  # handlers sorted by priority
+        self._type_handlers = {}  # data type => handler map
+        host.bridge.add_method("merge_requests_get", ".plugin",
+                              in_sign='ssiassss', out_sign='s',
+                              method=self._get,
+                              async_=True
+                              )
+        host.bridge.add_method("merge_request_set", ".plugin",
+                              in_sign='ssssa{sas}ssss', out_sign='s',
+                              method=self._set,
+                              async_=True)
+        host.bridge.add_method("merge_requests_schema_get", ".plugin",
+                              in_sign='sss', out_sign='s',
+                              method=lambda service, nodeIdentifier, profile_key:
+                                self._s._get_ui_schema(service,
+                                                     nodeIdentifier,
+                                                     default_node=self.namespace,
+                                                     profile_key=profile_key),
+                              async_=True)
+        host.bridge.add_method("merge_request_parse_data", ".plugin",
+                              in_sign='ss', out_sign='aa{ss}',
+                              method=self._parse_data,
+                              async_=True)
+        host.bridge.add_method("merge_requests_import", ".plugin",
+                              in_sign='ssssa{ss}s', out_sign='',
+                              method=self._import,
+                              async_=True
+                              )
+
+    def register(self, name, handler, data_types, short_desc, priority=0):
+        """register an merge request handler
+
+        @param name(unicode): name of the handler
+        @param handler(object): instance of the handler.
+            It must have the following methods, which may all return a Deferred:
+                - check(repository)->bool: True if repository can be handled
+                - export(repository)->str: return export data, i.e. the patches
+                - parse(export_data): parse report data and return a list of dict
+                                      (1 per patch) with:
+                    - title: title of the commit message (first line)
+                    - body: body of the commit message
+        @aram data_types(list[unicode]): data types that his handler can generate or parse
+        """
+        if name in self._handlers:
+            raise exceptions.ConflictError(_("a handler with name {name} already "
+                                             "exists!").format(name = name))
+        self._handlers[name] = MergeRequestHandler(name,
+                                                   handler,
+                                                   data_types,
+                                                   short_desc,
+                                                   priority)
+        self._handlers_list.append(name)
+        self._handlers_list.sort(key=lambda name: self._handlers[name].priority)
+        if isinstance(data_types, str):
+            data_types = [data_types]
+        for data_type in data_types:
+            if data_type in self._type_handlers:
+                log.warning(_('merge requests of type {type} are already handled by '
+                              '{old_handler}, ignoring {new_handler}').format(
+                                type = data_type,
+                old_handler = self._type_handlers[data_type].name,
+                new_handler = name))
+                continue
+            self._type_handlers[data_type] = self._handlers[name]
+
+    def serialise(self, get_data):
+        tickets_xmlui, metadata, items_patches = get_data
+        tickets_xmlui_s, metadata = self._p.trans_items_data((tickets_xmlui, metadata))
+        return data_format.serialise({
+            "items": tickets_xmlui_s,
+            "metadata": metadata,
+            "items_patches": items_patches,
+        })
+
+    def _get(self, service='', node='', max_items=10, item_ids=None, sub_id=None,
+             extra="", profile_key=C.PROF_KEY_NONE):
+        extra = data_format.deserialise(extra)
+        client, service, node, max_items, extra, sub_id = self._s.prepare_bridge_get(
+            service, node, max_items, sub_id, extra, profile_key)
+        d = self.get(client, service, node or None, max_items, item_ids, sub_id or None,
+                     extra.rsm_request, extra.extra)
+        d.addCallback(self.serialise)
+        return d
+
+    @defer.inlineCallbacks
+    def get(self, client, service=None, node=None, max_items=None, item_ids=None,
+            sub_id=None, rsm_request=None, extra=None):
+        """Retrieve merge requests and convert them to XMLUI
+
+        @param extra(XEP-0060.parse, None): can have following keys:
+            - update(bool): if True, will return list of parsed request data
+        other params are the same as for [TICKETS._get]
+        @return (tuple[list[unicode], list[dict[unicode, unicode]])): tuple with
+            - XMLUI of the tickets, like [TICKETS._get]
+            - node metadata
+            - list of parsed request data (if extra['parse'] is set, else empty list)
+        """
+        if not node:
+            node = self.namespace
+        if extra is None:
+            extra = {}
+        # XXX: Q&D way to get list for labels when displaying them, but text when we
+        #      have to modify them
+        if C.bool(extra.get('labels_as_list', C.BOOL_FALSE)):
+            filters = {'labels': self._s.textbox_2_list_filter}
+        else:
+            filters = {}
+        tickets_xmlui, metadata = yield defer.ensureDeferred(
+            self._s.get_data_form_items(
+                client,
+                service,
+                node,
+                max_items=max_items,
+                item_ids=item_ids,
+                sub_id=sub_id,
+                rsm_request=rsm_request,
+                extra=extra,
+                form_ns=APP_NS_MERGE_REQUESTS,
+                filters = filters)
+        )
+        parsed_patches = []
+        if extra.get('parse', False):
+            for ticket in tickets_xmlui:
+                request_type = ticket.named_widgets[FIELD_DATA_TYPE].value
+                request_data = ticket.named_widgets[FIELD_DATA].value
+                parsed_data = yield self.parse_data(request_type, request_data)
+                parsed_patches.append(parsed_data)
+        defer.returnValue((tickets_xmlui, metadata, parsed_patches))
+
+    def _set(self, service, node, repository, method, values, schema=None, item_id=None,
+             extra="", profile_key=C.PROF_KEY_NONE):
+        client, service, node, schema, item_id, extra = self._s.prepare_bridge_set(
+            service, node, schema, item_id, extra, profile_key)
+        d = defer.ensureDeferred(
+            self.set(
+                client, service, node, repository, method, values, schema,
+                item_id or None, extra, deserialise=True
+            )
+        )
+        d.addCallback(lambda ret: ret or '')
+        return d
+
+    async def set(self, client, service, node, repository, method='auto', values=None,
+            schema=None, item_id=None, extra=None, deserialise=False):
+        """Publish a tickets
+
+        @param service(None, jid.JID): Pubsub service to use
+        @param node(unicode, None): Pubsub node to use
+            None to use default tickets node
+        @param repository(unicode): path to the repository where the code stands
+        @param method(unicode): name of one of the registered handler,
+                                or "auto" to try autodetection.
+        other arguments are same as for [TICKETS.set]
+        @return (unicode): id of the created item
+        """
+        if not node:
+            node = self.namespace
+        if values is None:
+            values = {}
+        update = extra.get('update', False)
+        if not repository and not update:
+            # in case of update, we may re-user former patches data
+            # so repository is not mandatory
+            raise exceptions.DataError(_("repository must be specified"))
+
+        if FIELD_DATA in values:
+            raise exceptions.DataError(_("{field} is set by backend, you must not set "
+                                         "it in frontend").format(field = FIELD_DATA))
+
+        if repository:
+            if method == 'auto':
+                for name in self._handlers_list:
+                    handler = self._handlers[name].handler
+                    can_handle = await handler.check(repository)
+                    if can_handle:
+                        log.info(_("{name} handler will be used").format(name=name))
+                        break
+                else:
+                    log.warning(_("repository {path} can't be handled by any installed "
+                                  "handler").format(
+                        path = repository))
+                    raise exceptions.NotFound(_("no handler for this repository has "
+                                                "been found"))
+            else:
+                try:
+                    handler = self._handlers[name].handler
+                except KeyError:
+                    raise exceptions.NotFound(_("No handler of this name found"))
+
+            data = await handler.export(repository)
+            if not data.strip():
+                raise exceptions.DataError(_('export data is empty, do you have any '
+                                             'change to send?'))
+
+            if not values.get('title') or not values.get('body'):
+                patches = handler.parse(data, values.get(FIELD_DATA_TYPE))
+                commits_msg = patches[-1][self.META_COMMIT_MSG]
+                msg_lines = commits_msg.splitlines()
+                if not values.get('title'):
+                    values['title'] = msg_lines[0]
+                if not values.get('body'):
+                    ts = self.host.plugins['TEXT_SYNTAXES']
+                    xhtml = await ts.convert(
+                        '\n'.join(msg_lines[1:]),
+                        syntax_from = ts.SYNTAX_TEXT,
+                        syntax_to = ts.SYNTAX_XHTML,
+                        profile = client.profile)
+                    values['body'] = '<div xmlns="{ns}">{xhtml}</div>'.format(
+                        ns=C.NS_XHTML, xhtml=xhtml)
+
+            values[FIELD_DATA] = data
+
+        item_id = await self._t.set(client, service, node, values, schema, item_id, extra,
+                                    deserialise, form_ns=APP_NS_MERGE_REQUESTS)
+        return item_id
+
+    def _parse_data(self, data_type, data):
+        d = self.parse_data(data_type, data)
+        d.addCallback(lambda parsed_patches:
+            {key: str(value) for key, value in parsed_patches.items()})
+        return d
+
+    def parse_data(self, data_type, data):
+        """Parse a merge request data according to type
+
+        @param data_type(unicode): type of the data to parse
+        @param data(unicode): data to parse
+        @return(list[dict[unicode, unicode]]): parsed data
+            key of dictionary are self.META_* or keys specifics to handler
+        @raise NotFound: no handler can parse this data_type
+        """
+        try:
+            handler = self._type_handlers[data_type]
+        except KeyError:
+            raise exceptions.NotFound(_('No handler can handle data type "{type}"')
+                                      .format(type=data_type))
+        return defer.maybeDeferred(handler.handler.parse, data, data_type)
+
+    def _import(self, repository, item_id, service=None, node=None, extra=None,
+                profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        service = jid.JID(service) if service else None
+        d = self.import_request(client, repository, item_id, service, node or None,
+                                extra=extra or None)
+        return d
+
+    @defer.inlineCallbacks
+    def import_request(self, client, repository, item, service=None, node=None,
+                       extra=None):
+        """import a merge request in specified directory
+
+        @param repository(unicode): path to the repository where the code stands
+        """
+        if not node:
+            node = self.namespace
+        tickets_xmlui, metadata = yield defer.ensureDeferred(
+            self._s.get_data_form_items(
+                client,
+                service,
+                node,
+                max_items=1,
+                item_ids=[item],
+                form_ns=APP_NS_MERGE_REQUESTS)
+        )
+        ticket_xmlui = tickets_xmlui[0]
+        data = ticket_xmlui.named_widgets[FIELD_DATA].value
+        data_type = ticket_xmlui.named_widgets[FIELD_DATA_TYPE].value
+        try:
+            handler = self._type_handlers[data_type]
+        except KeyError:
+            raise exceptions.NotFound(_('No handler found to import {data_type}')
+                                      .format(data_type=data_type))
+        log.info(_("Importing patch [{item_id}] using {name} handler").format(
+            item_id = item,
+            name = handler.name))
+        yield handler.handler.import_(repository, data, data_type, item, service, node,
+                                      extra)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_nat_port.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for NAT port mapping
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+from twisted.internet import threads
+from twisted.internet import defer
+from twisted.python import failure
+import threading
+
+try:
+    import miniupnpc
+except ImportError:
+    raise exceptions.MissingModule(
+        "Missing module MiniUPnPc, please download/install it (and its Python binding) at http://miniupnp.free.fr/ (or use pip install miniupnpc)"
+    )
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "NAT port mapping",
+    C.PI_IMPORT_NAME: "NAT-PORT",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_MAIN: "NatPort",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Automatic NAT port mapping using UPnP"""),
+}
+
+STARTING_PORT = 6000  # starting point to automatically find a port
+DEFAULT_DESC = (
+    "SaT port mapping"
+)  # we don't use "à" here as some bugged NAT don't manage charset correctly
+
+
+class MappingError(Exception):
+    pass
+
+
+class NatPort(object):
+    # TODO: refresh data if a new connection is detected (see plugin_misc_ip)
+
+    def __init__(self, host):
+        log.info(_("plugin NAT Port initialization"))
+        self.host = host
+        self._external_ip = None
+        self._initialised = defer.Deferred()
+        self._upnp = miniupnpc.UPnP()  # will be None if no device is available
+        self._upnp.discoverdelay = 200
+        self._mutex = threading.Lock()  # used to protect access to self._upnp
+        self._starting_port_cache = None  # used to cache the first available port
+        self._to_unmap = []  # list of tuples (ext_port, protocol) of ports to unmap on unload
+        discover_d = threads.deferToThread(self._discover)
+        discover_d.chainDeferred(self._initialised)
+        self._initialised.addErrback(self._init_failed)
+
+    def unload(self):
+        if self._to_unmap:
+            log.info("Cleaning mapped ports")
+            return threads.deferToThread(self._unmap_ports_blocking)
+
+    def _init_failed(self, failure_):
+        e = failure_.trap(exceptions.NotFound, exceptions.FeatureNotFound)
+        if e == exceptions.FeatureNotFound:
+            log.info("UPnP-IGD seems to be not activated on the device")
+        else:
+            log.info("UPnP-IGD not available")
+        self._upnp = None
+
+    def _discover(self):
+        devices = self._upnp.discover()
+        if devices:
+            log.info("{nb} UPnP-IGD device(s) found".format(nb=devices))
+        else:
+            log.info("Can't find UPnP-IGD device on the local network")
+            raise failure.Failure(exceptions.NotFound())
+        self._upnp.selectigd()
+        try:
+            self._external_ip = self._upnp.externalipaddress()
+        except Exception:
+            raise failure.Failure(exceptions.FeatureNotFound())
+
+    def get_ip(self, local=False):
+        """Return IP address found with UPnP-IGD
+
+        @param local(bool): True to get external IP address, False to get local network one
+        @return (None, str): found IP address, or None of something got wrong
+        """
+
+        def get_ip(__):
+            if self._upnp is None:
+                return None
+            # lanaddr can be the empty string if not found,
+            # we need to return None in this case
+            return (self._upnp.lanaddr or None) if local else self._external_ip
+
+        return self._initialised.addCallback(get_ip)
+
+    def _unmap_ports_blocking(self):
+        """Unmap ports mapped in this session"""
+        self._mutex.acquire()
+        try:
+            for port, protocol in self._to_unmap:
+                log.info("Unmapping port {}".format(port))
+                unmapping = self._upnp.deleteportmapping(
+                    # the last parameter is remoteHost, we don't use it
+                    port,
+                    protocol,
+                    "",
+                )
+
+                if not unmapping:
+                    log.error(
+                        "Can't unmap port {port} ({protocol})".format(
+                            port=port, protocol=protocol
+                        )
+                    )
+            del self._to_unmap[:]
+        finally:
+            self._mutex.release()
+
+    def _map_port_blocking(self, int_port, ext_port, protocol, desc):
+        """Internal blocking method to map port
+
+        @param int_port(int): internal port to use
+        @param ext_port(int): external port to use, or None to find one automatically
+        @param protocol(str): 'TCP' or 'UDP'
+        @param desc(str): description of the mapping
+        @param return(int, None): external port used in case of success, otherwise None
+        """
+        # we use mutex to avoid race condition if 2 threads
+        # try to acquire a port at the same time
+        self._mutex.acquire()
+        try:
+            if ext_port is None:
+                # find a free port
+                starting_port = self._starting_port_cache
+                ext_port = STARTING_PORT if starting_port is None else starting_port
+                ret = self._upnp.getspecificportmapping(ext_port, protocol)
+                while ret != None and ext_port < 65536:
+                    ext_port += 1
+                    ret = self._upnp.getspecificportmapping(ext_port, protocol)
+                if starting_port is None:
+                    # XXX: we cache the first successfuly found external port
+                    #      to avoid testing again the first series the next time
+                    self._starting_port_cache = ext_port
+
+            try:
+                mapping = self._upnp.addportmapping(
+                    # the last parameter is remoteHost, we don't use it
+                    ext_port,
+                    protocol,
+                    self._upnp.lanaddr,
+                    int_port,
+                    desc,
+                    "",
+                )
+            except Exception as e:
+                log.error(_("addportmapping error: {msg}").format(msg=e))
+                raise failure.Failure(MappingError())
+
+            if not mapping:
+                raise failure.Failure(MappingError())
+            else:
+                self._to_unmap.append((ext_port, protocol))
+        finally:
+            self._mutex.release()
+
+        return ext_port
+
+    def map_port(self, int_port, ext_port=None, protocol="TCP", desc=DEFAULT_DESC):
+        """Add a port mapping
+
+        @param int_port(int): internal port to use
+        @param ext_port(int,None): external port to use, or None to find one automatically
+        @param protocol(str): 'TCP' or 'UDP'
+        @param desc(unicode): description of the mapping
+            Some UPnP IGD devices have broken encoding. It's probably a good idea to avoid non-ascii chars here
+        @return (D(int, None)): external port used in case of success, otherwise None
+        """
+        if self._upnp is None:
+            return defer.succeed(None)
+
+        def mapping_cb(ext_port):
+            log.info(
+                "{protocol} mapping from {int_port} to {ext_port} successful".format(
+                    protocol=protocol, int_port=int_port, ext_port=ext_port
+                )
+            )
+            return ext_port
+
+        def mapping_eb(failure_):
+            failure_.trap(MappingError)
+            log.warning("Can't map internal {int_port}".format(int_port=int_port))
+
+        def mapping_unknown_eb(failure_):
+            log.error(_("error while trying to map ports: {msg}").format(msg=failure_))
+
+        d = threads.deferToThread(
+            self._map_port_blocking, int_port, ext_port, protocol, desc
+        )
+        d.addCallbacks(mapping_cb, mapping_eb)
+        d.addErrback(mapping_unknown_eb)
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_quiz.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,456 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing Quiz game
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.words.xish import domish
+from twisted.internet import reactor
+from twisted.words.protocols.jabber import client as jabber_client, jid
+from time import time
+
+
+NS_QG = "http://www.goffi.org/protocol/quiz"
+QG_TAG = "quiz"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Quiz game plugin",
+    C.PI_IMPORT_NAME: "Quiz",
+    C.PI_TYPE: "Game",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249", "ROOM-GAME"],
+    C.PI_MAIN: "Quiz",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Quiz game"""),
+}
+
+
+class Quiz(object):
+    def inherit_from_room_game(self, host):
+        global RoomGame
+        RoomGame = host.plugins["ROOM-GAME"].__class__
+        self.__class__ = type(
+            self.__class__.__name__, (self.__class__, RoomGame, object), {}
+        )
+
+    def __init__(self, host):
+        log.info(_("Plugin Quiz initialization"))
+        self.inherit_from_room_game(host)
+        RoomGame._init_(
+            self,
+            host,
+            PLUGIN_INFO,
+            (NS_QG, QG_TAG),
+            game_init={"stage": None},
+            player_init={"score": 0},
+        )
+        host.bridge.add_method(
+            "quiz_game_launch",
+            ".plugin",
+            in_sign="asss",
+            out_sign="",
+            method=self._prepare_room,
+        )  # args: players, room_jid, profile
+        host.bridge.add_method(
+            "quiz_game_create",
+            ".plugin",
+            in_sign="sass",
+            out_sign="",
+            method=self._create_game,
+        )  # args: room_jid, players, profile
+        host.bridge.add_method(
+            "quiz_game_ready",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._player_ready,
+        )  # args: player, referee, profile
+        host.bridge.add_method(
+            "quiz_game_answer",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="",
+            method=self.player_answer,
+        )
+        host.bridge.add_signal(
+            "quiz_game_started", ".plugin", signature="ssass"
+        )  # args: room_jid, referee, players, profile
+        host.bridge.add_signal(
+            "quiz_game_new",
+            ".plugin",
+            signature="sa{ss}s",
+            doc={
+                "summary": "Start a new game",
+                "param_0": "room_jid: jid of game's room",
+                "param_1": "game_data: data of the game",
+                "param_2": "%(doc_profile)s",
+            },
+        )
+        host.bridge.add_signal(
+            "quiz_game_question",
+            ".plugin",
+            signature="sssis",
+            doc={
+                "summary": "Send the current question",
+                "param_0": "room_jid: jid of game's room",
+                "param_1": "question_id: question id",
+                "param_2": "question: question to ask",
+                "param_3": "timer: timer",
+                "param_4": "%(doc_profile)s",
+            },
+        )
+        host.bridge.add_signal(
+            "quiz_game_player_buzzed",
+            ".plugin",
+            signature="ssbs",
+            doc={
+                "summary": "A player just pressed the buzzer",
+                "param_0": "room_jid: jid of game's room",
+                "param_1": "player: player who pushed the buzzer",
+                "param_2": "pause: should the game be paused ?",
+                "param_3": "%(doc_profile)s",
+            },
+        )
+        host.bridge.add_signal(
+            "quiz_game_player_says",
+            ".plugin",
+            signature="sssis",
+            doc={
+                "summary": "A player just pressed the buzzer",
+                "param_0": "room_jid: jid of game's room",
+                "param_1": "player: player who pushed the buzzer",
+                "param_2": "text: what the player say",
+                "param_3": "delay: how long, in seconds, the text must appear",
+                "param_4": "%(doc_profile)s",
+            },
+        )
+        host.bridge.add_signal(
+            "quiz_game_answer_result",
+            ".plugin",
+            signature="ssba{si}s",
+            doc={
+                "summary": "Result of the just given answer",
+                "param_0": "room_jid: jid of game's room",
+                "param_1": "player: player who gave the answer",
+                "param_2": "good_answer: True if the answer is right",
+                "param_3": "score: dict of score with player as key",
+                "param_4": "%(doc_profile)s",
+            },
+        )
+        host.bridge.add_signal(
+            "quiz_game_timer_expired",
+            ".plugin",
+            signature="ss",
+            doc={
+                "summary": "Nobody answered the question in time",
+                "param_0": "room_jid: jid of game's room",
+                "param_1": "%(doc_profile)s",
+            },
+        )
+        host.bridge.add_signal(
+            "quiz_game_timer_restarted",
+            ".plugin",
+            signature="sis",
+            doc={
+                "summary": "Nobody answered the question in time",
+                "param_0": "room_jid: jid of game's room",
+                "param_1": "time_left: time left before timer expiration",
+                "param_2": "%(doc_profile)s",
+            },
+        )
+
+    def __game_data_to_xml(self, game_data):
+        """Convert a game data dict to domish element"""
+        game_data_elt = domish.Element((None, "game_data"))
+        for data in game_data:
+            data_elt = domish.Element((None, data))
+            data_elt.addContent(game_data[data])
+            game_data_elt.addChild(data_elt)
+        return game_data_elt
+
+    def __xml_to_game_data(self, game_data_elt):
+        """Convert a domish element with game_data to a dict"""
+        game_data = {}
+        for data_elt in game_data_elt.elements():
+            game_data[data_elt.name] = str(data_elt)
+        return game_data
+
+    def __answer_result_to_signal_args(self, answer_result_elt):
+        """Parse answer result element and return a tuple of signal arguments
+        @param answer_result_elt: answer result element
+        @return: (player, good_answer, score)"""
+        score = {}
+        for score_elt in answer_result_elt.elements():
+            score[score_elt["player"]] = int(score_elt["score"])
+        return (
+            answer_result_elt["player"],
+            answer_result_elt["good_answer"] == str(True),
+            score,
+        )
+
+    def __answer_result(self, player_answering, good_answer, game_data):
+        """Convert a domish an answer_result element
+        @param player_answering: player who gave the answer
+        @param good_answer: True is the answer is right
+        @param game_data: data of the game"""
+        players_data = game_data["players_data"]
+        score = {}
+        for player in game_data["players"]:
+            score[player] = players_data[player]["score"]
+
+        answer_result_elt = domish.Element((None, "answer_result"))
+        answer_result_elt["player"] = player_answering
+        answer_result_elt["good_answer"] = str(good_answer)
+
+        for player in score:
+            score_elt = domish.Element((None, "score"))
+            score_elt["player"] = player
+            score_elt["score"] = str(score[player])
+            answer_result_elt.addChild(score_elt)
+
+        return answer_result_elt
+
+    def __ask_question(self, question_id, question, timer):
+        """Create a element for asking a question"""
+        question_elt = domish.Element((None, "question"))
+        question_elt["id"] = question_id
+        question_elt["timer"] = str(timer)
+        question_elt.addContent(question)
+        return question_elt
+
+    def __start_play(self, room_jid, game_data, profile):
+        """Start the game (tell to the first player after dealer to play"""
+        client = self.host.get_client(profile)
+        game_data["stage"] = "play"
+        next_player_idx = game_data["current_player"] = (
+            game_data["init_player"] + 1
+        ) % len(
+            game_data["players"]
+        )  # the player after the dealer start
+        game_data["first_player"] = next_player = game_data["players"][next_player_idx]
+        to_jid = jid.JID(room_jid.userhost() + "/" + next_player)
+        mess = self.createGameElt(to_jid)
+        mess.firstChildElement().addElement("your_turn")
+        client.send(mess)
+
+    def player_answer(self, player, referee, answer, profile_key=C.PROF_KEY_NONE):
+        """Called when a player give an answer"""
+        client = self.host.get_client(profile_key)
+        log.debug(
+            "new player answer (%(profile)s): %(answer)s"
+            % {"profile": client.profile, "answer": answer}
+        )
+        mess = self.createGameElt(jid.JID(referee))
+        answer_elt = mess.firstChildElement().addElement("player_answer")
+        answer_elt["player"] = player
+        answer_elt.addContent(answer)
+        client.send(mess)
+
+    def timer_expired(self, room_jid, profile):
+        """Called when nobody answered the question in time"""
+        client = self.host.get_client(profile)
+        game_data = self.games[room_jid]
+        game_data["stage"] = "expired"
+        mess = self.createGameElt(room_jid)
+        mess.firstChildElement().addElement("timer_expired")
+        client.send(mess)
+        reactor.callLater(4, self.ask_question, room_jid, client.profile)
+
+    def pause_timer(self, room_jid):
+        """Stop the timer and save the time left"""
+        game_data = self.games[room_jid]
+        left = max(0, game_data["timer"].getTime() - time())
+        game_data["timer"].cancel()
+        game_data["time_left"] = int(left)
+        game_data["previous_stage"] = game_data["stage"]
+        game_data["stage"] = "paused"
+
+    def restart_timer(self, room_jid, profile):
+        """Restart a timer with the saved time"""
+        client = self.host.get_client(profile)
+        game_data = self.games[room_jid]
+        assert game_data["time_left"] is not None
+        mess = self.createGameElt(room_jid)
+        mess.firstChildElement().addElement("timer_restarted")
+        jabber_client.restarted_elt["time_left"] = str(game_data["time_left"])
+        client.send(mess)
+        game_data["timer"] = reactor.callLater(
+            game_data["time_left"], self.timer_expired, room_jid, profile
+        )
+        game_data["time_left"] = None
+        game_data["stage"] = game_data["previous_stage"]
+        del game_data["previous_stage"]
+
+    def ask_question(self, room_jid, profile):
+        """Ask a new question"""
+        client = self.host.get_client(profile)
+        game_data = self.games[room_jid]
+        game_data["stage"] = "question"
+        game_data["question_id"] = "1"
+        timer = 30
+        mess = self.createGameElt(room_jid)
+        mess.firstChildElement().addChild(
+            self.__ask_question(
+                game_data["question_id"], "Quel est l'âge du capitaine ?", timer
+            )
+        )
+        client.send(mess)
+        game_data["timer"] = reactor.callLater(
+            timer, self.timer_expired, room_jid, profile
+        )
+        game_data["time_left"] = None
+
+    def check_answer(self, room_jid, player, answer, profile):
+        """Check if the answer given is right"""
+        client = self.host.get_client(profile)
+        game_data = self.games[room_jid]
+        players_data = game_data["players_data"]
+        good_answer = game_data["question_id"] == "1" and answer == "42"
+        players_data[player]["score"] += 1 if good_answer else -1
+        players_data[player]["score"] = min(9, max(0, players_data[player]["score"]))
+
+        mess = self.createGameElt(room_jid)
+        mess.firstChildElement().addChild(
+            self.__answer_result(player, good_answer, game_data)
+        )
+        client.send(mess)
+
+        if good_answer:
+            reactor.callLater(4, self.ask_question, room_jid, profile)
+        else:
+            reactor.callLater(4, self.restart_timer, room_jid, profile)
+
+    def new_game(self, room_jid, profile):
+        """Launch a new round"""
+        common_data = {"game_score": 0}
+        new_game_data = {
+            "instructions": _(
+                """Bienvenue dans cette partie rapide de quizz, le premier à atteindre le score de 9 remporte le jeu
+
+Attention, tu es prêt ?"""
+            )
+        }
+        msg_elts = self.__game_data_to_xml(new_game_data)
+        RoomGame.new_round(self, room_jid, (common_data, msg_elts), profile)
+        reactor.callLater(10, self.ask_question, room_jid, profile)
+
+    def room_game_cmd(self, mess_elt, profile):
+        client = self.host.get_client(profile)
+        from_jid = jid.JID(mess_elt["from"])
+        room_jid = jid.JID(from_jid.userhost())
+        game_elt = mess_elt.firstChildElement()
+        game_data = self.games[room_jid]
+        #  if 'players_data' in game_data:
+        #      players_data = game_data['players_data']
+
+        for elt in game_elt.elements():
+
+            if elt.name == "started":  # new game created
+                players = []
+                for player in elt.elements():
+                    players.append(str(player))
+                self.host.bridge.quiz_game_started(
+                    room_jid.userhost(), from_jid.full(), players, profile
+                )
+
+            elif elt.name == "player_ready":  # ready to play
+                player = elt["player"]
+                status = self.games[room_jid]["status"]
+                nb_players = len(self.games[room_jid]["players"])
+                status[player] = "ready"
+                log.debug(
+                    _("Player %(player)s is ready to start [status: %(status)s]")
+                    % {"player": player, "status": status}
+                )
+                if (
+                    list(status.values()).count("ready") == nb_players
+                ):  # everybody is ready, we can start the game
+                    self.new_game(room_jid, profile)
+
+            elif elt.name == "game_data":
+                self.host.bridge.quiz_game_new(
+                    room_jid.userhost(), self.__xml_to_game_data(elt), profile
+                )
+
+            elif elt.name == "question":  # A question is asked
+                self.host.bridge.quiz_game_question(
+                    room_jid.userhost(),
+                    elt["id"],
+                    str(elt),
+                    int(elt["timer"]),
+                    profile,
+                )
+
+            elif elt.name == "player_answer":
+                player = elt["player"]
+                pause = (
+                    game_data["stage"] == "question"
+                )  # we pause the game only if we are have a question at the moment
+                # we first send a buzzer message
+                mess = self.createGameElt(room_jid)
+                buzzer_elt = mess.firstChildElement().addElement("player_buzzed")
+                buzzer_elt["player"] = player
+                buzzer_elt["pause"] = str(pause)
+                client.send(mess)
+                if pause:
+                    self.pause_timer(room_jid)
+                    # and we send the player answer
+                    mess = self.createGameElt(room_jid)
+                    _answer = str(elt)
+                    say_elt = mess.firstChildElement().addElement("player_says")
+                    say_elt["player"] = player
+                    say_elt.addContent(_answer)
+                    say_elt["delay"] = "3"
+                    reactor.callLater(2, client.send, mess)
+                    reactor.callLater(
+                        6, self.check_answer, room_jid, player, _answer, profile=profile
+                    )
+
+            elif elt.name == "player_buzzed":
+                self.host.bridge.quiz_game_player_buzzed(
+                    room_jid.userhost(), elt["player"], elt["pause"] == str(True), profile
+                )
+
+            elif elt.name == "player_says":
+                self.host.bridge.quiz_game_player_says(
+                    room_jid.userhost(),
+                    elt["player"],
+                    str(elt),
+                    int(elt["delay"]),
+                    profile,
+                )
+
+            elif elt.name == "answer_result":
+                player, good_answer, score = self.__answer_result_to_signal_args(elt)
+                self.host.bridge.quiz_game_answer_result(
+                    room_jid.userhost(), player, good_answer, score, profile
+                )
+
+            elif elt.name == "timer_expired":
+                self.host.bridge.quiz_game_timer_expired(room_jid.userhost(), profile)
+
+            elif elt.name == "timer_restarted":
+                self.host.bridge.quiz_game_timer_restarted(
+                    room_jid.userhost(), int(elt["time_left"]), profile
+                )
+
+            else:
+                log.error(_("Unmanaged game element: %s") % elt.name)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_radiocol.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,370 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing Radiocol
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.words.xish import domish
+from twisted.internet import reactor
+from twisted.words.protocols.jabber import jid
+from twisted.internet import defer
+from libervia.backend.core import exceptions
+import os.path
+import copy
+import time
+from os import unlink
+
+try:
+    from mutagen.oggvorbis import OggVorbis, OggVorbisHeaderError
+    from mutagen.mp3 import MP3, HeaderNotFoundError
+    from mutagen.easyid3 import EasyID3
+    from mutagen.id3 import ID3NoHeaderError
+except ImportError:
+    raise exceptions.MissingModule(
+        "Missing module Mutagen, please download/install from https://bitbucket.org/lazka/mutagen"
+    )
+
+
+NC_RADIOCOL = "http://www.goffi.org/protocol/radiocol"
+RADIOC_TAG = "radiocol"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Radio collective plugin",
+    C.PI_IMPORT_NAME: "Radiocol",
+    C.PI_TYPE: "Exp",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249", "ROOM-GAME"],
+    C.PI_MAIN: "Radiocol",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of radio collective"""),
+}
+
+
+# Number of songs needed in the queue before we start playing
+QUEUE_TO_START = 2
+# Maximum number of songs in the queue (the song being currently played doesn't count)
+QUEUE_LIMIT = 2
+
+
+class Radiocol(object):
+    def inherit_from_room_game(self, host):
+        global RoomGame
+        RoomGame = host.plugins["ROOM-GAME"].__class__
+        self.__class__ = type(
+            self.__class__.__name__, (self.__class__, RoomGame, object), {}
+        )
+
+    def __init__(self, host):
+        log.info(_("Radio collective initialization"))
+        self.inherit_from_room_game(host)
+        RoomGame._init_(
+            self,
+            host,
+            PLUGIN_INFO,
+            (NC_RADIOCOL, RADIOC_TAG),
+            game_init={
+                "queue": [],
+                "upload": True,
+                "playing": None,
+                "playing_time": 0,
+                "to_delete": {},
+            },
+        )
+        self.host = host
+        host.bridge.add_method(
+            "radiocol_launch",
+            ".plugin",
+            in_sign="asss",
+            out_sign="",
+            method=self._prepare_room,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "radiocol_create",
+            ".plugin",
+            in_sign="sass",
+            out_sign="",
+            method=self._create_game,
+        )
+        host.bridge.add_method(
+            "radiocol_song_added",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._radiocol_song_added,
+            async_=True,
+        )
+        host.bridge.add_signal(
+            "radiocol_players", ".plugin", signature="ssass"
+        )  # room_jid, referee, players, profile
+        host.bridge.add_signal(
+            "radiocol_started", ".plugin", signature="ssasais"
+        )  # room_jid, referee, players, [QUEUE_TO_START, QUEUE_LIMIT], profile
+        host.bridge.add_signal(
+            "radiocol_song_rejected", ".plugin", signature="sss"
+        )  # room_jid, reason, profile
+        host.bridge.add_signal(
+            "radiocol_preload", ".plugin", signature="ssssssss"
+        )  # room_jid, timestamp, filename, title, artist, album, profile
+        host.bridge.add_signal(
+            "radiocol_play", ".plugin", signature="sss"
+        )  # room_jid, filename, profile
+        host.bridge.add_signal(
+            "radiocol_no_upload", ".plugin", signature="ss"
+        )  # room_jid, profile
+        host.bridge.add_signal(
+            "radiocol_upload_ok", ".plugin", signature="ss"
+        )  # room_jid, profile
+
+    def __create_preload_elt(self, sender, song_added_elt):
+        preload_elt = copy.deepcopy(song_added_elt)
+        preload_elt.name = "preload"
+        preload_elt["sender"] = sender
+        preload_elt["timestamp"] = str(time.time())
+        # attributes filename, title, artist, album, length have been copied
+        # XXX: the frontend should know the temporary directory where file is put
+        return preload_elt
+
+    def _radiocol_song_added(self, referee_s, song_path, profile):
+        return self.radiocol_song_added(jid.JID(referee_s), song_path, profile)
+
+    def radiocol_song_added(self, referee, song_path, profile):
+        """This method is called by libervia when a song has been uploaded
+        @param referee (jid.JID): JID of the referee in the room (room userhost + '/' + nick)
+        @param song_path (unicode): absolute path of the song added
+        @param profile_key (unicode): %(doc_profile_key)s
+        @return: a Deferred instance
+        """
+        # XXX: this is a Q&D way for the proof of concept. In the future, the song should
+        #     be streamed to the backend using XMPP file copy
+        #     Here we cheat because we know we are on the same host, and we don't
+        #     check data. Referee will have to parse the song himself to check it
+        try:
+            if song_path.lower().endswith(".mp3"):
+                actual_song = MP3(song_path)
+                try:
+                    song = EasyID3(song_path)
+
+                    class Info(object):
+                        def __init__(self, length):
+                            self.length = length
+
+                    song.info = Info(actual_song.info.length)
+                except ID3NoHeaderError:
+                    song = actual_song
+            else:
+                song = OggVorbis(song_path)
+        except (OggVorbisHeaderError, HeaderNotFoundError):
+            # this file is not ogg vorbis nor mp3, we reject it
+            self.delete_file(song_path)  # FIXME: same host trick (see note above)
+            return defer.fail(
+                exceptions.DataError(
+                    D_(
+                        "The uploaded file has been rejected, only Ogg Vorbis and MP3 songs are accepted."
+                    )
+                )
+            )
+
+        attrs = {
+            "filename": os.path.basename(song_path),
+            "title": song.get("title", ["Unknown"])[0],
+            "artist": song.get("artist", ["Unknown"])[0],
+            "album": song.get("album", ["Unknown"])[0],
+            "length": str(song.info.length),
+        }
+        radio_data = self.games[
+            referee.userhostJID()
+        ]  # FIXME: referee comes from Libervia's client side, it's unsecure
+        radio_data["to_delete"][
+            attrs["filename"]
+        ] = (
+            song_path
+        )  # FIXME: works only because of the same host trick, see the note under the docstring
+        return self.send(referee, ("", "song_added"), attrs, profile=profile)
+
+    def play_next(self, room_jid, profile):
+        """"Play next song in queue if exists, and put a timer
+        which trigger after the song has been played to play next one"""
+        # TODO: songs need to be erased once played or found invalids
+        #      ==> unlink done the Q&D way with the same host trick (see above)
+        radio_data = self.games[room_jid]
+        if len(radio_data["players"]) == 0:
+            log.debug(_("No more participants in the radiocol: cleaning data"))
+            radio_data["queue"] = []
+            for filename in radio_data["to_delete"]:
+                self.delete_file(filename, radio_data)
+            radio_data["to_delete"] = {}
+        queue = radio_data["queue"]
+        if not queue:
+            # nothing left to play, we need to wait for uploads
+            radio_data["playing"] = None
+            return
+        song = queue.pop(0)
+        filename, length = song["filename"], float(song["length"])
+        self.send(room_jid, ("", "play"), {"filename": filename}, profile=profile)
+        radio_data["playing"] = song
+        radio_data["playing_time"] = time.time()
+
+        if not radio_data["upload"] and len(queue) < QUEUE_LIMIT:
+            # upload is blocked and we now have resources to get more, we reactivate it
+            self.send(room_jid, ("", "upload_ok"), profile=profile)
+            radio_data["upload"] = True
+
+        reactor.callLater(length, self.play_next, room_jid, profile)
+        # we wait more than the song length to delete the file, to manage poorly reactive networks/clients
+        reactor.callLater(
+            length + 90, self.delete_file, filename, radio_data
+        )  # FIXME: same host trick (see above)
+
+    def delete_file(self, filename, radio_data=None):
+        """
+        Delete a previously uploaded file.
+        @param filename: filename to delete, or full filepath if radio_data is None
+        @param radio_data: current game data
+        @return: True if the file has been deleted
+        """
+        if radio_data:
+            try:
+                file_to_delete = radio_data["to_delete"][filename]
+            except KeyError:
+                log.error(
+                    _("INTERNAL ERROR: can't find full path of the song to delete")
+                )
+                return False
+        else:
+            file_to_delete = filename
+        try:
+            unlink(file_to_delete)
+        except OSError:
+            log.error(
+                _("INTERNAL ERROR: can't find %s on the file system" % file_to_delete)
+            )
+            return False
+        return True
+
+    def room_game_cmd(self, mess_elt, profile):
+        from_jid = jid.JID(mess_elt["from"])
+        room_jid = from_jid.userhostJID()
+        nick = self.host.plugins["XEP-0045"].get_room_nick(room_jid, profile)
+
+        radio_elt = mess_elt.firstChildElement()
+        radio_data = self.games[room_jid]
+        if "queue" in radio_data:
+            queue = radio_data["queue"]
+
+        from_referee = self.is_referee(room_jid, from_jid.resource)
+        to_referee = self.is_referee(room_jid, jid.JID(mess_elt["to"]).user)
+        is_player = self.is_player(room_jid, nick)
+        for elt in radio_elt.elements():
+            if not from_referee and not (to_referee and elt.name == "song_added"):
+                continue  # sender must be referee, expect when a song is submitted
+            if not is_player and (elt.name not in ("started", "players")):
+                continue  # user is in the room but not playing
+
+            if elt.name in (
+                "started",
+                "players",
+            ):  # new game created and/or players list updated
+                players = []
+                for player in elt.elements():
+                    players.append(str(player))
+                signal = (
+                    self.host.bridge.radiocol_started
+                    if elt.name == "started"
+                    else self.host.bridge.radiocol_players
+                )
+                signal(
+                    room_jid.userhost(),
+                    from_jid.full(),
+                    players,
+                    [QUEUE_TO_START, QUEUE_LIMIT],
+                    profile,
+                )
+            elif elt.name == "preload":  # a song is in queue and must be preloaded
+                self.host.bridge.radiocol_preload(
+                    room_jid.userhost(),
+                    elt["timestamp"],
+                    elt["filename"],
+                    elt["title"],
+                    elt["artist"],
+                    elt["album"],
+                    elt["sender"],
+                    profile,
+                )
+            elif elt.name == "play":
+                self.host.bridge.radiocol_play(
+                    room_jid.userhost(), elt["filename"], profile
+                )
+            elif elt.name == "song_rejected":  # a song has been refused
+                self.host.bridge.radiocol_song_rejected(
+                    room_jid.userhost(), elt["reason"], profile
+                )
+            elif elt.name == "no_upload":
+                self.host.bridge.radiocol_no_upload(room_jid.userhost(), profile)
+            elif elt.name == "upload_ok":
+                self.host.bridge.radiocol_upload_ok(room_jid.userhost(), profile)
+            elif elt.name == "song_added":  # a song has been added
+                # FIXME: we are KISS for the proof of concept: every song is added, to a limit of 3 in queue.
+                #       Need to manage some sort of rules to allow peoples to send songs
+                if len(queue) >= QUEUE_LIMIT:
+                    # there are already too many songs in queue, we reject this one
+                    # FIXME: add an error code
+                    self.send(
+                        from_jid,
+                        ("", "song_rejected"),
+                        {"reason": "Too many songs in queue"},
+                        profile=profile,
+                    )
+                    return
+
+                # The song is accepted and added in queue
+                preload_elt = self.__create_preload_elt(from_jid.resource, elt)
+                queue.append(preload_elt)
+
+                if len(queue) >= QUEUE_LIMIT:
+                    # We are at the limit, we refuse new upload until next play
+                    self.send(room_jid, ("", "no_upload"), profile=profile)
+                    radio_data["upload"] = False
+
+                self.send(room_jid, preload_elt, profile=profile)
+                if not radio_data["playing"] and len(queue) == QUEUE_TO_START:
+                    # We have not started playing yet, and we have QUEUE_TO_START
+                    # songs in queue. We can now start the party :)
+                    self.play_next(room_jid, profile)
+            else:
+                log.error(_("Unmanaged game element: %s") % elt.name)
+
+    def get_sync_data_for_player(self, room_jid, nick):
+        game_data = self.games[room_jid]
+        elements = []
+        if game_data["playing"]:
+            preload = copy.deepcopy(game_data["playing"])
+            current_time = game_data["playing_time"] + 1 if self.testing else time.time()
+            preload["filename"] += "#t=%.2f" % (current_time - game_data["playing_time"])
+            elements.append(preload)
+            play = domish.Element(("", "play"))
+            play["filename"] = preload["filename"]
+            elements.append(play)
+        if len(game_data["queue"]) > 0:
+            elements.extend(copy.deepcopy(game_data["queue"]))
+            if len(game_data["queue"]) == QUEUE_LIMIT:
+                elements.append(domish.Element(("", "no_upload")))
+        return elements
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_register_account.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+
+
+# SàT plugin for registering a new XMPP account
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core.constants import Const as C
+from twisted.words.protocols.jabber import jid
+from libervia.backend.memory.memory import Sessions
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools.xml_tools import SAT_FORM_PREFIX, SAT_PARAM_SEPARATOR
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Register Account Plugin",
+    C.PI_IMPORT_NAME: "REGISTER-ACCOUNT",
+    C.PI_TYPE: "MISC",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0077"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "RegisterAccount",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Register XMPP account"""),
+}
+
+
+class RegisterAccount(object):
+    # FIXME: this plugin is messy and difficult to read, it needs to be cleaned up and documented
+
+    def __init__(self, host):
+        log.info(_("Plugin Register Account initialization"))
+        self.host = host
+        self._sessions = Sessions()
+        host.register_callback(
+            self.register_new_account_cb, with_data=True, force_id="register_new_account"
+        )
+        self.__register_account_id = host.register_callback(
+            self._register_confirmation, with_data=True
+        )
+
+    def register_new_account_cb(self, data, profile):
+        """Called when the user click on the "New account" button."""
+        session_data = {}
+
+        # FIXME: following loop is overcomplicated, hard to read
+        # FIXME: while used with parameters, hashed password is used and overwrite clear one
+        for param in ("JabberID", "Password", C.FORCE_PORT_PARAM, C.FORCE_SERVER_PARAM):
+            try:
+                session_data[param] = data[
+                    SAT_FORM_PREFIX + "Connection" + SAT_PARAM_SEPARATOR + param
+                ]
+            except KeyError:
+                if param in (C.FORCE_PORT_PARAM, C.FORCE_SERVER_PARAM):
+                    session_data[param] = ""
+
+        for param in ("JabberID", "Password"):
+            if not session_data[param]:
+                form_ui = xml_tools.XMLUI("popup", title=D_("Missing values"))
+                form_ui.addText(
+                    D_("No user JID or password given: can't register new account.")
+                )
+                return {"xmlui": form_ui.toXml()}
+
+        session_data["user"], host, resource = jid.parse(session_data["JabberID"])
+        session_data["server"] = session_data[C.FORCE_SERVER_PARAM] or host
+        session_id, __ = self._sessions.new_session(session_data, profile=profile)
+        form_ui = xml_tools.XMLUI(
+            "form",
+            title=D_("Register new account"),
+            submit_id=self.__register_account_id,
+            session_id=session_id,
+        )
+        form_ui.addText(
+            D_("Do you want to register a new XMPP account {jid}?").format(
+                jid=session_data["JabberID"]
+            )
+        )
+        return {"xmlui": form_ui.toXml()}
+
+    def _register_confirmation(self, data, profile):
+        """Save the related parameters and proceed the registration."""
+        session_data = self._sessions.profile_get(data["session_id"], profile)
+
+        self.host.memory.param_set(
+            "JabberID", session_data["JabberID"], "Connection", profile_key=profile
+        )
+        self.host.memory.param_set(
+            "Password", session_data["Password"], "Connection", profile_key=profile
+        )
+        self.host.memory.param_set(
+            C.FORCE_SERVER_PARAM,
+            session_data[C.FORCE_SERVER_PARAM],
+            "Connection",
+            profile_key=profile,
+        )
+        self.host.memory.param_set(
+            C.FORCE_PORT_PARAM,
+            session_data[C.FORCE_PORT_PARAM],
+            "Connection",
+            profile_key=profile,
+        )
+
+        d = self._register_new_account(
+            jid.JID(session_data["JabberID"]),
+            session_data["Password"],
+            None,
+            session_data["server"],
+        )
+        del self._sessions[data["session_id"]]
+        return d
+
+    def _register_new_account(self, client, jid_, password, email, server):
+        #  FIXME: port is not set here
+        def registered_cb(__):
+            xmlui = xml_tools.XMLUI("popup", title=D_("Confirmation"))
+            xmlui.addText(D_("Registration successful."))
+            return {"xmlui": xmlui.toXml()}
+
+        def registered_eb(failure):
+            xmlui = xml_tools.XMLUI("popup", title=D_("Failure"))
+            xmlui.addText(D_("Registration failed: %s") % failure.getErrorMessage())
+            try:
+                if failure.value.condition == "conflict":
+                    xmlui.addText(
+                        D_("Username already exists, please choose an other one.")
+                    )
+            except AttributeError:
+                pass
+            return {"xmlui": xmlui.toXml()}
+
+        registered_d = self.host.plugins["XEP-0077"].register_new_account(
+            client, jid_, password, email=email, host=server, port=C.XMPP_C2S_PORT
+        )
+        registered_d.addCallbacks(registered_cb, registered_eb)
+        return registered_d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_room_game.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,784 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
+from twisted.internet import defer
+from time import time
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+import copy
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+# Don't forget to set it to False before you commit
+_DEBUG = False
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Room game",
+    C.PI_IMPORT_NAME: "ROOM-GAME",
+    C.PI_TYPE: "MISC",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249"],
+    C.PI_MAIN: "RoomGame",
+    C.PI_HANDLER: "no",  # handler MUST be "no" (dynamic inheritance)
+    C.PI_DESCRIPTION: _("""Base class for MUC games"""),
+}
+
+
+# FIXME: this plugin is broken, need to be fixed
+
+
+class RoomGame(object):
+    """This class is used to help launching a MUC game.
+
+    bridge methods callbacks: _prepare_room, _player_ready, _create_game
+    Triggered methods: user_joined_trigger, user_left_trigger
+    Also called from subclasses: new_round
+
+    For examples of messages sequences, please look in sub-classes.
+    """
+
+    # Values for self.invite_mode (who can invite after the game creation)
+    FROM_ALL, FROM_NONE, FROM_REFEREE, FROM_PLAYERS = range(0, 4)
+    # Values for self.wait_mode (for who we should wait before creating the game)
+    FOR_ALL, FOR_NONE = range(0, 2)
+    # Values for self.join_mode (who can join the game - NONE means solo game)
+    ALL, INVITED, NONE = range(0, 3)
+    # Values for ready_mode (how to turn a MUC user into a player)
+    ASK, FORCE = range(0, 2)
+
+    MESSAGE = "/message"
+    REQUEST = '%s/%s[@xmlns="%s"]'
+
+    def __init__(self, host):
+        """For other plugin to dynamically inherit this class, it is necessary to not use __init__ but _init_.
+        The subclass itself must be initialized this way:
+
+        class MyGame(object):
+
+            def inherit_from_room_game(self, host):
+                global RoomGame
+                RoomGame = host.plugins["ROOM-GAME"].__class__
+                self.__class__ = type(self.__class__.__name__, (self.__class__, RoomGame, object), {})
+
+            def __init__(self, host):
+                self.inherit_from_room_game(host)
+                RoomGame._init_(self, host, ...)
+
+        """
+        self.host = host
+
+    def _init_(self, host, plugin_info, ns_tag, game_init=None, player_init=None):
+        """
+        @param host
+        @param plugin_info: PLUGIN_INFO map of the game plugin
+        @param ns_tag: couple (nameservice, tag) to construct the messages
+        @param game_init: dictionary for general game initialization
+        @param player_init: dictionary for player initialization, applicable to each player
+        """
+        self.host = host
+        self.name = plugin_info["import_name"]
+        self.ns_tag = ns_tag
+        self.request = self.REQUEST % (self.MESSAGE, ns_tag[1], ns_tag[0])
+        if game_init is None:
+            game_init = {}
+        if player_init is None:
+            player_init = {}
+        self.game_init = game_init
+        self.player_init = player_init
+        self.games = {}
+        self.invitations = {}  # values are a couple (x, y) with x the time and y a list of users
+
+        # These are the default settings, which can be overwritten by child class after initialization
+        self.invite_mode = self.FROM_PLAYERS if self.player_init == {} else self.FROM_NONE
+        self.wait_mode = self.FOR_NONE if self.player_init == {} else self.FOR_ALL
+        self.join_mode = self.INVITED
+        self.ready_mode = self.FORCE  # TODO: asking for confirmation is not implemented
+
+        # this has been added for testing purpose. It is sometimes needed to remove a dependence
+        # while building the synchronization data, for example to replace a call to time.time()
+        # by an arbitrary value. If needed, this attribute would be set to True from the testcase.
+        self.testing = False
+
+        host.trigger.add("MUC user joined", self.user_joined_trigger)
+        host.trigger.add("MUC user left", self.user_left_trigger)
+
+    def _create_or_invite(self, room_jid, other_players, profile):
+        """
+        This is called only when someone explicitly wants to play.
+
+        The game will not be created if one already exists in the room,
+        also its creation could be postponed until all the expected players
+        join the room (in that case it will be created from user_joined_trigger).
+        @param room (wokkel.muc.Room): the room
+        @param other_players (list[jid.JID]): list of the other players JID (bare)
+        """
+        # FIXME: broken !
+        raise NotImplementedError("To be fixed")
+        client = self.host.get_client(profile)
+        user_jid = self.host.get_jid_n_stream(profile)[0]
+        nick = self.host.plugins["XEP-0045"].get_room_nick(client, room_jid)
+        nicks = [nick]
+        if self._game_exists(room_jid):
+            if not self._check_join_auth(room_jid, user_jid, nick):
+                return
+            nicks.extend(self._invite_players(room_jid, other_players, nick, profile))
+            self._update_players(room_jid, nicks, True, profile)
+        else:
+            self._init_game(room_jid, nick)
+            (auth, waiting, missing) = self._check_wait_auth(room_jid, other_players)
+            nicks.extend(waiting)
+            nicks.extend(self._invite_players(room_jid, missing, nick, profile))
+            if auth:
+                self.create_game(room_jid, nicks, profile)
+            else:
+                self._update_players(room_jid, nicks, False, profile)
+
+    def _init_game(self, room_jid, referee_nick):
+        """
+
+        @param room_jid (jid.JID): JID of the room
+        @param referee_nick (unicode): nickname of the referee
+        """
+        # Important: do not add the referee to 'players' yet. For a
+        # <players /> message to be emitted whenever a new player is joining,
+        # it is necessary to not modify 'players' outside of _update_players.
+        referee_jid = jid.JID(room_jid.userhost() + "/" + referee_nick)
+        self.games[room_jid] = {
+            "referee": referee_jid,
+            "players": [],
+            "started": False,
+            "status": {},
+        }
+        self.games[room_jid].update(copy.deepcopy(self.game_init))
+        self.invitations.setdefault(room_jid, [])
+
+    def _game_exists(self, room_jid, started=False):
+        """Return True if a game has been initialized/started.
+        @param started: if False, the game must be initialized to return True,
+        otherwise it must be initialized and started with create_game.
+        @return: True if a game is initialized/started in that room"""
+        return room_jid in self.games and (not started or self.games[room_jid]["started"])
+
+    def _check_join_auth(self, room_jid, user_jid=None, nick="", verbose=False):
+        """Checks if this profile is allowed to join the game.
+
+        The parameter nick is used to check if the user is already
+        a player in that game. When this method is called from
+        user_joined_trigger, nick is also used to check the user
+        identity instead of user_jid_s (see TODO comment below).
+        @param room_jid (jid.JID): the JID of the room hosting the game
+        @param user_jid (jid.JID): JID of the user
+        @param nick (unicode): nick of the user
+        @return: True if this profile can join the game
+        """
+        auth = False
+        if not self._game_exists(room_jid):
+            auth = False
+        elif self.join_mode == self.ALL or self.is_player(room_jid, nick):
+            auth = True
+        elif self.join_mode == self.INVITED:
+            # considering all the batches of invitations
+            for invitations in self.invitations[room_jid]:
+                if user_jid is not None:
+                    if user_jid.userhostJID() in invitations[1]:
+                        auth = True
+                        break
+                else:
+                    # TODO: that's not secure enough but what to do if
+                    # wokkel.muc.User's 'entity' attribute is not set?!
+                    if nick in [invited.user for invited in invitations[1]]:
+                        auth = True
+                        break
+
+        if not auth and (verbose or _DEBUG):
+            log.debug(
+                _("%(user)s not allowed to join the game %(game)s in %(room)s")
+                % {
+                    "user": user_jid.userhost() or nick,
+                    "game": self.name,
+                    "room": room_jid.userhost(),
+                }
+            )
+        return auth
+
+    def _update_players(self, room_jid, nicks, sync, profile):
+        """Update the list of players and signal to the room that some players joined the game.
+        If sync is True, the news players are synchronized with the game data they have missed.
+        Remark: self.games[room_jid]['players'] should not be modified outside this method.
+        @param room_jid (jid.JID): JID of the room
+        @param nicks (list[unicode]): list of players nicks in the room (referee included, in first position)
+        @param sync (bool): set to True to send synchronization data to the new players
+        @param profile (unicode): %(doc_profile)s
+        """
+        if nicks == []:
+            return
+        # this is better than set(nicks).difference(...) as it keeps the order
+        new_nicks = [
+            nick for nick in nicks if nick not in self.games[room_jid]["players"]
+        ]
+        if len(new_nicks) == 0:
+            return
+
+        def setStatus(status):
+            for nick in new_nicks:
+                self.games[room_jid]["status"][nick] = status
+
+        sync = (
+            sync
+            and self._game_exists(room_jid, True)
+            and len(self.games[room_jid]["players"]) > 0
+        )
+        setStatus("desync" if sync else "init")
+        self.games[room_jid]["players"].extend(new_nicks)
+        self._synchronize_room(room_jid, [room_jid], profile)
+        if sync:
+            setStatus("init")
+
+    def _synchronize_room(self, room_jid, recipients, profile):
+        """Communicate the list of players to the whole room or only to some users,
+        also send the synchronization data to the players who recently joined the game.
+        @param room_jid (jid.JID): JID of the room
+        @recipients (list[jid.JID]): list of JIDs, the recipients of the message could be:
+            - room JID
+            - room JID + "/" + user nick
+        @param profile (unicode): %(doc_profile)s
+        """
+        if self._game_exists(room_jid, started=True):
+            element = self._create_start_element(self.games[room_jid]["players"])
+        else:
+            element = self._create_start_element(
+                self.games[room_jid]["players"], name="players"
+            )
+        elements = [(element, None, None)]
+
+        sync_args = []
+        sync_data = self._get_sync_data(room_jid)
+        for nick in sync_data:
+            user_jid = jid.JID(room_jid.userhost() + "/" + nick)
+            if user_jid in recipients:
+                user_elements = copy.deepcopy(elements)
+                for child in sync_data[nick]:
+                    user_elements.append((child, None, None))
+                recipients.remove(user_jid)
+            else:
+                user_elements = [(child, None, None) for child in sync_data[nick]]
+            sync_args.append(([user_jid, user_elements], {"profile": profile}))
+
+        for recipient in recipients:
+            self._send_elements(recipient, elements, profile=profile)
+        for args, kwargs in sync_args:
+            self._send_elements(*args, **kwargs)
+
+    def _get_sync_data(self, room_jid, force_nicks=None):
+        """The synchronization data are returned for each player who
+        has the state 'desync' or if he's been contained by force_nicks.
+        @param room_jid (jid.JID): JID of the room
+        @param force_nicks: force the synchronization for this list of the nicks
+        @return: a mapping between player nicks and a list of elements to
+        be sent by self._synchronize_room for the game to be synchronized.
+        """
+        if not self._game_exists(room_jid):
+            return {}
+        data = {}
+        status = self.games[room_jid]["status"]
+        nicks = [nick for nick in status if status[nick] == "desync"]
+        if force_nicks is None:
+            force_nicks = []
+        for nick in force_nicks:
+            if nick not in nicks:
+                nicks.append(nick)
+        for nick in nicks:
+            elements = self.get_sync_data_for_player(room_jid, nick)
+            if elements:
+                data[nick] = elements
+        return data
+
+    def get_sync_data_for_player(self, room_jid, nick):
+        """This method may (and should probably) be overwritten by a child class.
+        @param room_jid (jid.JID): JID of the room
+        @param nick: the nick of the player to be synchronized
+        @return: a list of elements to synchronize this player with the game.
+        """
+        return []
+
+    def _invite_players(self, room_jid, other_players, nick, profile):
+        """Invite players to a room, associated game may exist or not.
+
+        @param other_players (list[jid.JID]): list of the players to invite
+        @param nick (unicode): nick of the user who send the invitation
+        @return: list[unicode] of room nicks for invited players who are already in the room
+        """
+        raise NotImplementedError("Need to be fixed !")
+        # FIXME: this is broken and unsecure !
+        if not self._check_invite_auth(room_jid, nick):
+            return []
+        # TODO: remove invitation waiting for too long, using the time data
+        self.invitations[room_jid].append(
+            (time(), [player.userhostJID() for player in other_players])
+        )
+        nicks = []
+        for player_jid in [player.userhostJID() for player in other_players]:
+            # TODO: find a way to make it secure
+            other_nick = self.host.plugins["XEP-0045"].getRoomEntityNick(
+                room_jid, player_jid, secure=self.testing
+            )
+            if other_nick is None:
+                self.host.plugins["XEP-0249"].invite(
+                    player_jid, room_jid, {"game": self.name}, profile
+                )
+            else:
+                nicks.append(other_nick)
+        return nicks
+
+    def _check_invite_auth(self, room_jid, nick, verbose=False):
+        """Checks if this user is allowed to invite players
+
+        @param room_jid (jid.JID): JID of the room
+        @param nick: user nick in the room
+        @param verbose: display debug message
+        @return: True if the user is allowed to invite other players
+        """
+        auth = False
+        if self.invite_mode == self.FROM_ALL or not self._game_exists(room_jid):
+            auth = True
+        elif self.invite_mode == self.FROM_NONE:
+            auth = not self._game_exists(room_jid, started=True) and self.is_referee(
+                room_jid, nick
+            )
+        elif self.invite_mode == self.FROM_REFEREE:
+            auth = self.is_referee(room_jid, nick)
+        elif self.invite_mode == self.FROM_PLAYERS:
+            auth = self.is_player(room_jid, nick)
+        if not auth and (verbose or _DEBUG):
+            log.debug(
+                _("%(user)s not allowed to invite for the game %(game)s in %(room)s")
+                % {"user": nick, "game": self.name, "room": room_jid.userhost()}
+            )
+        return auth
+
+    def is_referee(self, room_jid, nick):
+        """Checks if the player with this nick is the referee for the game in this room"
+        @param room_jid (jid.JID): room JID
+        @param nick: user nick in the room
+        @return: True if the user is the referee of the game in this room
+        """
+        if not self._game_exists(room_jid):
+            return False
+        return (
+            jid.JID(room_jid.userhost() + "/" + nick) == self.games[room_jid]["referee"]
+        )
+
+    def is_player(self, room_jid, nick):
+        """Checks if the user with this nick is a player for the game in this room.
+        @param room_jid (jid.JID): JID of the room
+        @param nick: user nick in the room
+        @return: True if the user is a player of the game in this room
+        """
+        if not self._game_exists(room_jid):
+            return False
+        # Important: the referee is not in the 'players' list right after
+        # the game initialization, that's why we do also check with is_referee
+        return nick in self.games[room_jid]["players"] or self.is_referee(room_jid, nick)
+
+    def _check_wait_auth(self, room, other_players, verbose=False):
+        """Check if we must wait for other players before starting the game.
+
+        @param room (wokkel.muc.Room): the room
+        @param other_players (list[jid.JID]): list of the players without the referee
+        @param verbose (bool): display debug message
+        @return: (x, y, z) with:
+            x: False if we must wait, True otherwise
+            y: the nicks of the players that have been checked and confirmed
+            z: the JID of the players that have not been checked or that are missing
+        """
+        if self.wait_mode == self.FOR_NONE or other_players == []:
+            result = (True, [], other_players)
+        elif len(room.roster) < len(other_players):
+            # do not check the players until we may actually have them all
+            result = (False, [], other_players)
+        else:
+            # TODO: find a way to make it secure
+            (nicks, missing) = self.host.plugins["XEP-0045"].getRoomNicksOfUsers(
+                room, other_players, secure=False
+            )
+            result = (len(nicks) == len(other_players), nicks, missing)
+        if not result[0] and (verbose or _DEBUG):
+            log.debug(
+                _(
+                    "Still waiting for %(users)s before starting the game %(game)s in %(room)s"
+                )
+                % {
+                    "users": result[2],
+                    "game": self.name,
+                    "room": room.occupantJID.userhost(),
+                }
+            )
+        return result
+
+    def get_unique_name(self, muc_service=None, profile_key=C.PROF_KEY_NONE):
+        """Generate unique room name
+
+        @param muc_service (jid.JID): you can leave empty to autofind the muc service
+        @param profile_key (unicode): %(doc_profile_key)s
+        @return: jid.JID (unique name for a new room to be created)
+        """
+        client = self.host.get_client(profile_key)
+        # FIXME: jid.JID must be used instead of strings
+        room = self.host.plugins["XEP-0045"].get_unique_name(client, muc_service)
+        return jid.JID("sat_%s_%s" % (self.name.lower(), room.userhost()))
+
+    def _prepare_room(
+        self, other_players=None, room_jid_s="", profile_key=C.PROF_KEY_NONE
+    ):
+        room_jid = jid.JID(room_jid_s) if room_jid_s else None
+        other_players = [jid.JID(player).userhostJID() for player in other_players]
+        return self.prepare_room(other_players, room_jid, profile_key)
+
+    def prepare_room(self, other_players=None, room_jid=None, profile_key=C.PROF_KEY_NONE):
+        """Prepare the room for a game: create it if it doesn't exist and invite players.
+
+        @param other_players (list[JID]): list of other players JID (bare)
+        @param room_jid (jid.JID): JID of the room, or None to generate a unique name
+        @param profile_key (unicode): %(doc_profile_key)s
+        """
+        # FIXME: need to be refactored
+        client = self.host.get_client(profile_key)
+        log.debug(_("Preparing room for %s game") % self.name)
+        profile = self.host.memory.get_profile_name(profile_key)
+        if not profile:
+            log.error(_("Unknown profile"))
+            return defer.succeed(None)
+        if other_players is None:
+            other_players = []
+
+        # Create/join the given room, or a unique generated one if no room is specified.
+        if room_jid is None:
+            room_jid = self.get_unique_name(profile_key=profile_key)
+        else:
+            self.host.plugins["XEP-0045"].check_room_joined(client, room_jid)
+            self._create_or_invite(client, room_jid, other_players)
+            return defer.succeed(None)
+
+        user_jid = self.host.get_jid_n_stream(profile)[0]
+        d = self.host.plugins["XEP-0045"].join(room_jid, user_jid.user, {}, profile)
+        return d.addCallback(
+            lambda __: self._create_or_invite(client, room_jid, other_players)
+        )
+
+    def user_joined_trigger(self, room, user, profile):
+        """This trigger is used to check if the new user can take part of a game, create the game if we were waiting for him or just update the players list.
+
+        @room: wokkel.muc.Room object. room.roster is a dict{wokkel.muc.User.nick: wokkel.muc.User}
+        @user: wokkel.muc.User object. user.nick is a unicode and user.entity a JID
+        @return: True to not interrupt the main process.
+        """
+        room_jid = room.occupantJID.userhostJID()
+        profile_nick = room.occupantJID.resource
+        if not self.is_referee(room_jid, profile_nick):
+            return True  # profile is not the referee
+        if not self._check_join_auth(
+            room_jid, user.entity if user.entity else None, user.nick
+        ):
+            # user not allowed but let him know that we are playing :p
+            self._synchronize_room(
+                room_jid, [jid.JID(room_jid.userhost() + "/" + user.nick)], profile
+            )
+            return True
+        if self.wait_mode == self.FOR_ALL:
+            # considering the last batch of invitations
+            batch = len(self.invitations[room_jid]) - 1
+            if batch < 0:
+                log.error(
+                    "Invitations from %s to play %s in %s have been lost!"
+                    % (profile_nick, self.name, room_jid.userhost())
+                )
+                return True
+            other_players = self.invitations[room_jid][batch][1]
+            (auth, nicks, __) = self._check_wait_auth(room, other_players)
+            if auth:
+                del self.invitations[room_jid][batch]
+                nicks.insert(0, profile_nick)  # add the referee
+                self.create_game(room_jid, nicks, profile_key=profile)
+                return True
+        # let the room know that a new player joined
+        self._update_players(room_jid, [user.nick], True, profile)
+        return True
+
+    def user_left_trigger(self, room, user, profile):
+        """This trigger is used to update or stop the game when a user leaves.
+
+        @room: wokkel.muc.Room object. room.roster is a dict{wokkel.muc.User.nick: wokkel.muc.User}
+        @user: wokkel.muc.User object. user.nick is a unicode and user.entity a JID
+        @return: True to not interrupt the main process.
+        """
+        room_jid = room.occupantJID.userhostJID()
+        profile_nick = room.occupantJID.resource
+        if not self.is_referee(room_jid, profile_nick):
+            return True  # profile is not the referee
+        if self.is_player(room_jid, user.nick):
+            try:
+                self.games[room_jid]["players"].remove(user.nick)
+            except ValueError:
+                pass
+            if len(self.games[room_jid]["players"]) == 0:
+                return True
+            if self.wait_mode == self.FOR_ALL:
+                # allow this user to join the game again
+                user_jid = user.entity.userhostJID()
+                if len(self.invitations[room_jid]) == 0:
+                    self.invitations[room_jid].append((time(), [user_jid]))
+                else:
+                    batch = 0  # add to the first batch of invitations
+                    if user_jid not in self.invitations[room_jid][batch][1]:
+                        self.invitations[room_jid][batch][1].append(user_jid)
+        return True
+
+    def _check_create_game_and_init(self, room_jid, profile):
+        """Check if that profile can create the game. If the game can be created
+        but is not initialized yet, this method will also do the initialization.
+
+        @param room_jid (jid.JID): JID of the room
+        @param profile
+        @return: a couple (create, sync) with:
+                - create: set to True to allow the game creation
+                - sync: set to True to advice a game synchronization
+        """
+        user_nick = self.host.plugins["XEP-0045"].get_room_nick(room_jid, profile)
+        if not user_nick:
+            log.error(
+                "Internal error: profile %s has not joined the room %s"
+                % (profile, room_jid.userhost())
+            )
+            return False, False
+        if self._game_exists(room_jid):
+            is_referee = self.is_referee(room_jid, user_nick)
+            if self._game_exists(room_jid, started=True):
+                log.info(
+                    _("%(game)s game already created in room %(room)s")
+                    % {"game": self.name, "room": room_jid.userhost()}
+                )
+                return False, is_referee
+            elif not is_referee:
+                log.info(
+                    _("%(game)s game in room %(room)s can only be created by %(user)s")
+                    % {"game": self.name, "room": room_jid.userhost(), "user": user_nick}
+                )
+                return False, False
+        else:
+            self._init_game(room_jid, user_nick)
+        return True, False
+
+    def _create_game(self, room_jid_s, nicks=None, profile_key=C.PROF_KEY_NONE):
+        self.create_game(jid.JID(room_jid_s), nicks, profile_key)
+
+    def create_game(self, room_jid, nicks=None, profile_key=C.PROF_KEY_NONE):
+        """Create a new game.
+
+        This can be called directly from a frontend and skips all the checks and invitation system,
+        but the game must not exist and all the players must be in the room already.
+        @param room_jid (jid.JID): JID of the room
+        @param nicks (list[unicode]): list of players nicks in the room (referee included, in first position)
+        @param profile_key (unicode): %(doc_profile_key)s
+        """
+        log.debug(
+            _("Creating %(game)s game in room %(room)s")
+            % {"game": self.name, "room": room_jid}
+        )
+        profile = self.host.memory.get_profile_name(profile_key)
+        if not profile:
+            log.error(_("profile %s is unknown") % profile_key)
+            return
+        (create, sync) = self._check_create_game_and_init(room_jid, profile)
+        if nicks is None:
+            nicks = []
+        if not create:
+            if sync:
+                self._update_players(room_jid, nicks, True, profile)
+            return
+        self.games[room_jid]["started"] = True
+        self._update_players(room_jid, nicks, False, profile)
+        if self.player_init:
+            # specific data to each player (score, private data)
+            self.games[room_jid].setdefault("players_data", {})
+            for nick in nicks:
+                # The dict must be COPIED otherwise it is shared between all users
+                self.games[room_jid]["players_data"][nick] = copy.deepcopy(
+                    self.player_init
+                )
+
+    def _player_ready(self, player_nick, referee_jid_s, profile_key=C.PROF_KEY_NONE):
+        self.player_ready(player_nick, jid.JID(referee_jid_s), profile_key)
+
+    def player_ready(self, player_nick, referee_jid, profile_key=C.PROF_KEY_NONE):
+        """Must be called when player is ready to start a new game
+
+        @param player: the player nick in the room
+        @param referee_jid (jid.JID): JID of the referee
+        """
+        profile = self.host.memory.get_profile_name(profile_key)
+        if not profile:
+            log.error(_("profile %s is unknown") % profile_key)
+            return
+        log.debug("new player ready: %s" % profile)
+        # TODO: we probably need to add the game and room names in the sent message
+        self.send(referee_jid, "player_ready", {"player": player_nick}, profile=profile)
+
+    def new_round(self, room_jid, data, profile):
+        """Launch a new round (reinit the user data)
+
+        @param room_jid: room userhost
+        @param data: a couple (common_data, msg_elts) with:
+                    - common_data: backend initialization data for the new round
+                    - msg_elts: dict to map each user to his specific initialization message
+        @param profile
+        """
+        log.debug(_("new round for %s game") % self.name)
+        game_data = self.games[room_jid]
+        players = game_data["players"]
+        players_data = game_data["players_data"]
+        game_data["stage"] = "init"
+
+        common_data, msg_elts = copy.deepcopy(data) if data is not None else (None, None)
+
+        if isinstance(msg_elts, dict):
+            for player in players:
+                to_jid = jid.JID(room_jid.userhost() + "/" + player)  # FIXME: gof:
+                elem = (
+                    msg_elts[player]
+                    if isinstance(msg_elts[player], domish.Element)
+                    else None
+                )
+                self.send(to_jid, elem, profile=profile)
+        elif isinstance(msg_elts, domish.Element):
+            self.send(room_jid, msg_elts, profile=profile)
+        if common_data is not None:
+            for player in players:
+                players_data[player].update(copy.deepcopy(common_data))
+
+    def _create_game_elt(self, to_jid):
+        """Create a generic domish Element for the game messages
+
+        @param to_jid: JID of the recipient
+        @return: the created element
+        """
+        type_ = "normal" if to_jid.resource else "groupchat"
+        elt = domish.Element((None, "message"))
+        elt["to"] = to_jid.full()
+        elt["type"] = type_
+        elt.addElement(self.ns_tag)
+        return elt
+
+    def _create_start_element(self, players=None, name="started"):
+        """Create a domish Element listing the game users
+
+        @param players: list of the players
+        @param name: element name:
+                    - "started" to signal the players that the game has been started
+                    - "players" to signal the list of players when the game is not started yet
+        @return the create element
+        """
+        started_elt = domish.Element((None, name))
+        if players is None:
+            return started_elt
+        idx = 0
+        for player in players:
+            player_elt = domish.Element((None, "player"))
+            player_elt.addContent(player)
+            player_elt["index"] = str(idx)
+            idx += 1
+            started_elt.addChild(player_elt)
+        return started_elt
+
+    def _send_elements(self, to_jid, data, profile=None):
+        """ TODO
+
+        @param to_jid: recipient JID
+        @param data: list of (elem, attr, content) with:
+                    - elem: domish.Element, unicode or a couple:
+                            - domish.Element to be directly added as a child to the message
+                            - unicode name or couple (uri, name) to create a new domish.Element
+                              and add it as a child to the message (see domish.Element.addElement)
+                    - attrs: dictionary of attributes for the new child
+                    - content: unicode that is appended to the child content
+        @param profile: the profile from which the message is sent
+        @return: a Deferred instance
+        """
+        client = self.host.get_client(profile)
+        msg = self._create_game_elt(to_jid)
+        for elem, attrs, content in data:
+            if elem is not None:
+                if isinstance(elem, domish.Element):
+                    msg.firstChildElement().addChild(elem)
+                else:
+                    elem = msg.firstChildElement().addElement(elem)
+                if attrs is not None:
+                    elem.attributes.update(attrs)
+                if content is not None:
+                    elem.addContent(content)
+        client.send(msg)
+        return defer.succeed(None)
+
+    def send(self, to_jid, elem=None, attrs=None, content=None, profile=None):
+        """ TODO
+
+        @param to_jid: recipient JID
+        @param elem: domish.Element, unicode or a couple:
+                    - domish.Element to be directly added as a child to the message
+                    - unicode name or couple (uri, name) to create a new domish.Element
+                      and add it as a child to the message (see domish.Element.addElement)
+        @param attrs: dictionary of attributes for the new child
+        @param content: unicode that is appended to the child content
+        @param profile: the profile from which the message is sent
+        @return: a Deferred instance
+        """
+        return self._send_elements(to_jid, [(elem, attrs, content)], profile)
+
+    def get_handler(self, client):
+        return RoomGameHandler(self)
+
+
+@implementer(iwokkel.IDisco)
+class RoomGameHandler(XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            self.plugin_parent.request,
+            self.plugin_parent.room_game_cmd,
+            profile=self.parent.profile,
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(self.plugin_parent.ns_tag[0])]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_static_blog.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,108 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for static blogs
+# Copyright (C) 2014 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.tools import xml_tools
+
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Static Blog Plugin",
+    C.PI_IMPORT_NAME: "STATIC-BLOG",
+    C.PI_TYPE: "MISC",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [],
+    C.PI_RECOMMENDATIONS: [
+        "MISC-ACCOUNT"
+    ],  # TODO: remove when all blogs can be retrieved
+    C.PI_MAIN: "StaticBlog",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Plugin for static blogs"""),
+}
+
+
+class StaticBlog(object):
+
+    params = """
+    <params>
+    <individual>
+    <category name="{category_name}" label="{category_label}">
+        <param name="{title_name}" label="{title_label}" value="" type="string" security="0"/>
+        <param name="{banner_name}" label="{banner_label}" value="" type="string" security="0"/>
+        <param name="{background_name}" label="{background_label}" value ="" type="string" security="0"/>
+        <param name="{keywords_name}" label="{keywords_label}" value="" type="string" security="0"/>
+        <param name="{description_name}" label="{description_label}" value="" type="string" security="0"/>
+     </category>
+    </individual>
+    </params>
+    """.format(
+        category_name=C.STATIC_BLOG_KEY,
+        category_label=D_(C.STATIC_BLOG_KEY),
+        title_name=C.STATIC_BLOG_PARAM_TITLE,
+        title_label=D_("Page title"),
+        banner_name=C.STATIC_BLOG_PARAM_BANNER,
+        banner_label=D_("Banner URL"),
+        background_name="Background",
+        background_label=D_("Background image URL"),
+        keywords_name=C.STATIC_BLOG_PARAM_KEYWORDS,
+        keywords_label=D_("Keywords"),
+        description_name=C.STATIC_BLOG_PARAM_DESCRIPTION,
+        description_label=D_("Description"),
+    )
+
+    def __init__(self, host):
+        try:  # TODO: remove this attribute when all blogs can be retrieved
+            self.domain = host.plugins["MISC-ACCOUNT"].account_domain_new_get()
+        except KeyError:
+            self.domain = None
+        host.memory.update_params(self.params)
+        # host.import_menu((D_("User"), D_("Public blog")), self._display_public_blog, security_limit=1, help_string=D_("Display public blog page"), type_=C.MENU_JID_CONTEXT)
+
+    def _display_public_blog(self, menu_data, profile):
+        """Check if the blog can be displayed and answer the frontend.
+
+        @param menu_data: %(menu_data)s
+        @param profile: %(doc_profile)s
+        @return: dict
+        """
+        # FIXME: "public_blog" key has been removed
+        # TODO: replace this with a more generic widget call with URIs
+        try:
+            user_jid = jid.JID(menu_data["jid"])
+        except KeyError:
+            log.error(_("jid key is not present !"))
+            return defer.fail(exceptions.DataError)
+
+        # TODO: remove this check when all blogs can be retrieved
+        if self.domain and user_jid.host != self.domain:
+            info_ui = xml_tools.XMLUI("popup", title=D_("Not available"))
+            info_ui.addText(
+                D_("Retrieving a blog from an external domain is not implemented yet.")
+            )
+            return {"xmlui": info_ui.toXml()}
+
+        return {"public_blog": user_jid.userhost()}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_tarot.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,901 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing French Tarot game
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber import jid
+from twisted.internet import defer
+from wokkel import data_form
+
+from libervia.backend.memory import memory
+from libervia.backend.tools import xml_tools
+from sat_frontends.tools.games import TarotCard
+import random
+
+
+NS_CG = "http://www.goffi.org/protocol/card_game"
+CG_TAG = "card_game"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Tarot cards plugin",
+    C.PI_IMPORT_NAME: "Tarot",
+    C.PI_TYPE: "Misc",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249", "ROOM-GAME"],
+    C.PI_MAIN: "Tarot",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Tarot card game"""),
+}
+
+
+class Tarot(object):
+    def inherit_from_room_game(self, host):
+        global RoomGame
+        RoomGame = host.plugins["ROOM-GAME"].__class__
+        self.__class__ = type(
+            self.__class__.__name__, (self.__class__, RoomGame, object), {}
+        )
+
+    def __init__(self, host):
+        log.info(_("Plugin Tarot initialization"))
+        self._sessions = memory.Sessions()
+        self.inherit_from_room_game(host)
+        RoomGame._init_(
+            self,
+            host,
+            PLUGIN_INFO,
+            (NS_CG, CG_TAG),
+            game_init={
+                "hand_size": 18,
+                "init_player": 0,
+                "current_player": None,
+                "contrat": None,
+                "stage": None,
+            },
+            player_init={"score": 0},
+        )
+        self.contrats = [
+            _("Passe"),
+            _("Petite"),
+            _("Garde"),
+            _("Garde Sans"),
+            _("Garde Contre"),
+        ]
+        host.bridge.add_method(
+            "tarot_game_launch",
+            ".plugin",
+            in_sign="asss",
+            out_sign="",
+            method=self._prepare_room,
+            async_=True,
+        )  # args: players, room_jid, profile
+        host.bridge.add_method(
+            "tarot_game_create",
+            ".plugin",
+            in_sign="sass",
+            out_sign="",
+            method=self._create_game,
+        )  # args: room_jid, players, profile
+        host.bridge.add_method(
+            "tarot_game_ready",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._player_ready,
+        )  # args: player, referee, profile
+        host.bridge.add_method(
+            "tarot_game_play_cards",
+            ".plugin",
+            in_sign="ssa(ss)s",
+            out_sign="",
+            method=self.play_cards,
+        )  # args: player, referee, cards, profile
+        host.bridge.add_signal(
+            "tarot_game_players", ".plugin", signature="ssass"
+        )  # args: room_jid, referee, players, profile
+        host.bridge.add_signal(
+            "tarot_game_started", ".plugin", signature="ssass"
+        )  # args: room_jid, referee, players, profile
+        host.bridge.add_signal(
+            "tarot_game_new", ".plugin", signature="sa(ss)s"
+        )  # args: room_jid, hand, profile
+        host.bridge.add_signal(
+            "tarot_game_choose_contrat", ".plugin", signature="sss"
+        )  # args: room_jid, xml_data, profile
+        host.bridge.add_signal(
+            "tarot_game_show_cards", ".plugin", signature="ssa(ss)a{ss}s"
+        )  # args: room_jid, type ["chien", "poignée",...], cards, data[dict], profile
+        host.bridge.add_signal(
+            "tarot_game_cards_played", ".plugin", signature="ssa(ss)s"
+        )  # args: room_jid, player, type ["chien", "poignée",...], cards, data[dict], profile
+        host.bridge.add_signal(
+            "tarot_game_your_turn", ".plugin", signature="ss"
+        )  # args: room_jid, profile
+        host.bridge.add_signal(
+            "tarot_game_score", ".plugin", signature="ssasass"
+        )  # args: room_jid, xml_data, winners (list of nicks), loosers (list of nicks), profile
+        host.bridge.add_signal(
+            "tarot_game_invalid_cards", ".plugin", signature="ssa(ss)a(ss)s"
+        )  # args: room_jid, game phase, played_cards, invalid_cards, profile
+        self.deck_ordered = []
+        for value in ["excuse"] + list(map(str, list(range(1, 22)))):
+            self.deck_ordered.append(TarotCard(("atout", value)))
+        for suit in ["pique", "coeur", "carreau", "trefle"]:
+            for value in list(map(str, list(range(1, 11)))) + ["valet", "cavalier", "dame", "roi"]:
+                self.deck_ordered.append(TarotCard((suit, value)))
+        self.__choose_contrat_id = host.register_callback(
+            self._contrat_choosed, with_data=True
+        )
+        self.__score_id = host.register_callback(self._score_showed, with_data=True)
+
+    def __card_list_to_xml(self, cards_list, elt_name):
+        """Convert a card list to domish element"""
+        cards_list_elt = domish.Element((None, elt_name))
+        for card in cards_list:
+            card_elt = domish.Element((None, "card"))
+            card_elt["suit"] = card.suit
+            card_elt["value"] = card.value
+            cards_list_elt.addChild(card_elt)
+        return cards_list_elt
+
+    def __xml_to_list(self, cards_list_elt):
+        """Convert a domish element with cards to a list of tuples"""
+        cards_list = []
+        for card in cards_list_elt.elements():
+            cards_list.append((card["suit"], card["value"]))
+        return cards_list
+
+    def __ask_contrat(self):
+        """Create a element for asking contrat"""
+        contrat_elt = domish.Element((None, "contrat"))
+        form = data_form.Form("form", title=_("contrat selection"))
+        field = data_form.Field(
+            "list-single",
+            "contrat",
+            options=list(map(data_form.Option, self.contrats)),
+            required=True,
+        )
+        form.addField(field)
+        contrat_elt.addChild(form.toElement())
+        return contrat_elt
+
+    def __give_scores(self, scores, winners, loosers):
+        """Create an element to give scores
+        @param scores: unicode (can contain line feed)
+        @param winners: list of unicode nicks of winners
+        @param loosers: list of unicode nicks of loosers"""
+
+        score_elt = domish.Element((None, "score"))
+        form = data_form.Form("form", title=_("scores"))
+        for line in scores.split("\n"):
+            field = data_form.Field("fixed", value=line)
+            form.addField(field)
+        score_elt.addChild(form.toElement())
+        for winner in winners:
+            winner_elt = domish.Element((None, "winner"))
+            winner_elt.addContent(winner)
+            score_elt.addChild(winner_elt)
+        for looser in loosers:
+            looser_elt = domish.Element((None, "looser"))
+            looser_elt.addContent(looser)
+            score_elt.addChild(looser_elt)
+        return score_elt
+
+    def __invalid_cards_elt(self, played_cards, invalid_cards, game_phase):
+        """Create a element for invalid_cards error
+        @param list_cards: list of Card
+        @param game_phase: phase of the game ['ecart', 'play']"""
+        error_elt = domish.Element((None, "error"))
+        played_elt = self.__card_list_to_xml(played_cards, "played")
+        invalid_elt = self.__card_list_to_xml(invalid_cards, "invalid")
+        error_elt["type"] = "invalid_cards"
+        error_elt["phase"] = game_phase
+        error_elt.addChild(played_elt)
+        error_elt.addChild(invalid_elt)
+        return error_elt
+
+    def __next_player(self, game_data, next_pl=None):
+        """Increment player number & return player name
+        @param next_pl: if given, then next_player is forced to this one
+        """
+        if next_pl:
+            game_data["current_player"] = game_data["players"].index(next_pl)
+            return next_pl
+        else:
+            pl_idx = game_data["current_player"] = (
+                game_data["current_player"] + 1
+            ) % len(game_data["players"])
+            return game_data["players"][pl_idx]
+
+    def __winner(self, game_data):
+        """give the nick of the player who win this trick"""
+        players_data = game_data["players_data"]
+        first = game_data["first_player"]
+        first_idx = game_data["players"].index(first)
+        suit_asked = None
+        strongest = None
+        winner = None
+        for idx in [(first_idx + i) % 4 for i in range(4)]:
+            player = game_data["players"][idx]
+            card = players_data[player]["played"]
+            if card.value == "excuse":
+                continue
+            if suit_asked is None:
+                suit_asked = card.suit
+            if (card.suit == suit_asked or card.suit == "atout") and card > strongest:
+                strongest = card
+                winner = player
+        assert winner
+        return winner
+
+    def __excuse_hack(self, game_data, played, winner):
+        """give a low card to other team and keep excuse if trick is lost
+        @param game_data: data of the game
+        @param played: cards currently on the table
+        @param winner: nick of the trick winner"""
+        # TODO: manage the case where excuse is played on the last trick (and lost)
+        players_data = game_data["players_data"]
+        excuse = TarotCard(("atout", "excuse"))
+
+        # we first check if the Excuse was already played
+        # and if somebody is waiting for a card
+        for player in game_data["players"]:
+            if players_data[player]["wait_for_low"]:
+                # the excuse owner has to give a card to somebody
+                if winner == player:
+                    # the excuse owner win the trick, we check if we have something to give
+                    for card in played:
+                        if card.points == 0.5:
+                            pl_waiting = players_data[player]["wait_for_low"]
+                            played.remove(card)
+                            players_data[pl_waiting]["levees"].append(card)
+                            log.debug(
+                                _(
+                                    "Player %(excuse_owner)s give %(card_waited)s to %(player_waiting)s for Excuse compensation"
+                                )
+                                % {
+                                    "excuse_owner": player,
+                                    "card_waited": card,
+                                    "player_waiting": pl_waiting,
+                                }
+                            )
+                            return
+                return
+
+        if excuse not in played:
+            # the Excuse is not on the table, nothing to do
+            return
+
+        excuse_player = None  # Who has played the Excuse ?
+        for player in game_data["players"]:
+            if players_data[player]["played"] == excuse:
+                excuse_player = player
+                break
+
+        if excuse_player == winner:
+            return  # the excuse player win the trick, nothing to do
+
+        # first we remove the excuse from played cards
+        played.remove(excuse)
+        # then we give it back to the original owner
+        owner_levees = players_data[excuse_player]["levees"]
+        owner_levees.append(excuse)
+        # finally we give a low card to the trick winner
+        low_card = None
+        # We look backward in cards won by the Excuse owner to
+        # find a low value card
+        for card_idx in range(len(owner_levees) - 1, -1, -1):
+            if owner_levees[card_idx].points == 0.5:
+                low_card = owner_levees[card_idx]
+                del owner_levees[card_idx]
+                players_data[winner]["levees"].append(low_card)
+                log.debug(
+                    _(
+                        "Player %(excuse_owner)s give %(card_waited)s to %(player_waiting)s for Excuse compensation"
+                    )
+                    % {
+                        "excuse_owner": excuse_player,
+                        "card_waited": low_card,
+                        "player_waiting": winner,
+                    }
+                )
+                break
+        if not low_card:  # The player has no low card yet
+            # TODO: manage case when player never win a trick with low card
+            players_data[excuse_player]["wait_for_low"] = winner
+            log.debug(
+                _(
+                    "%(excuse_owner)s keep the Excuse but has not card to give, %(winner)s is waiting for one"
+                )
+                % {"excuse_owner": excuse_player, "winner": winner}
+            )
+
+    def __draw_game(self, game_data):
+        """The game is draw, no score change
+        @param game_data: data of the game
+        @return: tuple with (string victory message, list of winners, list of loosers)"""
+        players_data = game_data["players_data"]
+        scores_str = _("Draw game")
+        scores_str += "\n"
+        for player in game_data["players"]:
+            scores_str += _(
+                "\n--\n%(player)s:\nscore for this game ==> %(score_game)i\ntotal score ==> %(total_score)i"
+            ) % {
+                "player": player,
+                "score_game": 0,
+                "total_score": players_data[player]["score"],
+            }
+        log.debug(scores_str)
+
+        return (scores_str, [], [])
+
+    def __calculate_scores(self, game_data):
+        """The game is finished, time to know who won :)
+        @param game_data: data of the game
+        @return: tuple with (string victory message, list of winners, list of loosers)"""
+        players_data = game_data["players_data"]
+        levees = players_data[game_data["attaquant"]]["levees"]
+        score = 0
+        nb_bouts = 0
+        bouts = []
+        for card in levees:
+            if card.bout:
+                nb_bouts += 1
+                bouts.append(card.value)
+            score += card.points
+
+        # We do a basic check on score calculation
+        check_score = 0
+        defenseurs = game_data["players"][:]
+        defenseurs.remove(game_data["attaquant"])
+        for defenseur in defenseurs:
+            for card in players_data[defenseur]["levees"]:
+                check_score += card.points
+        if game_data["contrat"] == "Garde Contre":
+            for card in game_data["chien"]:
+                check_score += card.points
+        assert score + check_score == 91
+
+        point_limit = None
+        if nb_bouts == 3:
+            point_limit = 36
+        elif nb_bouts == 2:
+            point_limit = 41
+        elif nb_bouts == 1:
+            point_limit = 51
+        else:
+            point_limit = 56
+        if game_data["contrat"] == "Petite":
+            contrat_mult = 1
+        elif game_data["contrat"] == "Garde":
+            contrat_mult = 2
+        elif game_data["contrat"] == "Garde Sans":
+            contrat_mult = 4
+        elif game_data["contrat"] == "Garde Contre":
+            contrat_mult = 6
+        else:
+            log.error(_("INTERNAL ERROR: contrat not managed (mispelled ?)"))
+            assert False
+
+        victory = score >= point_limit
+        margin = abs(score - point_limit)
+        points_defenseur = (margin + 25) * contrat_mult * (-1 if victory else 1)
+        winners = []
+        loosers = []
+        player_score = {}
+        for player in game_data["players"]:
+            # TODO: adjust this for 3 and 5 players variants
+            # TODO: manage bonuses (petit au bout, poignée, chelem)
+            player_score[player] = (
+                points_defenseur
+                if player != game_data["attaquant"]
+                else points_defenseur * -3
+            )
+            players_data[player]["score"] += player_score[
+                player
+            ]  # we add score of this game to the global score
+            if player_score[player] > 0:
+                winners.append(player)
+            else:
+                loosers.append(player)
+
+        scores_str = _(
+            "The attacker (%(attaquant)s) makes %(points)i and needs to make %(point_limit)i (%(nb_bouts)s oulder%(plural)s%(separator)s%(bouts)s): (s)he %(victory)s"
+        ) % {
+            "attaquant": game_data["attaquant"],
+            "points": score,
+            "point_limit": point_limit,
+            "nb_bouts": nb_bouts,
+            "plural": "s" if nb_bouts > 1 else "",
+            "separator": ": " if nb_bouts != 0 else "",
+            "bouts": ",".join(map(str, bouts)),
+            "victory": "wins" if victory else "looses",
+        }
+        scores_str += "\n"
+        for player in game_data["players"]:
+            scores_str += _(
+                "\n--\n%(player)s:\nscore for this game ==> %(score_game)i\ntotal score ==> %(total_score)i"
+            ) % {
+                "player": player,
+                "score_game": player_score[player],
+                "total_score": players_data[player]["score"],
+            }
+        log.debug(scores_str)
+
+        return (scores_str, winners, loosers)
+
+    def __invalid_cards(self, game_data, cards):
+        """Checks that the player has the right to play what he wants to
+        @param game_data: Game data
+        @param cards: cards the player want to play
+        @return forbidden_cards cards or empty list if cards are ok"""
+        forbidden_cards = []
+        if game_data["stage"] == "ecart":
+            for card in cards:
+                if card.bout or card.value == "roi":
+                    forbidden_cards.append(card)
+                # TODO: manage case where atouts (trumps) are in the dog
+        elif game_data["stage"] == "play":
+            biggest_atout = None
+            suit_asked = None
+            players = game_data["players"]
+            players_data = game_data["players_data"]
+            idx = players.index(game_data["first_player"])
+            current_idx = game_data["current_player"]
+            current_player = players[current_idx]
+            if idx == current_idx:
+                # the player is the first to play, he can play what he wants
+                return forbidden_cards
+            while idx != current_idx:
+                player = players[idx]
+                played_card = players_data[player]["played"]
+                if not suit_asked and played_card.value != "excuse":
+                    suit_asked = played_card.suit
+                if played_card.suit == "atout" and played_card > biggest_atout:
+                    biggest_atout = played_card
+                idx = (idx + 1) % len(players)
+            has_suit = (
+                False
+            )  # True if there is one card of the asked suit in the hand of the player
+            has_atout = False
+            biggest_hand_atout = None
+
+            for hand_card in game_data["hand"][current_player]:
+                if hand_card.suit == suit_asked:
+                    has_suit = True
+                if hand_card.suit == "atout":
+                    has_atout = True
+                if hand_card.suit == "atout" and hand_card > biggest_hand_atout:
+                    biggest_hand_atout = hand_card
+
+            assert len(cards) == 1
+            card = cards[0]
+            if card.suit != suit_asked and has_suit and card.value != "excuse":
+                forbidden_cards.append(card)
+                return forbidden_cards
+            if card.suit != suit_asked and card.suit != "atout" and has_atout:
+                forbidden_cards.append(card)
+                return forbidden_cards
+            if (
+                card.suit == "atout"
+                and card < biggest_atout
+                and biggest_hand_atout > biggest_atout
+                and card.value != "excuse"
+            ):
+                forbidden_cards.append(card)
+        else:
+            log.error(_("Internal error: unmanaged game stage"))
+        return forbidden_cards
+
+    def __start_play(self, room_jid, game_data, profile):
+        """Start the game (tell to the first player after dealer to play"""
+        game_data["stage"] = "play"
+        next_player_idx = game_data["current_player"] = (
+            game_data["init_player"] + 1
+        ) % len(
+            game_data["players"]
+        )  # the player after the dealer start
+        game_data["first_player"] = next_player = game_data["players"][next_player_idx]
+        to_jid = jid.JID(room_jid.userhost() + "/" + next_player)  # FIXME: gof:
+        self.send(to_jid, "your_turn", profile=profile)
+
+    def _contrat_choosed(self, raw_data, profile):
+        """Will be called when the contrat is selected
+        @param raw_data: contains the choosed session id and the chosen contrat
+        @param profile_key: profile
+        """
+        try:
+            session_data = self._sessions.profile_get(raw_data["session_id"], profile)
+        except KeyError:
+            log.warning(_("session id doesn't exist, session has probably expired"))
+            # TODO: send error dialog
+            return defer.succeed({})
+
+        room_jid = session_data["room_jid"]
+        referee_jid = self.games[room_jid]["referee"]
+        player = self.host.plugins["XEP-0045"].get_room_nick(room_jid, profile)
+        data = xml_tools.xmlui_result_2_data_form_result(raw_data)
+        contrat = data["contrat"]
+        log.debug(
+            _("contrat [%(contrat)s] choosed by %(profile)s")
+            % {"contrat": contrat, "profile": profile}
+        )
+        d = self.send(
+            referee_jid,
+            ("", "contrat_choosed"),
+            {"player": player},
+            content=contrat,
+            profile=profile,
+        )
+        d.addCallback(lambda ignore: {})
+        del self._sessions[raw_data["session_id"]]
+        return d
+
+    def _score_showed(self, raw_data, profile):
+        """Will be called when the player closes the score dialog
+        @param raw_data: nothing to retrieve from here but the session id
+        @param profile_key: profile
+        """
+        try:
+            session_data = self._sessions.profile_get(raw_data["session_id"], profile)
+        except KeyError:
+            log.warning(_("session id doesn't exist, session has probably expired"))
+            # TODO: send error dialog
+            return defer.succeed({})
+
+        room_jid_s = session_data["room_jid"].userhost()
+        # XXX: empty hand means to the frontend "reset the display"...
+        self.host.bridge.tarot_game_new(room_jid_s, [], profile)
+        del self._sessions[raw_data["session_id"]]
+        return defer.succeed({})
+
+    def play_cards(self, player, referee, cards, profile_key=C.PROF_KEY_NONE):
+        """Must be call by player when the contrat is selected
+        @param player: player's name
+        @param referee: arbiter jid
+        @cards: cards played (list of tuples)
+        @profile_key: profile
+        """
+        profile = self.host.memory.get_profile_name(profile_key)
+        if not profile:
+            log.error(_("profile %s is unknown") % profile_key)
+            return
+        log.debug(
+            _("Cards played by %(profile)s: [%(cards)s]")
+            % {"profile": profile, "cards": cards}
+        )
+        elem = self.__card_list_to_xml(TarotCard.from_tuples(cards), "cards_played")
+        self.send(jid.JID(referee), elem, {"player": player}, profile=profile)
+
+    def new_round(self, room_jid, profile):
+        game_data = self.games[room_jid]
+        players = game_data["players"]
+        game_data["first_player"] = None  # first player for the current trick
+        game_data["contrat"] = None
+        common_data = {
+            "contrat": None,
+            "levees": [],  # cards won
+            "played": None,  # card on the table
+            "wait_for_low": None,  # Used when a player wait for a low card because of excuse
+        }
+
+        hand = game_data["hand"] = {}
+        hand_size = game_data["hand_size"]
+        chien = game_data["chien"] = []
+        deck = self.deck_ordered[:]
+        random.shuffle(deck)
+        for i in range(4):
+            hand[players[i]] = deck[0:hand_size]
+            del deck[0:hand_size]
+        chien.extend(deck)
+        del (deck[:])
+        msg_elts = {}
+        for player in players:
+            msg_elts[player] = self.__card_list_to_xml(hand[player], "hand")
+
+        RoomGame.new_round(self, room_jid, (common_data, msg_elts), profile)
+
+        pl_idx = game_data["current_player"] = (game_data["init_player"] + 1) % len(
+            players
+        )  # the player after the dealer start
+        player = players[pl_idx]
+        to_jid = jid.JID(room_jid.userhost() + "/" + player)  # FIXME: gof:
+        self.send(to_jid, self.__ask_contrat(), profile=profile)
+
+    def room_game_cmd(self, mess_elt, profile):
+        """
+        @param mess_elt: instance of twisted.words.xish.domish.Element
+        """
+        client = self.host.get_client(profile)
+        from_jid = jid.JID(mess_elt["from"])
+        room_jid = jid.JID(from_jid.userhost())
+        nick = self.host.plugins["XEP-0045"].get_room_nick(client, room_jid)
+
+        game_elt = mess_elt.firstChildElement()
+        game_data = self.games[room_jid]
+        is_player = self.is_player(room_jid, nick)
+        if "players_data" in game_data:
+            players_data = game_data["players_data"]
+
+        for elt in game_elt.elements():
+            if not is_player and (elt.name not in ("started", "players")):
+                continue  # user is in the room but not playing
+
+            if elt.name in (
+                "started",
+                "players",
+            ):  # new game created and/or players list updated
+                players = []
+                for player in elt.elements():
+                    players.append(str(player))
+                signal = (
+                    self.host.bridge.tarot_game_started
+                    if elt.name == "started"
+                    else self.host.bridge.tarot_game_players
+                )
+                signal(room_jid.userhost(), from_jid.full(), players, profile)
+
+            elif elt.name == "player_ready":  # ready to play
+                player = elt["player"]
+                status = self.games[room_jid]["status"]
+                nb_players = len(self.games[room_jid]["players"])
+                status[player] = "ready"
+                log.debug(
+                    _("Player %(player)s is ready to start [status: %(status)s]")
+                    % {"player": player, "status": status}
+                )
+                if (
+                    list(status.values()).count("ready") == nb_players
+                ):  # everybody is ready, we can start the game
+                    self.new_round(room_jid, profile)
+
+            elif elt.name == "hand":  # a new hand has been received
+                self.host.bridge.tarot_game_new(
+                    room_jid.userhost(), self.__xml_to_list(elt), profile
+                )
+
+            elif elt.name == "contrat":  # it's time to choose contrat
+                form = data_form.Form.fromElement(elt.firstChildElement())
+                session_id, session_data = self._sessions.new_session(profile=profile)
+                session_data["room_jid"] = room_jid
+                xml_data = xml_tools.data_form_2_xmlui(
+                    form, self.__choose_contrat_id, session_id
+                ).toXml()
+                self.host.bridge.tarot_game_choose_contrat(
+                    room_jid.userhost(), xml_data, profile
+                )
+
+            elif elt.name == "contrat_choosed":
+                # TODO: check we receive the contrat from the right person
+                # TODO: use proper XEP-0004 way for answering form
+                player = elt["player"]
+                players_data[player]["contrat"] = str(elt)
+                contrats = [players_data[p]["contrat"] for p in game_data["players"]]
+                if contrats.count(None):
+                    # not everybody has choosed his contrat, it's next one turn
+                    player = self.__next_player(game_data)
+                    to_jid = jid.JID(room_jid.userhost() + "/" + player)  # FIXME: gof:
+                    self.send(to_jid, self.__ask_contrat(), profile=profile)
+                else:
+                    best_contrat = [None, "Passe"]
+                    for player in game_data["players"]:
+                        contrat = players_data[player]["contrat"]
+                        idx_best = self.contrats.index(best_contrat[1])
+                        idx_pl = self.contrats.index(contrat)
+                        if idx_pl > idx_best:
+                            best_contrat[0] = player
+                            best_contrat[1] = contrat
+                    if best_contrat[1] == "Passe":
+                        log.debug(_("Everybody is passing, round ended"))
+                        to_jid = jid.JID(room_jid.userhost())
+                        self.send(
+                            to_jid,
+                            self.__give_scores(*self.__draw_game(game_data)),
+                            profile=profile,
+                        )
+                        game_data["init_player"] = (game_data["init_player"] + 1) % len(
+                            game_data["players"]
+                        )  # we change the dealer
+                        for player in game_data["players"]:
+                            game_data["status"][player] = "init"
+                        return
+                    log.debug(
+                        _("%(player)s win the bid with %(contrat)s")
+                        % {"player": best_contrat[0], "contrat": best_contrat[1]}
+                    )
+                    game_data["contrat"] = best_contrat[1]
+
+                    if (
+                        game_data["contrat"] == "Garde Sans"
+                        or game_data["contrat"] == "Garde Contre"
+                    ):
+                        self.__start_play(room_jid, game_data, profile)
+                        game_data["attaquant"] = best_contrat[0]
+                    else:
+                        # Time to show the chien to everybody
+                        to_jid = jid.JID(room_jid.userhost())  # FIXME: gof:
+                        elem = self.__card_list_to_xml(game_data["chien"], "chien")
+                        self.send(
+                            to_jid, elem, {"attaquant": best_contrat[0]}, profile=profile
+                        )
+                        # the attacker (attaquant) get the chien
+                        game_data["hand"][best_contrat[0]].extend(game_data["chien"])
+                        del game_data["chien"][:]
+
+                    if game_data["contrat"] == "Garde Sans":
+                        # The chien go into attaquant's (attacker) levees
+                        players_data[best_contrat[0]]["levees"].extend(game_data["chien"])
+                        del game_data["chien"][:]
+
+            elif elt.name == "chien":  # we have received the chien
+                log.debug(_("tarot: chien received"))
+                data = {"attaquant": elt["attaquant"]}
+                game_data["stage"] = "ecart"
+                game_data["attaquant"] = elt["attaquant"]
+                self.host.bridge.tarot_game_show_cards(
+                    room_jid.userhost(), "chien", self.__xml_to_list(elt), data, profile
+                )
+
+            elif elt.name == "cards_played":
+                if game_data["stage"] == "ecart":
+                    # TODO: show atouts (trumps) if player put some in écart
+                    assert (
+                        game_data["attaquant"] == elt["player"]
+                    )  # TODO: throw an xml error here
+                    list_cards = TarotCard.from_tuples(self.__xml_to_list(elt))
+                    # we now check validity of card
+                    invalid_cards = self.__invalid_cards(game_data, list_cards)
+                    if invalid_cards:
+                        elem = self.__invalid_cards_elt(
+                            list_cards, invalid_cards, game_data["stage"]
+                        )
+                        self.send(
+                            jid.JID(room_jid.userhost() + "/" + elt["player"]),
+                            elem,
+                            profile=profile,
+                        )
+                        return
+
+                    # FIXME: gof: manage Garde Sans & Garde Contre cases
+                    players_data[elt["player"]]["levees"].extend(
+                        list_cards
+                    )  # we add the chien to attaquant's levées
+                    for card in list_cards:
+                        game_data["hand"][elt["player"]].remove(card)
+
+                    self.__start_play(room_jid, game_data, profile)
+
+                elif game_data["stage"] == "play":
+                    current_player = game_data["players"][game_data["current_player"]]
+                    cards = TarotCard.from_tuples(self.__xml_to_list(elt))
+
+                    if mess_elt["type"] == "groupchat":
+                        self.host.bridge.tarot_game_cards_played(
+                            room_jid.userhost(),
+                            elt["player"],
+                            self.__xml_to_list(elt),
+                            profile,
+                        )
+                    else:
+                        # we first check validity of card
+                        invalid_cards = self.__invalid_cards(game_data, cards)
+                        if invalid_cards:
+                            elem = self.__invalid_cards_elt(
+                                cards, invalid_cards, game_data["stage"]
+                            )
+                            self.send(
+                                jid.JID(room_jid.userhost() + "/" + current_player),
+                                elem,
+                                profile=profile,
+                            )
+                            return
+                        # the card played is ok, we forward it to everybody
+                        # first we remove it from the hand and put in on the table
+                        game_data["hand"][current_player].remove(cards[0])
+                        players_data[current_player]["played"] = cards[0]
+
+                        # then we forward the message
+                        self.send(room_jid, elt, profile=profile)
+
+                        # Did everybody played ?
+                        played = [
+                            players_data[player]["played"]
+                            for player in game_data["players"]
+                        ]
+                        if all(played):
+                            # everybody has played
+                            winner = self.__winner(game_data)
+                            log.debug(_("The winner of this trick is %s") % winner)
+                            # the winner win the trick
+                            self.__excuse_hack(game_data, played, winner)
+                            players_data[elt["player"]]["levees"].extend(played)
+                            # nothing left on the table
+                            for player in game_data["players"]:
+                                players_data[player]["played"] = None
+                            if len(game_data["hand"][current_player]) == 0:
+                                # no card left: the game is finished
+                                elem = self.__give_scores(
+                                    *self.__calculate_scores(game_data)
+                                )
+                                self.send(room_jid, elem, profile=profile)
+                                game_data["init_player"] = (
+                                    game_data["init_player"] + 1
+                                ) % len(
+                                    game_data["players"]
+                                )  # we change the dealer
+                                for player in game_data["players"]:
+                                    game_data["status"][player] = "init"
+                                return
+                            # next player is the winner
+                            next_player = game_data["first_player"] = self.__next_player(
+                                game_data, winner
+                            )
+                        else:
+                            next_player = self.__next_player(game_data)
+
+                        # finally, we tell to the next player to play
+                        to_jid = jid.JID(room_jid.userhost() + "/" + next_player)
+                        self.send(to_jid, "your_turn", profile=profile)
+
+            elif elt.name == "your_turn":
+                self.host.bridge.tarot_game_your_turn(room_jid.userhost(), profile)
+
+            elif elt.name == "score":
+                form_elt = next(elt.elements(name="x", uri="jabber:x:data"))
+                winners = []
+                loosers = []
+                for winner in elt.elements(name="winner", uri=NS_CG):
+                    winners.append(str(winner))
+                for looser in elt.elements(name="looser", uri=NS_CG):
+                    loosers.append(str(looser))
+                form = data_form.Form.fromElement(form_elt)
+                session_id, session_data = self._sessions.new_session(profile=profile)
+                session_data["room_jid"] = room_jid
+                xml_data = xml_tools.data_form_2_xmlui(
+                    form, self.__score_id, session_id
+                ).toXml()
+                self.host.bridge.tarot_game_score(
+                    room_jid.userhost(), xml_data, winners, loosers, profile
+                )
+            elif elt.name == "error":
+                if elt["type"] == "invalid_cards":
+                    played_cards = self.__xml_to_list(
+                        next(elt.elements(name="played", uri=NS_CG))
+                    )
+                    invalid_cards = self.__xml_to_list(
+                        next(elt.elements(name="invalid", uri=NS_CG))
+                    )
+                    self.host.bridge.tarot_game_invalid_cards(
+                        room_jid.userhost(),
+                        elt["phase"],
+                        played_cards,
+                        invalid_cards,
+                        profile,
+                    )
+                else:
+                    log.error(_("Unmanaged error type: %s") % elt["type"])
+            else:
+                log.error(_("Unmanaged card game element: %s") % elt.name)
+
+    def get_sync_data_for_player(self, room_jid, nick):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_text_commands.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,471 @@
+#!/usr/bin/env python3
+
+
+# SàT plugin for managing text commands
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from twisted.words.protocols.jabber import jid
+from twisted.internet import defer
+from twisted.python import failure
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import utils
+
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Text commands",
+    C.PI_IMPORT_NAME: C.TEXT_CMDS,
+    C.PI_TYPE: "Misc",
+    C.PI_PROTOCOLS: ["XEP-0245"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "TextCommands",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""IRC like text commands"""),
+}
+
+
+class InvalidCommandSyntax(Exception):
+    """Throwed while parsing @command in docstring if syntax is invalid"""
+
+    pass
+
+
+CMD_KEY = "@command"
+CMD_TYPES = ("group", "one2one", "all")
+FEEDBACK_INFO_TYPE = "TEXT_CMD"
+
+
+class TextCommands(object):
+    # FIXME: doc strings for commands have to be translatable
+    #       plugins need a dynamic translation system (translation
+    #       should be downloadable independently)
+
+    HELP_SUGGESTION = _(
+        "Type '/help' to get a list of the available commands. If you didn't want to "
+        "use a command, please start your message with '//' to escape the slash."
+    )
+
+    def __init__(self, host):
+        log.info(_("Text commands initialization"))
+        self.host = host
+        # this is internal command, so we set high priority
+        host.trigger.add("sendMessage", self.send_message_trigger, priority=1000000)
+        self._commands = {}
+        self._whois = []
+        self.register_text_commands(self)
+
+    def _parse_doc_string(self, cmd, cmd_name):
+        """Parse a docstring to get text command data
+
+        @param cmd: function or method callback for the command,
+            its docstring will be used for self documentation in the following way:
+            - first line is the command short documentation, shown with /help
+            - @command keyword can be used,
+              see http://wiki.goffi.org/wiki/Coding_style/en for documentation
+        @return (dict): dictionary with parsed data where key can be:
+            - "doc_short_help" (default: ""): the untranslated short documentation
+            - "type" (default "all"): the command type as specified in documentation
+            - "args" (default: ""): the arguments available, using syntax specified in
+                documentation.
+            - "doc_arg_[name]": the doc of [name] argument
+        """
+        data = {
+            "doc_short_help": "",
+            "type": "all",
+            "args": "",
+        }
+        docstring = cmd.__doc__
+        if docstring is None:
+            log.warning("No docstring found for command {}".format(cmd_name))
+            docstring = ""
+
+        doc_data = docstring.split("\n")
+        data["doc_short_help"] = doc_data[0]
+
+        try:
+            cmd_indent = 0  # >0 when @command is found are we are parsing it
+
+            for line in doc_data:
+                stripped = line.strip()
+                if cmd_indent and line[cmd_indent : cmd_indent + 5] == "    -":
+                    colon_idx = line.find(":")
+                    if colon_idx == -1:
+                        raise InvalidCommandSyntax(
+                            "No colon found in argument description"
+                        )
+                    arg_name = line[cmd_indent + 6 : colon_idx].strip()
+                    if not arg_name:
+                        raise InvalidCommandSyntax(
+                            "No name found in argument description"
+                        )
+                    arg_help = line[colon_idx + 1 :].strip()
+                    data["doc_arg_{}".format(arg_name)] = arg_help
+                elif cmd_indent:
+                    # we are parsing command and indent level is not good, it's finished
+                    break
+                elif stripped.startswith(CMD_KEY):
+                    cmd_indent = line.find(CMD_KEY)
+
+                    # type
+                    colon_idx = stripped.find(":")
+                    if colon_idx == -1:
+                        raise InvalidCommandSyntax("missing colon")
+                    type_data = stripped[len(CMD_KEY) : colon_idx].strip()
+                    if len(type_data) == 0:
+                        type_data = "(all)"
+                    elif (
+                        len(type_data) <= 2 or type_data[0] != "(" or type_data[-1] != ")"
+                    ):
+                        raise InvalidCommandSyntax("Bad type data syntax")
+                    type_ = type_data[1:-1]
+                    if type_ not in CMD_TYPES:
+                        raise InvalidCommandSyntax("Unknown type {}".format(type_))
+                    data["type"] = type_
+
+                    # args
+                    data["args"] = stripped[colon_idx + 1 :].strip()
+        except InvalidCommandSyntax as e:
+            log.warning(
+                "Invalid command syntax for command {command}: {message}".format(
+                    command=cmd_name, message=e.message
+                )
+            )
+
+        return data
+
+    def register_text_commands(self, instance):
+        """ Add a text command
+
+        @param instance: instance of a class containing text commands
+        """
+        for attr in dir(instance):
+            if attr.startswith("cmd_"):
+                cmd = getattr(instance, attr)
+                if not callable(cmd):
+                    log.warning(_("Skipping not callable [%s] attribute") % attr)
+                    continue
+                cmd_name = attr[4:]
+                if not cmd_name:
+                    log.warning(_("Skipping cmd_ method"))
+                if cmd_name in self._commands:
+                    suff = 2
+                    while (cmd_name + str(suff)) in self._commands:
+                        suff += 1
+                    new_name = cmd_name + str(suff)
+                    log.warning(
+                        _(
+                            "Conflict for command [{old_name}], renaming it to [{new_name}]"
+                        ).format(old_name=cmd_name, new_name=new_name)
+                    )
+                    cmd_name = new_name
+                self._commands[cmd_name] = cmd_data = {"callback": cmd}
+                cmd_data.update(self._parse_doc_string(cmd, cmd_name))
+                log.info(_("Registered text command [%s]") % cmd_name)
+
+    def add_who_is_cb(self, callback, priority=0):
+        """Add a callback which give information to the /whois command
+
+        @param callback: a callback which will be called with the following arguments
+            - whois_msg: list of information strings to display, callback need to append
+                         its own strings to it
+            - target_jid: full jid from whom we want information
+            - profile: %(doc_profile)s
+        @param priority: priority of the information to show (the highest priority will
+            be displayed first)
+        """
+        self._whois.append((priority, callback))
+        self._whois.sort(key=lambda item: item[0], reverse=True)
+
+    def send_message_trigger(
+        self, client, mess_data, pre_xml_treatments, post_xml_treatments
+    ):
+        """Install SendMessage command hook """
+        pre_xml_treatments.addCallback(self._send_message_cmd_hook, client)
+        return True
+
+    def _send_message_cmd_hook(self, mess_data, client):
+        """ Check text commands in message, and react consequently
+
+        msg starting with / are potential command. If a command is found, it is executed,
+        else an help message is sent.
+        msg starting with // are escaped: they are sent with a single /
+        commands can abord message sending (if they return anything evaluating to False),
+        or continue it (if they return True), eventually after modifying the message
+        an "unparsed" key is added to message, containing part of the message not yet
+        parsed.
+        Commands can be deferred or not
+        @param mess_data(dict): data comming from sendMessage trigger
+        @param profile: %(doc_profile)s
+        """
+        try:
+            msg = mess_data["message"][""]
+            msg_lang = ""
+        except KeyError:
+            try:
+                # we have not default message, we try to take the first found
+                msg_lang, msg = next(iter(mess_data["message"].items()))
+            except StopIteration:
+                log.debug("No message found, skipping text commands")
+                return mess_data
+
+        try:
+            if msg[:2] == "//":
+                # we have a double '/', it's the escape sequence
+                mess_data["message"][msg_lang] = msg[1:]
+                return mess_data
+            if msg[0] != "/":
+                return mess_data
+        except IndexError:
+            return mess_data
+
+        # we have a command
+        d = None
+        command = msg[1:].partition(" ")[0].lower().strip()
+        if not command.isidentifier():
+            self.feed_back(
+                client,
+                _("Invalid command /%s. ") % command + self.HELP_SUGGESTION,
+                mess_data,
+            )
+            raise failure.Failure(exceptions.CancelError())
+
+        # looks like an actual command, we try to call the corresponding method
+        def ret_handling(ret):
+            """ Handle command return value:
+            if ret is True, normally send message (possibly modified by command)
+            else, abord message sending
+            """
+            if ret:
+                return mess_data
+            else:
+                log.debug("text command detected ({})".format(command))
+                raise failure.Failure(exceptions.CancelError())
+
+        def generic_errback(failure):
+            try:
+                msg = "with condition {}".format(failure.value.condition)
+            except AttributeError:
+                msg = "with error {}".format(failure.value)
+            self.feed_back(client, "Command failed {}".format(msg), mess_data)
+            return False
+
+        mess_data["unparsed"] = msg[
+            1 + len(command) :
+        ]  # part not yet parsed of the message
+        try:
+            cmd_data = self._commands[command]
+        except KeyError:
+            self.feed_back(
+                client,
+                _("Unknown command /%s. ") % command + self.HELP_SUGGESTION,
+                mess_data,
+            )
+            log.debug("text command help message")
+            raise failure.Failure(exceptions.CancelError())
+        else:
+            if not self._context_valid(mess_data, cmd_data):
+                # The command is not launched in the right context, we throw a message with help instructions
+                context_txt = (
+                    _("group discussions")
+                    if cmd_data["type"] == "group"
+                    else _("one to one discussions")
+                )
+                feedback = _("/{command} command only applies in {context}.").format(
+                    command=command, context=context_txt
+                )
+                self.feed_back(
+                    client, "{} {}".format(feedback, self.HELP_SUGGESTION), mess_data
+                )
+                log.debug("text command invalid message")
+                raise failure.Failure(exceptions.CancelError())
+            else:
+                d = utils.as_deferred(cmd_data["callback"], client, mess_data)
+                d.addErrback(generic_errback)
+                d.addCallback(ret_handling)
+
+        return d
+
+    def _context_valid(self, mess_data, cmd_data):
+        """Tell if a command can be used in the given context
+
+        @param mess_data(dict): message data as given in sendMessage trigger
+        @param cmd_data(dict): command data as returned by self._parse_doc_string
+        @return (bool): True if command can be used in this context
+        """
+        if (cmd_data["type"] == "group" and mess_data["type"] != "groupchat") or (
+            cmd_data["type"] == "one2one" and mess_data["type"] == "groupchat"
+        ):
+            return False
+        return True
+
+    def get_room_jid(self, arg, service_jid):
+        """Return a room jid with a shortcut
+
+        @param arg: argument: can be a full room jid (e.g.: sat@chat.jabberfr.org)
+                    or a shortcut (e.g.: sat or sat@ for sat on current service)
+        @param service_jid: jid of the current service (e.g.: chat.jabberfr.org)
+        """
+        nb_arobas = arg.count("@")
+        if nb_arobas == 1:
+            if arg[-1] != "@":
+                return jid.JID(arg)
+            return jid.JID(arg + service_jid)
+        return jid.JID(f"{arg}@{service_jid}")
+
+    def feed_back(self, client, message, mess_data, info_type=FEEDBACK_INFO_TYPE):
+        """Give a message back to the user"""
+        if mess_data["type"] == "groupchat":
+            to_ = mess_data["to"].userhostJID()
+        else:
+            to_ = client.jid
+
+        # we need to invert send message back, so sender need to original destinee
+        mess_data["from"] = mess_data["to"]
+        mess_data["to"] = to_
+        mess_data["type"] = C.MESS_TYPE_INFO
+        mess_data["message"] = {"": message}
+        mess_data["extra"]["info_type"] = info_type
+        client.message_send_to_bridge(mess_data)
+
+    def cmd_whois(self, client, mess_data):
+        """show informations on entity
+
+        @command: [JID|ROOM_NICK]
+            - JID: entity to request
+            - ROOM_NICK: nick of the room to request
+        """
+        log.debug("Catched whois command")
+
+        entity = mess_data["unparsed"].strip()
+
+        if mess_data["type"] == "groupchat":
+            room = mess_data["to"].userhostJID()
+            try:
+                if self.host.plugins["XEP-0045"].is_nick_in_room(client, room, entity):
+                    entity = "%s/%s" % (room, entity)
+            except KeyError:
+                log.warning("plugin XEP-0045 is not present")
+
+        if not entity:
+            target_jid = mess_data["to"]
+        else:
+            try:
+                target_jid = jid.JID(entity)
+                if not target_jid.user or not target_jid.host:
+                    raise jid.InvalidFormat
+            except (RuntimeError, jid.InvalidFormat, AttributeError):
+                self.feed_back(client, _("Invalid jid, can't whois"), mess_data)
+                return False
+
+        if not target_jid.resource:
+            target_jid.resource = self.host.memory.main_resource_get(client, target_jid)
+
+        whois_msg = [_("whois for %(jid)s") % {"jid": target_jid}]
+
+        d = defer.succeed(None)
+        for __, callback in self._whois:
+            d.addCallback(
+                lambda __: callback(client, whois_msg, mess_data, target_jid)
+            )
+
+        def feed_back(__):
+            self.feed_back(client, "\n".join(whois_msg), mess_data)
+            return False
+
+        d.addCallback(feed_back)
+        return d
+
+    def _get_args_help(self, cmd_data):
+        """Return help string for args of cmd_name, according to docstring data
+
+        @param cmd_data: command data
+        @return (list[unicode]): help strings
+        """
+        strings = []
+        for doc_name, doc_help in cmd_data.items():
+            if doc_name.startswith("doc_arg_"):
+                arg_name = doc_name[8:]
+                strings.append(
+                    "- {name}: {doc_help}".format(name=arg_name, doc_help=_(doc_help))
+                )
+
+        return strings
+
+    def cmd_me(self, client, mess_data):
+        """display a message at third person
+
+        @command (all): message
+            - message: message to show at third person
+                e.g.: "/me clenches his fist" will give "[YOUR_NICK] clenches his fist"
+        """
+        # We just ignore the command as the match is done on receiption by clients
+        return True
+
+    def cmd_whoami(self, client, mess_data):
+        """give your own jid"""
+        self.feed_back(client, client.jid.full(), mess_data)
+
+    def cmd_help(self, client, mess_data):
+        """show help on available commands
+
+        @command: [cmd_name]
+            - cmd_name: name of the command for detailed help
+        """
+        cmd_name = mess_data["unparsed"].strip()
+        if cmd_name and cmd_name[0] == "/":
+            cmd_name = cmd_name[1:]
+        if cmd_name and cmd_name not in self._commands:
+            self.feed_back(
+                client, _("Invalid command name [{}]\n".format(cmd_name)), mess_data
+            )
+            cmd_name = ""
+        if not cmd_name:
+            # we show the global help
+            longuest = max([len(command) for command in self._commands])
+            help_cmds = []
+
+            for command in sorted(self._commands):
+                cmd_data = self._commands[command]
+                if not self._context_valid(mess_data, cmd_data):
+                    continue
+                spaces = (longuest - len(command)) * " "
+                help_cmds.append(
+                    "    /{command}: {spaces} {short_help}".format(
+                        command=command,
+                        spaces=spaces,
+                        short_help=cmd_data["doc_short_help"],
+                    )
+                )
+
+            help_mess = _("Text commands available:\n%s") % ("\n".join(help_cmds),)
+        else:
+            # we show detailled help for a command
+            cmd_data = self._commands[cmd_name]
+            syntax = cmd_data["args"]
+            help_mess = _("/{name}: {short_help}\n{syntax}{args_help}").format(
+                name=cmd_name,
+                short_help=cmd_data["doc_short_help"],
+                syntax=_(" " * 4 + "syntax: {}\n").format(syntax) if syntax else "",
+                args_help="\n".join(
+                    [" " * 8 + "{}".format(line) for line in self._get_args_help(cmd_data)]
+                ),
+            )
+
+        self.feed_back(client, help_mess, mess_data)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_text_syntaxes.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,479 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing various text syntaxes
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from functools import partial
+from html import escape
+import re
+from typing import Set
+
+from twisted.internet import defer
+from twisted.internet.threads import deferToThread
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xml_tools
+
+try:
+    from lxml import html
+    from lxml.html import clean
+    from lxml import etree
+except ImportError:
+    raise exceptions.MissingModule(
+        "Missing module lxml, please download/install it from http://lxml.de/"
+    )
+
+log = getLogger(__name__)
+
+CATEGORY = D_("Composition")
+NAME = "Syntax"
+_SYNTAX_XHTML = "xhtml"  # must be lower case
+_SYNTAX_CURRENT = "@CURRENT@"
+
+# TODO: check/adapt following list
+# list initialy based on feedparser list (http://pythonhosted.org/feedparser/html-sanitization.html)
+STYLES_WHITELIST = (
+    "azimuth",
+    "background-color",
+    "border-bottom-color",
+    "border-collapse",
+    "border-color",
+    "border-left-color",
+    "border-right-color",
+    "border-top-color",
+    "clear",
+    "color",
+    "cursor",
+    "direction",
+    "display",
+    "elevation",
+    "float",
+    "font",
+    "font-family",
+    "font-size",
+    "font-style",
+    "font-variant",
+    "font-weight",
+    "height",
+    "letter-spacing",
+    "line-height",
+    "overflow",
+    "pause",
+    "pause-after",
+    "pause-before",
+    "pitch",
+    "pitch-range",
+    "richness",
+    "speak",
+    "speak-header",
+    "speak-numeral",
+    "speak-punctuation",
+    "speech-rate",
+    "stress",
+    "text-align",
+    "text-decoration",
+    "text-indent",
+    "unicode-bidi",
+    "vertical-align",
+    "voice-family",
+    "volume",
+    "white-space",
+    "width",
+)
+
+# cf. https://www.w3.org/TR/html/syntax.html#void-elements
+VOID_ELEMENTS = (
+    "area",
+    "base",
+    "br",
+    "col",
+    "embed",
+    "hr",
+    "img",
+    "input",
+    "keygen",
+    "link",
+    "menuitem",
+    "meta",
+    "param",
+    "source",
+    "track",
+    "wbr")
+
+SAFE_ATTRS = html.defs.safe_attrs.union({"style", "poster", "controls"}) - {"id"}
+SAFE_CLASSES = {
+    # those classes are used for code highlighting
+    "bp", "c", "ch", "cm", "cp", "cpf", "cs", "dl", "err", "fm", "gd", "ge", "get", "gh",
+    "gi", "go", "gp", "gr", "gs", "gt", "gu", "highlight", "hll", "il", "k", "kc", "kd",
+    "kn", "kp", "kr", "kt", "m", "mb", "mf", "mh", "mi", "mo", "na", "nb", "nc", "nd",
+    "ne", "nf", "ni", "nl", "nn", "no", "nt", "nv", "o", "ow", "s", "sa", "sb", "sc",
+    "sd", "se", "sh", "si", "sr", "ss", "sx", "vc", "vg", "vi", "vm", "w", "write",
+}
+STYLES_VALUES_REGEX = (
+    r"^("
+    + "|".join(
+        [
+            "([a-z-]+)",  # alphabetical names
+            "(#[0-9a-f]+)",  # hex value
+            "(\d+(.\d+)? *(|%|em|ex|px|in|cm|mm|pt|pc))",  # values with units (or not)
+            "rgb\( *((\d+(.\d+)?), *){2}(\d+(.\d+)?) *\)",  # rgb function
+            "rgba\( *((\d+(.\d+)?), *){3}(\d+(.\d+)?) *\)",  # rgba function
+        ]
+    )
+    + ") *(!important)?$"
+)  # we accept "!important" at the end
+STYLES_ACCEPTED_VALUE = re.compile(STYLES_VALUES_REGEX)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Text syntaxes",
+    C.PI_IMPORT_NAME: "TEXT_SYNTAXES",
+    C.PI_TYPE: "MISC",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "TextSyntaxes",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _(
+        """Management of various text syntaxes (XHTML-IM, Markdown, etc)"""
+    ),
+}
+
+
+class TextSyntaxes(object):
+    """ Text conversion class
+    XHTML utf-8 is used as intermediate language for conversions
+    """
+
+    OPT_DEFAULT = "DEFAULT"
+    OPT_HIDDEN = "HIDDEN"
+    OPT_NO_THREAD = "NO_THREAD"
+    SYNTAX_XHTML = _SYNTAX_XHTML
+    SYNTAX_MARKDOWN = "markdown"
+    SYNTAX_TEXT = "text"
+    # default_syntax must be lower case
+    default_syntax = SYNTAX_XHTML
+
+
+    def __init__(self, host):
+        log.info(_("Text syntaxes plugin initialization"))
+        self.host = host
+        self.syntaxes = {}
+
+        self.params = """
+            <params>
+            <individual>
+            <category name="%(category_name)s" label="%(category_label)s">
+                <param name="%(name)s" label="%(label)s" type="list" security="0">
+                    %(options)s
+                </param>
+            </category>
+            </individual>
+            </params>
+        """
+
+        self.params_data = {
+            "category_name": CATEGORY,
+            "category_label": _(CATEGORY),
+            "name": NAME,
+            "label": _(NAME),
+            "syntaxes": self.syntaxes,
+        }
+
+        self.add_syntax(
+            self.SYNTAX_XHTML,
+            lambda xhtml: defer.succeed(xhtml),
+            lambda xhtml: defer.succeed(xhtml),
+            TextSyntaxes.OPT_NO_THREAD,
+        )
+        # TODO: text => XHTML should add <a/> to url like in frontends
+        #       it's probably best to move sat_frontends.tools.strings to sat.tools.common or similar
+        self.add_syntax(
+            self.SYNTAX_TEXT,
+            lambda text: escape(text),
+            lambda xhtml: self._remove_markups(xhtml),
+            [TextSyntaxes.OPT_HIDDEN],
+        )
+        try:
+            import markdown, html2text
+            from markdown.extensions import Extension
+
+            # XXX: we disable raw HTML parsing by default, to avoid parsing error
+            #      when the user is not aware of markdown and HTML
+            class EscapeHTML(Extension):
+                def extendMarkdown(self, md):
+                    md.preprocessors.deregister('html_block')
+                    md.inlinePatterns.deregister('html')
+
+            def _html2text(html, baseurl=""):
+                h = html2text.HTML2Text(baseurl=baseurl)
+                h.body_width = 0  # do not truncate the lines, it breaks the long URLs
+                return h.handle(html)
+
+            self.add_syntax(
+                self.SYNTAX_MARKDOWN,
+                partial(markdown.markdown,
+                        extensions=[
+                            EscapeHTML(),
+                            'nl2br',
+                            'codehilite',
+                            'fenced_code',
+                            'sane_lists',
+                            'tables',
+                            ],
+                        extension_configs = {
+                            "codehilite": {
+                                "css_class": "highlight",
+                            }
+                        }),
+                _html2text,
+                [TextSyntaxes.OPT_DEFAULT],
+            )
+        except ImportError:
+            log.warning("markdown or html2text not found, can't use Markdown syntax")
+            log.info(
+                "You can download/install them from https://pythonhosted.org/Markdown/ "
+                "and https://github.com/Alir3z4/html2text/"
+            )
+        host.bridge.add_method(
+            "syntax_convert",
+            ".plugin",
+            in_sign="sssbs",
+            out_sign="s",
+            async_=True,
+            method=self.convert,
+        )
+        host.bridge.add_method(
+            "syntax_get", ".plugin", in_sign="s", out_sign="s", method=self.get_syntax
+        )
+        if xml_tools.clean_xhtml is None:
+            log.debug("Installing cleaning method")
+            xml_tools.clean_xhtml = self.clean_xhtml
+
+    def _update_param_options(self):
+        data_synt = self.syntaxes
+        default_synt = TextSyntaxes.default_syntax
+        syntaxes = []
+
+        for syntax in list(data_synt.keys()):
+            flags = data_synt[syntax]["flags"]
+            if TextSyntaxes.OPT_HIDDEN not in flags:
+                syntaxes.append(syntax)
+
+        syntaxes.sort(key=lambda synt: synt.lower())
+        options = []
+
+        for syntax in syntaxes:
+            selected = 'selected="true"' if syntax == default_synt else ""
+            options.append('<option value="%s" %s/>' % (syntax, selected))
+
+        self.params_data["options"] = "\n".join(options)
+        self.host.memory.update_params(self.params % self.params_data)
+
+    def get_current_syntax(self, profile):
+        """ Return the selected syntax for the given profile
+
+        @param profile: %(doc_profile)s
+        @return: profile selected syntax
+        """
+        return self.host.memory.param_get_a(NAME, CATEGORY, profile_key=profile)
+
+    def _log_error(self, failure, action="converting syntax"):
+        log.error(
+            "Error while {action}: {failure}".format(action=action, failure=failure)
+        )
+        return failure
+
+    def clean_style(self, styles_raw: str) -> str:
+        """"Clean unsafe CSS styles
+
+        Remove styles not in the whitelist, or where the value doesn't match the regex
+        @param styles_raw: CSS styles
+        @return: cleaned styles
+        """
+        styles: List[str] = styles_raw.split(";")
+        cleaned_styles = []
+        for style in styles:
+            try:
+                key, value = style.split(":")
+            except ValueError:
+                continue
+            key = key.lower().strip()
+            if key not in STYLES_WHITELIST:
+                continue
+            value = value.lower().strip()
+            if not STYLES_ACCEPTED_VALUE.match(value):
+                continue
+            if value == "none":
+                continue
+            cleaned_styles.append((key, value))
+        return "; ".join(
+            ["%s: %s" % (key_, value_) for key_, value_ in cleaned_styles]
+        )
+
+    def clean_classes(self, classes_raw: str) -> str:
+        """Remove any non whitelisted class
+
+        @param classes_raw: classes set on an element
+        @return: remaining classes (can be empty string)
+        """
+        return " ".join(SAFE_CLASSES.intersection(classes_raw.split()))
+
+    def clean_xhtml(self, xhtml):
+        """Clean XHTML text by removing potentially dangerous/malicious parts
+
+        @param xhtml(unicode, lxml.etree._Element): raw HTML/XHTML text to clean
+        @return (unicode): cleaned XHTML
+        """
+
+        if isinstance(xhtml, str):
+            try:
+                xhtml_elt = html.fromstring(xhtml)
+            except etree.ParserError as e:
+                if not xhtml.strip():
+                    return ""
+                log.error("Can't clean XHTML: {xhtml}".format(xhtml=xhtml))
+                raise e
+        elif isinstance(xhtml, html.HtmlElement):
+            xhtml_elt = xhtml
+        else:
+            log.error("Only strings and HtmlElements can be cleaned")
+            raise exceptions.DataError
+        cleaner = clean.Cleaner(
+            style=False, add_nofollow=False, safe_attrs=SAFE_ATTRS
+        )
+        xhtml_elt = cleaner.clean_html(xhtml_elt)
+        for elt in xhtml_elt.xpath("//*[@style]"):
+            elt.set("style", self.clean_style(elt.get("style")))
+        for elt in xhtml_elt.xpath("//*[@class]"):
+            elt.set("class", self.clean_classes(elt.get("class")))
+        # we remove self-closing elements for non-void elements
+        for element in xhtml_elt.iter(tag=etree.Element):
+            if not element.text:
+                if element.tag in VOID_ELEMENTS:
+                    element.text = None
+                else:
+                    element.text = ''
+        return html.tostring(xhtml_elt, encoding=str, method="xml")
+
+    def convert(self, text, syntax_from, syntax_to=_SYNTAX_XHTML, safe=True,
+                profile=None):
+        """Convert a text between two syntaxes
+
+        @param text: text to convert
+        @param syntax_from: source syntax (e.g. "markdown")
+        @param syntax_to: dest syntax (e.g.: "XHTML")
+        @param safe: clean resulting XHTML to avoid malicious code if True
+        @param profile: needed only when syntax_from or syntax_to is set to
+            _SYNTAX_CURRENT
+        @return(unicode): converted text
+        """
+        # FIXME: convert should be abled to handle domish.Element directly
+        #        when dealing with XHTML
+        # TODO: a way for parser to return parsing errors/warnings
+
+        if syntax_from == _SYNTAX_CURRENT:
+            syntax_from = self.get_current_syntax(profile)
+        else:
+            syntax_from = syntax_from.lower().strip()
+        if syntax_to == _SYNTAX_CURRENT:
+            syntax_to = self.get_current_syntax(profile)
+        else:
+            syntax_to = syntax_to.lower().strip()
+        syntaxes = self.syntaxes
+        if syntax_from not in syntaxes:
+            raise exceptions.NotFound(syntax_from)
+        if syntax_to not in syntaxes:
+            raise exceptions.NotFound(syntax_to)
+        d = None
+
+        if TextSyntaxes.OPT_NO_THREAD in syntaxes[syntax_from]["flags"]:
+            d = defer.maybeDeferred(syntaxes[syntax_from]["to"], text)
+        else:
+            d = deferToThread(syntaxes[syntax_from]["to"], text)
+
+        # TODO: keep only body element and change it to a div here ?
+
+        if safe:
+            d.addCallback(self.clean_xhtml)
+
+        if TextSyntaxes.OPT_NO_THREAD in syntaxes[syntax_to]["flags"]:
+            d.addCallback(syntaxes[syntax_to]["from"])
+        else:
+            d.addCallback(lambda xhtml: deferToThread(syntaxes[syntax_to]["from"], xhtml))
+
+        # converters can add new lines that disturb the microblog change detection
+        d.addCallback(lambda text: text.rstrip())
+        return d
+
+    def add_syntax(self, name, to_xhtml_cb, from_xhtml_cb, flags=None):
+        """Add a new syntax to the manager
+
+        @param name: unique name of the syntax
+        @param to_xhtml_cb: callback to convert from syntax to XHTML
+        @param from_xhtml_cb: callback to convert from XHTML to syntax
+        @param flags: set of optional flags, can be:
+            TextSyntaxes.OPT_DEFAULT: use as the default syntax (replace former one)
+            TextSyntaxes.OPT_HIDDEN: do not show in parameters
+            TextSyntaxes.OPT_NO_THREAD: do not defer to thread when converting (the callback may then return a deferred)
+        """
+        flags = flags if flags is not None else []
+        if TextSyntaxes.OPT_HIDDEN in flags and TextSyntaxes.OPT_DEFAULT in flags:
+            raise ValueError(
+                "{} and {} are mutually exclusive".format(
+                    TextSyntaxes.OPT_HIDDEN, TextSyntaxes.OPT_DEFAULT
+                )
+            )
+
+        syntaxes = self.syntaxes
+        key = name.lower().strip()
+        if key in syntaxes:
+            raise exceptions.ConflictError(
+                "This syntax key already exists: {}".format(key)
+            )
+        syntaxes[key] = {
+            "name": name,
+            "to": to_xhtml_cb,
+            "from": from_xhtml_cb,
+            "flags": flags,
+        }
+        if TextSyntaxes.OPT_DEFAULT in flags:
+            TextSyntaxes.default_syntax = key
+
+        self._update_param_options()
+
+    def get_syntax(self, name):
+        """get syntax key corresponding to a name
+
+        @raise exceptions.NotFound: syntax doesn't exist
+        """
+        key = name.lower().strip()
+        if key in self.syntaxes:
+            return key
+        raise exceptions.NotFound
+
+    def _remove_markups(self, xhtml):
+        """Remove XHTML markups from the given string.
+
+        @param xhtml: the XHTML string to be cleaned
+        @return: the cleaned string
+        """
+        cleaner = clean.Cleaner(kill_tags=["style"])
+        cleaned = cleaner.clean_html(html.fromstring(xhtml))
+        return html.tostring(cleaned, encoding=str, method="text")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_upload.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+
+# SAT plugin for uploading files
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import os.path
+from pathlib import Path
+from typing import Optional, Tuple, Union
+
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import error as jabber_error
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools.common import data_format
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "File Upload",
+    C.PI_IMPORT_NAME: "UPLOAD",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_MAIN: "UploadPlugin",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""File upload management"""),
+}
+
+
+UPLOADING = D_("Please select a file to upload")
+UPLOADING_TITLE = D_("File upload")
+
+
+class UploadPlugin(object):
+    # TODO: plugin unload
+
+    def __init__(self, host):
+        log.info(_("plugin Upload initialization"))
+        self.host = host
+        host.bridge.add_method(
+            "file_upload",
+            ".plugin",
+            in_sign="sssss",
+            out_sign="a{ss}",
+            method=self._file_upload,
+            async_=True,
+        )
+        self._upload_callbacks = []
+
+    def _file_upload(
+        self, filepath, filename, upload_jid_s="", options='', profile=C.PROF_KEY_NONE
+    ):
+        client = self.host.get_client(profile)
+        upload_jid = jid.JID(upload_jid_s) if upload_jid_s else None
+        options = data_format.deserialise(options)
+
+        return defer.ensureDeferred(self.file_upload(
+            client, filepath, filename or None, upload_jid, options
+        ))
+
+    async def file_upload(self, client, filepath, filename, upload_jid, options):
+        """Send a file using best available method
+
+        parameters are the same as for [upload]
+        @return (dict): action dictionary, with progress id in case of success, else xmlui
+            message
+        """
+        try:
+            progress_id, __ = await self.upload(
+                client, filepath, filename, upload_jid, options)
+        except Exception as e:
+            if (isinstance(e, jabber_error.StanzaError)
+                and e.condition == 'not-acceptable'):
+                reason = e.text
+            else:
+                reason = str(e)
+            msg = D_("Can't upload file: {reason}").format(reason=reason)
+            log.warning(msg)
+            return {
+                "xmlui": xml_tools.note(
+                    msg, D_("Can't upload file"), C.XMLUI_DATA_LVL_WARNING
+                ).toXml()
+            }
+        else:
+            return {"progress": progress_id}
+
+    async def upload(
+        self,
+        client: SatXMPPEntity,
+        filepath: Union[Path, str],
+        filename: Optional[str] = None,
+        upload_jid: Optional[jid.JID] = None,
+        extra: Optional[dict]=None
+    ) -> Tuple[str, defer.Deferred]:
+        """Send a file using best available method
+
+        @param filepath: absolute path to the file
+        @param filename: name to use for the upload
+            None to use basename of the path
+        @param upload_jid: upload capable entity jid,
+            or None to use autodetected, if possible
+        @param extra: extra data/options to use for the upload, may be:
+            - ignore_tls_errors(bool): True to ignore SSL/TLS certificate verification
+                used only if HTTPS transport is needed
+            - progress_id(str): id to use for progression
+                if not specified, one will be generated
+        @param profile: %(doc_profile)s
+        @return: progress_id and a Deferred which fire download URL when upload is
+            finished
+        """
+        if extra is None:
+            extra = {}
+        if not os.path.isfile(filepath):
+            raise exceptions.DataError("The given path doesn't link to a file")
+        for method_name, available_cb, upload_cb, priority in self._upload_callbacks:
+            if upload_jid is None:
+                try:
+                    upload_jid = await available_cb(client, upload_jid)
+                except exceptions.NotFound:
+                    continue  # no entity managing this extension found
+
+            log.info(
+                "{name} method will be used to upload the file".format(name=method_name)
+            )
+            progress_id, download_d = await upload_cb(
+                client, filepath, filename, upload_jid, extra
+            )
+            return progress_id, download_d
+
+        raise exceptions.NotFound("Can't find any method to upload a file")
+
+    def register(self, method_name, available_cb, upload_cb, priority=0):
+        """Register a fileUploading method
+
+        @param method_name(unicode): short name for the method, must be unique
+        @param available_cb(callable): method to call to check if this method is usable
+           the callback must take two arguments: upload_jid (can be None) and profile
+           the callback must return the first entity found (being upload_jid or one of its
+           components)
+           exceptions.NotFound must be raised if no entity has been found
+        @param upload_cb(callable): method to upload a file
+            must have the same signature as [file_upload]
+            must return a tuple with progress_id and a Deferred which fire download URL
+            when upload is finished
+        @param priority(int): pririoty of this method, the higher available will be used
+        """
+        assert method_name
+        for data in self._upload_callbacks:
+            if method_name == data[0]:
+                raise exceptions.ConflictError(
+                    "A method with this name is already registered"
+                )
+        self._upload_callbacks.append((method_name, available_cb, upload_cb, priority))
+        self._upload_callbacks.sort(key=lambda data: data[3], reverse=True)
+
+    def unregister(self, method_name):
+        for idx, data in enumerate(self._upload_callbacks):
+            if data[0] == method_name:
+                del [idx]
+                return
+        raise exceptions.NotFound("The name to unregister doesn't exist")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_uri_finder.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,96 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin to find URIs
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from twisted.internet import defer
+import textwrap
+log = getLogger(__name__)
+import json
+import os.path
+import os
+import re
+
+PLUGIN_INFO = {
+    C.PI_NAME: _("URI finder"),
+    C.PI_IMPORT_NAME: "uri_finder",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "URIFinder",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: textwrap.dedent(_("""\
+    Plugin to find URIs in well know location.
+    This allows to retrieve settings to work with a project (e.g. pubsub node used for merge-requests).
+    """))
+}
+
+
+SEARCH_FILES = ('readme', 'contributing')
+
+
+class URIFinder(object):
+
+    def __init__(self, host):
+        log.info(_("URI finder plugin initialization"))
+        self.host = host
+        host.bridge.add_method("uri_find", ".plugin",
+                              in_sign='sas', out_sign='a{sa{ss}}',
+                              method=self.find,
+                              async_=True)
+
+    def find(self, path, keys):
+        """Look for URI in well known locations
+
+        @param path(unicode): path to start with
+        @param keys(list[unicode]): keys lookeds after
+            e.g.: "tickets", "merge-requests"
+        @return (dict[unicode, unicode]): map from key to found uri
+        """
+        keys_re = '|'.join(keys)
+        label_re = r'"(?P<label>[^"]+)"'
+        uri_re = re.compile(r'(?P<key>{keys_re})[ :]? +(?P<uri>xmpp:\S+)(?:.*use {label_re} label)?'.format(
+            keys_re=keys_re, label_re = label_re))
+        path = os.path.normpath(path)
+        if not os.path.isdir(path) or not os.path.isabs(path):
+            raise ValueError('path must be an absolute path to a directory')
+
+        found_uris = {}
+        while path != '/':
+            for filename in os.listdir(path):
+                name, __ = os.path.splitext(filename)
+                if name.lower() in SEARCH_FILES:
+                    file_path = os.path.join(path, filename)
+                    with open(file_path) as f:
+                        for m in uri_re.finditer(f.read()):
+                            key = m.group('key')
+                            uri = m.group('uri')
+                            label = m.group('label')
+                            if key in found_uris:
+                                log.warning(_("Ignoring already found uri for key \"{key}\"").format(key=key))
+                            else:
+                                uri_data = found_uris[key] = {'uri': uri}
+                                if label is not None:
+                                    uri_data['labels'] = json.dumps([label])
+            if found_uris:
+                break
+            path = os.path.dirname(path)
+
+        return defer.succeed(found_uris)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_watched.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+
+
+# SàT plugin to be notified on some entities presence
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+from libervia.backend.tools import xml_tools
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Watched",
+    C.PI_IMPORT_NAME: "WATCHED",
+    C.PI_TYPE: "Misc",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "Watched",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _(
+        """Watch for entities presence, and send notification accordingly"""
+    ),
+}
+
+
+CATEGORY = D_("Misc")
+NAME = "Watched"
+NOTIF = D_("Watched entity {entity} is connected")
+
+
+class Watched(object):
+    params = """
+    <params>
+    <individual>
+    <category name="{category_name}" label="{category_label}">
+        <param name="{name}" label="{label}" type="jids_list" security="0" />
+    </category>
+    </individual>
+    </params>
+    """.format(
+        category_name=CATEGORY, category_label=_(CATEGORY), name=NAME, label=_(NAME)
+    )
+
+    def __init__(self, host):
+        log.info(_("Watched initialisation"))
+        self.host = host
+        host.memory.update_params(self.params)
+        host.trigger.add("presence_received", self._presence_received_trigger)
+
+    def _presence_received_trigger(self, client, entity, show, priority, statuses):
+        if show == C.PRESENCE_UNAVAILABLE:
+            return True
+
+        # we check that the previous presence was unavailable (no notification else)
+        try:
+            old_show = self.host.memory.get_entity_datum(
+                client, entity, "presence").show
+        except (KeyError, exceptions.UnknownEntityError):
+            old_show = C.PRESENCE_UNAVAILABLE
+
+        if old_show == C.PRESENCE_UNAVAILABLE:
+            watched = self.host.memory.param_get_a(
+                NAME, CATEGORY, profile_key=client.profile)
+            if entity in watched or entity.userhostJID() in watched:
+                self.host.action_new(
+                    {
+                        "xmlui": xml_tools.note(
+                            _(NOTIF).format(entity=entity.full())
+                        ).toXml()
+                    },
+                    profile=client.profile,
+                )
+
+        return True
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_welcome.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for file tansfer
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.tools import xml_tools
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Welcome",
+    C.PI_IMPORT_NAME: "WELCOME",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_MAIN: "Welcome",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _(
+        """Plugin which manage welcome message and things to to on first connection."""
+    ),
+}
+
+
+WELCOME_PARAM_CATEGORY = "General"
+WELCOME_PARAM_NAME = "welcome"
+WELCOME_PARAM_LABEL = D_("Display welcome message")
+WELCOME_MSG_TITLE = D_("Welcome to Libervia/Salut à Toi")
+# XXX: this message is mainly targetting libervia new users for now
+#      (i.e.: it may look weird on other frontends)
+WELCOME_MSG = D_(
+    """Welcome to a free (as in freedom) network!
+
+If you have any trouble, or you want to help us for the bug hunting, you can contact us in real time chat by using the “Help / Official chat room”  menu.
+
+To use Libervia, you'll need to add contacts, either people you know, or people you discover by using the “Contacts / Search directory” menu.
+
+We hope that you'll enjoy using this project.
+
+The Libervia/Salut à Toi Team
+"""
+)
+
+
+PARAMS = """
+    <params>
+    <individual>
+    <category name="{category}">
+        <param name="{name}" label="{label}" type="bool" />
+    </category>
+    </individual>
+    </params>
+    """.format(
+    category=WELCOME_PARAM_CATEGORY, name=WELCOME_PARAM_NAME, label=WELCOME_PARAM_LABEL
+)
+
+
+class Welcome(object):
+    def __init__(self, host):
+        log.info(_("plugin Welcome initialization"))
+        self.host = host
+        host.memory.update_params(PARAMS)
+
+    def profile_connected(self, client):
+        # XXX: if you wan to try first_start again, you'll have to remove manually
+        #      the welcome value from your profile params in sat.db
+        welcome = self.host.memory.params.param_get_a(
+            WELCOME_PARAM_NAME,
+            WELCOME_PARAM_CATEGORY,
+            use_default=False,
+            profile_key=client.profile,
+        )
+        if welcome is None:
+            first_start = True
+            welcome = True
+        else:
+            first_start = False
+
+        if welcome:
+            xmlui = xml_tools.note(WELCOME_MSG, WELCOME_MSG_TITLE)
+            self.host.action_new({"xmlui": xmlui.toXml()}, profile=client.profile)
+            self.host.memory.param_set(
+                WELCOME_PARAM_NAME,
+                C.BOOL_FALSE,
+                WELCOME_PARAM_CATEGORY,
+                profile_key=client.profile,
+            )
+
+        self.host.trigger.point("WELCOME", first_start, welcome, client.profile)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_misc_xmllog.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+
+
+# SàT plugin for managing raw XML log
+# Copyright (C) 2011  Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from twisted.words.xish import domish
+from functools import partial
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Raw XML log Plugin",
+    C.PI_IMPORT_NAME: "XmlLog",
+    C.PI_TYPE: "Misc",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "XmlLog",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Send raw XML logs to bridge"""),
+}
+
+
+class XmlLog(object):
+
+    params = """
+    <params>
+    <general>
+    <category name="Debug">
+        <param name="Xml log" label="%(label_xmllog)s" value="false" type="bool" />
+    </category>
+    </general>
+    </params>
+    """ % {
+        "label_xmllog": _("Activate XML log")
+    }
+
+    def __init__(self, host):
+        log.info(_("Plugin XML Log initialization"))
+        self.host = host
+        host.memory.update_params(self.params)
+        host.bridge.add_signal(
+            "xml_log", ".plugin", signature="sss"
+        )  # args: direction("IN" or "OUT"), xml_data, profile
+
+        host.trigger.add("stream_hooks", self.add_hooks)
+
+    def add_hooks(self, client, receive_hooks, send_hooks):
+        self.do_log = self.host.memory.param_get_a("Xml log", "Debug")
+        if self.do_log:
+            receive_hooks.append(partial(self.on_receive, client=client))
+            send_hooks.append(partial(self.on_send, client=client))
+            log.info(_("XML log activated"))
+        return True
+
+    def on_receive(self, element, client):
+        self.host.bridge.xml_log("IN", element.toXml(), client.profile)
+
+    def on_send(self, obj, client):
+        if isinstance(obj, str):
+            xml_log = obj
+        elif isinstance(obj, domish.Element):
+            xml_log = obj.toXml()
+        else:
+            log.error(_("INTERNAL ERROR: Unmanaged XML type"))
+        self.host.bridge.xml_log("OUT", xml_log, client.profile)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_pubsub_cache.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,861 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for PubSub Caching
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import time
+from datetime import datetime
+from typing import Optional, List, Tuple, Dict, Any
+from twisted.words.protocols.jabber import jid, error
+from twisted.words.xish import domish
+from twisted.internet import defer
+from wokkel import pubsub, rsm
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.tools import xml_tools, utils
+from libervia.backend.tools.common import data_format
+from libervia.backend.memory.sqla import PubsubNode, PubsubItem, SyncState, IntegrityError
+
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "PubSub Cache",
+    C.PI_IMPORT_NAME: "PUBSUB_CACHE",
+    C.PI_TYPE: C.PLUG_TYPE_PUBSUB,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0059", "XEP-0060"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "PubsubCache",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Local Cache for PubSub"""),
+}
+
+ANALYSER_KEYS_TO_COPY = ("name", "type", "to_sync", "parser")
+# maximum of items to cache
+CACHE_LIMIT = 5000
+# number of second before a progress caching is considered failed and tried again
+PROGRESS_DEADLINE = 60 * 60 * 6
+
+
+
+class PubsubCache:
+    # TODO: there is currently no notification for (un)subscribe events with XEP-0060,
+    #   but it would be necessary to have this data if some devices unsubscribe a cached
+    #   node, as we can then get out of sync. A protoXEP could be proposed to fix this
+    #   situation.
+    # TODO: handle configuration events
+
+    def __init__(self, host):
+        log.info(_("PubSub Cache initialization"))
+        strategy = host.memory.config_get(None, "pubsub_cache_strategy")
+        if strategy == "no_cache":
+            log.info(
+                _(
+                    "Pubsub cache won't be used due to pubsub_cache_strategy={value} "
+                    "setting."
+                ).format(value=repr(strategy))
+            )
+            self.use_cache = False
+        else:
+            self.use_cache = True
+        self.host = host
+        self._p = host.plugins["XEP-0060"]
+        self.analysers = {}
+        # map for caching in progress (node, service) => Deferred
+        self.in_progress = {}
+        self.host.trigger.add("XEP-0060_getItems", self._get_items_trigger)
+        self._p.add_managed_node(
+            "",
+            items_cb=self.on_items_event,
+            delete_cb=self.on_delete_event,
+            purge_db=self.on_purge_event,
+        )
+        host.bridge.add_method(
+            "ps_cache_get",
+            ".plugin",
+            in_sign="ssiassss",
+            out_sign="s",
+            method=self._get_items_from_cache,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_cache_sync",
+            ".plugin",
+            "sss",
+            out_sign="",
+            method=self._synchronise,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_cache_purge",
+            ".plugin",
+            "s",
+            out_sign="",
+            method=self._purge,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_cache_reset",
+            ".plugin",
+            "",
+            out_sign="",
+            method=self._reset,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_cache_search",
+            ".plugin",
+            "s",
+            out_sign="s",
+            method=self._search,
+            async_=True,
+        )
+
+    def register_analyser(self, analyser: dict) -> None:
+        """Register a new pubsub node analyser
+
+        @param analyser: An analyser is a dictionary which may have the following keys
+        (keys with a ``*`` are mandatory, at least one of ``node`` or ``namespace`` keys
+        must be used):
+
+            :name (str)*:
+              a unique name for this analyser. This name will be stored in database
+              to retrieve the analyser when necessary (notably to get the parsing method),
+              thus it is recommended to use a stable name such as the source plugin name
+              instead of a name which may change with standard evolution, such as the
+              feature namespace.
+
+            :type (str)*:
+              indicates what kind of items we are dealing with. Type must be a human
+              readable word, as it may be used in searches. Good types examples are
+              **blog** or **event**.
+
+            :node (str):
+              prefix of a node name which may be used to identify its type. Example:
+              *urn:xmpp:microblog:0* (a node starting with this name will be identified as
+              *blog* node).
+
+            :namespace (str):
+              root namespace of items. When analysing a node, the first item will be
+              retrieved. The analyser will be chosen its given namespace match the
+              namespace of the first child element of ``<item>`` element.
+
+            :to_sync (bool):
+              if True, the node must be synchronised in cache. The default False value
+              means that the pubsub service will always be requested.
+
+            :parser (callable):
+              method (which may be sync, a coroutine or a method returning a "Deferred")
+              to call to parse the ``domish.Element`` of the item. The result must be
+              dictionary which can be serialised to JSON.
+
+              The method must have the following signature:
+
+              .. function:: parser(client: SatXMPPEntity, item_elt: domish.Element, \
+                                   service: Optional[jid.JID], node: Optional[str]) \
+                                   -> dict
+                :noindex:
+
+            :match_cb (callable):
+              method (which may be sync, a coroutine or a method returning a "Deferred")
+              called when the analyser matches. The method is called with the curreny
+              analyse which is can modify **in-place**.
+
+              The method must have the following signature:
+
+              .. function:: match_cb(client: SatXMPPEntity, analyse: dict) -> None
+                :noindex:
+
+        @raise exceptions.Conflict: a analyser with this name already exists
+        """
+
+        name = analyser.get("name", "").strip().lower()
+        # we want the normalised name
+        analyser["name"] = name
+        if not name:
+            raise ValueError('"name" is mandatory in analyser')
+        if "type" not in analyser:
+            raise ValueError('"type" is mandatory in analyser')
+        type_test_keys = {"node", "namespace"}
+        if not type_test_keys.intersection(analyser):
+            raise ValueError(f'at least one of {type_test_keys} must be used')
+        if name in self.analysers:
+            raise exceptions.Conflict(
+                f"An analyser with the name {name!r} is already registered"
+            )
+        self.analysers[name] = analyser
+
+    async def cache_items(
+        self,
+        client: SatXMPPEntity,
+        pubsub_node: PubsubNode,
+        items: List[domish.Element]
+    ) -> None:
+        try:
+            parser = self.analysers[pubsub_node.analyser].get("parser")
+        except KeyError:
+            parser = None
+
+        if parser is not None:
+            parsed_items = [
+                await utils.as_deferred(
+                    parser,
+                    client,
+                    item,
+                    pubsub_node.service,
+                    pubsub_node.name
+                )
+                for item in items
+            ]
+        else:
+            parsed_items = None
+
+        await self.host.memory.storage.cache_pubsub_items(
+            client, pubsub_node, items, parsed_items
+        )
+
+    async def _cache_node(
+        self,
+        client: SatXMPPEntity,
+        pubsub_node: PubsubNode
+    ) -> None:
+        await self.host.memory.storage.update_pubsub_node_sync_state(
+            pubsub_node, SyncState.IN_PROGRESS
+        )
+        service, node = pubsub_node.service, pubsub_node.name
+        try:
+            log.debug(
+                f"Caching node {node!r} at {service} for {client.profile}"
+            )
+            if not pubsub_node.subscribed:
+                try:
+                    sub = await self._p.subscribe(client, service, node)
+                except Exception as e:
+                    log.warning(
+                        _(
+                            "Can't subscribe node {pubsub_node}, that means that "
+                            "synchronisation can't be maintained: {reason}"
+                        ).format(pubsub_node=pubsub_node, reason=e)
+                    )
+                else:
+                    if sub.state == "subscribed":
+                        sub_id = sub.subscriptionIdentifier
+                        log.debug(
+                            f"{pubsub_node} subscribed (subscription id: {sub_id!r})"
+                        )
+                        pubsub_node.subscribed = True
+                        await self.host.memory.storage.add(pubsub_node)
+                    else:
+                        log.warning(
+                            _(
+                                "{pubsub_node} is not subscribed, that means that "
+                                "synchronisation can't be maintained, and you may have "
+                                "to enforce subscription manually. Subscription state: "
+                                "{state}"
+                            ).format(pubsub_node=pubsub_node, state=sub.state)
+                        )
+
+            try:
+                await self.host.check_features(
+                    client, [rsm.NS_RSM, self._p.DISCO_RSM], pubsub_node.service
+                )
+            except error.StanzaError as e:
+                if e.condition == "service-unavailable":
+                    log.warning(
+                        "service {service} is hidding disco infos, we'll only cache "
+                        "latest 20 items"
+                    )
+                    items, __ = await client.pubsub_client.items(
+                        pubsub_node.service, pubsub_node.name, maxItems=20
+                    )
+                    await self.cache_items(
+                        client, pubsub_node, items
+                    )
+                else:
+                    raise e
+            except exceptions.FeatureNotFound:
+                log.warning(
+                    f"service {service} doesn't handle Result Set Management "
+                    "(XEP-0059), we'll only cache latest 20 items"
+                )
+                items, __ = await client.pubsub_client.items(
+                    pubsub_node.service, pubsub_node.name, maxItems=20
+                )
+                await self.cache_items(
+                    client, pubsub_node, items
+                )
+            else:
+                rsm_p = self.host.plugins["XEP-0059"]
+                rsm_request = rsm.RSMRequest()
+                cached_ids = set()
+                while True:
+                    items, rsm_response = await client.pubsub_client.items(
+                        service, node, rsm_request=rsm_request
+                    )
+                    await self.cache_items(
+                        client, pubsub_node, items
+                    )
+                    for item in items:
+                        item_id = item["id"]
+                        if item_id in cached_ids:
+                            log.warning(
+                                f"Pubsub node {node!r} at {service} is returning several "
+                                f"times the same item ({item_id!r}). This is illegal "
+                                "behaviour, and it means that Pubsub service "
+                                f"{service} is buggy and can't be cached properly. "
+                                f"Please report this to {service.host} administrators"
+                            )
+                            rsm_request = None
+                            break
+                        cached_ids.add(item["id"])
+                        if len(cached_ids) >= CACHE_LIMIT:
+                            log.warning(
+                                f"Pubsub node {node!r} at {service} contains more items "
+                                f"than the cache limit ({CACHE_LIMIT}). We stop "
+                                "caching here, at item {item['id']!r}."
+                            )
+                            rsm_request = None
+                            break
+                    rsm_request = rsm_p.get_next_request(rsm_request, rsm_response)
+                    if rsm_request is None:
+                        break
+
+            await self.host.memory.storage.update_pubsub_node_sync_state(
+                pubsub_node, SyncState.COMPLETED
+            )
+        except Exception as e:
+            import traceback
+            tb = traceback.format_tb(e.__traceback__)
+            log.error(
+                f"Can't cache node {node!r} at {service} for {client.profile}: {e}\n{tb}"
+            )
+            await self.host.memory.storage.update_pubsub_node_sync_state(
+                pubsub_node, SyncState.ERROR
+            )
+            await self.host.memory.storage.delete_pubsub_items(pubsub_node)
+            raise e
+
+    def _cache_node_clean(self, __, pubsub_node):
+        del self.in_progress[(pubsub_node.service, pubsub_node.name)]
+
+    def cache_node(
+        self,
+        client: SatXMPPEntity,
+        pubsub_node: PubsubNode
+    ) -> None:
+        """Launch node caching as a background task"""
+        d = defer.ensureDeferred(self._cache_node(client, pubsub_node))
+        d.addBoth(self._cache_node_clean, pubsub_node=pubsub_node)
+        self.in_progress[(pubsub_node.service, pubsub_node.name)] = d
+        return d
+
+    async def analyse_node(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        node: str,
+        pubsub_node : PubsubNode = None,
+    ) -> dict:
+        """Use registered analysers on a node to determine what it is used for"""
+        analyse = {"service": service, "node": node}
+        if pubsub_node is None:
+            try:
+                first_item = (await client.pubsub_client.items(
+                    service, node, 1
+                ))[0][0]
+            except IndexError:
+                pass
+            except error.StanzaError as e:
+                if e.condition == "item-not-found":
+                    pass
+                else:
+                    log.warning(
+                        f"Can't retrieve last item on node {node!r} at service "
+                        f"{service} for {client.profile}: {e}"
+                    )
+            else:
+                try:
+                    uri = first_item.firstChildElement().uri
+                except Exception as e:
+                    log.warning(
+                        f"Can't retrieve item namespace on node {node!r} at service "
+                        f"{service} for {client.profile}: {e}"
+                    )
+                else:
+                    analyse["namespace"] = uri
+            try:
+                conf = await self._p.getConfiguration(client, service, node)
+            except Exception as e:
+                log.warning(
+                    f"Can't retrieve configuration for node {node!r} at service {service} "
+                    f"for {client.profile}: {e}"
+                )
+            else:
+                analyse["conf"] = conf
+
+        for analyser in self.analysers.values():
+            try:
+                an_node = analyser["node"]
+            except KeyError:
+                pass
+            else:
+                if node.startswith(an_node):
+                    for key in ANALYSER_KEYS_TO_COPY:
+                        try:
+                            analyse[key] = analyser[key]
+                        except KeyError:
+                            pass
+                    found = True
+                    break
+            try:
+                namespace = analyse["namespace"]
+                an_namespace = analyser["namespace"]
+            except KeyError:
+                pass
+            else:
+                if namespace == an_namespace:
+                    for key in ANALYSER_KEYS_TO_COPY:
+                        try:
+                            analyse[key] = analyser[key]
+                        except KeyError:
+                            pass
+                    found = True
+                    break
+
+        else:
+            found = False
+            log.debug(
+                f"node {node!r} at service {service} doesn't match any known type"
+            )
+        if found:
+            try:
+                match_cb = analyser["match_cb"]
+            except KeyError:
+                pass
+            else:
+                await utils.as_deferred(match_cb, client, analyse)
+        return analyse
+
+    def _get_items_from_cache(
+        self, service="", node="", max_items=10, item_ids=None, sub_id=None,
+        extra="", profile_key=C.PROF_KEY_NONE
+    ):
+        d = defer.ensureDeferred(self._a_get_items_from_cache(
+            service, node, max_items, item_ids, sub_id, extra, profile_key
+        ))
+        d.addCallback(self._p.trans_items_data)
+        d.addCallback(self._p.serialise_items)
+        return d
+
+    async def _a_get_items_from_cache(
+        self, service, node, max_items, item_ids, sub_id, extra, profile_key
+    ):
+        client = self.host.get_client(profile_key)
+        service = jid.JID(service) if service else client.jid.userhostJID()
+        pubsub_node = await self.host.memory.storage.get_pubsub_node(
+            client, service, node
+        )
+        if pubsub_node is None:
+            raise exceptions.NotFound(
+                f"{node!r} at {service} doesn't exist in cache for {client.profile!r}"
+            )
+        max_items = None if max_items == C.NO_LIMIT else max_items
+        extra = self._p.parse_extra(data_format.deserialise(extra))
+        items, metadata = await self.get_items_from_cache(
+            client,
+            pubsub_node,
+            max_items,
+            item_ids,
+            sub_id or None,
+            extra.rsm_request,
+            extra.extra,
+        )
+        return [i.data for i in items], metadata
+
+    async def get_items_from_cache(
+        self,
+        client: SatXMPPEntity,
+        node: PubsubNode,
+        max_items: Optional[int] = None,
+        item_ids: Optional[List[str]] = None,
+        sub_id: Optional[str] = None,
+        rsm_request: Optional[rsm.RSMRequest] = None,
+        extra: Optional[Dict[str, Any]] = None
+    ) -> Tuple[List[PubsubItem], dict]:
+        """Get items from cache, using same arguments as for external Pubsub request"""
+        if extra is None:
+            extra = {}
+        if "mam" in extra:
+            raise NotImplementedError("MAM queries are not supported yet")
+        if max_items is None and rsm_request is None:
+            max_items = 20
+            pubsub_items, metadata = await self.host.memory.storage.get_items(
+                node, max_items=max_items, item_ids=item_ids or None,
+                order_by=extra.get(C.KEY_ORDER_BY)
+            )
+        elif max_items is not None:
+            if rsm_request is not None:
+                raise exceptions.InternalError(
+                    "Pubsub max items and RSM must not be used at the same time"
+                )
+            elif item_ids:
+                raise exceptions.InternalError(
+                    "Pubsub max items and item IDs must not be used at the same time"
+                )
+            pubsub_items, metadata = await self.host.memory.storage.get_items(
+                node, max_items=max_items, order_by=extra.get(C.KEY_ORDER_BY)
+            )
+        else:
+            desc = False
+            if rsm_request.before == "":
+                before = None
+                desc = True
+            else:
+                before = rsm_request.before
+            pubsub_items, metadata = await self.host.memory.storage.get_items(
+                node, max_items=rsm_request.max, before=before, after=rsm_request.after,
+                from_index=rsm_request.index, order_by=extra.get(C.KEY_ORDER_BY),
+                desc=desc, force_rsm=True,
+            )
+
+        return pubsub_items, metadata
+
+    async def on_items_event(self, client, event):
+        node = await self.host.memory.storage.get_pubsub_node(
+            client, event.sender, event.nodeIdentifier
+        )
+        if node is None:
+            return
+        if node.sync_state in (SyncState.COMPLETED, SyncState.IN_PROGRESS):
+            items = []
+            retract_ids = []
+            for elt in event.items:
+                if elt.name == "item":
+                    items.append(elt)
+                elif elt.name == "retract":
+                    item_id = elt.getAttribute("id")
+                    if not item_id:
+                        log.warning(
+                            "Ignoring invalid retract item element: "
+                            f"{xml_tools.p_fmt_elt(elt)}"
+                        )
+                        continue
+
+                    retract_ids.append(elt["id"])
+                else:
+                    log.warning(
+                        f"Unexpected Pubsub event element: {xml_tools.p_fmt_elt(elt)}"
+                    )
+            if items:
+                log.debug(f"[{client.profile}] caching new items received from {node}")
+                await self.cache_items(
+                    client, node, items
+                )
+            if retract_ids:
+                log.debug(f"deleting retracted items from {node}")
+                await self.host.memory.storage.delete_pubsub_items(
+                    node, items_names=retract_ids
+                )
+
+    async def on_delete_event(self, client, event):
+        log.debug(
+            f"deleting node {event.nodeIdentifier} from {event.sender} for "
+            f"{client.profile}"
+        )
+        await self.host.memory.storage.delete_pubsub_node(
+            [client.profile], [event.sender], [event.nodeIdentifier]
+        )
+
+    async def on_purge_event(self, client, event):
+        node = await self.host.memory.storage.get_pubsub_node(
+            client, event.sender, event.nodeIdentifier
+        )
+        if node is None:
+            return
+        log.debug(f"purging node {node} for {client.profile}")
+        await self.host.memory.storage.delete_pubsub_items(node)
+
+    async def _get_items_trigger(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+        max_items: Optional[int],
+        item_ids: Optional[List[str]],
+        sub_id: Optional[str],
+        rsm_request: Optional[rsm.RSMRequest],
+        extra: dict
+    ) -> Tuple[bool, Optional[Tuple[List[dict], dict]]]:
+        if not self.use_cache:
+            log.debug("cache disabled in settings")
+            return True, None
+        if extra.get(C.KEY_USE_CACHE) == False:
+            log.debug("skipping pubsub cache as requested")
+            return True, None
+        if service is None:
+            service = client.jid.userhostJID()
+        for __ in range(5):
+            pubsub_node = await self.host.memory.storage.get_pubsub_node(
+                client, service, node
+            )
+            if pubsub_node is not None and pubsub_node.sync_state == SyncState.COMPLETED:
+                analyse = {"to_sync": True}
+            else:
+                analyse = await self.analyse_node(client, service, node)
+
+            if pubsub_node is None:
+                try:
+                    pubsub_node = await self.host.memory.storage.set_pubsub_node(
+                        client,
+                        service,
+                        node,
+                        analyser=analyse.get("name"),
+                        type_=analyse.get("type"),
+                        subtype=analyse.get("subtype"),
+                    )
+                except IntegrityError as e:
+                    if "unique" in str(e.orig).lower():
+                        log.debug(
+                            "race condition on pubsub node creation in cache, trying "
+                            "again"
+                        )
+                    else:
+                        raise e
+            break
+        else:
+            raise exceptions.InternalError(
+                "Too many IntegrityError with UNIQUE constraint, something is going wrong"
+            )
+
+        if analyse.get("to_sync"):
+            if pubsub_node.sync_state == SyncState.COMPLETED:
+                if "mam" in extra:
+                    log.debug("MAM caching is not supported yet, skipping cache")
+                    return True, None
+                pubsub_items, metadata = await self.get_items_from_cache(
+                    client, pubsub_node, max_items, item_ids, sub_id, rsm_request, extra
+                )
+                return False, ([i.data for i in pubsub_items], metadata)
+
+            if pubsub_node.sync_state == SyncState.IN_PROGRESS:
+                if (service, node) not in self.in_progress:
+                    log.warning(
+                        f"{pubsub_node} is reported as being cached, but not caching is "
+                        "in progress, this is most probably due to the backend being "
+                        "restarted. Resetting the status, caching will be done again."
+                    )
+                    pubsub_node.sync_state = None
+                    await self.host.memory.storage.delete_pubsub_items(pubsub_node)
+                elif time.time() - pubsub_node.sync_state_updated > PROGRESS_DEADLINE:
+                    log.warning(
+                        f"{pubsub_node} is in progress for too long "
+                        f"({pubsub_node.sync_state_updated//60} minutes), "
+                        "cancelling it and retrying."
+                    )
+                    self.in_progress.pop[(service, node)].cancel()
+                    pubsub_node.sync_state = None
+                    await self.host.memory.storage.delete_pubsub_items(pubsub_node)
+                else:
+                    log.debug(
+                        f"{pubsub_node} synchronisation is already in progress, skipping"
+                    )
+            if pubsub_node.sync_state is None:
+                key = (service, node)
+                if key in self.in_progress:
+                    raise exceptions.InternalError(
+                        f"There is already a caching in progress for {pubsub_node}, this "
+                        "should not happen"
+                    )
+                self.cache_node(client, pubsub_node)
+            elif pubsub_node.sync_state == SyncState.ERROR:
+                log.debug(
+                    f"{pubsub_node} synchronisation has previously failed, skipping"
+                )
+
+        return True, None
+
+    async def _subscribe_trigger(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        nodeIdentifier: str,
+        sub_jid: Optional[jid.JID],
+        options: Optional[dict],
+        subscription: pubsub.Subscription
+    ) -> None:
+        pass
+
+    async def _unsubscribe_trigger(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        nodeIdentifier: str,
+        sub_jid,
+        subscriptionIdentifier,
+        sender,
+    ) -> None:
+        pass
+
+    def _synchronise(self, service, node, profile_key):
+        client = self.host.get_client(profile_key)
+        service = client.jid.userhostJID() if not service else jid.JID(service)
+        return defer.ensureDeferred(self.synchronise(client, service, node))
+
+    async def synchronise(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        node: str,
+        resync: bool = True
+    ) -> None:
+        """Synchronise a node with a pubsub service
+
+        The node will be synchronised even if there is no matching analyser.
+
+        Note that when a node is synchronised, it is automatically subscribed.
+        @param resync: if True and the node is already synchronised, it will be
+            resynchronised (all items will be deleted and re-downloaded).
+
+        """
+        pubsub_node = await self.host.memory.storage.get_pubsub_node(
+            client, service, node
+        )
+        if pubsub_node is None:
+            log.info(
+                _(
+                    "Synchronising the new node {node} at {service}"
+                ).format(node=node, service=service.full)
+            )
+            analyse = await self.analyse_node(client, service, node)
+            pubsub_node = await self.host.memory.storage.set_pubsub_node(
+                client,
+                service,
+                node,
+                analyser=analyse.get("name"),
+                type_=analyse.get("type"),
+            )
+        elif not resync and pubsub_node.sync_state is not None:
+                # the node exists, nothing to do
+                return
+
+        if ((pubsub_node.sync_state == SyncState.IN_PROGRESS
+             or (service, node) in self.in_progress)):
+            log.warning(
+                _(
+                    "{node} at {service} is already being synchronised, can't do a new "
+                    "synchronisation."
+                ).format(node=node, service=service)
+            )
+        else:
+            log.info(
+                _(
+                    "(Re)Synchronising the node {node} at {service} on user request"
+                ).format(node=node, service=service.full())
+            )
+            # we first delete and recreate the node (will also delete its items)
+            await self.host.memory.storage.delete(pubsub_node)
+            analyse = await self.analyse_node(client, service, node)
+            pubsub_node = await self.host.memory.storage.set_pubsub_node(
+                client,
+                service,
+                node,
+                analyser=analyse.get("name"),
+                type_=analyse.get("type"),
+            )
+            # then we can put node in cache
+            await self.cache_node(client, pubsub_node)
+
+    async def purge(self, purge_filters: dict) -> None:
+        """Remove items according to filters
+
+        filters can have on of the following keys, all are optional:
+
+            :services:
+                list of JIDs of services from which items must be deleted
+            :nodes:
+                list of node names to delete
+            :types:
+                list of node types to delete
+            :subtypes:
+                list of node subtypes to delete
+            :profiles:
+                list of profiles from which items must be deleted
+            :created_before:
+                datetime before which items must have been created to be deleted
+            :created_update:
+                datetime before which items must have been updated last to be deleted
+        """
+        purge_filters["names"] = purge_filters.pop("nodes", None)
+        await self.host.memory.storage.purge_pubsub_items(**purge_filters)
+
+    def _purge(self, purge_filters: str) -> None:
+        purge_filters = data_format.deserialise(purge_filters)
+        for key in "created_before", "updated_before":
+            try:
+                purge_filters[key] = datetime.fromtimestamp(purge_filters[key])
+            except (KeyError, TypeError):
+                pass
+        return defer.ensureDeferred(self.purge(purge_filters))
+
+    async def reset(self) -> None:
+        """Remove ALL nodes and items from cache
+
+        After calling this method, cache will be refilled progressively as if it where new
+        """
+        await self.host.memory.storage.delete_pubsub_node(None, None, None)
+
+    def _reset(self) -> defer.Deferred:
+        return defer.ensureDeferred(self.reset())
+
+    async def search(self, query: dict) -> List[PubsubItem]:
+        """Search pubsub items in cache"""
+        return await self.host.memory.storage.search_pubsub_items(query)
+
+    async def serialisable_search(self, query: dict) -> List[dict]:
+        """Search pubsub items in cache and returns parsed data
+
+        The returned data can be serialised.
+
+        "pubsub_service" and "pubsub_name" will be added to each data (both as strings)
+        """
+        items = await self.search(query)
+        ret = []
+        for item in items:
+            parsed = item.parsed
+            parsed["pubsub_service"] = item.node.service.full()
+            parsed["pubsub_node"] = item.node.name
+            if query.get("with_payload"):
+                parsed["item_payload"] = item.data.toXml()
+            parsed["node_profile"] = self.host.memory.storage.get_profile_by_id(
+                item.node.profile_id
+            )
+
+            ret.append(parsed)
+        return ret
+
+    def _search(self, query: str) -> defer.Deferred:
+        query = data_format.deserialise(query)
+        services = query.get("services")
+        if services:
+            query["services"] = [jid.JID(s) for s in services]
+        d = defer.ensureDeferred(self.serialisable_search(query))
+        d.addCallback(data_format.serialise)
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_sec_aesgcm.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,327 @@
+#!/usr/bin/env python3
+
+# SàT plugin for handling AES-GCM file encryption
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import re
+from textwrap import dedent
+from functools import partial
+from urllib import parse
+import mimetypes
+import secrets
+from cryptography.hazmat.primitives import ciphers
+from cryptography.hazmat.primitives.ciphers import modes
+from cryptography.hazmat import backends
+from cryptography.exceptions import AlreadyFinalized
+import treq
+from twisted.internet import defer
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.tools import stream
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.web import treq_client_no_ssl
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "AES-GCM",
+    C.PI_IMPORT_NAME: "AES-GCM",
+    C.PI_TYPE: "SEC",
+    C.PI_PROTOCOLS: ["OMEMO Media sharing"],
+    C.PI_DEPENDENCIES: ["XEP-0363", "XEP-0384", "DOWNLOAD", "ATTACH"],
+    C.PI_MAIN: "AESGCM",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: dedent(_("""\
+    Implementation of AES-GCM scheme, a way to encrypt files (not official XMPP standard).
+    See https://xmpp.org/extensions/inbox/omemo-media-sharing.html for details
+    """)),
+}
+
+AESGCM_RE = re.compile(
+    r'aesgcm:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9'
+    r'()@:%_\+.~#?&\/\/=]*)')
+
+
+class AESGCM(object):
+
+    def __init__(self, host):
+        self.host = host
+        log.info(_("AESGCM plugin initialization"))
+        self._http_upload = host.plugins['XEP-0363']
+        self._attach = host.plugins["ATTACH"]
+        host.plugins["DOWNLOAD"].register_scheme(
+            "aesgcm", self.download
+        )
+        self._attach.register(
+            self.can_handle_attachment, self.attach, encrypted=True)
+        host.trigger.add("XEP-0363_upload_pre_slot", self._upload_pre_slot)
+        host.trigger.add("XEP-0363_upload", self._upload_trigger)
+        host.trigger.add("message_received", self._message_received_trigger)
+
+    async def download(self, client, uri_parsed, dest_path, options):
+        fragment = bytes.fromhex(uri_parsed.fragment)
+
+        # legacy method use 16 bits IV, but OMEMO media sharing published spec indicates
+        # which is 12 bits IV (AES-GCM spec recommandation), so we have to determine
+        # which size has been used.
+        if len(fragment) == 48:
+            iv_size = 16
+        elif len(fragment) == 44:
+            iv_size = 12
+        else:
+            raise ValueError(
+                f"Invalid URL fragment, can't decrypt file at {uri_parsed.get_url()}")
+
+        iv, key = fragment[:iv_size], fragment[iv_size:]
+
+        decryptor = ciphers.Cipher(
+            ciphers.algorithms.AES(key),
+            modes.GCM(iv),
+            backend=backends.default_backend(),
+        ).decryptor()
+
+        download_url = parse.urlunparse(
+            ('https', uri_parsed.netloc, uri_parsed.path, '', '', ''))
+
+        if options.get('ignore_tls_errors', False):
+            log.warning(
+                "TLS certificate check disabled, this is highly insecure"
+            )
+            treq_client = treq_client_no_ssl
+        else:
+            treq_client = treq
+
+        head_data = await treq_client.head(download_url)
+        content_length = int(head_data.headers.getRawHeaders('content-length')[0])
+        # the 128 bits tag is put at the end
+        file_size = content_length - 16
+
+        file_obj = stream.SatFile(
+            self.host,
+            client,
+            dest_path,
+            mode="wb",
+            size = file_size,
+        )
+
+        progress_id = file_obj.uid
+
+        resp = await treq_client.get(download_url, unbuffered=True)
+        if resp.code == 200:
+            d = treq.collect(resp, partial(
+                self.on_data_download,
+                client=client,
+                file_obj=file_obj,
+                decryptor=decryptor))
+        else:
+            d = defer.Deferred()
+            self.host.plugins["DOWNLOAD"].errback_download(file_obj, d, resp)
+        return progress_id, d
+
+    async def can_handle_attachment(self, client, data):
+        try:
+            await self._http_upload.get_http_upload_entity(client)
+        except exceptions.NotFound:
+            return False
+        else:
+            return True
+
+    async def _upload_cb(self, client, filepath, filename, extra):
+        extra['encryption'] = C.ENC_AES_GCM
+        return await self._http_upload.file_http_upload(
+            client=client,
+            filepath=filepath,
+            filename=filename,
+            extra=extra
+        )
+
+    async def attach(self, client, data):
+        # XXX: the attachment removal/resend code below is due to the one file per
+        #   message limitation of OMEMO media sharing unofficial XEP. We have to remove
+        #   attachments from original message, and send them one by one.
+        # TODO: this is to be removed when a better mechanism is available with OMEMO (now
+        #   possible with the 0.4 version of OMEMO, it's possible to encrypt other stanza
+        #   elements than body).
+        attachments = data["extra"][C.KEY_ATTACHMENTS]
+        if not data['message'] or data['message'] == {'': ''}:
+            extra_attachments = attachments[1:]
+            del attachments[1:]
+            await self._attach.upload_files(client, data, upload_cb=self._upload_cb)
+        else:
+            # we have a message, we must send first attachment separately
+            extra_attachments = attachments[:]
+            attachments.clear()
+            del data["extra"][C.KEY_ATTACHMENTS]
+
+        body_elt = data["xml"].body
+        if body_elt is None:
+            body_elt = data["xml"].addElement("body")
+
+        for attachment in attachments:
+            body_elt.addContent(attachment["url"])
+
+        for attachment in extra_attachments:
+            # we send all remaining attachment in a separate message
+            await client.sendMessage(
+                to_jid=data['to'],
+                message={'': ''},
+                subject=data['subject'],
+                mess_type=data['type'],
+                extra={C.KEY_ATTACHMENTS: [attachment]},
+            )
+
+        if ((not data['extra']
+             and (not data['message'] or data['message'] == {'': ''})
+             and not data['subject'])):
+            # nothing left to send, we can cancel the message
+            raise exceptions.CancelError("Cancelled by AESGCM attachment handling")
+
+    def on_data_download(self, data, client, file_obj, decryptor):
+        if file_obj.tell() + len(data) > file_obj.size:
+            # we're reaching end of file with this bunch of data
+            # we may still have a last bunch if the tag is incomplete
+            bytes_left = file_obj.size - file_obj.tell()
+            if bytes_left > 0:
+                decrypted = decryptor.update(data[:bytes_left])
+                file_obj.write(decrypted)
+                tag = data[bytes_left:]
+            else:
+                tag = data
+            if len(tag) < 16:
+                # the tag is incomplete, either we'll get the rest in next data bunch
+                # or we have already the other part from last bunch of data
+                try:
+                    # we store partial tag in decryptor._sat_tag
+                    tag = decryptor._sat_tag + tag
+                except AttributeError:
+                    # no other part, we'll get the rest at next bunch
+                    decryptor.sat_tag = tag
+                else:
+                    # we have the complete tag, it must be 128 bits
+                    if len(tag) != 16:
+                        raise ValueError(f"Invalid tag: {tag}")
+            remain = decryptor.finalize_with_tag(tag)
+            file_obj.write(remain)
+            file_obj.close()
+        else:
+            decrypted = decryptor.update(data)
+            file_obj.write(decrypted)
+
+    def _upload_pre_slot(self, client, extra, file_metadata):
+        if extra.get('encryption') != C.ENC_AES_GCM:
+            return True
+        # the tag is appended to the file
+        file_metadata["size"] += 16
+        return True
+
+    def _encrypt(self, data, encryptor):
+        if data:
+            return encryptor.update(data)
+        else:
+            try:
+                # end of file is reached, me must finalize
+                ret = encryptor.finalize()
+                tag = encryptor.tag
+                return ret + tag
+            except AlreadyFinalized:
+                # as we have already finalized, we can now send EOF
+                return b''
+
+    def _upload_trigger(self, client, extra, sat_file, file_producer, slot):
+        if extra.get('encryption') != C.ENC_AES_GCM:
+            return True
+        log.debug("encrypting file with AES-GCM")
+        iv = secrets.token_bytes(12)
+        key = secrets.token_bytes(32)
+        fragment = f'{iv.hex()}{key.hex()}'
+        ori_url = parse.urlparse(slot.get)
+        # we change the get URL with the one with aesgcm scheme and containing the
+        # encoded key + iv
+        slot.get = parse.urlunparse(['aesgcm', *ori_url[1:5], fragment])
+
+        # encrypted data size will be bigger than original file size
+        # so we need to check with final data length to avoid a warning on close()
+        sat_file.check_size_with_read = True
+
+        # file_producer get length directly from file, and this cause trouble as
+        # we have to change the size because of encryption. So we adapt it here,
+        # else the producer would stop reading prematurely
+        file_producer.length = sat_file.size
+
+        encryptor = ciphers.Cipher(
+            ciphers.algorithms.AES(key),
+            modes.GCM(iv),
+            backend=backends.default_backend(),
+        ).encryptor()
+
+        if sat_file.data_cb is not None:
+            raise exceptions.InternalError(
+                f"data_cb was expected to be None, it is set to {sat_file.data_cb}")
+
+        # with data_cb we encrypt the file on the fly
+        sat_file.data_cb = partial(self._encrypt, encryptor=encryptor)
+        return True
+
+
+    def _pop_aesgcm_links(self, match, links):
+        link = match.group()
+        if link not in links:
+            links.append(link)
+        return ""
+
+    def _check_aesgcm_attachments(self, client, data):
+        if not data.get('message'):
+            return data
+        links = []
+
+        for lang, message in list(data['message'].items()):
+            message = AESGCM_RE.sub(
+                partial(self._pop_aesgcm_links, links=links),
+                message)
+            if links:
+                message = message.strip()
+                if not message:
+                    del data['message'][lang]
+                else:
+                    data['message'][lang] = message
+                mess_encrypted = client.encryption.isEncrypted(data)
+                attachments = data['extra'].setdefault(C.KEY_ATTACHMENTS, [])
+                for link in links:
+                    path = parse.urlparse(link).path
+                    attachment = {
+                        "url": link,
+                    }
+                    media_type = mimetypes.guess_type(path, strict=False)[0]
+                    if media_type is not None:
+                        attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE] = media_type
+
+                    if mess_encrypted:
+                        # we don't add the encrypted flag if the message itself is not
+                        # encrypted, because the decryption key is part of the link,
+                        # so sending it over unencrypted channel is like having no
+                        # encryption at all.
+                        attachment['encrypted'] = True
+                    attachments.append(attachment)
+
+        return data
+
+    def _message_received_trigger(self, client, message_elt, post_treat):
+        # we use a post_treat callback instead of "message_parse" trigger because we need
+        # to check if the "encrypted" flag is set to decide if we add the same flag to the
+        # attachment
+        post_treat.addCallback(partial(self._check_aesgcm_attachments, client))
+        return True
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_sec_otr.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,839 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for OTR encryption
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# XXX: thanks to Darrik L Mazey for his documentation
+#      (https://blog.darmasoft.net/2013/06/30/using-pure-python-otr.html)
+#      this implentation is based on it
+
+import copy
+import time
+import uuid
+from binascii import hexlify, unhexlify
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.tools import xml_tools
+from twisted.words.protocols.jabber import jid
+from twisted.python import failure
+from twisted.internet import defer
+from libervia.backend.memory import persistent
+import potr
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "OTR",
+    C.PI_IMPORT_NAME: "OTR",
+    C.PI_MODES: [C.PLUG_MODE_CLIENT],
+    C.PI_TYPE: "SEC",
+    C.PI_PROTOCOLS: ["XEP-0364"],
+    C.PI_DEPENDENCIES: ["XEP-0280", "XEP-0334"],
+    C.PI_MAIN: "OTR",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of OTR"""),
+}
+
+NS_OTR = "urn:xmpp:otr:0"
+PRIVATE_KEY = "PRIVATE KEY"
+OTR_MENU = D_("OTR")
+AUTH_TXT = D_(
+    "To authenticate your correspondent, you need to give your below fingerprint "
+    "*BY AN EXTERNAL CANAL* (i.e. not in this chat), and check that the one he gives "
+    "you is the same as below. If there is a mismatch, there can be a spy between you!"
+)
+DROP_TXT = D_(
+    "You private key is used to encrypt messages for your correspondent, nobody except "
+    "you must know it, if you are in doubt, you should drop it!\n\nAre you sure you "
+    "want to drop your private key?"
+)
+# NO_LOG_AND = D_(u"/!\\Your history is not logged anymore, and")   # FIXME: not used at the moment
+NO_ADV_FEATURES = D_("Some of advanced features are disabled !")
+
+DEFAULT_POLICY_FLAGS = {"ALLOW_V1": False, "ALLOW_V2": True, "REQUIRE_ENCRYPTION": True}
+
+OTR_STATE_TRUSTED = "trusted"
+OTR_STATE_UNTRUSTED = "untrusted"
+OTR_STATE_UNENCRYPTED = "unencrypted"
+OTR_STATE_ENCRYPTED = "encrypted"
+
+
+class Context(potr.context.Context):
+    def __init__(self, context_manager, other_jid):
+        self.context_manager = context_manager
+        super(Context, self).__init__(context_manager.account, other_jid)
+
+    @property
+    def host(self):
+        return self.context_manager.host
+
+    @property
+    def _p_hints(self):
+        return self.context_manager.parent._p_hints
+
+    @property
+    def _p_carbons(self):
+        return self.context_manager.parent._p_carbons
+
+    def get_policy(self, key):
+        if key in DEFAULT_POLICY_FLAGS:
+            return DEFAULT_POLICY_FLAGS[key]
+        else:
+            return False
+
+    def inject(self, msg_str, appdata=None):
+        """Inject encrypted data in the stream
+
+        if appdata is not None, we are sending a message in sendMessageDataTrigger
+        stanza will be injected directly if appdata is None,
+        else we just update the element and follow normal workflow
+        @param msg_str(str): encrypted message body
+        @param appdata(None, dict): None for signal message,
+            message data when an encrypted message is going to be sent
+        """
+        assert isinstance(self.peer, jid.JID)
+        msg = msg_str.decode('utf-8')
+        client = self.user.client
+        log.debug("injecting encrypted message to {to}".format(to=self.peer))
+        if appdata is None:
+            mess_data = {
+                "from": client.jid,
+                "to": self.peer,
+                "uid": str(uuid.uuid4()),
+                "message": {"": msg},
+                "subject": {},
+                "type": "chat",
+                "extra": {},
+                "timestamp": time.time(),
+            }
+            client.generate_message_xml(mess_data)
+            xml = mess_data['xml']
+            self._p_carbons.set_private(xml)
+            self._p_hints.add_hint_elements(xml, [
+                self._p_hints.HINT_NO_COPY,
+                self._p_hints.HINT_NO_PERMANENT_STORE])
+            client.send(mess_data["xml"])
+        else:
+            message_elt = appdata["xml"]
+            assert message_elt.name == "message"
+            message_elt.addElement("body", content=msg)
+
+    def stop_cb(self, __, feedback):
+        client = self.user.client
+        self.host.bridge.otr_state(
+            OTR_STATE_UNENCRYPTED, self.peer.full(), client.profile
+        )
+        client.feedback(self.peer, feedback)
+
+    def stop_eb(self, failure_):
+        # encryption may be already stopped in case of manual stop
+        if not failure_.check(exceptions.NotFound):
+            log.error("Error while stopping OTR encryption: {msg}".format(msg=failure_))
+
+    def is_trusted(self):
+        # we have to check value because potr code says that a 2-tuples should be
+        # returned while in practice it's either None or u"trusted"
+        trusted = self.getCurrentTrust()
+        if trusted is None:
+            return False
+        elif trusted == 'trusted':
+            return True
+        else:
+            log.error("Unexpected getCurrentTrust() value: {value}".format(
+                value=trusted))
+            return False
+
+    def set_state(self, state):
+        client = self.user.client
+        old_state = self.state
+        super(Context, self).set_state(state)
+        log.debug("set_state: %s (old_state=%s)" % (state, old_state))
+
+        if state == potr.context.STATE_PLAINTEXT:
+            feedback = _("/!\\ conversation with %(other_jid)s is now UNENCRYPTED") % {
+                "other_jid": self.peer.full()
+            }
+            d = defer.ensureDeferred(client.encryption.stop(self.peer, NS_OTR))
+            d.addCallback(self.stop_cb, feedback=feedback)
+            d.addErrback(self.stop_eb)
+            return
+        elif state == potr.context.STATE_ENCRYPTED:
+            defer.ensureDeferred(client.encryption.start(self.peer, NS_OTR))
+            try:
+                trusted = self.is_trusted()
+            except TypeError:
+                trusted = False
+            trusted_str = _("trusted") if trusted else _("untrusted")
+
+            if old_state == potr.context.STATE_ENCRYPTED:
+                feedback = D_(
+                    "{trusted} OTR conversation with {other_jid} REFRESHED"
+                ).format(trusted=trusted_str, other_jid=self.peer.full())
+            else:
+                feedback = D_(
+                    "{trusted} encrypted OTR conversation started with {other_jid}\n"
+                    "{extra_info}"
+                ).format(
+                    trusted=trusted_str,
+                    other_jid=self.peer.full(),
+                    extra_info=NO_ADV_FEATURES,
+                )
+            self.host.bridge.otr_state(
+                OTR_STATE_ENCRYPTED, self.peer.full(), client.profile
+            )
+        elif state == potr.context.STATE_FINISHED:
+            feedback = D_("OTR conversation with {other_jid} is FINISHED").format(
+                other_jid=self.peer.full()
+            )
+            d = defer.ensureDeferred(client.encryption.stop(self.peer, NS_OTR))
+            d.addCallback(self.stop_cb, feedback=feedback)
+            d.addErrback(self.stop_eb)
+            return
+        else:
+            log.error(D_("Unknown OTR state"))
+            return
+
+        client.feedback(self.peer, feedback)
+
+    def disconnect(self):
+        """Disconnect the session."""
+        if self.state != potr.context.STATE_PLAINTEXT:
+            super(Context, self).disconnect()
+
+    def finish(self):
+        """Finish the session
+
+        avoid to send any message but the user still has to end the session himself.
+        """
+        if self.state == potr.context.STATE_ENCRYPTED:
+            self.processTLVs([potr.proto.DisconnectTLV()])
+
+
+class Account(potr.context.Account):
+    # TODO: manage trusted keys: if a fingerprint is not used anymore,
+    #       we have no way to remove it from database yet (same thing for a
+    #       correspondent jid)
+    # TODO: manage explicit message encryption
+
+    def __init__(self, host, client):
+        log.debug("new account: %s" % client.jid)
+        if not client.jid.resource:
+            log.warning("Account created without resource")
+        super(Account, self).__init__(str(client.jid), "xmpp", 1024)
+        self.host = host
+        self.client = client
+
+    def load_privkey(self):
+        log.debug("load_privkey")
+        return self.privkey
+
+    def save_privkey(self):
+        log.debug("save_privkey")
+        if self.privkey is None:
+            raise exceptions.InternalError(_("Save is called but privkey is None !"))
+        priv_key = hexlify(self.privkey.serializePrivateKey())
+        encrypted_priv_key = self.host.memory.encrypt_value(priv_key, self.client.profile)
+        self.client._otr_data[PRIVATE_KEY] = encrypted_priv_key
+
+    def load_trusts(self):
+        trust_data = self.client._otr_data.get("trust", {})
+        for jid_, jid_data in trust_data.items():
+            for fingerprint, trust_level in jid_data.items():
+                log.debug(
+                    'setting trust for {jid}: [{fingerprint}] = "{trust_level}"'.format(
+                        jid=jid_, fingerprint=fingerprint, trust_level=trust_level
+                    )
+                )
+                self.trusts.setdefault(jid.JID(jid_), {})[fingerprint] = trust_level
+
+    def save_trusts(self):
+        log.debug("saving trusts for {profile}".format(profile=self.client.profile))
+        log.debug("trusts = {}".format(self.client._otr_data["trust"]))
+        self.client._otr_data.force("trust")
+
+    def set_trust(self, other_jid, fingerprint, trustLevel):
+        try:
+            trust_data = self.client._otr_data["trust"]
+        except KeyError:
+            trust_data = {}
+            self.client._otr_data["trust"] = trust_data
+        jid_data = trust_data.setdefault(other_jid.full(), {})
+        jid_data[fingerprint] = trustLevel
+        super(Account, self).set_trust(other_jid, fingerprint, trustLevel)
+
+
+class ContextManager(object):
+    def __init__(self, parent, client):
+        self.parent = parent
+        self.account = Account(parent.host, client)
+        self.contexts = {}
+
+    @property
+    def host(self):
+        return self.parent.host
+
+    def start_context(self, other_jid):
+        assert isinstance(other_jid, jid.JID)
+        context = self.contexts.setdefault(
+            other_jid, Context(self, other_jid)
+        )
+        return context
+
+    def get_context_for_user(self, other):
+        log.debug("get_context_for_user [%s]" % other)
+        if not other.resource:
+            log.warning("get_context_for_user called with a bare jid: %s" % other.full())
+        return self.start_context(other)
+
+
+class OTR(object):
+
+    def __init__(self, host):
+        log.info(_("OTR plugin initialization"))
+        self.host = host
+        self.context_managers = {}
+        self.skipped_profiles = (
+            set()
+        )  #  FIXME: OTR should not be skipped per profile, this need to be refactored
+        self._p_hints = host.plugins["XEP-0334"]
+        self._p_carbons = host.plugins["XEP-0280"]
+        host.trigger.add("message_received", self.message_received_trigger, priority=100000)
+        host.trigger.add("sendMessage", self.send_message_trigger, priority=100000)
+        host.trigger.add("send_message_data", self._send_message_data_trigger)
+        host.bridge.add_method(
+            "skip_otr", ".plugin", in_sign="s", out_sign="", method=self._skip_otr
+        )  # FIXME: must be removed, must be done on per-message basis
+        host.bridge.add_signal(
+            "otr_state", ".plugin", signature="sss"
+        )  # args: state, destinee_jid, profile
+        # XXX: menus are disabled in favor to the new more generic encryption menu
+        #      there are let here commented for a little while as a reference
+        # host.import_menu(
+        #     (OTR_MENU, D_(u"Start/Refresh")),
+        #     self._otr_start_refresh,
+        #     security_limit=0,
+        #     help_string=D_(u"Start or refresh an OTR session"),
+        #     type_=C.MENU_SINGLE,
+        # )
+        # host.import_menu(
+        #     (OTR_MENU, D_(u"End session")),
+        #     self._otr_session_end,
+        #     security_limit=0,
+        #     help_string=D_(u"Finish an OTR session"),
+        #     type_=C.MENU_SINGLE,
+        # )
+        # host.import_menu(
+        #     (OTR_MENU, D_(u"Authenticate")),
+        #     self._otr_authenticate,
+        #     security_limit=0,
+        #     help_string=D_(u"Authenticate user/see your fingerprint"),
+        #     type_=C.MENU_SINGLE,
+        # )
+        # host.import_menu(
+        #     (OTR_MENU, D_(u"Drop private key")),
+        #     self._drop_priv_key,
+        #     security_limit=0,
+        #     type_=C.MENU_SINGLE,
+        # )
+        host.trigger.add("presence_received", self._presence_received_trigger)
+        self.host.register_encryption_plugin(self, "OTR", NS_OTR, directed=True)
+
+    def _skip_otr(self, profile):
+        """Tell the backend to not handle OTR for this profile.
+
+        @param profile (str): %(doc_profile)s
+        """
+        # FIXME: should not be done per profile but per message, using extra data
+        #        for message received, profile wide hook may be need, but client
+        #        should be used anyway instead of a class attribute
+        self.skipped_profiles.add(profile)
+
+    @defer.inlineCallbacks
+    def profile_connecting(self, client):
+        if client.profile in self.skipped_profiles:
+            return
+        ctxMng = client._otr_context_manager = ContextManager(self, client)
+        client._otr_data = persistent.PersistentBinaryDict(NS_OTR, client.profile)
+        yield client._otr_data.load()
+        encrypted_priv_key = client._otr_data.get(PRIVATE_KEY, None)
+        if encrypted_priv_key is not None:
+            priv_key = self.host.memory.decrypt_value(
+                encrypted_priv_key, client.profile
+            )
+            ctxMng.account.privkey = potr.crypt.PK.parsePrivateKey(
+                unhexlify(priv_key.encode('utf-8'))
+            )[0]
+        else:
+            ctxMng.account.privkey = None
+        ctxMng.account.load_trusts()
+
+    def profile_disconnected(self, client):
+        if client.profile in self.skipped_profiles:
+            self.skipped_profiles.remove(client.profile)
+            return
+        for context in list(client._otr_context_manager.contexts.values()):
+            context.disconnect()
+        del client._otr_context_manager
+
+    # encryption plugin methods
+
+    def start_encryption(self, client, entity_jid):
+        self.start_refresh(client, entity_jid)
+
+    def stop_encryption(self, client, entity_jid):
+        self.end_session(client, entity_jid)
+
+    def get_trust_ui(self, client, entity_jid):
+        if not entity_jid.resource:
+            entity_jid.resource = self.host.memory.main_resource_get(
+                client, entity_jid
+            )  # FIXME: temporary and unsecure, must be changed when frontends
+               #        are refactored
+        ctxMng = client._otr_context_manager
+        otrctx = ctxMng.get_context_for_user(entity_jid)
+        priv_key = ctxMng.account.privkey
+
+        if priv_key is None:
+            # we have no private key yet
+            dialog = xml_tools.XMLUI(
+                C.XMLUI_DIALOG,
+                dialog_opt={
+                    C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_MESSAGE,
+                    C.XMLUI_DATA_MESS: _(
+                        "You have no private key yet, start an OTR conversation to "
+                        "have one"
+                    ),
+                    C.XMLUI_DATA_LVL: C.XMLUI_DATA_LVL_WARNING,
+                },
+                title=_("No private key"),
+            )
+            return dialog
+
+        other_fingerprint = otrctx.getCurrentKey()
+
+        if other_fingerprint is None:
+            # we have a private key, but not the fingerprint of our correspondent
+            dialog = xml_tools.XMLUI(
+                C.XMLUI_DIALOG,
+                dialog_opt={
+                    C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_MESSAGE,
+                    C.XMLUI_DATA_MESS: _(
+                        "Your fingerprint is:\n{fingerprint}\n\n"
+                        "Start an OTR conversation to have your correspondent one."
+                    ).format(fingerprint=priv_key),
+                    C.XMLUI_DATA_LVL: C.XMLUI_DATA_LVL_INFO,
+                },
+                title=_("Fingerprint"),
+            )
+            return dialog
+
+        def set_trust(raw_data, profile):
+            if xml_tools.is_xmlui_cancelled(raw_data):
+                return {}
+            # This method is called when authentication form is submited
+            data = xml_tools.xmlui_result_2_data_form_result(raw_data)
+            if data["match"] == "yes":
+                otrctx.setCurrentTrust(OTR_STATE_TRUSTED)
+                note_msg = _("Your correspondent {correspondent} is now TRUSTED")
+                self.host.bridge.otr_state(
+                    OTR_STATE_TRUSTED, entity_jid.full(), client.profile
+                )
+            else:
+                otrctx.setCurrentTrust("")
+                note_msg = _("Your correspondent {correspondent} is now UNTRUSTED")
+                self.host.bridge.otr_state(
+                    OTR_STATE_UNTRUSTED, entity_jid.full(), client.profile
+                )
+            note = xml_tools.XMLUI(
+                C.XMLUI_DIALOG,
+                dialog_opt={
+                    C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE,
+                    C.XMLUI_DATA_MESS: note_msg.format(correspondent=otrctx.peer),
+                },
+            )
+            return {"xmlui": note.toXml()}
+
+        submit_id = self.host.register_callback(set_trust, with_data=True, one_shot=True)
+        trusted = otrctx.is_trusted()
+
+        xmlui = xml_tools.XMLUI(
+            C.XMLUI_FORM,
+            title=_("Authentication ({entity_jid})").format(entity_jid=entity_jid.full()),
+            submit_id=submit_id,
+        )
+        xmlui.addText(_(AUTH_TXT))
+        xmlui.addDivider()
+        xmlui.addText(
+            D_("Your own fingerprint is:\n{fingerprint}").format(fingerprint=priv_key)
+        )
+        xmlui.addText(
+            D_("Your correspondent fingerprint should be:\n{fingerprint}").format(
+                fingerprint=other_fingerprint
+            )
+        )
+        xmlui.addDivider("blank")
+        xmlui.change_container("pairs")
+        xmlui.addLabel(D_("Is your correspondent fingerprint the same as here ?"))
+        xmlui.addList(
+            "match", [("yes", _("yes")), ("no", _("no"))], ["yes" if trusted else "no"]
+        )
+        return xmlui
+
+    def _otr_start_refresh(self, menu_data, profile):
+        """Start or refresh an OTR session
+
+        @param menu_data: %(menu_data)s
+        @param profile: %(doc_profile)s
+        """
+        client = self.host.get_client(profile)
+        try:
+            to_jid = jid.JID(menu_data["jid"])
+        except KeyError:
+            log.error(_("jid key is not present !"))
+            return defer.fail(exceptions.DataError)
+        self.start_refresh(client, to_jid)
+        return {}
+
+    def start_refresh(self, client, to_jid):
+        """Start or refresh an OTR session
+
+        @param to_jid(jid.JID): jid to start encrypted session with
+        """
+        encrypted_session = client.encryption.getSession(to_jid.userhostJID())
+        if encrypted_session and encrypted_session['plugin'].namespace != NS_OTR:
+            raise exceptions.ConflictError(_(
+                "Can't start an OTR session, there is already an encrypted session "
+                "with {name}").format(name=encrypted_session['plugin'].name))
+        if not to_jid.resource:
+            to_jid.resource = self.host.memory.main_resource_get(
+                client, to_jid
+            )  # FIXME: temporary and unsecure, must be changed when frontends
+               #        are refactored
+        otrctx = client._otr_context_manager.get_context_for_user(to_jid)
+        query = otrctx.sendMessage(0, b"?OTRv?")
+        otrctx.inject(query)
+
+    def _otr_session_end(self, menu_data, profile):
+        """End an OTR session
+
+        @param menu_data: %(menu_data)s
+        @param profile: %(doc_profile)s
+        """
+        client = self.host.get_client(profile)
+        try:
+            to_jid = jid.JID(menu_data["jid"])
+        except KeyError:
+            log.error(_("jid key is not present !"))
+            return defer.fail(exceptions.DataError)
+        self.end_session(client, to_jid)
+        return {}
+
+    def end_session(self, client, to_jid):
+        """End an OTR session"""
+        if not to_jid.resource:
+            to_jid.resource = self.host.memory.main_resource_get(
+                client, to_jid
+            )  # FIXME: temporary and unsecure, must be changed when frontends
+               #        are refactored
+        otrctx = client._otr_context_manager.get_context_for_user(to_jid)
+        otrctx.disconnect()
+        return {}
+
+    def _otr_authenticate(self, menu_data, profile):
+        """End an OTR session
+
+        @param menu_data: %(menu_data)s
+        @param profile: %(doc_profile)s
+        """
+        client = self.host.get_client(profile)
+        try:
+            to_jid = jid.JID(menu_data["jid"])
+        except KeyError:
+            log.error(_("jid key is not present !"))
+            return defer.fail(exceptions.DataError)
+        return self.authenticate(client, to_jid)
+
+    def authenticate(self, client, to_jid):
+        """Authenticate other user and see our own fingerprint"""
+        xmlui = self.get_trust_ui(client, to_jid)
+        return {"xmlui": xmlui.toXml()}
+
+    def _drop_priv_key(self, menu_data, profile):
+        """Drop our private Key
+
+        @param menu_data: %(menu_data)s
+        @param profile: %(doc_profile)s
+        """
+        client = self.host.get_client(profile)
+        try:
+            to_jid = jid.JID(menu_data["jid"])
+            if not to_jid.resource:
+                to_jid.resource = self.host.memory.main_resource_get(
+                    client, to_jid
+                )  # FIXME: temporary and unsecure, must be changed when frontends
+                   #        are refactored
+        except KeyError:
+            log.error(_("jid key is not present !"))
+            return defer.fail(exceptions.DataError)
+
+        ctxMng = client._otr_context_manager
+        if ctxMng.account.privkey is None:
+            return {
+                "xmlui": xml_tools.note(_("You don't have a private key yet !")).toXml()
+            }
+
+        def drop_key(data, profile):
+            if C.bool(data["answer"]):
+                # we end all sessions
+                for context in list(ctxMng.contexts.values()):
+                    context.disconnect()
+                ctxMng.account.privkey = None
+                ctxMng.account.getPrivkey()  # as account.privkey is None, getPrivkey
+                                             # will generate a new key, and save it
+                return {
+                    "xmlui": xml_tools.note(
+                        D_("Your private key has been dropped")
+                    ).toXml()
+                }
+            return {}
+
+        submit_id = self.host.register_callback(drop_key, with_data=True, one_shot=True)
+
+        confirm = xml_tools.XMLUI(
+            C.XMLUI_DIALOG,
+            title=_("Confirm private key drop"),
+            dialog_opt={"type": C.XMLUI_DIALOG_CONFIRM, "message": _(DROP_TXT)},
+            submit_id=submit_id,
+        )
+        return {"xmlui": confirm.toXml()}
+
+    def _received_treatment(self, data, client):
+        from_jid = data["from"]
+        log.debug("_received_treatment [from_jid = %s]" % from_jid)
+        otrctx = client._otr_context_manager.get_context_for_user(from_jid)
+
+        try:
+            message = (
+                next(iter(data["message"].values()))
+            )  # FIXME: Q&D fix for message refactoring, message is now a dict
+            res = otrctx.receiveMessage(message.encode("utf-8"))
+        except (potr.context.UnencryptedMessage, potr.context.NotOTRMessage):
+            # potr has a bug with Python 3 and test message against str while bytes are
+            # expected, resulting in a NoOTRMessage raised instead of UnencryptedMessage;
+            # so we catch NotOTRMessage as a workaround
+            # TODO: report this upstream
+            encrypted = False
+            if otrctx.state == potr.context.STATE_ENCRYPTED:
+                log.warning(
+                    "Received unencrypted message in an encrypted context (from {jid})"
+                    .format(jid=from_jid.full())
+                )
+
+                feedback = (
+                    D_(
+                        "WARNING: received unencrypted data in a supposedly encrypted "
+                        "context"
+                    ),
+                )
+                client.feedback(from_jid, feedback)
+        except potr.context.NotEncryptedError:
+            msg = D_("WARNING: received OTR encrypted data in an unencrypted context")
+            log.warning(msg)
+            feedback = msg
+            client.feedback(from_jid, msg)
+            raise failure.Failure(exceptions.CancelError(msg))
+        except potr.context.ErrorReceived as e:
+            msg = D_("WARNING: received OTR error message: {msg}".format(msg=e))
+            log.warning(msg)
+            feedback = msg
+            client.feedback(from_jid, msg)
+            raise failure.Failure(exceptions.CancelError(msg))
+        except potr.crypt.InvalidParameterError as e:
+            msg = D_("Error while trying de decrypt OTR message: {msg}".format(msg=e))
+            log.warning(msg)
+            feedback = msg
+            client.feedback(from_jid, msg)
+            raise failure.Failure(exceptions.CancelError(msg))
+        except StopIteration:
+            return data
+        else:
+            encrypted = True
+
+        if encrypted:
+            if res[0] != None:
+                # decrypted messages handling.
+                # receiveMessage() will return a tuple,
+                # the first part of which will be the decrypted message
+                data["message"] = {
+                    "": res[0]
+                }  # FIXME: Q&D fix for message refactoring, message is now a dict
+                try:
+                    # we want to keep message in history, even if no store is
+                    # requested in message hints
+                    del data["history"]
+                except KeyError:
+                    pass
+                # TODO: add skip history as an option, but by default we don't skip it
+                # data[u'history'] = C.HISTORY_SKIP # we send the decrypted message to
+                                                    # frontends, but we don't want it in
+                                                    # history
+            else:
+                raise failure.Failure(
+                    exceptions.CancelError("Cancelled by OTR")
+                )  # no message at all (no history, no signal)
+
+            client.encryption.mark_as_encrypted(data, namespace=NS_OTR)
+            trusted = otrctx.is_trusted()
+
+            if trusted:
+                client.encryption.mark_as_trusted(data)
+            else:
+                client.encryption.mark_as_untrusted(data)
+
+        return data
+
+    def _received_treatment_for_skipped_profiles(self, data):
+        """This profile must be skipped because the frontend manages OTR itself,
+
+        but we still need to check if the message must be stored in history or not
+        """
+        #  XXX: FIXME: this should not be done on a per-profile basis, but  per-message
+        try:
+            message = (
+                iter(data["message"].values()).next().encode("utf-8")
+            )  # FIXME: Q&D fix for message refactoring, message is now a dict
+        except StopIteration:
+            return data
+        if message.startswith(potr.proto.OTRTAG):
+            #  FIXME: it may be better to cancel the message and send it direclty to
+            #         bridge
+            #        this is used by Libervia, but this may send garbage message to
+            #        other frontends
+            #        if they are used at the same time as Libervia.
+            #        Hard to avoid with decryption on Libervia though.
+            data["history"] = C.HISTORY_SKIP
+        return data
+
+    def message_received_trigger(self, client, message_elt, post_treat):
+        if client.is_component:
+            return True
+        if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT:
+            # OTR is not possible in group chats
+            return True
+        from_jid = jid.JID(message_elt['from'])
+        if not from_jid.resource or from_jid.userhostJID() == client.jid.userhostJID():
+            # OTR is only usable when resources are present
+            return True
+        if client.profile in self.skipped_profiles:
+            post_treat.addCallback(self._received_treatment_for_skipped_profiles)
+        else:
+            post_treat.addCallback(self._received_treatment, client)
+        return True
+
+    def _send_message_data_trigger(self, client, mess_data):
+        if client.is_component:
+            return True
+        encryption = mess_data.get(C.MESS_KEY_ENCRYPTION)
+        if encryption is None or encryption['plugin'].namespace != NS_OTR:
+            return
+        to_jid = mess_data['to']
+        if not to_jid.resource:
+            to_jid.resource = self.host.memory.main_resource_get(
+                client, to_jid
+            )  # FIXME: temporary and unsecure, must be changed when frontends
+        otrctx = client._otr_context_manager.get_context_for_user(to_jid)
+        message_elt = mess_data["xml"]
+        if otrctx.state == potr.context.STATE_ENCRYPTED:
+            log.debug("encrypting message")
+            body = None
+            for child in list(message_elt.children):
+                if child.name == "body":
+                    # we remove all unencrypted body,
+                    # and will only encrypt the first one
+                    if body is None:
+                        body = child
+                    message_elt.children.remove(child)
+                elif child.name == "html":
+                    # we don't want any XHTML-IM element
+                    message_elt.children.remove(child)
+            if body is None:
+                log.warning("No message found")
+            else:
+                self._p_carbons.set_private(message_elt)
+                self._p_hints.add_hint_elements(message_elt, [
+                    self._p_hints.HINT_NO_COPY,
+                    self._p_hints.HINT_NO_PERMANENT_STORE])
+                otrctx.sendMessage(0, str(body).encode("utf-8"), appdata=mess_data)
+        else:
+            feedback = D_(
+                "Your message was not sent because your correspondent closed the "
+                "encrypted conversation on his/her side. "
+                "Either close your own side, or refresh the session."
+            )
+            log.warning(_("Message discarded because closed encryption channel"))
+            client.feedback(to_jid, feedback)
+            raise failure.Failure(exceptions.CancelError("Cancelled by OTR plugin"))
+
+    def send_message_trigger(self, client, mess_data, pre_xml_treatments,
+                           post_xml_treatments):
+        if client.is_component:
+            return True
+        if mess_data["type"] == "groupchat":
+            return True
+
+        if client.profile in self.skipped_profiles:
+            #  FIXME: should not be done on a per-profile basis
+            return True
+
+        to_jid = copy.copy(mess_data["to"])
+        if client.encryption.getSession(to_jid.userhostJID()):
+            # there is already an encrypted session with this entity
+            return True
+
+        if not to_jid.resource:
+            to_jid.resource = self.host.memory.main_resource_get(
+                client, to_jid
+            )  # FIXME: full jid may not be known
+
+        otrctx = client._otr_context_manager.get_context_for_user(to_jid)
+
+        if otrctx.state != potr.context.STATE_PLAINTEXT:
+            defer.ensureDeferred(client.encryption.start(to_jid, NS_OTR))
+            client.encryption.set_encryption_flag(mess_data)
+            if not mess_data["to"].resource:
+                # if not resource was given, we force it here
+                mess_data["to"] = to_jid
+        return True
+
+    def _presence_received_trigger(self, client, entity, show, priority, statuses):
+        if show != C.PRESENCE_UNAVAILABLE:
+            return True
+        if not entity.resource:
+            try:
+                entity.resource = self.host.memory.main_resource_get(
+                    client, entity
+                )  # FIXME: temporary and unsecure, must be changed when frontends
+                   #        are refactored
+            except exceptions.UnknownEntityError:
+                return True  #  entity was not connected
+        if entity in client._otr_context_manager.contexts:
+            otrctx = client._otr_context_manager.get_context_for_user(entity)
+            otrctx.disconnect()
+        return True
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_sec_oxps.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,788 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Pubsub Encryption
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import base64
+import dataclasses
+import secrets
+import time
+from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
+from collections import OrderedDict
+
+import shortuuid
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid, xmlstream
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from wokkel import rsm
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.memory import persistent
+from libervia.backend.tools import utils
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common.async_utils import async_lru
+
+from .plugin_xep_0373 import NS_OX, get_gpg_provider
+
+
+log = getLogger(__name__)
+
+IMPORT_NAME = "OXPS"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "OpenPGP for XMPP Pubsub",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0334", "XEP-0373"],
+    C.PI_MAIN: "PubsubEncryption",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Pubsub e2e encryption via OpenPGP"""),
+}
+NS_OXPS = "urn:xmpp:openpgp:pubsub:0"
+
+KEY_REVOKED = "revoked"
+CACHE_MAX = 5
+
+
+@dataclasses.dataclass
+class SharedSecret:
+    id: str
+    key: str
+    timestamp: float
+    # bare JID of who has generated the secret
+    origin: jid.JID
+    revoked: bool = False
+    shared_with: Set[jid.JID] = dataclasses.field(default_factory=set)
+
+
+class PubsubEncryption:
+    namespace = NS_OXPS
+
+    def __init__(self, host):
+        log.info(_("OpenPGP for XMPP Pubsub plugin initialization"))
+        host.register_namespace("oxps", NS_OXPS)
+        self.host = host
+        self._p = host.plugins["XEP-0060"]
+        self._h = host.plugins["XEP-0334"]
+        self._ox = host.plugins["XEP-0373"]
+        host.trigger.add("XEP-0060_publish", self._publish_trigger)
+        host.trigger.add("XEP-0060_items", self._items_trigger)
+        host.trigger.add(
+            "message_received",
+            self._message_received_trigger,
+        )
+        host.bridge.add_method(
+            "ps_secret_share",
+            ".plugin",
+            in_sign="sssass",
+            out_sign="",
+            method=self._ps_secret_share,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_secret_revoke",
+            ".plugin",
+            in_sign="sssass",
+            out_sign="",
+            method=self._ps_secret_revoke,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_secret_rotate",
+            ".plugin",
+            in_sign="ssass",
+            out_sign="",
+            method=self._ps_secret_rotate,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_secrets_list",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._ps_secrets_list,
+            async_=True,
+        )
+
+    def get_handler(self, client):
+        return PubsubEncryption_Handler()
+
+    async def profile_connecting(self, client):
+        client.__storage = persistent.LazyPersistentBinaryDict(
+            IMPORT_NAME, client.profile
+        )
+        # cache to avoid useless DB access, and to avoid race condition by ensuring that
+        # the same shared_secrets instance is always used for a given node.
+        client.__cache = OrderedDict()
+        self.gpg_provider = get_gpg_provider(self.host, client)
+
+    async def load_secrets(
+        self,
+        client: SatXMPPEntity,
+        node_uri: str
+    ) -> Optional[Dict[str, SharedSecret]]:
+        """Load shared secret from databse or cache
+
+        A cache is used per client to avoid usueless db access, as shared secrets are
+        often needed several times in a row. Cache is also necessary to avoir race
+        condition, when updating a secret, by ensuring that the same instance is used
+        for all updates during a session.
+
+        @param node_uri: XMPP URI of the encrypted pubsub node
+        @return shared secrets, or None if no secrets are known yet
+        """
+        try:
+            shared_secrets = client.__cache[node_uri]
+        except KeyError:
+            pass
+        else:
+            client.__cache.move_to_end(node_uri)
+            return shared_secrets
+
+        secrets_as_dict = await client.__storage.get(node_uri)
+
+        if secrets_as_dict is None:
+            return None
+        else:
+            shared_secrets = {
+                s["id"]: SharedSecret(
+                    id=s["id"],
+                    key=s["key"],
+                    timestamp=s["timestamp"],
+                    origin=jid.JID(s["origin"]),
+                    revoked=s["revoked"],
+                    shared_with={jid.JID(w) for w in s["shared_with"]}
+                ) for s in secrets_as_dict
+            }
+            client.__cache[node_uri] = shared_secrets
+            while len(client.__cache) > CACHE_MAX:
+                client.__cache.popitem(False)
+            return shared_secrets
+
+    def __secrect_dict_factory(self, data: List[Tuple[str, Any]]) -> Dict[str, Any]:
+        ret = {}
+        for k, v in data:
+            if k == "origin":
+                v = v.full()
+            elif k == "shared_with":
+                v = [j.full() for j in v]
+            ret[k] = v
+        return ret
+
+    async def store_secrets(
+        self,
+        client: SatXMPPEntity,
+        node_uri: str,
+        shared_secrets: Dict[str, SharedSecret]
+    ) -> None:
+        """Store shared secrets to database
+
+        Shared secrets are serialised before being stored.
+        If ``node_uri`` is not in cache, the shared_secrets instance is also put in cache/
+
+        @param node_uri: XMPP URI of the encrypted pubsub node
+        @param shared_secrets: shared secrets to store
+        """
+        if node_uri not in client.__cache:
+            client.__cache[node_uri] = shared_secrets
+            while len(client.__cache) > CACHE_MAX:
+                client.__cache.popitem(False)
+
+        secrets_as_dict = [
+            dataclasses.asdict(s, dict_factory=self.__secrect_dict_factory)
+            for s in shared_secrets.values()
+        ]
+        await client.__storage.aset(node_uri, secrets_as_dict)
+
+    def generate_secret(self, client: SatXMPPEntity) -> SharedSecret:
+        """Generate a new shared secret"""
+        log.info("Generating a new shared secret.")
+        secret_key = secrets.token_urlsafe(64)
+        secret_id = shortuuid.uuid()
+        return SharedSecret(
+            id = secret_id,
+            key = secret_key,
+            timestamp = time.time(),
+            origin = client.jid.userhostJID()
+        )
+
+    def _ps_secret_revoke(
+        self,
+        service: str,
+        node: str,
+        secret_id: str,
+        recipients: List[str],
+        profile_key: str
+    ) -> defer.Deferred:
+        return defer.ensureDeferred(
+            self.revoke(
+                self.host.get_client(profile_key),
+                jid.JID(service) if service else None,
+                node,
+                secret_id,
+                [jid.JID(r) for r in recipients] or None,
+            )
+        )
+
+    async def revoke(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+        secret_id: str,
+        recipients: Optional[Iterable[jid.JID]] = None
+    ) -> None:
+        """Revoke a secret and notify entities
+
+        @param service: pubsub/PEP service where the node is
+        @param node: node name
+        @param secret_id: ID of the secret to revoke (must have been generated by
+            ourselves)
+        recipients: JIDs of entities to send the revocation notice to. If None, all
+            entities known to have the shared secret will be notified.
+            Use empty list if you don't want to notify anybody (not recommended)
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if not shared_secrets:
+            raise exceptions.NotFound(f"No shared secret is known for {node_uri}")
+        try:
+            shared_secret = shared_secrets[secret_id]
+        except KeyError:
+            raise exceptions.NotFound(
+                f"No shared secret with ID {secret_id!r} has been found for {node_uri}"
+            )
+        else:
+            if shared_secret.origin != client.jid.userhostJID():
+                raise exceptions.PermissionError(
+                    f"The shared secret {shared_secret.id} originate from "
+                    f"{shared_secret.origin}, not you ({client.jid.userhostJID()}). You "
+                    "can't revoke it"
+                )
+            shared_secret.revoked = True
+        await self.store_secrets(client, node_uri, shared_secrets)
+        log.info(
+            f"shared secret {secret_id!r} for {node_uri} has been revoked."
+        )
+        if recipients is None:
+            recipients = shared_secret.shared_with
+        if recipients:
+            for recipient in recipients:
+                await self.send_revoke_notification(
+                    client, service, node, shared_secret.id, recipient
+                )
+            log.info(
+                f"shared secret {shared_secret.id} revocation notification for "
+                f"{node_uri} has been send to {''.join(str(r) for r in recipients)}"
+            )
+        else:
+            log.info(
+                "Due to empty recipients list, no revocation notification has been sent "
+                f"for shared secret {shared_secret.id} for {node_uri}"
+            )
+
+    async def send_revoke_notification(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        node: str,
+        secret_id: str,
+        recipient: jid.JID
+    ) -> None:
+        revoke_elt = domish.Element((NS_OXPS, "revoke"))
+        revoke_elt["jid"] = service.full()
+        revoke_elt["node"] = node
+        revoke_elt["id"] = secret_id
+        signcrypt_elt, payload_elt = self._ox.build_signcrypt_element([recipient])
+        payload_elt.addChild(revoke_elt)
+        openpgp_elt = await self._ox.build_openpgp_element(
+            client, signcrypt_elt, {recipient}
+        )
+        message_elt = domish.Element((None, "message"))
+        message_elt["from"] = client.jid.full()
+        message_elt["to"] = recipient.full()
+        message_elt.addChild((openpgp_elt))
+        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
+        client.send(message_elt)
+
+    def _ps_secret_share(
+        self,
+        recipient: str,
+        service: str,
+        node: str,
+        secret_ids: List[str],
+        profile_key: str
+    ) -> defer.Deferred:
+        return defer.ensureDeferred(
+            self.share_secrets(
+                self.host.get_client(profile_key),
+                jid.JID(recipient),
+                jid.JID(service) if service else None,
+                node,
+                secret_ids or None,
+            )
+        )
+
+    async def share_secret(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+        shared_secret: SharedSecret,
+        recipient: jid.JID
+    ) -> None:
+        """Create and send <shared-secret> element"""
+        if service is None:
+            service = client.jid.userhostJID()
+        shared_secret_elt = domish.Element((NS_OXPS, "shared-secret"))
+        shared_secret_elt["jid"] = service.full()
+        shared_secret_elt["node"] = node
+        shared_secret_elt["id"] = shared_secret.id
+        shared_secret_elt["timestamp"] = utils.xmpp_date(shared_secret.timestamp)
+        if shared_secret.revoked:
+            shared_secret_elt["revoked"] = C.BOOL_TRUE
+        # TODO: add type attribute
+        shared_secret_elt.addContent(shared_secret.key)
+        signcrypt_elt, payload_elt = self._ox.build_signcrypt_element([recipient])
+        payload_elt.addChild(shared_secret_elt)
+        openpgp_elt = await self._ox.build_openpgp_element(
+            client, signcrypt_elt, {recipient}
+        )
+        message_elt = domish.Element((None, "message"))
+        message_elt["from"] = client.jid.full()
+        message_elt["to"] = recipient.full()
+        message_elt.addChild((openpgp_elt))
+        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
+        client.send(message_elt)
+        shared_secret.shared_with.add(recipient)
+
+    async def share_secrets(
+        self,
+        client: SatXMPPEntity,
+        recipient: jid.JID,
+        service: Optional[jid.JID],
+        node: str,
+        secret_ids: Optional[List[str]] = None,
+    ) -> None:
+        """Share secrets of a pubsub node with a recipient
+
+        @param recipient: who to share secrets with
+        @param service: pubsub/PEP service where the node is
+        @param node: node name
+        @param secret_ids: IDs of the secrets to share, or None to share all known secrets
+            (disabled or not)
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if shared_secrets is None:
+            # no secret shared yet, let's generate one
+            shared_secret = self.generate_secret(client)
+            shared_secrets = {shared_secret.id: shared_secret}
+            await self.store_secrets(client, node_uri, shared_secrets)
+        if secret_ids is None:
+            # we share all secrets of the node
+            to_share = shared_secrets.values()
+        else:
+            try:
+                to_share = [shared_secrets[s_id] for s_id in secret_ids]
+            except KeyError as e:
+                raise exceptions.NotFound(
+                    f"no shared secret found with given ID: {e}"
+                )
+        for shared_secret in to_share:
+            await self.share_secret(client, service, node, shared_secret, recipient)
+        await self.store_secrets(client, node_uri, shared_secrets)
+
+    def _ps_secret_rotate(
+        self,
+        service: str,
+        node: str,
+        recipients: List[str],
+        profile_key: str,
+    ) -> defer.Deferred:
+        return defer.ensureDeferred(
+            self.rotate_secret(
+                self.host.get_client(profile_key),
+                jid.JID(service) if service else None,
+                node,
+                [jid.JID(r) for r in recipients] or None
+            )
+        )
+
+    async def rotate_secret(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+        recipients: Optional[List[jid.JID]] = None
+    ) -> None:
+        """Revoke all current known secrets, create and share a new one
+
+        @param service: pubsub/PEP service where the node is
+        @param node: node name
+        @param recipients: who must receive the new shared secret
+            if None, all recipients known to have last active shared secret will get the
+            new secret
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if shared_secrets is None:
+            shared_secrets = {}
+        for shared_secret in shared_secrets.values():
+            if not shared_secret.revoked:
+                await self.revoke(client, service, node, shared_secret.id)
+                shared_secret.revoked = True
+
+        if recipients is None:
+            if shared_secrets:
+                # we get recipients from latests shared secret's shared_with list,
+                # regarless of deprecation (cause all keys may be deprecated)
+                recipients = list(sorted(
+                    shared_secrets.values(),
+                    key=lambda s: s.timestamp,
+                    reverse=True
+                )[0].shared_with)
+            else:
+                recipients = []
+
+        shared_secret = self.generate_secret(client)
+        shared_secrets[shared_secret.id] = shared_secret
+        # we send notification to last entities known to already have the shared secret
+        for recipient in recipients:
+            await self.share_secret(client, service, node, shared_secret, recipient)
+        await self.store_secrets(client, node_uri, shared_secrets)
+
+    def _ps_secrets_list(
+        self,
+        service: str,
+        node: str,
+        profile_key: str
+    ) -> defer.Deferred:
+        d = defer.ensureDeferred(
+            self.list_shared_secrets(
+                self.host.get_client(profile_key),
+                jid.JID(service) if service else None,
+                node,
+            )
+        )
+        d.addCallback(lambda ret: data_format.serialise(ret))
+        return d
+
+    async def list_shared_secrets(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+    ) -> List[Dict[str, Any]]:
+        """Retrieve for shared secrets of a pubsub node
+
+        @param service: pubsub/PEP service where the node is
+        @param node: node name
+        @return: shared secrets data
+        @raise exceptions.NotFound: no shared secret found for this node
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if shared_secrets is None:
+            raise exceptions.NotFound(f"No shared secrets found for {node_uri}")
+        return [
+            dataclasses.asdict(s, dict_factory=self.__secrect_dict_factory)
+            for s in shared_secrets.values()
+        ]
+
+    async def handle_revoke_elt(
+        self,
+        client: SatXMPPEntity,
+        sender: jid.JID,
+        revoke_elt: domish.Element
+    ) -> None:
+        """Parse a <revoke> element and update local secrets
+
+        @param sender: bare jid of the entity who has signed the secret
+        @param revoke: <revoke/> element
+        """
+        try:
+            service = jid.JID(revoke_elt["jid"])
+            node = revoke_elt["node"]
+            secret_id = revoke_elt["id"]
+        except (KeyError, RuntimeError) as e:
+            log.warning(
+                f"ignoring invalid <revoke> element: {e}\n{revoke_elt.toXml()}"
+            )
+            return
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if shared_secrets is None:
+            log.warning(
+                f"Can't revoke shared secret {secret_id}: no known shared secrets for "
+                f"{node_uri}"
+            )
+            return
+
+        if any(s.origin != sender for s in shared_secrets.values()):
+            log.warning(
+                f"Rejecting shared secret revocation signed by invalid entity ({sender}):"
+                f"\n{revoke_elt.toXml}"
+            )
+            return
+
+        try:
+            shared_secret = shared_secrets[secret_id]
+        except KeyError:
+            log.warning(
+                f"Can't revoke shared secret {secret_id}: this secret ID is unknown for "
+                f"{node_uri}"
+            )
+            return
+
+        shared_secret.revoked = True
+        await self.store_secrets(client, node_uri, shared_secrets)
+        log.info(f"Shared secret {secret_id} has been revoked for {node_uri}")
+
+    async def handle_shared_secret_elt(
+        self,
+        client: SatXMPPEntity,
+        sender: jid.JID,
+        shared_secret_elt: domish.Element
+    ) -> None:
+        """Parse a <shared-secret> element and update local secrets
+
+        @param sender: bare jid of the entity who has signed the secret
+        @param shared_secret_elt: <shared-secret/> element
+        """
+        try:
+            service = jid.JID(shared_secret_elt["jid"])
+            node = shared_secret_elt["node"]
+            secret_id = shared_secret_elt["id"]
+            timestamp = utils.parse_xmpp_date(shared_secret_elt["timestamp"])
+            # TODO: handle "type" attribute
+            revoked = C.bool(shared_secret_elt.getAttribute("revoked", C.BOOL_FALSE))
+        except (KeyError, RuntimeError, ValueError) as e:
+            log.warning(
+                f"ignoring invalid <shared-secret> element: "
+                f"{e}\n{shared_secret_elt.toXml()}"
+            )
+            return
+        key = str(shared_secret_elt)
+        if not key:
+            log.warning(
+                "ignoring <shared-secret> element with empty key: "
+                f"{shared_secret_elt.toXml()}"
+            )
+            return
+        shared_secret = SharedSecret(
+            id=secret_id, key=key, timestamp=timestamp, origin=sender, revoked=revoked
+        )
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if shared_secrets is None:
+            shared_secrets = {}
+            # no known shared secret yet for this node, we have to trust first user who
+            # send it
+        else:
+            if any(s.origin != sender for s in shared_secrets.values()):
+                log.warning(
+                    f"Rejecting shared secret signed by invalid entity ({sender}):\n"
+                    f"{shared_secret_elt.toXml}"
+                )
+                return
+
+        shared_secrets[shared_secret.id] = shared_secret
+        await self.store_secrets(client, node_uri, shared_secrets)
+        log.info(
+            f"shared secret {shared_secret.id} added for {node_uri} [{client.profile}]"
+        )
+
+    async def _publish_trigger(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        node: str,
+        items: Optional[List[domish.Element]],
+        options: Optional[dict],
+        sender: jid.JID,
+        extra: Dict[str, Any]
+    ) -> bool:
+        if not items or not extra.get("encrypted"):
+            return True
+        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+        shared_secrets = await self.load_secrets(client, node_uri)
+        if shared_secrets is None:
+            shared_secrets = {}
+            shared_secret = None
+        else:
+            current_secrets = [s for s in shared_secrets.values() if not s.revoked]
+            if not current_secrets:
+                shared_secret = None
+            elif len(current_secrets) > 1:
+                log.warning(
+                    f"more than one active shared secret found for node {node!r} at "
+                    f"{service}, using the most recent one"
+                )
+                current_secrets.sort(key=lambda s: s.timestamp, reverse=True)
+                shared_secret = current_secrets[0]
+            else:
+                shared_secret = current_secrets[0]
+
+        if shared_secret is None:
+            if any(s.origin != client.jid.userhostJID() for s in shared_secrets.values()):
+                raise exceptions.PermissionError(
+                    "there is no known active shared secret, and you are not the "
+                    "creator of previous shared secrets, we can't encrypt items at "
+                    f"{node_uri} ."
+                )
+            shared_secret = self.generate_secret(client)
+            shared_secrets[shared_secret.id] = shared_secret
+            await self.store_secrets(client, node_uri, shared_secrets)
+            # TODO: notify other entities
+
+        for item in items:
+            item_elts = list(item.elements())
+            if len(item_elts) != 1:
+                raise ValueError(
+                    f"there should be exactly one item payload: {item.toXml()}"
+                )
+            item_payload = item_elts[0]
+            log.debug(f"encrypting item {item.getAttribute('id', '')}")
+            encrypted_item = self.gpg_provider.encrypt_symmetrically(
+                item_payload.toXml().encode(), shared_secret.key
+            )
+            item.children.clear()
+            encrypted_elt = domish.Element((NS_OXPS, "encrypted"))
+            encrypted_elt["key"] = shared_secret.id
+            encrypted_elt.addContent(base64.b64encode(encrypted_item).decode())
+            item.addChild(encrypted_elt)
+
+        return True
+
+    async def _items_trigger(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+        items: List[domish.Element],
+        rsm_response: rsm.RSMResponse,
+        extra: Dict[str, Any],
+    ) -> bool:
+        if not extra.get(C.KEY_DECRYPT, True):
+            return True
+        if service is None:
+            service = client.jid.userhostJID()
+        shared_secrets = None
+        for item in items:
+            payload = item.firstChildElement()
+            if (payload is not None
+                and payload.name == "encrypted"
+                and payload.uri == NS_OXPS):
+                encrypted_elt = payload
+                secret_id = encrypted_elt.getAttribute("key")
+                if not secret_id:
+                    log.warning(
+                        f'"key" attribute is missing from encrypted item: {item.toXml()}'
+                    )
+                    continue
+                if shared_secrets is None:
+                    node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
+                    shared_secrets = await self.load_secrets(client, node_uri)
+                    if shared_secrets is None:
+                        log.warning(
+                            f"No known shared secret for {node_uri}, can't decrypt"
+                        )
+                        return True
+                try:
+                    shared_secret = shared_secrets[secret_id]
+                except KeyError:
+                    log.warning(
+                        f"No key known for encrypted item {item['id']!r} (shared secret "
+                        f"id: {secret_id!r})"
+                    )
+                    continue
+                log.debug(f"decrypting item {item.getAttribute('id', '')}")
+                decrypted = self.gpg_provider.decrypt_symmetrically(
+                    base64.b64decode(str(encrypted_elt)),
+                    shared_secret.key
+                )
+                decrypted_elt = xml_tools.parse(decrypted)
+                item.children.clear()
+                item.addChild(decrypted_elt)
+                extra.setdefault("encrypted", {})[item["id"]] = {"type": NS_OXPS}
+        return True
+
+    async def _message_received_trigger(
+        self,
+        client: SatXMPPEntity,
+        message_elt: domish.Element,
+        post_treat: defer.Deferred
+    ) -> bool:
+        sender = jid.JID(message_elt["from"]).userhostJID()
+        # there may be an openpgp element if OXIM is not activate, in this case we have to
+        # decrypt it here
+        openpgp_elt = next(message_elt.elements(NS_OX, "openpgp"), None)
+        if openpgp_elt is not None:
+            try:
+                payload_elt, __ = await self._ox.unpack_openpgp_element(
+                    client,
+                    openpgp_elt,
+                    "signcrypt",
+                    sender
+                )
+            except Exception as e:
+                log.warning(f"Can't decrypt element: {e}\n{message_elt.toXml()}")
+                return False
+            message_elt.children.remove(openpgp_elt)
+            for c in payload_elt.children:
+                message_elt.addChild(c)
+
+        shared_secret_elt = next(message_elt.elements(NS_OXPS, "shared-secret"), None)
+        if shared_secret_elt is None:
+            # no <shared-secret>, we check if there is a <revoke> element
+            revoke_elt = next(message_elt.elements(NS_OXPS, "revoke"), None)
+            if revoke_elt is None:
+                return True
+            else:
+                await self.handle_revoke_elt(client, sender, revoke_elt)
+        else:
+            await self.handle_shared_secret_elt(client, sender, shared_secret_elt)
+
+        return False
+
+
+@implementer(iwokkel.IDisco)
+class PubsubEncryption_Handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_OXPS)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_sec_pte.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Pubsub Targeted Encryption
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Any, Dict, List, Optional
+
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid, xmlstream
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from wokkel import rsm
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+
+log = getLogger(__name__)
+
+IMPORT_NAME = "PTE"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Pubsub Targeted Encryption",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0384"],
+    C.PI_MAIN: "PTE",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Encrypt some items to specific entities"""),
+}
+NS_PTE = "urn:xmpp:pte:0"
+
+
+class PTE:
+    namespace = NS_PTE
+
+    def __init__(self, host):
+        log.info(_("Pubsub Targeted Encryption plugin initialization"))
+        host.register_namespace("pte", NS_PTE)
+        self.host = host
+        self._o = host.plugins["XEP-0384"]
+        host.trigger.add("XEP-0060_publish", self._publish_trigger)
+        host.trigger.add("XEP-0060_items", self._items_trigger)
+
+    def get_handler(self, client):
+        return PTE_Handler()
+
+    async def _publish_trigger(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        node: str,
+        items: Optional[List[domish.Element]],
+        options: Optional[dict],
+        sender: jid.JID,
+        extra: Dict[str, Any]
+    ) -> bool:
+        if not items or extra.get("encrypted_for") is None:
+            return True
+        encrypt_data = extra["encrypted_for"]
+        try:
+            targets = {jid.JID(t) for t in encrypt_data["targets"]}
+        except (KeyError, RuntimeError):
+            raise exceptions.DataError(f"Invalid encryption data: {encrypt_data}")
+        for item in items:
+            log.debug(
+                f"encrypting item {item.getAttribute('id', '')} for "
+                f"{', '.join(t.full() for t in targets)}"
+            )
+            encryption_type = encrypt_data.get("type", self._o.NS_TWOMEMO)
+            if encryption_type != self._o.NS_TWOMEMO:
+                raise NotImplementedError("only TWOMEMO is supported for now")
+            await self._o.encrypt(
+                client,
+                self._o.NS_TWOMEMO,
+                item,
+                targets,
+                is_muc_message=False,
+                stanza_id=None
+            )
+            item_elts = list(item.elements())
+            if len(item_elts) != 1:
+                raise ValueError(
+                    f"there should be exactly one item payload: {item.toXml()}"
+                )
+            encrypted_payload = item_elts[0]
+            item.children.clear()
+            encrypted_elt = item.addElement((NS_PTE, "encrypted"))
+            encrypted_elt["by"] = sender.userhost()
+            encrypted_elt["type"] = encryption_type
+            encrypted_elt.addChild(encrypted_payload)
+
+        return True
+
+    async def _items_trigger(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+        items: List[domish.Element],
+        rsm_response: rsm.RSMResponse,
+        extra: Dict[str, Any],
+    ) -> bool:
+        if not extra.get(C.KEY_DECRYPT, True):
+            return True
+        if service is None:
+            service = client.jid.userhostJID()
+        for item in items:
+            payload = item.firstChildElement()
+            if (payload is not None
+                and payload.name == "encrypted"
+                and payload.uri == NS_PTE):
+                encrypted_elt = payload
+                item.children.clear()
+                try:
+                    encryption_type = encrypted_elt.getAttribute("type")
+                    encrypted_by = jid.JID(encrypted_elt["by"])
+                except (KeyError, RuntimeError):
+                    raise exceptions.DataError(
+                        f"invalid <encrypted> element: {encrypted_elt.toXml()}"
+                    )
+                if encryption_type!= self._o.NS_TWOMEMO:
+                    raise NotImplementedError("only TWOMEMO is supported for now")
+                log.debug(f"decrypting item {item.getAttribute('id', '')}")
+
+                # FIXME: we do use _message_received_trigger now to decrypt the stanza, a
+                #   cleaner separated decrypt method should be used
+                encrypted_elt["from"] = encrypted_by.full()
+                if not await self._o._message_received_trigger(
+                    client,
+                    encrypted_elt,
+                    defer.Deferred()
+                ) or not encrypted_elt.children:
+                    raise exceptions.EncryptionError("can't decrypt the message")
+
+                item.addChild(encrypted_elt.firstChildElement())
+
+                extra.setdefault("encrypted", {})[item["id"]] = {
+                    "type": NS_PTE,
+                    "algorithm": encryption_type
+                }
+        return True
+
+
+@implementer(iwokkel.IDisco)
+class PTE_Handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_PTE)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_sec_pubsub_signing.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,335 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Pubsub Items Signature
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import base64
+import time
+from typing import Any, Dict, List, Optional
+
+from lxml import etree
+import shortuuid
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid, xmlstream
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from wokkel import pubsub
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import utils
+from libervia.backend.tools.common import data_format
+
+from .plugin_xep_0373 import VerificationFailed
+
+
+log = getLogger(__name__)
+
+IMPORT_NAME = "pubsub-signing"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Pubsub Signing",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0373", "XEP-0470"],
+    C.PI_MAIN: "PubsubSigning",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _(
+        """Pubsub Signature can be used to strongly authenticate a pubsub item"""
+    ),
+}
+NS_PUBSUB_SIGNING = "urn:xmpp:pubsub-signing:0"
+NS_PUBSUB_SIGNING_OPENPGP = "urn:xmpp:pubsub-signing:openpgp:0"
+
+
+class PubsubSigning:
+    namespace = NS_PUBSUB_SIGNING
+
+    def __init__(self, host):
+        log.info(_("Pubsub Signing plugin initialization"))
+        host.register_namespace("pubsub-signing", NS_PUBSUB_SIGNING)
+        self.host = host
+        self._p = host.plugins["XEP-0060"]
+        self._ox = host.plugins["XEP-0373"]
+        self._a = host.plugins["XEP-0470"]
+        self._a.register_attachment_handler(
+            "signature", NS_PUBSUB_SIGNING, self.signature_get, self.signature_set
+        )
+        host.trigger.add("XEP-0060_publish", self._publish_trigger)
+        host.bridge.add_method(
+            "ps_signature_check",
+            ".plugin",
+            in_sign="sssss",
+            out_sign="s",
+            method=self._check,
+            async_=True,
+        )
+
+    def get_handler(self, client):
+        return PubsubSigning_Handler()
+
+    def get_data_to_sign(
+        self,
+        item_elt: domish.Element,
+        to_jid: jid.JID,
+        timestamp: float,
+        signer: str,
+    ) -> bytes:
+        """Generate the wrapper element, normalize, serialize and return it"""
+        # we remove values which must not be in the serialised data
+        item_id = item_elt.attributes.pop("id")
+        item_publisher = item_elt.attributes.pop("publisher", None)
+        item_parent = item_elt.parent
+
+        # we need to be sure that item element namespace is right
+        item_elt.uri = item_elt.defaultUri = pubsub.NS_PUBSUB
+
+        sign_data_elt = domish.Element((NS_PUBSUB_SIGNING, "sign-data"))
+        to_elt = sign_data_elt.addElement("to")
+        to_elt["jid"] = to_jid.userhost()
+        time_elt = sign_data_elt.addElement("time")
+        time_elt["stamp"] = utils.xmpp_date(timestamp)
+        sign_data_elt.addElement("signer", content=signer)
+        sign_data_elt.addChild(item_elt)
+        # FIXME: xml_tools.domish_elt_2_et_elt must be used once implementation is
+        #   complete. For now serialisation/deserialisation is more secure.
+        # et_sign_data_elt = xml_tools.domish_elt_2_et_elt(sign_data_elt, True)
+        et_sign_data_elt = etree.fromstring(sign_data_elt.toXml())
+        to_sign = etree.tostring(
+            et_sign_data_elt,
+            method="c14n2",
+            with_comments=False,
+            strip_text=True
+        )
+        # the data to sign is serialised, we cna restore original values
+        item_elt["id"] = item_id
+        if item_publisher is not None:
+            item_elt["publisher"] = item_publisher
+        item_elt.parent = item_parent
+        return to_sign
+
+    def _check(
+        self,
+        service: str,
+        node: str,
+        item_id: str,
+        signature_data_s: str,
+        profile_key: str,
+    ) -> defer.Deferred:
+        d = defer.ensureDeferred(
+            self.check(
+                self.host.get_client(profile_key),
+                jid.JID(service),
+                node,
+                item_id,
+                data_format.deserialise(signature_data_s)
+            )
+        )
+        d.addCallback(data_format.serialise)
+        return d
+
+    async def check(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        node: str,
+        item_id: str,
+        signature_data: Dict[str, Any],
+    ) -> Dict[str, Any]:
+        items, __ = await self._p.get_items(
+            client, service, node, item_ids=[item_id]
+        )
+        if not items != 1:
+            raise exceptions.NotFound(
+                f"target item not found for {item_id!r} at {node!r} for {service}"
+            )
+        item_elt = items[0]
+        timestamp = signature_data["timestamp"]
+        signers = signature_data["signers"]
+        if not signers:
+            raise ValueError("we must have at least one signer to check the signature")
+        if len(signers) > 1:
+            raise NotImplemented("multiple signers are not supported yet")
+        signer = jid.JID(signers[0])
+        signature = base64.b64decode(signature_data["signature"])
+        verification_keys = {
+            k for k in await self._ox.import_all_public_keys(client, signer)
+            if client.gpg_provider.can_sign(k)
+        }
+        signed_data = self.get_data_to_sign(item_elt, service, timestamp, signer.full())
+        try:
+            client.gpg_provider.verify_detached(signed_data, signature, verification_keys)
+        except VerificationFailed:
+            validated = False
+        else:
+            validated = True
+
+        trusts = {
+            k.fingerprint: (await self._ox.get_trust(client, k, signer)).value.lower()
+            for k in verification_keys
+        }
+        return {
+            "signer": signer.full(),
+            "validated": validated,
+            "trusts": trusts,
+        }
+
+    def signature_get(
+        self,
+        client: SatXMPPEntity,
+        attachments_elt: domish.Element,
+        data: Dict[str, Any],
+    ) -> None:
+        try:
+            signature_elt = next(
+                attachments_elt.elements(NS_PUBSUB_SIGNING, "signature")
+            )
+        except StopIteration:
+            pass
+        else:
+            time_elts = list(signature_elt.elements(NS_PUBSUB_SIGNING, "time"))
+            if len(time_elts) != 1:
+                raise exceptions.DataError("only a single <time/> element is allowed")
+            try:
+                timestamp = utils.parse_xmpp_date(time_elts[0]["stamp"])
+            except (KeyError, exceptions.ParsingError):
+                raise exceptions.DataError(
+                    "invalid time element: {signature_elt.toXml()}"
+                )
+
+            signature_data: Dict[str, Any] = {
+                "timestamp": timestamp,
+                "signers": [
+                    str(s) for s in signature_elt.elements(NS_PUBSUB_SIGNING, "signer")
+                ]
+            }
+            # FIXME: only OpenPGP signature is available for now, to be updated if and
+            #   when more algorithms are available.
+            sign_elt = next(
+                signature_elt.elements(NS_PUBSUB_SIGNING_OPENPGP, "sign"),
+                None
+            )
+            if sign_elt is None:
+                log.warning(
+                    "no known signature profile element found, ignoring signature: "
+                    f"{signature_elt.toXml()}"
+                )
+                return
+            else:
+                signature_data["signature"] = str(sign_elt)
+
+            data["signature"] = signature_data
+
+    async def signature_set(
+        self,
+        client: SatXMPPEntity,
+        attachments_data: Dict[str, Any],
+        former_elt: Optional[domish.Element]
+    ) -> Optional[domish.Element]:
+        signature_data = attachments_data["extra"].get("signature")
+        if signature_data is None:
+            return former_elt
+        elif signature_data:
+            item_elt = signature_data.get("item_elt")
+            service = jid.JID(attachments_data["service"])
+            if item_elt is None:
+                node = attachments_data["node"]
+                item_id = attachments_data["id"]
+                items, __ = await self._p.get_items(
+                    client, service, node, item_ids=[item_id]
+                )
+                if not items != 1:
+                    raise exceptions.NotFound(
+                        f"target item not found for {item_id!r} at {node!r} for {service}"
+                    )
+                item_elt = items[0]
+
+            signer = signature_data.get("signer") or client.jid.userhost()
+            timestamp = time.time()
+            timestamp_xmpp = utils.xmpp_date(timestamp)
+            to_sign = self.get_data_to_sign(item_elt, service, timestamp, signer)
+
+            signature_elt = domish.Element(
+                (NS_PUBSUB_SIGNING, "signature"),
+            )
+            time_elt = signature_elt.addElement("time")
+            time_elt["stamp"] = timestamp_xmpp
+            signature_elt.addElement("signer", content=signer)
+
+            sign_elt = signature_elt.addElement((NS_PUBSUB_SIGNING_OPENPGP, "sign"))
+            signing_keys = {
+                k for k in self._ox.list_secret_keys(client)
+                if client.gpg_provider.can_sign(k.public_key)
+            }
+            # the base64 encoded signature itself
+            sign_elt.addContent(
+                base64.b64encode(
+                    client.gpg_provider.sign_detached(to_sign, signing_keys)
+                ).decode()
+            )
+            return signature_elt
+        else:
+            return None
+
+    async def _publish_trigger(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        node: str,
+        items: Optional[List[domish.Element]],
+        options: Optional[dict],
+        sender: jid.JID,
+        extra: Dict[str, Any]
+    ) -> bool:
+        if not items or not extra.get("signed"):
+            return True
+
+        for item_elt in items:
+            # we need an ID to find corresponding attachment node, and so to sign an item
+            if not item_elt.hasAttribute("id"):
+                item_elt["id"] = shortuuid.uuid()
+            await self._a.set_attachements(
+                client,
+                {
+                    "service": service.full(),
+                    "node": node,
+                    "id": item_elt["id"],
+                    "extra": {
+                        "signature": {
+                            "item_elt": item_elt,
+                            "signer": sender.userhost(),
+                        }
+                    }
+                }
+            )
+
+        return True
+
+
+@implementer(iwokkel.IDisco)
+class PubsubSigning_Handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_PUBSUB_SIGNING)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_syntax_wiki_dotclear.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,678 @@
+#!/usr/bin/env python3
+
+
+# SàT plugin for Dotclear Wiki Syntax
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# XXX: ref used: http://dotclear.org/documentation/2.0/usage/syntaxes#wiki-syntax-and-xhtml-equivalent
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from twisted.words.xish import domish
+from libervia.backend.tools import xml_tools
+import copy
+import re
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Dotclear Wiki Syntax Plugin",
+    C.PI_IMPORT_NAME: "SYNT_DC_WIKI",
+    C.PI_TYPE: C.PLUG_TYPE_SYNTAXE,
+    C.PI_DEPENDENCIES: ["TEXT_SYNTAXES"],
+    C.PI_MAIN: "DCWikiSyntax",
+    C.PI_HANDLER: "",
+    C.PI_DESCRIPTION: _("""Implementation of Dotclear wiki syntax"""),
+}
+
+NOTE_TPL = "[{}]"  # Note template
+NOTE_A_REV_TPL = "rev_note_{}"
+NOTE_A_TPL = "note_{}"
+ESCAPE_CHARS_BASE = r"(?P<escape_char>[][{}%|\\/*#@{{}}~$-])"
+ESCAPE_CHARS_EXTRA = (
+    r"!?_+'()"
+)  # These chars are not escaped in XHTML => dc_wiki conversion,
+# but are used in the other direction
+ESCAPE_CHARS = ESCAPE_CHARS_BASE.format("")
+FLAG_UL = "ul"  # must be the name of the element
+FLAG_OL = "ol"
+ELT_WITH_STYLE = ("img", "div")  # elements where a style attribute is expected
+
+wiki = [
+    r"\\" + ESCAPE_CHARS_BASE.format(ESCAPE_CHARS_EXTRA),
+    r"^!!!!!(?P<h1_title>.+?)$",
+    r"^!!!!(?P<h2_title>.+?)$",
+    r"^!!!(?P<h3_title>.+?)$",
+    r"^!!(?P<h4_title>.+?)$",
+    r"^!(?P<h5_title>.+?)$",
+    r"^----$(?P<horizontal_rule>)",
+    r"^\*(?P<list_bullet>.*?)$",
+    r"^#(?P<list_ordered>.*?)$",
+    r"^ (?P<preformated>.*?)$",
+    r"^> +?(?P<quote>.*?)$",
+    r"''(?P<emphasis>.+?)''",
+    r"__(?P<strong_emphasis>.+?)__",
+    r"%%%(?P<line_break>)",
+    r"\+\+(?P<insertion>.+?)\+\+",
+    r"--(?P<deletion>.+?)--",
+    r"\[(?P<link>.+?)\]",
+    r"\(\((?P<image>.+?)\)\)",
+    r"~(?P<anchor>.+?)~",
+    r"\?\?(?P<acronym>.+?\|.+?)\?\?",
+    r"{{(?P<inline_quote>.+?)}}",
+    r"@@(?P<code>.+?)@@",
+    r"\$\$(?P<footnote>.+?)\$\$",
+    r"(?P<text>.+?)",
+]
+
+wiki_re = re.compile("|".join(wiki), re.MULTILINE | re.DOTALL)
+wiki_block_level_re = re.compile(
+    r"^///html(?P<html>.+?)///\n\n|(?P<paragraph>.+?)(?:\n{2,}|\Z)",
+    re.MULTILINE | re.DOTALL,
+)
+
+
+class DCWikiParser(object):
+    def __init__(self):
+        self._footnotes = None
+        for i in range(5):
+            setattr(
+                self,
+                "parser_h{}_title".format(i),
+                lambda string, parent, i=i: self._parser_title(
+                    string, parent, "h{}".format(i)
+                ),
+            )
+
+    def parser_paragraph(self, string, parent):
+        p_elt = parent.addElement("p")
+        self._parse(string, p_elt)
+
+    def parser_html(self, string, parent):
+        wrapped_html = "<div>{}</div>".format(string)
+        try:
+            div_elt = xml_tools.ElementParser()(wrapped_html)
+        except domish.ParserError as e:
+            log.warning("Error while parsing HTML content, ignoring it: {}".format(e))
+            return
+        children = list(div_elt.elements())
+        if len(children) == 1 and children[0].name == "div":
+            div_elt = children[0]
+        parent.addChild(div_elt)
+
+    def parser_escape_char(self, string, parent):
+        parent.addContent(string)
+
+    def _parser_title(self, string, parent, name):
+        elt = parent.addElement(name)
+        elt.addContent(string)
+
+    def parser_horizontal_rule(self, string, parent):
+        parent.addElement("hr")
+
+    def _parser_list(self, string, parent, list_type):
+        depth = 0
+        while string[depth : depth + 1] == "*":
+            depth += 1
+
+        string = string[depth:].lstrip()
+
+        for i in range(depth + 1):
+            list_elt = getattr(parent, list_type)
+            if not list_elt:
+                parent = parent.addElement(list_type)
+            else:
+                parent = list_elt
+
+        li_elt = parent.addElement("li")
+        self._parse(string, li_elt)
+
+    def parser_list_bullet(self, string, parent):
+        self._parser_list(string, parent, "ul")
+
+    def parser_list_ordered(self, string, parent):
+        self._parser_list(string, parent, "ol")
+
+    def parser_preformated(self, string, parent):
+        pre_elt = parent.pre
+        if pre_elt is None:
+            pre_elt = parent.addElement("pre")
+        else:
+            # we are on a new line, and this is important for <pre/>
+            pre_elt.addContent("\n")
+        pre_elt.addContent(string)
+
+    def parser_quote(self, string, parent):
+        blockquote_elt = parent.blockquote
+        if blockquote_elt is None:
+            blockquote_elt = parent.addElement("blockquote")
+        p_elt = blockquote_elt.p
+        if p_elt is None:
+            p_elt = blockquote_elt.addElement("p")
+        else:
+            string = "\n" + string
+
+        self._parse(string, p_elt)
+
+    def parser_emphasis(self, string, parent):
+        em_elt = parent.addElement("em")
+        self._parse(string, em_elt)
+
+    def parser_strong_emphasis(self, string, parent):
+        strong_elt = parent.addElement("strong")
+        self._parse(string, strong_elt)
+
+    def parser_line_break(self, string, parent):
+        parent.addElement("br")
+
+    def parser_insertion(self, string, parent):
+        ins_elt = parent.addElement("ins")
+        self._parse(string, ins_elt)
+
+    def parser_deletion(self, string, parent):
+        del_elt = parent.addElement("del")
+        self._parse(string, del_elt)
+
+    def parser_link(self, string, parent):
+        url_data = string.split("|")
+        a_elt = parent.addElement("a")
+        length = len(url_data)
+        if length == 1:
+            url = url_data[0]
+            a_elt["href"] = url
+            a_elt.addContent(url)
+        else:
+            name = url_data[0]
+            url = url_data[1]
+            a_elt["href"] = url
+            a_elt.addContent(name)
+            if length >= 3:
+                a_elt["lang"] = url_data[2]
+            if length >= 4:
+                a_elt["title"] = url_data[3]
+            if length > 4:
+                log.warning("too much data for url, ignoring extra data")
+
+    def parser_image(self, string, parent):
+        image_data = string.split("|")
+        img_elt = parent.addElement("img")
+
+        for idx, attribute in enumerate(("src", "alt", "position", "longdesc")):
+            try:
+                data = image_data[idx]
+            except IndexError:
+                break
+
+            if attribute != "position":
+                img_elt[attribute] = data
+            else:
+                data = data.lower()
+                if data in ("l", "g"):
+                    img_elt["style"] = "display:block; float:left; margin:0 1em 1em 0"
+                elif data in ("r", "d"):
+                    img_elt["style"] = "display:block; float:right; margin:0 0 1em 1em"
+                elif data == "c":
+                    img_elt[
+                        "style"
+                    ] = "display:block; margin-left:auto; margin-right:auto"
+                else:
+                    log.warning("bad position argument for image, ignoring it")
+
+    def parser_anchor(self, string, parent):
+        a_elt = parent.addElement("a")
+        a_elt["id"] = string
+
+    def parser_acronym(self, string, parent):
+        acronym, title = string.split("|", 1)
+        acronym_elt = parent.addElement("acronym", content=acronym)
+        acronym_elt["title"] = title
+
+    def parser_inline_quote(self, string, parent):
+        quote_data = string.split("|")
+        quote = quote_data[0]
+        q_elt = parent.addElement("q", content=quote)
+        for idx, attribute in enumerate(("lang", "cite"), 1):
+            try:
+                data = quote_data[idx]
+            except IndexError:
+                break
+            q_elt[attribute] = data
+
+    def parser_code(self, string, parent):
+        parent.addElement("code", content=string)
+
+    def parser_footnote(self, string, parent):
+        idx = len(self._footnotes) + 1
+        note_txt = NOTE_TPL.format(idx)
+        sup_elt = parent.addElement("sup")
+        sup_elt["class"] = "note"
+        a_elt = sup_elt.addElement("a", content=note_txt)
+        a_elt["id"] = NOTE_A_REV_TPL.format(idx)
+        a_elt["href"] = "#{}".format(NOTE_A_TPL.format(idx))
+
+        p_elt = domish.Element((None, "p"))
+        a_elt = p_elt.addElement("a", content=note_txt)
+        a_elt["id"] = NOTE_A_TPL.format(idx)
+        a_elt["href"] = "#{}".format(NOTE_A_REV_TPL.format(idx))
+        self._parse(string, p_elt)
+        # footnotes are actually added at the end of the parsing
+        self._footnotes.append(p_elt)
+
+    def parser_text(self, string, parent):
+        parent.addContent(string)
+
+    def _parse(self, string, parent, block_level=False):
+        regex = wiki_block_level_re if block_level else wiki_re
+
+        for match in regex.finditer(string):
+            if match.lastgroup is None:
+                parent.addContent(string)
+                return
+            matched = match.group(match.lastgroup)
+            try:
+                parser = getattr(self, "parser_{}".format(match.lastgroup))
+            except AttributeError:
+                log.warning("No parser found for {}".format(match.lastgroup))
+                # parent.addContent(string)
+                continue
+            parser(matched, parent)
+
+    def parse(self, string):
+        self._footnotes = []
+        div_elt = domish.Element((None, "div"))
+        self._parse(string, parent=div_elt, block_level=True)
+        if self._footnotes:
+            foot_div_elt = div_elt.addElement("div")
+            foot_div_elt["class"] = "footnotes"
+            # we add a simple horizontal rule which can be customized
+            # with footnotes class, instead of a text which would need
+            # to be translated
+            foot_div_elt.addElement("hr")
+            for elt in self._footnotes:
+                foot_div_elt.addChild(elt)
+        return div_elt
+
+
+class XHTMLParser(object):
+    def __init__(self):
+        self.flags = None
+        self.toto = 0
+        self.footnotes = None  # will hold a map from url to buffer id
+        for i in range(1, 6):
+            setattr(
+                self,
+                "parser_h{}".format(i),
+                lambda elt, buf, level=i: self.parser_heading(elt, buf, level),
+            )
+
+    def parser_a(self, elt, buf):
+        try:
+            url = elt["href"]
+        except KeyError:
+            # probably an anchor
+            try:
+                id_ = elt["id"]
+                if not id_:
+                    # we don't want empty values
+                    raise KeyError
+            except KeyError:
+                self.parser_generic(elt, buf)
+            else:
+                buf.append("~~{}~~".format(id_))
+            return
+
+        link_data = [url]
+        name = str(elt)
+        if name != url:
+            link_data.insert(0, name)
+
+        lang = elt.getAttribute("lang")
+        title = elt.getAttribute("title")
+        if lang is not None:
+            link_data.append(lang)
+        elif title is not None:
+            link_data.appand("")
+        if title is not None:
+            link_data.append(title)
+        buf.append("[")
+        buf.append("|".join(link_data))
+        buf.append("]")
+
+    def parser_acronym(self, elt, buf):
+        try:
+            title = elt["title"]
+        except KeyError:
+            log.debug("Acronyme without title, using generic parser")
+            self.parser_generic(elt, buf)
+            return
+        buf.append("??{}|{}??".format(str(elt), title))
+
+    def parser_blockquote(self, elt, buf):
+        # we remove wrapping <p> to avoid empty line with "> "
+        children = list(
+            [child for child in elt.children if str(child).strip() not in ("", "\n")]
+        )
+        if len(children) == 1 and children[0].name == "p":
+            elt = children[0]
+        tmp_buf = []
+        self.parse_children(elt, tmp_buf)
+        blockquote = "> " + "\n> ".join("".join(tmp_buf).split("\n"))
+        buf.append(blockquote)
+
+    def parser_br(self, elt, buf):
+        buf.append("%%%")
+
+    def parser_code(self, elt, buf):
+        buf.append("@@")
+        self.parse_children(elt, buf)
+        buf.append("@@")
+
+    def parser_del(self, elt, buf):
+        buf.append("--")
+        self.parse_children(elt, buf)
+        buf.append("--")
+
+    def parser_div(self, elt, buf):
+        if elt.getAttribute("class") == "footnotes":
+            self.parser_footnote(elt, buf)
+        else:
+            self.parse_children(elt, buf, block=True)
+
+    def parser_em(self, elt, buf):
+        buf.append("''")
+        self.parse_children(elt, buf)
+        buf.append("''")
+
+    def parser_h6(self, elt, buf):
+        # XXX: <h6/> heading is not managed by wiki syntax
+        #      so we handle it with a <h5/>
+        elt = copy.copy(elt)  # we don't want to change to original element
+        elt.name = "h5"
+        self._parse(elt, buf)
+
+    def parser_hr(self, elt, buf):
+        buf.append("\n----\n")
+
+    def parser_img(self, elt, buf):
+        try:
+            url = elt["src"]
+        except KeyError:
+            log.warning("Ignoring <img/> without src")
+            return
+
+        image_data = [url]
+
+        alt = elt.getAttribute("alt")
+        style = elt.getAttribute("style", "")
+        desc = elt.getAttribute("longdesc")
+
+        if "0 1em 1em 0" in style:
+            position = "L"
+        elif "0 0 1em 1em" in style:
+            position = "R"
+        elif "auto" in style:
+            position = "C"
+        else:
+            position = None
+
+        if alt:
+            image_data.append(alt)
+        elif position or desc:
+            image_data.append("")
+
+        if position:
+            image_data.append(position)
+        elif desc:
+            image_data.append("")
+
+        if desc:
+            image_data.append(desc)
+
+        buf.append("((")
+        buf.append("|".join(image_data))
+        buf.append("))")
+
+    def parser_ins(self, elt, buf):
+        buf.append("++")
+        self.parse_children(elt, buf)
+        buf.append("++")
+
+    def parser_li(self, elt, buf):
+        flag = None
+        current_flag = None
+        bullets = []
+        for flag in reversed(self.flags):
+            if flag in (FLAG_UL, FLAG_OL):
+                if current_flag is None:
+                    current_flag = flag
+                if flag == current_flag:
+                    bullets.append("*" if flag == FLAG_UL else "#")
+                else:
+                    break
+
+        if flag != current_flag and buf[-1] == " ":
+            # this trick is to avoid a space when we switch
+            # from (un)ordered to the other type on the same row
+            # e.g. *# unorder + ordered item
+            del buf[-1]
+
+        buf.extend(bullets)
+
+        buf.append(" ")
+        self.parse_children(elt, buf)
+        buf.append("\n")
+
+    def parser_ol(self, elt, buf):
+        self.parser_list(elt, buf, FLAG_OL)
+
+    def parser_p(self, elt, buf):
+        self.parse_children(elt, buf)
+        buf.append("\n\n")
+
+    def parser_pre(self, elt, buf):
+        pre = "".join(
+            [
+                child.toXml() if domish.IElement.providedBy(child) else str(child)
+                for child in elt.children
+            ]
+        )
+        pre = " " + "\n ".join(pre.split("\n"))
+        buf.append(pre)
+
+    def parser_q(self, elt, buf):
+        quote_data = [str(elt)]
+
+        lang = elt.getAttribute("lang")
+        cite = elt.getAttribute("url")
+
+        if lang:
+            quote_data.append(lang)
+        elif cite:
+            quote_data.append("")
+
+        if cite:
+            quote_data.append(cite)
+
+        buf.append("{{")
+        buf.append("|".join(quote_data))
+        buf.append("}}")
+
+    def parser_span(self, elt, buf):
+        self.parse_children(elt, buf, block=True)
+
+    def parser_strong(self, elt, buf):
+        buf.append("__")
+        self.parse_children(elt, buf)
+        buf.append("__")
+
+    def parser_sup(self, elt, buf):
+        # sup is mainly used for footnotes, so we check if we have an anchor inside
+        children = list(
+            [child for child in elt.children if str(child).strip() not in ("", "\n")]
+        )
+        if (
+            len(children) == 1
+            and domish.IElement.providedBy(children[0])
+            and children[0].name == "a"
+            and "#" in children[0].getAttribute("href", "")
+        ):
+            url = children[0]["href"]
+            note_id = url[url.find("#") + 1 :]
+            if not note_id:
+                log.warning("bad link found in footnote")
+                self.parser_generic(elt, buf)
+                return
+            # this looks like a footnote
+            buf.append("$$")
+            buf.append(" ")  # placeholder
+            self.footnotes[note_id] = len(buf) - 1
+            buf.append("$$")
+        else:
+            self.parser_generic(elt, buf)
+
+    def parser_ul(self, elt, buf):
+        self.parser_list(elt, buf, FLAG_UL)
+
+    def parser_list(self, elt, buf, type_):
+        self.flags.append(type_)
+        self.parse_children(elt, buf, block=True)
+        idx = 0
+        for flag in reversed(self.flags):
+            idx -= 1
+            if flag == type_:
+                del self.flags[idx]
+                break
+
+        if idx == 0:
+            raise exceptions.InternalError("flag has been removed by an other parser")
+
+    def parser_heading(self, elt, buf, level):
+        buf.append((6 - level) * "!")
+        for child in elt.children:
+            # we ignore other elements for a Hx title
+            self.parser_text(child, buf)
+        buf.append("\n")
+
+    def parser_footnote(self, elt, buf):
+        for elt in elt.elements():
+            # all children other than <p/> are ignored
+            if elt.name == "p":
+                a_elt = elt.a
+                if a_elt is None:
+                    log.warning(
+                        "<p/> element doesn't contain <a/> in footnote, ignoring it"
+                    )
+                    continue
+                try:
+                    note_idx = self.footnotes[a_elt["id"]]
+                except KeyError:
+                    log.warning("Note id doesn't match any known note, ignoring it")
+                # we create a dummy element to parse all children after the <a/>
+                dummy_elt = domish.Element((None, "note"))
+                a_idx = elt.children.index(a_elt)
+                dummy_elt.children = elt.children[a_idx + 1 :]
+                note_buf = []
+                self.parse_children(dummy_elt, note_buf)
+                # now we can replace the placeholder
+                buf[note_idx] = "".join(note_buf)
+
+    def parser_text(self, txt, buf, keep_whitespaces=False):
+        txt = str(txt)
+        if not keep_whitespaces:
+            # we get text and only let one inter word space
+            txt = " ".join(txt.split())
+        txt = re.sub(ESCAPE_CHARS, r"\\\1", txt)
+        if txt:
+            buf.append(txt)
+        return txt
+
+    def parser_generic(self, elt, buf):
+        # as dotclear wiki syntax handle arbitrary XHTML code
+        # we use this feature to add elements that we don't know
+        buf.append("\n\n///html\n{}\n///\n\n".format(elt.toXml()))
+
+    def parse_children(self, elt, buf, block=False):
+        first_visible = True
+        for child in elt.children:
+            if not block and not first_visible and buf and buf[-1][-1] not in (" ", "\n"):
+                # we add separation if it isn't already there
+                buf.append(" ")
+            if domish.IElement.providedBy(child):
+                self._parse(child, buf)
+                first_visible = False
+            else:
+                appended = self.parser_text(child, buf)
+                if appended:
+                    first_visible = False
+
+    def _parse(self, elt, buf):
+        elt_name = elt.name.lower()
+        style = elt.getAttribute("style")
+        if style and elt_name not in ELT_WITH_STYLE:
+            # if we have style we use generic parser to put raw HTML
+            # to avoid losing it
+            parser = self.parser_generic
+        else:
+            try:
+                parser = getattr(self, "parser_{}".format(elt_name))
+            except AttributeError:
+                log.debug(
+                    "Can't find parser for {} element, using generic one".format(elt.name)
+                )
+                parser = self.parser_generic
+        parser(elt, buf)
+
+    def parse(self, elt):
+        self.flags = []
+        self.footnotes = {}
+        buf = []
+        self._parse(elt, buf)
+        return "".join(buf)
+
+    def parseString(self, string):
+        wrapped_html = "<div>{}</div>".format(string)
+        try:
+            div_elt = xml_tools.ElementParser()(wrapped_html)
+        except domish.ParserError as e:
+            log.warning("Error while parsing HTML content: {}".format(e))
+            return
+        children = list(div_elt.elements())
+        if len(children) == 1 and children[0].name == "div":
+            div_elt = children[0]
+        return self.parse(div_elt)
+
+
+class DCWikiSyntax(object):
+    SYNTAX_NAME = "wiki_dotclear"
+
+    def __init__(self, host):
+        log.info(_("Dotclear wiki syntax plugin initialization"))
+        self.host = host
+        self._dc_parser = DCWikiParser()
+        self._xhtml_parser = XHTMLParser()
+        self._stx = self.host.plugins["TEXT_SYNTAXES"]
+        self._stx.add_syntax(
+            self.SYNTAX_NAME, self.parse_wiki, self.parse_xhtml, [self._stx.OPT_NO_THREAD]
+        )
+
+    def parse_wiki(self, wiki_stx):
+        div_elt = self._dc_parser.parse(wiki_stx)
+        return div_elt.toXml()
+
+    def parse_xhtml(self, xhtml):
+        return self._xhtml_parser.parseString(xhtml)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_tickets_import.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+
+
+# SàT plugin for import external ticketss
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.internet import defer
+from libervia.backend.tools.common import uri
+from libervia.backend.tools import utils
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "tickets import",
+    C.PI_IMPORT_NAME: "TICKETS_IMPORT",
+    C.PI_TYPE: C.PLUG_TYPE_IMPORT,
+    C.PI_DEPENDENCIES: ["IMPORT", "XEP-0060", "XEP-0277", "XEP-0346"],
+    C.PI_MAIN: "TicketsImportPlugin",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _(
+        """Tickets import management:
+This plugin manage the different tickets importers which can register to it, and handle generic importing tasks."""
+    ),
+}
+
+OPT_MAPPING = "mapping"
+FIELDS_LIST = ("labels", "cc_emails")  # fields which must have a list as value
+FIELDS_DATE = ("created", "updated")
+
+NS_TICKETS = "fdp/submitted/org.salut-a-toi.tickets:0"
+
+
+class TicketsImportPlugin(object):
+    BOOL_OPTIONS = ()
+    JSON_OPTIONS = (OPT_MAPPING,)
+    OPT_DEFAULTS = {}
+
+    def __init__(self, host):
+        log.info(_("plugin Tickets import initialization"))
+        self.host = host
+        self._importers = {}
+        self._p = host.plugins["XEP-0060"]
+        self._m = host.plugins["XEP-0277"]
+        self._s = host.plugins["XEP-0346"]
+        host.plugins["IMPORT"].initialize(self, "tickets")
+
+    @defer.inlineCallbacks
+    def import_item(
+        self, client, item_import_data, session, options, return_data, service, node
+    ):
+        """
+
+        @param item_import_data(dict): no key is mandatory, but if a key doesn't exists in dest form, it will be ignored.
+            Following names are recommendations which should be used where suitable in importers.
+            except if specified in description, values are unicode
+            'id': unique id (must be unique in the node) of the ticket
+            'title': title (or short description/summary) of the ticket
+            'body': main description of the ticket
+            'created': date of creation (unix time)
+            'updated': date of last update (unix time)
+            'author': full name of reporter
+            'author_jid': jid of reporter
+            'author_email': email of reporter
+            'assigned_to_name': full name of person working on it
+            'assigned_to_email': email of person working on it
+            'cc_emails': list of emails subscribed to the ticket
+            'priority': priority of the ticket
+            'severity': severity of the ticket
+            'labels': list of unicode values to use as label
+            'product': product concerned by this ticket
+            'component': part of the product concerned by this ticket
+            'version': version of the product/component concerned by this ticket
+            'platform': platform converned by this ticket
+            'os': operating system concerned by this ticket
+            'status': current status of the ticket, values:
+                - "queued": ticket is waiting
+                - "started": progress is ongoing
+                - "review": ticket is fixed and waiting for review
+                - "closed": ticket is finished or invalid
+            'milestone': target milestone for this ticket
+            'comments': list of microblog data (comment metadata, check [XEP_0277.send] data argument)
+        @param options(dict, None): Below are the generic options,
+            tickets importer can have specific ones. All options are serialized unicode values
+            generic options:
+                - OPT_MAPPING (json): dict of imported ticket key => exported ticket key
+                    e.g.: if you want to map "component" to "labels", you can specify:
+                        {'component': 'labels'}
+                    If you specify several import ticket key to the same dest key,
+                    the values will be joined with line feeds
+        """
+        if "comments_uri" in item_import_data:
+            raise exceptions.DataError(
+                _("comments_uri key will be generated and must not be used by importer")
+            )
+        for key in FIELDS_LIST:
+            if not isinstance(item_import_data.get(key, []), list):
+                raise exceptions.DataError(_("{key} must be a list").format(key=key))
+        for key in FIELDS_DATE:
+            try:
+                item_import_data[key] = utils.xmpp_date(item_import_data[key])
+            except KeyError:
+                continue
+        if session["root_node"] is None:
+            session["root_node"] = NS_TICKETS
+        if not "schema" in session:
+            session["schema"] = yield self._s.get_schema_form(
+                client, service, node or session["root_node"]
+            )
+        defer.returnValue(item_import_data)
+
+    @defer.inlineCallbacks
+    def import_sub_items(self, client, item_import_data, ticket_data, session, options):
+        # TODO: force "open" permission (except if private, check below)
+        # TODO: handle "private" metadata, to have non public access for node
+        # TODO: node access/publish model should be customisable
+        comments = ticket_data.get("comments", [])
+        service = yield self._m.get_comments_service(client)
+        node = self._m.get_comments_node(session["root_node"] + "_" + ticket_data["id"])
+        node_options = {
+            self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
+            self._p.OPT_PERSIST_ITEMS: 1,
+            self._p.OPT_DELIVER_PAYLOADS: 1,
+            self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
+            self._p.OPT_PUBLISH_MODEL: self._p.ACCESS_OPEN,
+        }
+        yield self._p.create_if_new_node(client, service, node, options=node_options)
+        ticket_data["comments_uri"] = uri.build_xmpp_uri(
+            "pubsub", subtype="microblog", path=service.full(), node=node
+        )
+        for comment in comments:
+            if "updated" not in comment and "published" in comment:
+                # we don't want an automatic update date
+                comment["updated"] = comment["published"]
+            yield self._m.send(client, comment, service, node)
+
+    def publish_item(self, client, ticket_data, service, node, session):
+        if node is None:
+            node = NS_TICKETS
+        id_ = ticket_data.pop("id", None)
+        log.debug(
+            "uploading item [{id}]: {title}".format(
+                id=id_, title=ticket_data.get("title", "")
+            )
+        )
+        return defer.ensureDeferred(
+            self._s.send_data_form_item(
+                client, service, node, ticket_data, session["schema"], id_
+            )
+        )
+
+    def item_filters(self, client, ticket_data, session, options):
+        mapping = options.get(OPT_MAPPING)
+        if mapping is not None:
+            if not isinstance(mapping, dict):
+                raise exceptions.DataError(_("mapping option must be a dictionary"))
+
+            for source, dest in mapping.items():
+                if not isinstance(source, str) or not isinstance(dest, str):
+                    raise exceptions.DataError(
+                        _(
+                            "keys and values of mapping must be sources and destinations ticket fields"
+                        )
+                    )
+                if source in ticket_data:
+                    value = ticket_data.pop(source)
+                    if dest in FIELDS_LIST:
+                        values = ticket_data[dest] = ticket_data.get(dest, [])
+                        values.append(value)
+                    else:
+                        if dest in ticket_data:
+                            ticket_data[dest] = ticket_data[dest] + "\n" + value
+                        else:
+                            ticket_data[dest] = value
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_tickets_import_bugzilla.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,142 @@
+#!/usr/bin/env python3
+
+
+# SàT plugin for import external blogs
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+
+# from twisted.internet import threads
+from twisted.internet import defer
+import os.path
+from lxml import etree
+from libervia.backend.tools.common import date_utils
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Bugzilla import",
+    C.PI_IMPORT_NAME: "IMPORT_BUGZILLA",
+    C.PI_TYPE: C.PLUG_TYPE_BLOG,
+    C.PI_DEPENDENCIES: ["TICKETS_IMPORT"],
+    C.PI_MAIN: "BugzillaImport",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Tickets importer for Bugzilla"""),
+}
+
+SHORT_DESC = D_("import tickets from Bugzilla xml export file")
+
+LONG_DESC = D_(
+    """This importer handle Bugzilla xml export file.
+
+To use it, you'll need to export tickets using XML.
+Tickets will be uploaded with the same ID as for Bugzilla, any existing ticket with this ID will be replaced.
+
+location: you must use the absolute path to your .xml file
+"""
+)
+
+STATUS_MAP = {
+    "NEW": "queued",
+    "ASSIGNED": "started",
+    "RESOLVED": "review",
+    "CLOSED": "closed",
+    "REOPENED": "started",  # we loose data here because there is no need on basic workflow to have a reopened status
+}
+
+
+class BugzillaParser(object):
+    # TODO: add a way to reassign values
+
+    def parse(self, file_path):
+        tickets = []
+        root = etree.parse(file_path)
+
+        for bug in root.xpath("bug"):
+            ticket = {}
+            ticket["id"] = bug.findtext("bug_id")
+            ticket["created"] = date_utils.date_parse(bug.findtext("creation_ts"))
+            ticket["updated"] = date_utils.date_parse(bug.findtext("delta_ts"))
+            ticket["title"] = bug.findtext("short_desc")
+            reporter_elt = bug.find("reporter")
+            ticket["author"] = reporter_elt.get("name")
+            if ticket["author"] is None:
+                if "@" in reporter_elt.text:
+                    ticket["author"] = reporter_elt.text[
+                        : reporter_elt.text.find("@")
+                    ].title()
+                else:
+                    ticket["author"] = "no name"
+            ticket["author_email"] = reporter_elt.text
+            assigned_to_elt = bug.find("assigned_to")
+            ticket["assigned_to_name"] = assigned_to_elt.get("name")
+            ticket["assigned_to_email"] = assigned_to_elt.text
+            ticket["cc_emails"] = [e.text for e in bug.findall("cc")]
+            ticket["priority"] = bug.findtext("priority").lower().strip()
+            ticket["severity"] = bug.findtext("bug_severity").lower().strip()
+            ticket["product"] = bug.findtext("product")
+            ticket["component"] = bug.findtext("component")
+            ticket["version"] = bug.findtext("version")
+            ticket["platform"] = bug.findtext("rep_platform")
+            ticket["os"] = bug.findtext("op_sys")
+            ticket["status"] = STATUS_MAP.get(bug.findtext("bug_status"), "queued")
+            ticket["milestone"] = bug.findtext("target_milestone")
+
+            body = None
+            comments = []
+            for longdesc in bug.findall("long_desc"):
+                if body is None:
+                    body = longdesc.findtext("thetext")
+                else:
+                    who = longdesc.find("who")
+                    comment = {
+                        "id": longdesc.findtext("commentid"),
+                        "author_email": who.text,
+                        "published": date_utils.date_parse(longdesc.findtext("bug_when")),
+                        "author": who.get("name", who.text),
+                        "content": longdesc.findtext("thetext"),
+                    }
+                    comments.append(comment)
+
+            ticket["body"] = body
+            ticket["comments"] = comments
+            tickets.append(ticket)
+
+        tickets.sort(key=lambda t: int(t["id"]))
+        return (tickets, len(tickets))
+
+
+class BugzillaImport(object):
+    def __init__(self, host):
+        log.info(_("Bugilla import plugin initialization"))
+        self.host = host
+        host.plugins["TICKETS_IMPORT"].register(
+            "bugzilla", self.import_, SHORT_DESC, LONG_DESC
+        )
+
+    def import_(self, client, location, options=None):
+        if not os.path.isabs(location):
+            raise exceptions.DataError(
+                "An absolute path to XML data need to be given as location"
+            )
+        bugzilla_parser = BugzillaParser()
+        # d = threads.deferToThread(bugzilla_parser.parse, location)
+        d = defer.maybeDeferred(bugzilla_parser.parse, location)
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_tmp_directory_subscription.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for directory subscription
+# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2015, 2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Directory subscription plugin",
+    C.PI_IMPORT_NAME: "DIRECTORY-SUBSCRIPTION",
+    C.PI_TYPE: "TMP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0050", "XEP-0055"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "DirectorySubscription",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of directory subscription"""),
+}
+
+
+NS_COMMANDS = "http://jabber.org/protocol/commands"
+CMD_UPDATE_SUBSCRIBTION = "update"
+
+
+class DirectorySubscription(object):
+    def __init__(self, host):
+        log.info(_("Directory subscription plugin initialization"))
+        self.host = host
+        host.import_menu(
+            (D_("Service"), D_("Directory subscription")),
+            self.subscribe,
+            security_limit=1,
+            help_string=D_("User directory subscription"),
+        )
+
+    def subscribe(self, raw_data, profile):
+        """Request available commands on the jabber search service associated to profile's host.
+
+        @param raw_data (dict): data received from the frontend
+        @param profile (unicode): %(doc_profile)s
+        @return: a deferred dict{unicode: unicode}
+        """
+        d = self.host.plugins["XEP-0055"]._get_host_services(profile)
+
+        def got_services(services):
+            service_jid = services[0]
+            session_id, session_data = self.host.plugins[
+                "XEP-0050"
+            ].requesting.new_session(profile=profile)
+            session_data["jid"] = service_jid
+            session_data["node"] = CMD_UPDATE_SUBSCRIBTION
+            data = {"session_id": session_id}
+            return self.host.plugins["XEP-0050"]._requesting_entity(data, profile)
+
+        return d.addCallback(got_services)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0020.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing xep-0020
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+from twisted.words.xish import domish
+
+from zope.interface import implementer
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+from wokkel import disco, iwokkel, data_form
+
+NS_FEATURE_NEG = "http://jabber.org/protocol/feature-neg"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP 0020 Plugin",
+    C.PI_IMPORT_NAME: "XEP-0020",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0020"],
+    C.PI_MAIN: "XEP_0020",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Feature Negotiation"""),
+}
+
+
+class XEP_0020(object):
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0020 initialization"))
+
+    def get_handler(self, client):
+        return XEP_0020_handler()
+
+    def get_feature_elt(self, elt):
+        """Check element's children to find feature elements
+
+        @param elt(domish.Element): parent element of the feature element
+        @return: feature elements
+        @raise exceptions.NotFound: no feature element found
+        """
+        try:
+            feature_elt = next(elt.elements(NS_FEATURE_NEG, "feature"))
+        except StopIteration:
+            raise exceptions.NotFound
+        return feature_elt
+
+    def _get_form(self, elt, namespace):
+        """Return the first child data form
+
+        @param elt(domish.Element): parent of the data form
+        @param namespace (None, unicode): form namespace or None to ignore
+        @return (None, data_form.Form): data form or None is nothing is found
+        """
+        if namespace is None:
+            try:
+                form_elt = next(elt.elements(data_form.NS_X_DATA))
+            except StopIteration:
+                return None
+            else:
+                return data_form.Form.fromElement(form_elt)
+        else:
+            return data_form.findForm(elt, namespace)
+
+    def get_choosed_options(self, feature_elt, namespace):
+        """Return choosed feature for feature element
+
+        @param feature_elt(domish.Element): feature domish element
+        @param namespace (None, unicode): form namespace or None to ignore
+        @return (dict): feature name as key, and choosed option as value
+        @raise exceptions.NotFound: not data form is found
+        """
+        form = self._get_form(feature_elt, namespace)
+        if form is None:
+            raise exceptions.NotFound
+        result = {}
+        for field in form.fields:
+            values = form.fields[field].values
+            result[field] = values[0] if values else None
+            if len(values) > 1:
+                log.warning(
+                    _(
+                        "More than one value choosed for {}, keeping the first one"
+                    ).format(field)
+                )
+        return result
+
+    def negotiate(self, feature_elt, name, negotiable_values, namespace):
+        """Negotiate the feature options
+
+        @param feature_elt(domish.Element): feature element
+        @param name: the option name (i.e. field's var attribute) to negotiate
+        @param negotiable_values(iterable): acceptable values for this negotiation
+            first corresponding value will be returned
+        @param namespace (None, unicode): form namespace or None to ignore
+        @raise KeyError: name is not found in data form fields
+        """
+        form = self._get_form(feature_elt, namespace)
+        options = [option.value for option in form.fields[name].options]
+        for value in negotiable_values:
+            if value in options:
+                return value
+        return None
+
+    def choose_option(self, options, namespace):
+        """Build a feature element with choosed options
+
+        @param options(dict): dict with feature as key and choosed option as value
+        @param namespace (None, unicode): form namespace or None to ignore
+        """
+        feature_elt = domish.Element((NS_FEATURE_NEG, "feature"))
+        x_form = data_form.Form("submit", formNamespace=namespace)
+        x_form.makeFields(options)
+        feature_elt.addChild(x_form.toElement())
+        return feature_elt
+
+    def propose_features(self, options_dict, namespace):
+        """Build a feature element with options to propose
+
+        @param options_dict(dict): dict with feature as key and iterable of acceptable options as value
+        @param namespace(None, unicode): feature namespace
+        """
+        feature_elt = domish.Element((NS_FEATURE_NEG, "feature"))
+        x_form = data_form.Form("form", formNamespace=namespace)
+        for field in options_dict:
+            x_form.addField(
+                data_form.Field(
+                    "list-single",
+                    field,
+                    options=[data_form.Option(option) for option in options_dict[field]],
+                )
+            )
+        feature_elt.addChild(x_form.toElement())
+        return feature_elt
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0020_handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_FEATURE_NEG)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0033.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Extended Stanza Addressing (xep-0033)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+from twisted.words.protocols.jabber.jid import JID
+from twisted.python import failure
+import copy
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+from twisted.words.xish import domish
+from twisted.internet import defer
+
+from libervia.backend.tools import trigger
+from time import time
+
+# TODO: fix Prosody "addressing" plugin to leave the concerned bcc according to the spec:
+#
+# http://xmpp.org/extensions/xep-0033.html#addr-type-bcc
+# "This means that the server MUST remove these addresses before the stanza is delivered to anyone other than the given bcc addressee or the multicast service of the bcc addressee."
+#
+# http://xmpp.org/extensions/xep-0033.html#multicast
+# "Each 'bcc' recipient MUST receive only the <address type='bcc'/> associated with that addressee."
+
+# TODO: fix Prosody "addressing" plugin to determine itself if remote servers supports this XEP
+
+
+NS_XMPP_CLIENT = "jabber:client"
+NS_ADDRESS = "http://jabber.org/protocol/address"
+ATTRIBUTES = ["jid", "uri", "node", "desc", "delivered", "type"]
+ADDRESS_TYPES = ["to", "cc", "bcc", "replyto", "replyroom", "noreply"]
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Extended Stanza Addressing Protocol Plugin",
+    C.PI_IMPORT_NAME: "XEP-0033",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0033"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "XEP_0033",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Extended Stanza Addressing"""),
+}
+
+
+class XEP_0033(object):
+    """
+    Implementation for XEP 0033
+    """
+
+    def __init__(self, host):
+        log.info(_("Extended Stanza Addressing plugin initialization"))
+        self.host = host
+        self.internal_data = {}
+        host.trigger.add(
+            "sendMessage", self.send_message_trigger, trigger.TriggerManager.MIN_PRIORITY
+        )
+        host.trigger.add("message_received", self.message_received_trigger)
+
+    def send_message_trigger(
+        self, client, mess_data, pre_xml_treatments, post_xml_treatments
+    ):
+        """Process the XEP-0033 related data to be sent"""
+        profile = client.profile
+
+        def treatment(mess_data):
+            if not "address" in mess_data["extra"]:
+                return mess_data
+
+            def disco_callback(entities):
+                if not entities:
+                    log.warning(
+                        _("XEP-0033 is being used but the server doesn't support it!")
+                    )
+                    raise failure.Failure(
+                        exceptions.CancelError("Cancelled by XEP-0033")
+                    )
+                if mess_data["to"] not in entities:
+                    expected = _(" or ").join([entity.userhost() for entity in entities])
+                    log.warning(
+                        _(
+                            "Stanzas using XEP-0033 should be addressed to %(expected)s, not %(current)s!"
+                        )
+                        % {"expected": expected, "current": mess_data["to"]}
+                    )
+                    log.warning(
+                        _(
+                            "TODO: addressing has been fixed by the backend... fix it in the frontend!"
+                        )
+                    )
+                    mess_data["to"] = list(entities)[0].userhostJID()
+                element = mess_data["xml"].addElement("addresses", NS_ADDRESS)
+                entries = [
+                    entry.split(":")
+                    for entry in mess_data["extra"]["address"].split("\n")
+                    if entry != ""
+                ]
+                for type_, jid_ in entries:
+                    element.addChild(
+                        domish.Element(
+                            (None, "address"), None, {"type": type_, "jid": jid_}
+                        )
+                    )
+                # when the prosody plugin is completed, we can immediately return mess_data from here
+                self.send_and_store_message(mess_data, entries, profile)
+                log.debug("XEP-0033 took over")
+                raise failure.Failure(exceptions.CancelError("Cancelled by XEP-0033"))
+
+            d = self.host.find_features_set(client, [NS_ADDRESS])
+            d.addCallbacks(disco_callback, lambda __: disco_callback(None))
+            return d
+
+        post_xml_treatments.addCallback(treatment)
+        return True
+
+    def send_and_store_message(self, mess_data, entries, profile):
+        """Check if target servers support XEP-0033, send and store the messages
+        @return: a friendly failure to let the core know that we sent the message already
+
+        Later we should be able to remove this method because:
+        # XXX: sending the messages should be done by the local server
+        # FIXME: for now we duplicate the messages in the history for each recipient, this should change
+        # FIXME: for now we duplicate the echoes to the sender, this should also change
+        Ideas:
+        - fix Prosody plugin to check if target server support the feature
+        - redesign the database to save only one entry to the database
+        - change the message_new signal to eventually pass more than one recipient
+        """
+        client = self.host.get_client(profile)
+
+        def send(mess_data, skip_send=False):
+            d = defer.Deferred()
+            if not skip_send:
+                d.addCallback(
+                    lambda ret: defer.ensureDeferred(client.send_message_data(ret))
+                )
+            d.addCallback(
+                lambda ret: defer.ensureDeferred(client.message_add_to_history(ret))
+            )
+            d.addCallback(client.message_send_to_bridge)
+            d.addErrback(lambda failure: failure.trap(exceptions.CancelError))
+            return d.callback(mess_data)
+
+        def disco_callback(entities, to_jid_s):
+            history_data = copy.deepcopy(mess_data)
+            history_data["to"] = JID(to_jid_s)
+            history_data["xml"]["to"] = to_jid_s
+            if entities:
+                if entities not in self.internal_data[timestamp]:
+                    sent_data = copy.deepcopy(mess_data)
+                    sent_data["to"] = JID(JID(to_jid_s).host)
+                    sent_data["xml"]["to"] = JID(to_jid_s).host
+                    send(sent_data)
+                    self.internal_data[timestamp].append(entities)
+                # we still need to fill the history and signal the echo...
+                send(history_data, skip_send=True)
+            else:
+                # target server misses the addressing feature
+                send(history_data)
+
+        def errback(failure, to_jid):
+            disco_callback(None, to_jid)
+
+        timestamp = time()
+        self.internal_data[timestamp] = []
+        defer_list = []
+        for type_, jid_ in entries:
+            d = defer.Deferred()
+            d.addCallback(
+                self.host.find_features_set, client=client, jid_=JID(JID(jid_).host)
+            )
+            d.addCallbacks(
+                disco_callback, errback, callbackArgs=[jid_], errbackArgs=[jid_]
+            )
+            d.callback([NS_ADDRESS])
+            defer_list.append(d)
+        d = defer.Deferred().addCallback(lambda __: self.internal_data.pop(timestamp))
+        defer.DeferredList(defer_list).chainDeferred(d)
+
+    def message_received_trigger(self, client, message, post_treat):
+        """In order to save the addressing information in the history"""
+
+        def post_treat_addr(data, addresses):
+            data["extra"]["addresses"] = ""
+            for address in addresses:
+                # Depending how message has been constructed, we could get here
+                # some noise like "\n        " instead of an address element.
+                if isinstance(address, domish.Element):
+                    data["extra"]["addresses"] += "%s:%s\n" % (
+                        address["type"],
+                        address["jid"],
+                    )
+            return data
+
+        try:
+            addresses = next(message.elements(NS_ADDRESS, "addresses"))
+        except StopIteration:
+            pass  # no addresses
+        else:
+            post_treat.addCallback(post_treat_addr, addresses.children)
+        return True
+
+    def get_handler(self, client):
+        return XEP_0033_handler(self, client.profile)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0033_handler(XMPPHandler):
+
+    def __init__(self, plugin_parent, profile):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+        self.profile = profile
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_ADDRESS)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0045.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1483 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing xep-0045
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import time
+from typing import Optional
+import uuid
+
+from twisted.internet import defer
+from twisted.python import failure
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import error as xmpp_error
+from wokkel import disco, iwokkel, muc
+from wokkel import rsm
+from wokkel import mam
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.log import getLogger
+from libervia.backend.memory import memory
+from libervia.backend.tools import xml_tools, utils
+
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP-0045 Plugin",
+    C.PI_IMPORT_NAME: "XEP-0045",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0045"],
+    C.PI_DEPENDENCIES: ["XEP-0359"],
+    C.PI_RECOMMENDATIONS: [C.TEXT_CMDS, "XEP-0313"],
+    C.PI_MAIN: "XEP_0045",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Multi-User Chat""")
+}
+
+NS_MUC = 'http://jabber.org/protocol/muc'
+AFFILIATIONS = ('owner', 'admin', 'member', 'none', 'outcast')
+ROOM_USER_JOINED = 'ROOM_USER_JOINED'
+ROOM_USER_LEFT = 'ROOM_USER_LEFT'
+OCCUPANT_KEYS = ('nick', 'entity', 'affiliation', 'role')
+ROOM_STATE_OCCUPANTS = "occupants"
+ROOM_STATE_SELF_PRESENCE = "self-presence"
+ROOM_STATE_LIVE = "live"
+ROOM_STATES = (ROOM_STATE_OCCUPANTS, ROOM_STATE_SELF_PRESENCE, ROOM_STATE_LIVE)
+HISTORY_LEGACY = "legacy"
+HISTORY_MAM = "mam"
+
+
+CONFIG_SECTION = 'plugin muc'
+
+default_conf = {"default_muc": 'sat@chat.jabberfr.org'}
+
+
+class AlreadyJoined(exceptions.ConflictError):
+
+    def __init__(self, room):
+        super(AlreadyJoined, self).__init__()
+        self.room = room
+
+
+class XEP_0045(object):
+    # TODO: handle invitations
+    # FIXME: this plugin need a good cleaning, join method is messy
+
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0045 initialization"))
+        self.host = host
+        self._sessions = memory.Sessions()
+        # return same arguments as muc_room_joined + a boolean set to True is the room was
+        # already joined (first argument)
+        host.bridge.add_method(
+            "muc_join", ".plugin", in_sign='ssa{ss}s', out_sign='(bsa{sa{ss}}ssass)',
+            method=self._join, async_=True)
+        host.bridge.add_method(
+            "muc_nick", ".plugin", in_sign='sss', out_sign='', method=self._nick)
+        host.bridge.add_method(
+            "muc_nick_get", ".plugin", in_sign='ss', out_sign='s', method=self._get_room_nick)
+        host.bridge.add_method(
+            "muc_leave", ".plugin", in_sign='ss', out_sign='', method=self._leave,
+            async_=True)
+        host.bridge.add_method(
+            "muc_occupants_get", ".plugin", in_sign='ss', out_sign='a{sa{ss}}',
+            method=self._get_room_occupants)
+        host.bridge.add_method(
+            "muc_subject", ".plugin", in_sign='sss', out_sign='', method=self._subject)
+        host.bridge.add_method(
+            "muc_get_rooms_joined", ".plugin", in_sign='s', out_sign='a(sa{sa{ss}}ssas)',
+            method=self._get_rooms_joined)
+        host.bridge.add_method(
+            "muc_get_unique_room_name", ".plugin", in_sign='ss', out_sign='s',
+            method=self._get_unique_name)
+        host.bridge.add_method(
+            "muc_configure_room", ".plugin", in_sign='ss', out_sign='s',
+            method=self._configure_room, async_=True)
+        host.bridge.add_method(
+            "muc_get_default_service", ".plugin", in_sign='', out_sign='s',
+            method=self.get_default_muc)
+        host.bridge.add_method(
+            "muc_get_service", ".plugin", in_sign='ss', out_sign='s',
+            method=self._get_muc_service, async_=True)
+        # called when a room will be joined but must be locked until join is received
+        # (room is prepared, history is getting retrieved)
+        # args: room_jid, profile
+        host.bridge.add_signal(
+            "muc_room_prepare_join", ".plugin", signature='ss')
+        # args: room_jid, occupants, user_nick, subject, profile
+        host.bridge.add_signal(
+            "muc_room_joined", ".plugin", signature='sa{sa{ss}}ssass')
+        # args: room_jid, profile
+        host.bridge.add_signal(
+            "muc_room_left", ".plugin", signature='ss')
+        # args: room_jid, old_nick, new_nick, profile
+        host.bridge.add_signal(
+            "muc_room_user_changed_nick", ".plugin", signature='ssss')
+        # args: room_jid, subject, profile
+        host.bridge.add_signal(
+            "muc_room_new_subject", ".plugin", signature='sss')
+        self.__submit_conf_id = host.register_callback(
+            self._submit_configuration, with_data=True)
+        self._room_join_id = host.register_callback(self._ui_room_join_cb, with_data=True)
+        host.import_menu(
+            (D_("MUC"), D_("configure")), self._configure_room_menu, security_limit=0,
+            help_string=D_("Configure Multi-User Chat room"), type_=C.MENU_ROOM)
+        try:
+            self.text_cmds = self.host.plugins[C.TEXT_CMDS]
+        except KeyError:
+            log.info(_("Text commands not available"))
+        else:
+            self.text_cmds.register_text_commands(self)
+            self.text_cmds.add_who_is_cb(self._whois, 100)
+
+        self._mam = self.host.plugins.get("XEP-0313")
+        self._si = self.host.plugins["XEP-0359"]
+
+        host.trigger.add("presence_available", self.presence_trigger)
+        host.trigger.add("presence_received", self.presence_received_trigger)
+        host.trigger.add("message_received", self.message_received_trigger, priority=1000000)
+        host.trigger.add("message_parse", self._message_parse_trigger)
+
+    async def profile_connected(self, client):
+        client.muc_service = await self.get_muc_service(client)
+
+    def _message_parse_trigger(self, client, message_elt, data):
+        """Add stanza-id from the room if present"""
+        if message_elt.getAttribute("type") != C.MESS_TYPE_GROUPCHAT:
+            return True
+
+        # stanza_id will not be filled by parse_message because the emitter
+        # is the room and not our server, so we have to parse it here
+        room_jid = data["from"].userhostJID()
+        stanza_id = self._si.get_stanza_id(message_elt, room_jid)
+        if stanza_id:
+            data["extra"]["stanza_id"] = stanza_id
+
+    def message_received_trigger(self, client, message_elt, post_treat):
+        if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT:
+            if message_elt.subject or message_elt.delay:
+                return False
+            from_jid = jid.JID(message_elt['from'])
+            room_jid = from_jid.userhostJID()
+            if room_jid in client._muc_client.joined_rooms:
+                room = client._muc_client.joined_rooms[room_jid]
+                if room.state != ROOM_STATE_LIVE:
+                    if getattr(room, "_history_type", HISTORY_LEGACY) == HISTORY_LEGACY:
+                        # With MAM history, order is different, and we can get live
+                        # messages before history is complete, so this is not a warning
+                        # but an expected case.
+                        # On the other hand, with legacy history, it's not normal.
+                        log.warning(_(
+                            "Received non delayed message in a room before its "
+                            "initialisation: state={state}, msg={msg}").format(
+                        state=room.state,
+                        msg=message_elt.toXml()))
+                    room._cache.append(message_elt)
+                    return False
+            else:
+                log.warning("Received groupchat message for a room which has not been "
+                            "joined, ignoring it: {}".format(message_elt.toXml()))
+                return False
+        return True
+
+    def get_room(self, client: SatXMPPEntity, room_jid: jid.JID) -> muc.Room:
+        """Retrieve Room instance from its jid
+
+        @param room_jid: jid of the room
+        @raise exceptions.NotFound: the room has not been joined
+        """
+        try:
+            return client._muc_client.joined_rooms[room_jid]
+        except KeyError:
+            raise exceptions.NotFound(_("This room has not been joined"))
+
+    def check_room_joined(self, client, room_jid):
+        """Check that given room has been joined in current session
+
+        @param room_jid (JID): room JID
+        """
+        if room_jid not in client._muc_client.joined_rooms:
+            raise exceptions.NotFound(_("This room has not been joined"))
+
+    def is_joined_room(self, client: SatXMPPEntity, room_jid: jid.JID) -> bool:
+        """Tell if a jid is a known and joined room
+
+        @room_jid: jid of the room
+        """
+        try:
+            self.check_room_joined(client, room_jid)
+        except exceptions.NotFound:
+            return False
+        else:
+            return True
+
+    def is_room(self, client, entity_jid):
+        """Tell if a jid is a joined MUC
+
+        similar to is_joined_room but returns a boolean
+        @param entity_jid(jid.JID): full or bare jid of the entity check
+        @return (bool): True if the bare jid of the entity is a room jid
+        """
+        try:
+            self.check_room_joined(client, entity_jid.userhostJID())
+        except exceptions.NotFound:
+            return False
+        else:
+            return True
+
+    def get_bare_or_full(self, client, peer_jid):
+        """use full jid if peer_jid is an occupant of a room, bare jid else
+
+        @param peer_jid(jid.JID): entity to test
+        @return (jid.JID): bare or full jid
+        """
+        if peer_jid.resource:
+            if not self.is_room(client, peer_jid):
+                return peer_jid.userhostJID()
+        return peer_jid
+
+    def _get_room_joined_args(self, room, profile):
+        return [
+            room.roomJID.userhost(),
+            XEP_0045._get_occupants(room),
+            room.nick,
+            room.subject,
+            [s.name for s in room.statuses],
+            profile
+            ]
+
+    def _ui_room_join_cb(self, data, profile):
+        room_jid = jid.JID(data['index'])
+        client = self.host.get_client(profile)
+        self.join(client, room_jid)
+        return {}
+
+    def _password_ui_cb(self, data, client, room_jid, nick):
+        """Called when the user has given room password (or cancelled)"""
+        if C.bool(data.get(C.XMLUI_DATA_CANCELLED, "false")):
+            log.info("room join for {} is cancelled".format(room_jid.userhost()))
+            raise failure.Failure(exceptions.CancelError(D_("Room joining cancelled by user")))
+        password = data[xml_tools.form_escape('password')]
+        return client._muc_client.join(room_jid, nick, password).addCallbacks(self._join_cb, self._join_eb, (client, room_jid, nick), errbackArgs=(client, room_jid, nick, password))
+
+    def _show_list_ui(self, items, client, service):
+        xmlui = xml_tools.XMLUI(title=D_('Rooms in {}'.format(service.full())))
+        adv_list = xmlui.change_container('advanced_list', columns=1, selectable='single', callback_id=self._room_join_id)
+        items = sorted(items, key=lambda i: i.name.lower())
+        for item in items:
+            adv_list.set_row_index(item.entity.full())
+            xmlui.addText(item.name)
+        adv_list.end()
+        self.host.action_new({'xmlui': xmlui.toXml()}, profile=client.profile)
+
+    def _join_cb(self, room, client, room_jid, nick):
+        """Called when the user is in the requested room"""
+        if room.locked:
+            # FIXME: the current behaviour is to create an instant room
+            # and send the signal only when the room is unlocked
+            # a proper configuration management should be done
+            log.debug(_("room locked !"))
+            d = client._muc_client.configure(room.roomJID, {})
+            d.addErrback(self.host.log_errback,
+                         msg=_('Error while configuring the room: {failure_}'))
+        return room.fully_joined
+
+    def _join_eb(self, failure_, client, room_jid, nick, password):
+        """Called when something is going wrong when joining the room"""
+        try:
+            condition = failure_.value.condition
+        except AttributeError:
+            msg_suffix = f': {failure_}'
+        else:
+            if condition == 'conflict':
+                # we have a nickname conflict, we try again with "_" suffixed to current nickname
+                nick += '_'
+                return client._muc_client.join(room_jid, nick, password).addCallbacks(self._join_cb, self._join_eb, (client, room_jid, nick), errbackArgs=(client, room_jid, nick, password))
+            elif condition == 'not-allowed':
+                # room is restricted, we need a password
+                password_ui = xml_tools.XMLUI("form", title=D_('Room {} is restricted').format(room_jid.userhost()), submit_id='')
+                password_ui.addText(D_("This room is restricted, please enter the password"))
+                password_ui.addPassword('password')
+                d = xml_tools.defer_xmlui(self.host, password_ui, profile=client.profile)
+                d.addCallback(self._password_ui_cb, client, room_jid, nick)
+                return d
+
+            msg_suffix = ' with condition "{}"'.format(failure_.value.condition)
+
+        mess = D_("Error while joining the room {room}{suffix}".format(
+            room = room_jid.userhost(), suffix = msg_suffix))
+        log.warning(mess)
+        xmlui = xml_tools.note(mess, D_("Group chat error"), level=C.XMLUI_DATA_LVL_ERROR)
+        self.host.action_new({'xmlui': xmlui.toXml()}, profile=client.profile)
+
+    @staticmethod
+    def _get_occupants(room):
+        """Get occupants of a room in a form suitable for bridge"""
+        return {u.nick: {k:str(getattr(u,k) or '') for k in OCCUPANT_KEYS} for u in list(room.roster.values())}
+
+    def _get_room_occupants(self, room_jid_s, profile_key):
+        client = self.host.get_client(profile_key)
+        room_jid = jid.JID(room_jid_s)
+        return self.get_room_occupants(client, room_jid)
+
+    def get_room_occupants(self, client, room_jid):
+        room = self.get_room(client, room_jid)
+        return self._get_occupants(room)
+
+    def _get_rooms_joined(self, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        return self.get_rooms_joined(client)
+
+    def get_rooms_joined(self, client):
+        """Return rooms where user is"""
+        result = []
+        for room in list(client._muc_client.joined_rooms.values()):
+            if room.state == ROOM_STATE_LIVE:
+                result.append(
+                    (room.roomJID.userhost(),
+                     self._get_occupants(room),
+                     room.nick,
+                     room.subject,
+                     [s.name for s in room.statuses],
+                    )
+                )
+        return result
+
+    def _get_room_nick(self, room_jid_s, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        return self.get_room_nick(client, jid.JID(room_jid_s))
+
+    def get_room_nick(self, client, room_jid):
+        """return nick used in room by user
+
+        @param room_jid (jid.JID): JID of the room
+        @profile_key: profile
+        @return: nick or empty string in case of error
+        @raise exceptions.Notfound: use has not joined the room
+        """
+        self.check_room_joined(client, room_jid)
+        return client._muc_client.joined_rooms[room_jid].nick
+
+    def _configure_room(self, room_jid_s, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        d = self.configure_room(client, jid.JID(room_jid_s))
+        d.addCallback(lambda xmlui: xmlui.toXml())
+        return d
+
+    def _configure_room_menu(self, menu_data, profile):
+        """Return room configuration form
+
+        @param menu_data: %(menu_data)s
+        @param profile: %(doc_profile)s
+        """
+        client = self.host.get_client(profile)
+        try:
+            room_jid = jid.JID(menu_data['room_jid'])
+        except KeyError:
+            log.error(_("room_jid key is not present !"))
+            return defer.fail(exceptions.DataError)
+
+        def xmlui_received(xmlui):
+            if not xmlui:
+                msg = D_("No configuration available for this room")
+                return {"xmlui": xml_tools.note(msg).toXml()}
+            return {"xmlui": xmlui.toXml()}
+        return self.configure_room(client, room_jid).addCallback(xmlui_received)
+
+    def configure_room(self, client, room_jid):
+        """return the room configuration form
+
+        @param room: jid of the room to configure
+        @return: configuration form as XMLUI
+        """
+        self.check_room_joined(client, room_jid)
+
+        def config_2_xmlui(result):
+            if not result:
+                return ""
+            session_id, session_data = self._sessions.new_session(profile=client.profile)
+            session_data["room_jid"] = room_jid
+            xmlui = xml_tools.data_form_2_xmlui(result, submit_id=self.__submit_conf_id)
+            xmlui.session_id = session_id
+            return xmlui
+
+        d = client._muc_client.getConfiguration(room_jid)
+        d.addCallback(config_2_xmlui)
+        return d
+
+    def _submit_configuration(self, raw_data, profile):
+        cancelled = C.bool(raw_data.get("cancelled", C.BOOL_FALSE))
+        if cancelled:
+            return defer.succeed({})
+        client = self.host.get_client(profile)
+        try:
+            session_data = self._sessions.profile_get(raw_data["session_id"], profile)
+        except KeyError:
+            log.warning(D_("Session ID doesn't exist, session has probably expired."))
+            _dialog = xml_tools.XMLUI('popup', title=D_('Room configuration failed'))
+            _dialog.addText(D_("Session ID doesn't exist, session has probably expired."))
+            return defer.succeed({'xmlui': _dialog.toXml()})
+
+        data = xml_tools.xmlui_result_2_data_form_result(raw_data)
+        d = client._muc_client.configure(session_data['room_jid'], data)
+        _dialog = xml_tools.XMLUI('popup', title=D_('Room configuration succeed'))
+        _dialog.addText(D_("The new settings have been saved."))
+        d.addCallback(lambda ignore: {'xmlui': _dialog.toXml()})
+        del self._sessions[raw_data["session_id"]]
+        return d
+
+    def is_nick_in_room(self, client, room_jid, nick):
+        """Tell if a nick is currently present in a room"""
+        self.check_room_joined(client, room_jid)
+        return client._muc_client.joined_rooms[room_jid].inRoster(muc.User(nick))
+
+    def _get_muc_service(self, jid_=None, profile=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile)
+        d = defer.ensureDeferred(self.get_muc_service(client, jid_ or None))
+        d.addCallback(lambda service_jid: service_jid.full() if service_jid is not None else '')
+        return d
+
+    async def get_muc_service(
+        self,
+        client: SatXMPPEntity,
+        jid_: Optional[jid.JID] = None) -> Optional[jid.JID]:
+        """Return first found MUC service of an entity
+
+        @param jid_: entity which may have a MUC service, or None for our own server
+        @return: found service jid or None
+        """
+        if jid_ is None:
+            try:
+                muc_service = client.muc_service
+            except AttributeError:
+                pass
+            else:
+                # we have a cached value, we return it
+                return muc_service
+        services = await self.host.find_service_entities(client, "conference", "text", jid_)
+        for service in services:
+            if ".irc." not in service.userhost():
+                # FIXME:
+                # This ugly hack is here to avoid an issue with openfire: the IRC gateway
+                # use "conference/text" identity (instead of "conference/irc")
+                muc_service = service
+                break
+        else:
+            muc_service = None
+        return muc_service
+
+    def _get_unique_name(self, muc_service="", profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        return self.get_unique_name(client, muc_service or None).full()
+
+    def get_unique_name(self, client, muc_service=None):
+        """Return unique name for a room, avoiding collision
+
+        @param muc_service (jid.JID) : leave empty string to use the default service
+        @return: jid.JID (unique room bare JID)
+        """
+        # TODO: we should use #RFC-0045 10.1.4 when available here
+        room_name = str(uuid.uuid4())
+        if muc_service is None:
+            try:
+                muc_service = client.muc_service
+            except AttributeError:
+                raise exceptions.NotReady("Main server MUC service has not been checked yet")
+            if muc_service is None:
+                log.warning(_("No MUC service found on main server"))
+                raise exceptions.FeatureNotFound
+
+        muc_service = muc_service.userhost()
+        return jid.JID("{}@{}".format(room_name, muc_service))
+
+    def get_default_muc(self):
+        """Return the default MUC.
+
+        @return: unicode
+        """
+        return self.host.memory.config_get(CONFIG_SECTION, 'default_muc', default_conf['default_muc'])
+
+    def _join_eb(self, failure_, client):
+        failure_.trap(AlreadyJoined)
+        room = failure_.value.room
+        return [True] + self._get_room_joined_args(room, client.profile)
+
+    def _join(self, room_jid_s, nick, options, profile_key=C.PROF_KEY_NONE):
+        """join method used by bridge
+
+        @return (tuple): already_joined boolean + room joined arguments (see [_get_room_joined_args])
+        """
+        client = self.host.get_client(profile_key)
+        if room_jid_s:
+            muc_service = client.muc_service
+            try:
+                room_jid = jid.JID(room_jid_s)
+            except (RuntimeError, jid.InvalidFormat, AttributeError):
+                return defer.fail(jid.InvalidFormat(_("Invalid room identifier: {room_id}'. Please give a room short or full identifier like 'room' or 'room@{muc_service}'.").format(
+                    room_id=room_jid_s,
+                    muc_service=str(muc_service))))
+            if not room_jid.user:
+                room_jid.user, room_jid.host = room_jid.host, muc_service
+        else:
+            room_jid = self.get_unique_name(profile_key=client.profile)
+        # TODO: error management + signal in bridge
+        d = self.join(client, room_jid, nick, options or None)
+        d.addCallback(lambda room: [False] + self._get_room_joined_args(room, client.profile))
+        d.addErrback(self._join_eb, client)
+        return d
+
+    async def join(
+        self,
+        client: SatXMPPEntity,
+        room_jid: jid.JID,
+        nick: Optional[str] = None,
+        options: Optional[dict] = None
+    ) -> Optional[muc.Room]:
+        if not nick:
+            nick = client.jid.user
+        if options is None:
+            options = {}
+        if room_jid in client._muc_client.joined_rooms:
+            room = client._muc_client.joined_rooms[room_jid]
+            log.info(_('{profile} is already in room {room_jid}').format(
+                profile=client.profile, room_jid = room_jid.userhost()))
+            raise AlreadyJoined(room)
+        log.info(_("[{profile}] is joining room {room} with nick {nick}").format(
+            profile=client.profile, room=room_jid.userhost(), nick=nick))
+        self.host.bridge.muc_room_prepare_join(room_jid.userhost(), client.profile)
+
+        password = options.get("password")
+
+        try:
+            room = await client._muc_client.join(room_jid, nick, password)
+        except Exception as e:
+            room = await utils.as_deferred(
+                self._join_eb(failure.Failure(e), client, room_jid, nick, password)
+            )
+        else:
+            await defer.ensureDeferred(
+                self._join_cb(room, client, room_jid, nick)
+            )
+        return room
+
+    def pop_rooms(self, client):
+        """Remove rooms and return data needed to re-join them
+
+        This methods is to be called before a hot reconnection
+        @return (list[(jid.JID, unicode)]): arguments needed to re-join the rooms
+            This list can be used directly (unpacked) with self.join
+        """
+        args_list = []
+        for room in list(client._muc_client.joined_rooms.values()):
+            client._muc_client._removeRoom(room.roomJID)
+            args_list.append((client, room.roomJID, room.nick))
+        return args_list
+
+    def _nick(self, room_jid_s, nick, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        return self.nick(client, jid.JID(room_jid_s), nick)
+
+    def nick(self, client, room_jid, nick):
+        """Change nickname in a room"""
+        self.check_room_joined(client, room_jid)
+        return client._muc_client.nick(room_jid, nick)
+
+    def _leave(self, room_jid, profile_key):
+        client = self.host.get_client(profile_key)
+        return self.leave(client, jid.JID(room_jid))
+
+    def leave(self, client, room_jid):
+        self.check_room_joined(client, room_jid)
+        return client._muc_client.leave(room_jid)
+
+    def _subject(self, room_jid_s, new_subject, profile_key):
+        client = self.host.get_client(profile_key)
+        return self.subject(client, jid.JID(room_jid_s), new_subject)
+
+    def subject(self, client, room_jid, subject):
+        self.check_room_joined(client, room_jid)
+        return client._muc_client.subject(room_jid, subject)
+
+    def get_handler(self, client):
+        # create a MUC client and associate it with profile' session
+        muc_client = client._muc_client = LiberviaMUCClient(self)
+        return muc_client
+
+    def kick(self, client, nick, room_jid, options=None):
+        """Kick a participant from the room
+
+        @param nick (str): nick of the user to kick
+        @param room_jid_s (JID): jid of the room
+        @param options (dict): attribute with extra info (reason, password) as in #XEP-0045
+        """
+        if options is None:
+            options = {}
+        self.check_room_joined(client, room_jid)
+        return client._muc_client.kick(room_jid, nick, reason=options.get('reason', None))
+
+    def ban(self, client, entity_jid, room_jid, options=None):
+        """Ban an entity from the room
+
+        @param entity_jid (JID): bare jid of the entity to be banned
+        @param room_jid (JID): jid of the room
+        @param options: attribute with extra info (reason, password) as in #XEP-0045
+        """
+        self.check_room_joined(client, room_jid)
+        if options is None:
+            options = {}
+        assert not entity_jid.resource
+        assert not room_jid.resource
+        return client._muc_client.ban(room_jid, entity_jid, reason=options.get('reason', None))
+
+    def affiliate(self, client, entity_jid, room_jid, options):
+        """Change the affiliation of an entity
+
+        @param entity_jid (JID): bare jid of the entity
+        @param room_jid_s (JID): jid of the room
+        @param options: attribute with extra info (reason, nick) as in #XEP-0045
+        """
+        self.check_room_joined(client, room_jid)
+        assert not entity_jid.resource
+        assert not room_jid.resource
+        assert 'affiliation' in options
+        # TODO: handles reason and nick
+        return client._muc_client.modifyAffiliationList(room_jid, [entity_jid], options['affiliation'])
+
+    # Text commands #
+
+    def cmd_nick(self, client, mess_data):
+        """change nickname
+
+        @command (group): new_nick
+            - new_nick: new nick to use
+        """
+        nick = mess_data["unparsed"].strip()
+        if nick:
+            room = mess_data["to"]
+            self.nick(client, room, nick)
+
+        return False
+
+    def cmd_join(self, client, mess_data):
+        """join a new room
+
+        @command (all): JID
+            - JID: room to join (on the same service if full jid is not specified)
+        """
+        room_raw = mess_data["unparsed"].strip()
+        if room_raw:
+            if self.is_joined_room(client, mess_data["to"]):
+                # we use the same service as the one from the room where the command has
+                # been entered if full jid is not entered
+                muc_service = mess_data["to"].host
+                nick = self.get_room_nick(client, mess_data["to"]) or client.jid.user
+            else:
+                # the command has been entered in a one2one conversation, so we use
+                # our server MUC service as default service
+                muc_service = client.muc_service or ""
+                nick = client.jid.user
+            room_jid = self.text_cmds.get_room_jid(room_raw, muc_service)
+            self.join(client, room_jid, nick, {})
+
+        return False
+
+    def cmd_leave(self, client, mess_data):
+        """quit a room
+
+        @command (group): [ROOM_JID]
+            - ROOM_JID: jid of the room to live (current room if not specified)
+        """
+        room_raw = mess_data["unparsed"].strip()
+        if room_raw:
+            room = self.text_cmds.get_room_jid(room_raw, mess_data["to"].host)
+        else:
+            room = mess_data["to"]
+
+        self.leave(client, room)
+
+        return False
+
+    def cmd_part(self, client, mess_data):
+        """just a synonym of /leave
+
+        @command (group): [ROOM_JID]
+            - ROOM_JID: jid of the room to live (current room if not specified)
+        """
+        return self.cmd_leave(client, mess_data)
+
+    def cmd_kick(self, client, mess_data):
+        """kick a room member
+
+        @command (group): ROOM_NICK
+            - ROOM_NICK: the nick of the person to kick
+        """
+        options = mess_data["unparsed"].strip().split()
+        try:
+            nick = options[0]
+            assert self.is_nick_in_room(client, mess_data["to"], nick)
+        except (IndexError, AssertionError):
+            feedback = _("You must provide a member's nick to kick.")
+            self.text_cmds.feed_back(client, feedback, mess_data)
+            return False
+
+        reason = ' '.join(options[1:]) if len(options) > 1 else None
+
+        d = self.kick(client, nick, mess_data["to"], {"reason": reason})
+
+        def cb(__):
+            feedback_msg = _('You have kicked {}').format(nick)
+            if reason is not None:
+                feedback_msg += _(' for the following reason: {reason}').format(
+                    reason=reason
+                )
+            self.text_cmds.feed_back(client, feedback_msg, mess_data)
+            return True
+        d.addCallback(cb)
+        return d
+
+    def cmd_ban(self, client, mess_data):
+        """ban an entity from the room
+
+        @command (group): (JID) [reason]
+            - JID: the JID of the entity to ban
+            - reason: the reason why this entity is being banned
+        """
+        options = mess_data["unparsed"].strip().split()
+        try:
+            jid_s = options[0]
+            entity_jid = jid.JID(jid_s).userhostJID()
+            assert(entity_jid.user)
+            assert(entity_jid.host)
+        except (RuntimeError, jid.InvalidFormat, AttributeError, IndexError,
+                AssertionError):
+            feedback = _(
+                "You must provide a valid JID to ban, like in '/ban contact@example.net'"
+            )
+            self.text_cmds.feed_back(client, feedback, mess_data)
+            return False
+
+        reason = ' '.join(options[1:]) if len(options) > 1 else None
+
+        d = self.ban(client, entity_jid, mess_data["to"], {"reason": reason})
+
+        def cb(__):
+            feedback_msg = _('You have banned {}').format(entity_jid)
+            if reason is not None:
+                feedback_msg += _(' for the following reason: {reason}').format(
+                    reason=reason
+                )
+            self.text_cmds.feed_back(client, feedback_msg, mess_data)
+            return True
+        d.addCallback(cb)
+        return d
+
+    def cmd_affiliate(self, client, mess_data):
+        """affiliate an entity to the room
+
+        @command (group): (JID) [owner|admin|member|none|outcast]
+            - JID: the JID of the entity to affiliate
+            - owner: grant owner privileges
+            - admin: grant admin privileges
+            - member: grant member privileges
+            - none: reset entity privileges
+            - outcast: ban entity
+        """
+        options = mess_data["unparsed"].strip().split()
+        try:
+            jid_s = options[0]
+            entity_jid = jid.JID(jid_s).userhostJID()
+            assert(entity_jid.user)
+            assert(entity_jid.host)
+        except (RuntimeError, jid.InvalidFormat, AttributeError, IndexError, AssertionError):
+            feedback = _("You must provide a valid JID to affiliate, like in '/affiliate contact@example.net member'")
+            self.text_cmds.feed_back(client, feedback, mess_data)
+            return False
+
+        affiliation = options[1] if len(options) > 1 else 'none'
+        if affiliation not in AFFILIATIONS:
+            feedback = _("You must provide a valid affiliation: %s") % ' '.join(AFFILIATIONS)
+            self.text_cmds.feed_back(client, feedback, mess_data)
+            return False
+
+        d = self.affiliate(client, entity_jid, mess_data["to"], {'affiliation': affiliation})
+
+        def cb(__):
+            feedback_msg = _('New affiliation for {entity}: {affiliation}').format(
+                entity=entity_jid, affiliation=affiliation)
+            self.text_cmds.feed_back(client, feedback_msg, mess_data)
+            return True
+        d.addCallback(cb)
+        return d
+
+    def cmd_title(self, client, mess_data):
+        """change room's subject
+
+        @command (group): title
+            - title: new room subject
+        """
+        subject = mess_data["unparsed"].strip()
+
+        if subject:
+            room = mess_data["to"]
+            self.subject(client, room, subject)
+
+        return False
+
+    def cmd_topic(self, client, mess_data):
+        """just a synonym of /title
+
+        @command (group): title
+            - title: new room subject
+        """
+        return self.cmd_title(client, mess_data)
+
+    def cmd_list(self, client, mess_data):
+        """list available rooms in a muc server
+
+        @command (all): [MUC_SERVICE]
+            - MUC_SERVICE: service to request
+               empty value will request room's service for a room,
+               or user's server default MUC service in a one2one chat
+        """
+        unparsed = mess_data["unparsed"].strip()
+        try:
+            service = jid.JID(unparsed)
+        except RuntimeError:
+            if mess_data['type'] == C.MESS_TYPE_GROUPCHAT:
+                room_jid = mess_data["to"]
+                service = jid.JID(room_jid.host)
+            elif client.muc_service is not None:
+                service = client.muc_service
+            else:
+                msg = D_("No known default MUC service {unparsed}").format(
+                    unparsed=unparsed)
+                self.text_cmds.feed_back(client, msg, mess_data)
+                return False
+        except jid.InvalidFormat:
+            msg = D_("{} is not a valid JID!".format(unparsed))
+            self.text_cmds.feed_back(client, msg, mess_data)
+            return False
+        d = self.host.getDiscoItems(client, service)
+        d.addCallback(self._show_list_ui, client, service)
+
+        return False
+
+    def _whois(self, client, whois_msg, mess_data, target_jid):
+        """ Add MUC user information to whois """
+        if mess_data['type'] != "groupchat":
+            return
+        if target_jid.userhostJID() not in client._muc_client.joined_rooms:
+            log.warning(_("This room has not been joined"))
+            return
+        if not target_jid.resource:
+            return
+        user = client._muc_client.joined_rooms[target_jid.userhostJID()].getUser(target_jid.resource)
+        whois_msg.append(_("Nickname: %s") % user.nick)
+        if user.entity:
+            whois_msg.append(_("Entity: %s") % user.entity)
+        if user.affiliation != 'none':
+            whois_msg.append(_("Affiliation: %s") % user.affiliation)
+        if user.role != 'none':
+            whois_msg.append(_("Role: %s") % user.role)
+        if user.status:
+            whois_msg.append(_("Status: %s") % user.status)
+        if user.show:
+            whois_msg.append(_("Show: %s") % user.show)
+
+    def presence_trigger(self, presence_elt, client):
+        # FIXME: should we add a privacy parameters in settings to activate before
+        #        broadcasting the presence to all MUC rooms ?
+        muc_client = client._muc_client
+        for room_jid, room in muc_client.joined_rooms.items():
+            elt = xml_tools.element_copy(presence_elt)
+            elt['to'] = room_jid.userhost() + '/' + room.nick
+            client.presence.send(elt)
+        return True
+
+    def presence_received_trigger(self, client, entity, show, priority, statuses):
+        entity_bare = entity.userhostJID()
+        muc_client = client._muc_client
+        if entity_bare in muc_client.joined_rooms:
+            # presence is already handled in (un)availableReceived
+            return False
+        return True
+
+
+@implementer(iwokkel.IDisco)
+class LiberviaMUCClient(muc.MUCClient):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        muc.MUCClient.__init__(self)
+        self._changing_nicks = set()  # used to keep trace of who is changing nick,
+                                      # and to discard userJoinedRoom signal in this case
+        print("init SatMUCClient OK")
+
+    @property
+    def joined_rooms(self):
+        return self._rooms
+
+    @property
+    def host(self):
+        return self.plugin_parent.host
+
+    @property
+    def client(self):
+        return self.parent
+
+    @property
+    def _mam(self):
+        return self.plugin_parent._mam
+
+    @property
+    def _si(self):
+        return self.plugin_parent._si
+
+    def change_room_state(self, room, new_state):
+        """Check that room is in expected state, and change it
+
+        @param new_state: one of ROOM_STATE_*
+        """
+        new_state_idx = ROOM_STATES.index(new_state)
+        if new_state_idx == -1:
+            raise exceptions.InternalError("unknown room state")
+        if new_state_idx < 1:
+            raise exceptions.InternalError("unexpected new room state ({room}): {state}".format(
+                room=room.userhost(),
+                state=new_state))
+        expected_state = ROOM_STATES[new_state_idx-1]
+        if room.state != expected_state:
+            log.error(_(
+                "room {room} is not in expected state: room is in state {current_state} "
+                "while we were expecting {expected_state}").format(
+                room=room.roomJID.userhost(),
+                current_state=room.state,
+                expected_state=expected_state))
+        room.state = new_state
+
+    def _addRoom(self, room):
+        super(LiberviaMUCClient, self)._addRoom(room)
+        room._roster_ok = False  # True when occupants list has been fully received
+        room.state = ROOM_STATE_OCCUPANTS
+        # FIXME: check if history_d is not redundant with fully_joined
+        room.fully_joined = defer.Deferred()  # called when everything is OK
+        # cache data until room is ready
+        # list of elements which will be re-injected in stream
+        room._cache = []
+        # we only need to keep last presence status for each jid, so a dict is suitable
+        room._cache_presence = {}
+
+    async def _join_legacy(
+        self,
+        client: SatXMPPEntity,
+        room_jid: jid.JID,
+        nick: str,
+        password: Optional[str]
+    ) -> muc.Room:
+        """Join room an retrieve history with legacy method"""
+        mess_data_list = await self.host.memory.history_get(
+            room_jid,
+            client.jid.userhostJID(),
+            limit=1,
+            between=True,
+            profile=client.profile
+        )
+        if mess_data_list:
+            timestamp = mess_data_list[0][1]
+            # we use seconds since last message to get backlog without duplicates
+            # and we remove 1 second to avoid getting the last message again
+            seconds = int(time.time() - timestamp) - 1
+        else:
+            seconds = None
+
+        room = await super(LiberviaMUCClient, self).join(
+            room_jid, nick, muc.HistoryOptions(seconds=seconds), password)
+        # used to send bridge signal once backlog are written in history
+        room._history_type = HISTORY_LEGACY
+        room._history_d = defer.Deferred()
+        room._history_d.callback(None)
+        return room
+
+    async def _get_mam_history(
+        self,
+        client: SatXMPPEntity,
+        room: muc.Room,
+        room_jid: jid.JID
+    ) -> None:
+        """Retrieve history for rooms handling MAM"""
+        history_d = room._history_d = defer.Deferred()
+        # we trigger now the deferred so all callback are processed as soon as possible
+        # and in order
+        history_d.callback(None)
+
+        last_mess = await self.host.memory.history_get(
+            room_jid,
+            None,
+            limit=1,
+            between=False,
+            filters={
+                'types': C.MESS_TYPE_GROUPCHAT,
+                'last_stanza_id': True},
+            profile=client.profile)
+        if last_mess:
+            stanza_id = last_mess[0][-1]['stanza_id']
+            rsm_req = rsm.RSMRequest(max_=20, after=stanza_id)
+            no_loop=False
+        else:
+            log.info("We have no MAM archive for room {room_jid}.".format(
+                room_jid=room_jid))
+            # we don't want the whole archive if we have no archive yet
+            # as it can be huge
+            rsm_req = rsm.RSMRequest(max_=50, before='')
+            no_loop=True
+
+        mam_req = mam.MAMRequest(rsm_=rsm_req)
+        complete = False
+        count = 0
+        while not complete:
+            try:
+                mam_data = await self._mam.get_archives(client, mam_req,
+                                                       service=room_jid)
+            except xmpp_error.StanzaError as e:
+                if last_mess and e.condition == 'item-not-found':
+                    log.info(
+                        f"requested item (with id {stanza_id!r}) can't be found in "
+                        f"history of {room_jid}, history has probably been purged on "
+                        f"server.")
+                    # we get last items like for a new room
+                    rsm_req = rsm.RSMRequest(max_=50, before='')
+                    mam_req = mam.MAMRequest(rsm_=rsm_req)
+                    no_loop=True
+                    continue
+                else:
+                    raise e
+            elt_list, rsm_response, mam_response = mam_data
+            complete = True if no_loop else mam_response["complete"]
+            # we update MAM request for next iteration
+            mam_req.rsm.after = rsm_response.last
+
+            if not elt_list:
+                break
+            else:
+                count += len(elt_list)
+
+                for mess_elt in elt_list:
+                    try:
+                        fwd_message_elt = self._mam.get_message_from_result(
+                            client, mess_elt, mam_req, service=room_jid)
+                    except exceptions.DataError:
+                        continue
+                    if fwd_message_elt.getAttribute("to"):
+                        log.warning(
+                            'Forwarded message element has a "to" attribute while it is '
+                            'forbidden by specifications')
+                    fwd_message_elt["to"] = client.jid.full()
+                    try:
+                        mess_data = client.messageProt.parse_message(fwd_message_elt)
+                    except Exception as e:
+                        log.error(
+                            f"Can't parse message, ignoring it: {e}\n"
+                            f"{fwd_message_elt.toXml()}"
+                        )
+                        continue
+                    # we attache parsed message data to element, to avoid parsing
+                    # again in _add_to_history
+                    fwd_message_elt._mess_data = mess_data
+                    # and we inject to MUC workflow
+                    client._muc_client._onGroupChat(fwd_message_elt)
+
+        if not count:
+            log.info(_("No message received while offline in {room_jid}".format(
+                room_jid=room_jid)))
+        else:
+            log.info(
+                _("We have received {num_mess} message(s) in {room_jid} while "
+                  "offline.")
+                .format(num_mess=count, room_jid=room_jid))
+
+        # for legacy history, the following steps are done in receivedSubject but for MAM
+        # the order is different (we have to join then get MAM archive, so subject
+        # is received before archive), so we change state and add the callbacks here.
+        self.change_room_state(room, ROOM_STATE_LIVE)
+        history_d.addCallbacks(self._history_cb, self._history_eb, [room],
+                                     errbackArgs=[room])
+
+        # we wait for all callbacks to be processed
+        await history_d
+
+    async def _join_mam(
+        self,
+        client: SatXMPPEntity,
+        room_jid: jid.JID,
+        nick: str,
+        password: Optional[str]
+    ) -> muc.Room:
+        """Join room and retrieve history using MAM"""
+        room = await super(LiberviaMUCClient, self).join(
+            # we don't want any history from room as we'll get it with MAM
+            room_jid, nick, muc.HistoryOptions(maxStanzas=0), password=password
+        )
+        room._history_type = HISTORY_MAM
+        # MAM history retrieval can be very long, and doesn't need to be sync, so we don't
+        # wait for it
+        defer.ensureDeferred(self._get_mam_history(client, room, room_jid))
+        room.fully_joined.callback(room)
+
+        return room
+
+    async def join(self, room_jid, nick, password=None):
+        room_service = jid.JID(room_jid.host)
+        has_mam = await self.host.hasFeature(self.client, mam.NS_MAM, room_service)
+        if not self._mam or not has_mam:
+            return await self._join_legacy(self.client, room_jid, nick, password)
+        else:
+            return await self._join_mam(self.client, room_jid, nick, password)
+
+    ## presence/roster ##
+
+    def availableReceived(self, presence):
+        """
+        Available presence was received.
+        """
+        # XXX: we override MUCClient.availableReceived to fix bugs
+        # (affiliation and role are not set)
+
+        room, user = self._getRoomUser(presence)
+
+        if room is None:
+            return
+
+        if user is None:
+            nick = presence.sender.resource
+            if not nick:
+                log.warning(_("missing nick in presence: {xml}").format(
+                    xml = presence.toElement().toXml()))
+                return
+            user = muc.User(nick, presence.entity)
+
+        # we want to keep statuses with room
+        # XXX: presence if broadcasted, and we won't have status code
+        #      like 110 (REALJID_PUBLIC) after first <presence/> received
+        #      so we keep only the initial <presence> (with SELF_PRESENCE),
+        #      thus we check if attribute already exists
+        if (not hasattr(room, 'statuses')
+            and muc.STATUS_CODE.SELF_PRESENCE in presence.mucStatuses):
+            room.statuses = presence.mucStatuses
+
+        # Update user data
+        user.role = presence.role
+        user.affiliation = presence.affiliation
+        user.status = presence.status
+        user.show = presence.show
+
+        if room.inRoster(user):
+            self.userUpdatedStatus(room, user, presence.show, presence.status)
+        else:
+            room.addUser(user)
+            self.userJoinedRoom(room, user)
+
+    def unavailableReceived(self, presence):
+        # XXX: we override this method to manage nickname change
+        """
+        Unavailable presence was received.
+
+        If this was received from a MUC room occupant JID, that occupant has
+        left the room.
+        """
+        room, user = self._getRoomUser(presence)
+
+        if room is None or user is None:
+            return
+
+        room.removeUser(user)
+
+        if muc.STATUS_CODE.NEW_NICK in presence.mucStatuses:
+            self._changing_nicks.add(presence.nick)
+            self.user_changed_nick(room, user, presence.nick)
+        else:
+            self._changing_nicks.discard(presence.nick)
+            self.userLeftRoom(room, user)
+
+    def userJoinedRoom(self, room, user):
+        if user.nick == room.nick:
+            # we have received our own nick,
+            # this mean that the full room roster was received
+            self.change_room_state(room, ROOM_STATE_SELF_PRESENCE)
+            log.debug("room {room} joined with nick {nick}".format(
+                room=room.occupantJID.userhost(), nick=user.nick))
+            # we set type so we don't have to use a deferred
+            # with disco to check entity type
+            self.host.memory.update_entity_data(
+                self.client, room.roomJID, C.ENTITY_TYPE, C.ENTITY_TYPE_MUC
+            )
+        elif room.state not in (ROOM_STATE_OCCUPANTS, ROOM_STATE_LIVE):
+            log.warning(
+                "Received user presence data in a room before its initialisation "
+                "(current state: {state}),"
+                "this is not standard! Ignoring it: {room} ({nick})".format(
+                    state=room.state,
+                    room=room.roomJID.userhost(),
+                    nick=user.nick))
+            return
+        else:
+            if not room.fully_joined.called:
+                return
+            try:
+                self._changing_nicks.remove(user.nick)
+            except KeyError:
+                # this is a new user
+                log.debug(_("user {nick} has joined room {room_id}").format(
+                    nick=user.nick, room_id=room.occupantJID.userhost()))
+                if not self.host.trigger.point(
+                        "MUC user joined", room, user, self.client.profile):
+                    return
+
+                extra = {'info_type': ROOM_USER_JOINED,
+                         'user_affiliation': user.affiliation,
+                         'user_role': user.role,
+                         'user_nick': user.nick
+                         }
+                if user.entity is not None:
+                    extra['user_entity'] = user.entity.full()
+                mess_data = {  # dict is similar to the one used in client.onMessage
+                    "from": room.roomJID,
+                    "to": self.client.jid,
+                    "uid": str(uuid.uuid4()),
+                    "message": {'': D_("=> {} has joined the room").format(user.nick)},
+                    "subject": {},
+                    "type": C.MESS_TYPE_INFO,
+                    "extra": extra,
+                    "timestamp": time.time(),
+                }
+                # FIXME: we disable presence in history as it's taking a lot of space
+                #        while not indispensable. In the future an option may allow
+                #        to re-enable it
+                # self.client.message_add_to_history(mess_data)
+                self.client.message_send_to_bridge(mess_data)
+
+
+    def userLeftRoom(self, room, user):
+        if not self.host.trigger.point("MUC user left", room, user, self.client.profile):
+            return
+        if user.nick == room.nick:
+            # we left the room
+            room_jid_s = room.roomJID.userhost()
+            log.info(_("Room ({room}) left ({profile})").format(
+                room = room_jid_s, profile = self.client.profile))
+            self.host.memory.del_entity_cache(room.roomJID, profile_key=self.client.profile)
+            self.host.bridge.muc_room_left(room.roomJID.userhost(), self.client.profile)
+        elif room.state != ROOM_STATE_LIVE:
+            log.warning("Received user presence data in a room before its initialisation (current state: {state}),"
+                "this is not standard! Ignoring it: {room} ({nick})".format(
+                state=room.state,
+                room=room.roomJID.userhost(),
+                nick=user.nick))
+            return
+        else:
+            if not room.fully_joined.called:
+                return
+            log.debug(_("user {nick} left room {room_id}").format(nick=user.nick, room_id=room.occupantJID.userhost()))
+            extra = {'info_type': ROOM_USER_LEFT,
+                     'user_affiliation': user.affiliation,
+                     'user_role': user.role,
+                     'user_nick': user.nick
+                     }
+            if user.entity is not None:
+                extra['user_entity'] = user.entity.full()
+            mess_data = {  # dict is similar to the one used in client.onMessage
+                "from": room.roomJID,
+                "to": self.client.jid,
+                "uid": str(uuid.uuid4()),
+                "message": {'': D_("<= {} has left the room").format(user.nick)},
+                "subject": {},
+                "type": C.MESS_TYPE_INFO,
+                "extra": extra,
+                "timestamp": time.time(),
+            }
+            # FIXME: disable history, see userJoinRoom comment
+            # self.client.message_add_to_history(mess_data)
+            self.client.message_send_to_bridge(mess_data)
+
+    def user_changed_nick(self, room, user, new_nick):
+        self.host.bridge.muc_room_user_changed_nick(room.roomJID.userhost(), user.nick, new_nick, self.client.profile)
+
+    def userUpdatedStatus(self, room, user, show, status):
+        entity = jid.JID(tuple=(room.roomJID.user, room.roomJID.host, user.nick))
+        if hasattr(room, "_cache_presence"):
+            # room has a cache for presence, meaning it has not been fully
+            # joined yet. So we put presence in cache, and stop workflow.
+            # Or delete current presence and continue workflow if it's an
+            # "unavailable" presence
+            cache = room._cache_presence
+            cache[entity] = {
+                "room": room,
+                "user": user,
+                "show": show,
+                "status": status,
+                }
+            return
+        statuses = {C.PRESENCE_STATUSES_DEFAULT: status or ''}
+        self.host.bridge.presence_update(
+            entity.full(), show or '', 0, statuses, self.client.profile)
+
+    ## messages ##
+
+    def receivedGroupChat(self, room, user, body):
+        log.debug('receivedGroupChat: room=%s user=%s body=%s' % (room.roomJID.full(), user, body))
+
+    def _add_to_history(self, __, user, message):
+        try:
+            # message can be already parsed (with MAM), in this case mess_data
+            # it attached to the element
+            mess_data = message.element._mess_data
+        except AttributeError:
+            mess_data = self.client.messageProt.parse_message(message.element)
+        if mess_data['message'] or mess_data['subject']:
+            return defer.ensureDeferred(
+                self.host.memory.add_to_history(self.client, mess_data)
+            )
+        else:
+            return defer.succeed(None)
+
+    def _add_to_history_eb(self, failure):
+        failure.trap(exceptions.CancelError)
+
+    def receivedHistory(self, room, user, message):
+        """Called when history (backlog) message are received
+
+        we check if message is not already in our history
+        and add it if needed
+        @param room(muc.Room): room instance
+        @param user(muc.User, None): the user that sent the message
+            None if the message come from the room
+        @param message(muc.GroupChat): the parsed message
+        """
+        if room.state != ROOM_STATE_SELF_PRESENCE:
+            log.warning(_(
+                "received history in unexpected state in room {room} (state: "
+                "{state})").format(room = room.roomJID.userhost(),
+                                    state = room.state))
+            if not hasattr(room, "_history_d"):
+                # XXX: this hack is due to buggy behaviour seen in the wild because of the
+                #      "mod_delay" prosody module being activated. This module add an
+                #      unexpected <delay> elements which break our workflow.
+                log.warning(_("storing the unexpected message anyway, to avoid loss"))
+                # we have to restore URI which are stripped by wokkel parsing
+                for c in message.element.elements():
+                    if c.uri is None:
+                        c.uri = C.NS_CLIENT
+                mess_data = self.client.messageProt.parse_message(message.element)
+                message.element._mess_data = mess_data
+                self._add_to_history(None, user, message)
+                if mess_data['message'] or mess_data['subject']:
+                    self.host.bridge.message_new(
+                        *self.client.message_get_bridge_args(mess_data),
+                        profile=self.client.profile
+                    )
+                return
+        room._history_d.addCallback(self._add_to_history, user, message)
+        room._history_d.addErrback(self._add_to_history_eb)
+
+    ## subject ##
+
+    def groupChatReceived(self, message):
+        """
+        A group chat message has been received from a MUC room.
+
+        There are a few event methods that may get called here.
+        L{receivedGroupChat}, L{receivedSubject} or L{receivedHistory}.
+        """
+        # We override this method to fix subject handling (empty strings were discarded)
+        # FIXME: remove this once fixed upstream
+        room, user = self._getRoomUser(message)
+
+        if room is None:
+            log.warning("No room found for message: {message}"
+                        .format(message=message.toElement().toXml()))
+            return
+
+        if message.subject is not None:
+            self.receivedSubject(room, user, message.subject)
+        elif message.delay is None:
+            self.receivedGroupChat(room, user, message)
+        else:
+            self.receivedHistory(room, user, message)
+
+    def subject(self, room, subject):
+        return muc.MUCClientProtocol.subject(self, room, subject)
+
+    def _history_cb(self, __, room):
+        """Called when history have been written to database and subject is received
+
+        this method will finish joining by:
+            - sending message to bridge
+            - calling fully_joined deferred (for legacy history)
+            - sending stanza put in cache
+            - cleaning variables not needed anymore
+        """
+        args = self.plugin_parent._get_room_joined_args(room, self.client.profile)
+        self.host.bridge.muc_room_joined(*args)
+        if room._history_type == HISTORY_LEGACY:
+            room.fully_joined.callback(room)
+        del room._history_d
+        del room._history_type
+        cache = room._cache
+        del room._cache
+        cache_presence = room._cache_presence
+        del room._cache_presence
+        for elem in cache:
+            self.client.xmlstream.dispatch(elem)
+        for presence_data in cache_presence.values():
+            if not presence_data['show'] and not presence_data['status']:
+                # occupants are already sent in muc_room_joined, so if we don't have
+                # extra information like show or statuses, we can discard the signal
+                continue
+            else:
+                self.userUpdatedStatus(**presence_data)
+
+    def _history_eb(self, failure_, room):
+        log.error("Error while managing history: {}".format(failure_))
+        self._history_cb(None, room)
+
+    def receivedSubject(self, room, user, subject):
+        # when subject is received, we know that we have whole roster and history
+        # cf. http://xmpp.org/extensions/xep-0045.html#enter-subject
+        room.subject = subject  # FIXME: subject doesn't handle xml:lang
+        if room.state != ROOM_STATE_LIVE:
+            if room._history_type == HISTORY_LEGACY:
+                self.change_room_state(room, ROOM_STATE_LIVE)
+                room._history_d.addCallbacks(self._history_cb, self._history_eb, [room], errbackArgs=[room])
+        else:
+            # the subject has been changed
+            log.debug(_("New subject for room ({room_id}): {subject}").format(room_id = room.roomJID.full(), subject = subject))
+            self.host.bridge.muc_room_new_subject(room.roomJID.userhost(), subject, self.client.profile)
+
+    ## disco ##
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
+        return [disco.DiscoFeature(NS_MUC)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=''):
+        # TODO: manage room queries ? Bad for privacy, must be disabled by default
+        #       see XEP-0045 § 6.7
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0047.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,385 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing gateways (xep-0047)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.protocols.jabber import error
+from twisted.internet import reactor
+from twisted.internet import defer
+from twisted.python import failure
+
+from wokkel import disco, iwokkel
+
+from zope.interface import implementer
+
+import base64
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+MESSAGE = "/message"
+IQ_SET = '/iq[@type="set"]'
+NS_IBB = "http://jabber.org/protocol/ibb"
+IBB_OPEN = IQ_SET + '/open[@xmlns="' + NS_IBB + '"]'
+IBB_CLOSE = IQ_SET + '/close[@xmlns="' + NS_IBB + '" and @sid="{}"]'
+IBB_IQ_DATA = IQ_SET + '/data[@xmlns="' + NS_IBB + '" and @sid="{}"]'
+IBB_MESSAGE_DATA = MESSAGE + '/data[@xmlns="' + NS_IBB + '" and @sid="{}"]'
+TIMEOUT = 120  # timeout for workflow
+DEFER_KEY = "finished"  # key of the deferred used to track session end
+
+PLUGIN_INFO = {
+    C.PI_NAME: "In-Band Bytestream Plugin",
+    C.PI_IMPORT_NAME: "XEP-0047",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0047"],
+    C.PI_MAIN: "XEP_0047",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of In-Band Bytestreams"""),
+}
+
+
+class XEP_0047(object):
+    NAMESPACE = NS_IBB
+    BLOCK_SIZE = 4096
+
+    def __init__(self, host):
+        log.info(_("In-Band Bytestreams plugin initialization"))
+        self.host = host
+
+    def get_handler(self, client):
+        return XEP_0047_handler(self)
+
+    def profile_connected(self, client):
+        client.xep_0047_current_stream = {}  # key: stream_id, value: data(dict)
+
+    def _time_out(self, sid, client):
+        """Delete current_stream id, called after timeout
+
+        @param sid(unicode): session id of client.xep_0047_current_stream
+        @param client: %(doc_client)s
+        """
+        log.info(
+            "In-Band Bytestream: TimeOut reached for id {sid} [{profile}]".format(
+                sid=sid, profile=client.profile
+            )
+        )
+        self._kill_session(sid, client, "TIMEOUT")
+
+    def _kill_session(self, sid, client, failure_reason=None):
+        """Delete a current_stream id, clean up associated observers
+
+        @param sid(unicode): session id
+        @param client: %(doc_client)s
+        @param failure_reason(None, unicode): if None the session is successful
+            else, will be used to call failure_cb
+        """
+        try:
+            session = client.xep_0047_current_stream[sid]
+        except KeyError:
+            log.warning("kill id called on a non existant id")
+            return
+
+        try:
+            observer_cb = session["observer_cb"]
+        except KeyError:
+            pass
+        else:
+            client.xmlstream.removeObserver(session["event_data"], observer_cb)
+
+        if session["timer"].active():
+            session["timer"].cancel()
+
+        del client.xep_0047_current_stream[sid]
+
+        success = failure_reason is None
+        stream_d = session[DEFER_KEY]
+
+        if success:
+            stream_d.callback(None)
+        else:
+            stream_d.errback(failure.Failure(exceptions.DataError(failure_reason)))
+
+    def create_session(self, *args, **kwargs):
+        """like [_create_session] but return the session deferred instead of the whole session
+
+        session deferred is fired when transfer is finished
+        """
+        return self._create_session(*args, **kwargs)[DEFER_KEY]
+
+    def _create_session(self, client, stream_object, local_jid, to_jid, sid):
+        """Called when a bytestream is imminent
+
+        @param stream_object(IConsumer): stream object where data will be written
+        @param local_jid(jid.JID): same as [start_stream]
+        @param to_jid(jid.JId): jid of the other peer
+        @param sid(unicode): session id
+        @return (dict): session data
+        """
+        if sid in client.xep_0047_current_stream:
+            raise exceptions.ConflictError("A session with this id already exists !")
+        session_data = client.xep_0047_current_stream[sid] = {
+            "id": sid,
+            DEFER_KEY: defer.Deferred(),
+            "local_jid": local_jid,
+            "to": to_jid,
+            "stream_object": stream_object,
+            "seq": -1,
+            "timer": reactor.callLater(TIMEOUT, self._time_out, sid, client),
+        }
+
+        return session_data
+
+    def _on_ibb_open(self, iq_elt, client):
+        """"Called when an IBB <open> element is received
+
+        @param iq_elt(domish.Element): the whole <iq> stanza
+        """
+        log.debug(_("IBB stream opening"))
+        iq_elt.handled = True
+        open_elt = next(iq_elt.elements(NS_IBB, "open"))
+        block_size = open_elt.getAttribute("block-size")
+        sid = open_elt.getAttribute("sid")
+        stanza = open_elt.getAttribute("stanza", "iq")
+        if not sid or not block_size or int(block_size) > 65535:
+            return self._sendError("not-acceptable", sid or None, iq_elt, client)
+        if not sid in client.xep_0047_current_stream:
+            log.warning(_("Ignoring unexpected IBB transfer: %s" % sid))
+            return self._sendError("not-acceptable", sid or None, iq_elt, client)
+        session_data = client.xep_0047_current_stream[sid]
+        if session_data["to"] != jid.JID(iq_elt["from"]):
+            log.warning(
+                _("sended jid inconsistency (man in the middle attack attempt ?)")
+            )
+            return self._sendError("not-acceptable", sid, iq_elt, client)
+
+        # at this stage, the session looks ok and will be accepted
+
+        # we reset the timeout:
+        session_data["timer"].reset(TIMEOUT)
+
+        # we save the xmlstream, events and observer data to allow observer removal
+        session_data["event_data"] = event_data = (
+            IBB_MESSAGE_DATA if stanza == "message" else IBB_IQ_DATA
+        ).format(sid)
+        session_data["observer_cb"] = observer_cb = self._on_ibb_data
+        event_close = IBB_CLOSE.format(sid)
+        # we now set the stream observer to look after data packet
+        # FIXME: if we never get the events, the observers stay.
+        #        would be better to have generic observer and check id once triggered
+        client.xmlstream.addObserver(event_data, observer_cb, client=client)
+        client.xmlstream.addOnetimeObserver(event_close, self._on_ibb_close, client=client)
+        # finally, we send the accept stanza
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        client.send(iq_result_elt)
+
+    def _on_ibb_close(self, iq_elt, client):
+        """"Called when an IBB <close> element is received
+
+        @param iq_elt(domish.Element): the whole <iq> stanza
+        """
+        iq_elt.handled = True
+        log.debug(_("IBB stream closing"))
+        close_elt = next(iq_elt.elements(NS_IBB, "close"))
+        # XXX: this observer is only triggered on valid sid, so we don't need to check it
+        sid = close_elt["sid"]
+
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        client.send(iq_result_elt)
+        self._kill_session(sid, client)
+
+    def _on_ibb_data(self, element, client):
+        """Observer called on <iq> or <message> stanzas with data element
+
+        Manage the data elelement (check validity and write to the stream_object)
+        @param element(domish.Element): <iq> or <message> stanza
+        """
+        element.handled = True
+        data_elt = next(element.elements(NS_IBB, "data"))
+        sid = data_elt["sid"]
+
+        try:
+            session_data = client.xep_0047_current_stream[sid]
+        except KeyError:
+            log.warning(_("Received data for an unknown session id"))
+            return self._sendError("item-not-found", None, element, client)
+
+        from_jid = session_data["to"]
+        stream_object = session_data["stream_object"]
+
+        if from_jid.full() != element["from"]:
+            log.warning(
+                _(
+                    "sended jid inconsistency (man in the middle attack attempt ?)\ninitial={initial}\ngiven={given}"
+                ).format(initial=from_jid, given=element["from"])
+            )
+            if element.name == "iq":
+                self._sendError("not-acceptable", sid, element, client)
+            return
+
+        session_data["seq"] = (session_data["seq"] + 1) % 65535
+        if int(data_elt.getAttribute("seq", -1)) != session_data["seq"]:
+            log.warning(_("Sequence error"))
+            if element.name == "iq":
+                reason = "not-acceptable"
+                self._sendError(reason, sid, element, client)
+            self.terminate_stream(session_data, client, reason)
+            return
+
+        # we reset the timeout:
+        session_data["timer"].reset(TIMEOUT)
+
+        # we can now decode the data
+        try:
+            stream_object.write(base64.b64decode(str(data_elt)))
+        except TypeError:
+            # The base64 data is invalid
+            log.warning(_("Invalid base64 data"))
+            if element.name == "iq":
+                self._sendError("not-acceptable", sid, element, client)
+            self.terminate_stream(session_data, client, reason)
+            return
+
+        # we can now ack success
+        if element.name == "iq":
+            iq_result_elt = xmlstream.toResponse(element, "result")
+            client.send(iq_result_elt)
+
+    def _sendError(self, error_condition, sid, iq_elt, client):
+        """Send error stanza
+
+        @param error_condition: one of twisted.words.protocols.jabber.error.STANZA_CONDITIONS keys
+        @param sid(unicode,None): jingle session id, or None, if session must not be destroyed
+        @param iq_elt(domish.Element): full <iq> stanza
+        @param client: %(doc_client)s
+        """
+        iq_elt = error.StanzaError(error_condition).toResponse(iq_elt)
+        log.warning(
+            "Error while managing in-band bytestream session, cancelling: {}".format(
+                error_condition
+            )
+        )
+        if sid is not None:
+            self._kill_session(sid, client, error_condition)
+        client.send(iq_elt)
+
+    def start_stream(self, client, stream_object, local_jid, to_jid, sid, block_size=None):
+        """Launch the stream workflow
+
+        @param stream_object(ifaces.IStreamProducer): stream object to send
+        @param local_jid(jid.JID): jid to use as local jid
+            This is needed for client which can be addressed with a different jid than
+            client.jid if a local part is used (e.g. piotr@file.example.net where
+            client.jid would be file.example.net)
+        @param to_jid(jid.JID): JID of the recipient
+        @param sid(unicode): Stream session id
+        @param block_size(int, None): size of the block (or None for default)
+        """
+        session_data = self._create_session(client, stream_object, local_jid, to_jid, sid)
+
+        if block_size is None:
+            block_size = XEP_0047.BLOCK_SIZE
+        assert block_size <= 65535
+        session_data["block_size"] = block_size
+
+        iq_elt = client.IQ()
+        iq_elt["from"] = local_jid.full()
+        iq_elt["to"] = to_jid.full()
+        open_elt = iq_elt.addElement((NS_IBB, "open"))
+        open_elt["block-size"] = str(block_size)
+        open_elt["sid"] = sid
+        open_elt["stanza"] = "iq"  # TODO: manage <message> stanza ?
+        args = [session_data, client]
+        d = iq_elt.send()
+        d.addCallbacks(self._iq_data_stream_cb, self._iq_data_stream_eb, args, None, args)
+        return session_data[DEFER_KEY]
+
+    def _iq_data_stream_cb(self, iq_elt, session_data, client):
+        """Called during the whole data streaming
+
+        @param iq_elt(domish.Element): iq result
+        @param session_data(dict): data of this streaming session
+        @param client: %(doc_client)s
+        """
+        session_data["timer"].reset(TIMEOUT)
+
+        # FIXME: producer/consumer mechanism is not used properly here
+        buffer_ = session_data["stream_object"].file_obj.read(session_data["block_size"])
+        if buffer_:
+            next_iq_elt = client.IQ()
+            next_iq_elt["from"] = session_data["local_jid"].full()
+            next_iq_elt["to"] = session_data["to"].full()
+            data_elt = next_iq_elt.addElement((NS_IBB, "data"))
+            seq = session_data["seq"] = (session_data["seq"] + 1) % 65535
+            data_elt["seq"] = str(seq)
+            data_elt["sid"] = session_data["id"]
+            data_elt.addContent(base64.b64encode(buffer_).decode())
+            args = [session_data, client]
+            d = next_iq_elt.send()
+            d.addCallbacks(self._iq_data_stream_cb, self._iq_data_stream_eb, args, None, args)
+        else:
+            self.terminate_stream(session_data, client)
+
+    def _iq_data_stream_eb(self, failure, session_data, client):
+        if failure.check(error.StanzaError):
+            log.warning("IBB transfer failed: {}".format(failure.value))
+        else:
+            log.error("IBB transfer failed: {}".format(failure.value))
+        self.terminate_stream(session_data, client, "IQ_ERROR")
+
+    def terminate_stream(self, session_data, client, failure_reason=None):
+        """Terminate the stream session
+
+        @param session_data(dict): data of this streaming session
+        @param client: %(doc_client)s
+        @param failure_reason(unicode, None): reason of the failure, or None if steam was successful
+        """
+        iq_elt = client.IQ()
+        iq_elt["from"] = session_data["local_jid"].full()
+        iq_elt["to"] = session_data["to"].full()
+        close_elt = iq_elt.addElement((NS_IBB, "close"))
+        close_elt["sid"] = session_data["id"]
+        iq_elt.send()
+        self._kill_session(session_data["id"], client, failure_reason)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0047_handler(XMPPHandler):
+
+    def __init__(self, parent):
+        self.plugin_parent = parent
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            IBB_OPEN, self.plugin_parent._on_ibb_open, client=self.parent
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_IBB)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0048.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,523 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Bookmarks (xep-0048)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.memory.persistent import PersistentBinaryDict
+from libervia.backend.tools import xml_tools
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber.error import StanzaError
+
+from twisted.internet import defer
+
+NS_BOOKMARKS = "storage:bookmarks"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Bookmarks",
+    C.PI_IMPORT_NAME: "XEP-0048",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0048"],
+    C.PI_DEPENDENCIES: ["XEP-0045"],
+    C.PI_RECOMMENDATIONS: ["XEP-0049"],
+    C.PI_MAIN: "XEP_0048",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of bookmarks"""),
+}
+
+
+class XEP_0048(object):
+    MUC_TYPE = "muc"
+    URL_TYPE = "url"
+    MUC_KEY = "jid"
+    URL_KEY = "url"
+    MUC_ATTRS = ("autojoin", "name")
+    URL_ATTRS = ("name",)
+
+    def __init__(self, host):
+        log.info(_("Bookmarks plugin initialization"))
+        self.host = host
+        # self.__menu_id = host.register_callback(self._bookmarks_menu, with_data=True)
+        self.__bm_save_id = host.register_callback(self._bookmarks_save_cb, with_data=True)
+        host.import_menu(
+            (D_("Groups"), D_("Bookmarks")),
+            self._bookmarks_menu,
+            security_limit=0,
+            help_string=D_("Use and manage bookmarks"),
+        )
+        self.__selected_id = host.register_callback(
+            self._bookmark_selected_cb, with_data=True
+        )
+        host.bridge.add_method(
+            "bookmarks_list",
+            ".plugin",
+            in_sign="sss",
+            out_sign="a{sa{sa{ss}}}",
+            method=self._bookmarks_list,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "bookmarks_remove",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="",
+            method=self._bookmarks_remove,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "bookmarks_add",
+            ".plugin",
+            in_sign="ssa{ss}ss",
+            out_sign="",
+            method=self._bookmarks_add,
+            async_=True,
+        )
+        try:
+            self.private_plg = self.host.plugins["XEP-0049"]
+        except KeyError:
+            self.private_plg = None
+        try:
+            self.host.plugins[C.TEXT_CMDS].register_text_commands(self)
+        except KeyError:
+            log.info(_("Text commands not available"))
+
+    async def profile_connected(self, client):
+        local = client.bookmarks_local = PersistentBinaryDict(
+            NS_BOOKMARKS, client.profile
+        )
+        await local.load()
+        if not local:
+            local[XEP_0048.MUC_TYPE] = dict()
+            local[XEP_0048.URL_TYPE] = dict()
+        private = await self._get_server_bookmarks("private", client.profile)
+        pubsub = client.bookmarks_pubsub = None
+
+        for bookmarks in (local, private, pubsub):
+            if bookmarks is not None:
+                for (room_jid, data) in list(bookmarks[XEP_0048.MUC_TYPE].items()):
+                    if data.get("autojoin", "false") == "true":
+                        nick = data.get("nick", client.jid.user)
+                        defer.ensureDeferred(
+                            self.host.plugins["XEP-0045"].join(client, room_jid, nick, {})
+                        )
+
+        # we don't use a DeferredList to gather result here, as waiting for all room would
+        # slow down a lot the connection process, and result in a bad user experience.
+
+    @defer.inlineCallbacks
+    def _get_server_bookmarks(self, storage_type, profile):
+        """Get distants bookmarks
+
+        update also the client.bookmarks_[type] key, with None if service is not available
+        @param storage_type: storage type, can be:
+            - 'private': XEP-0049 storage
+            - 'pubsub': XEP-0223 storage
+        @param profile: %(doc_profile)s
+        @return: data dictionary, or None if feature is not available
+        """
+        client = self.host.get_client(profile)
+        if storage_type == "private":
+            try:
+                bookmarks_private_xml = yield self.private_plg.private_xml_get(
+                    "storage", NS_BOOKMARKS, profile
+                )
+                data = client.bookmarks_private = self._bookmark_elt_2_dict(
+                    bookmarks_private_xml
+                )
+            except (StanzaError, AttributeError):
+                log.info(_("Private XML storage not available"))
+                data = client.bookmarks_private = None
+        elif storage_type == "pubsub":
+            raise NotImplementedError
+        else:
+            raise ValueError("storage_type must be 'private' or 'pubsub'")
+        defer.returnValue(data)
+
+    @defer.inlineCallbacks
+    def _set_server_bookmarks(self, storage_type, bookmarks_elt, profile):
+        """Save bookmarks on server
+
+        @param storage_type: storage type, can be:
+            - 'private': XEP-0049 storage
+            - 'pubsub': XEP-0223 storage
+        @param bookmarks_elt (domish.Element): bookmarks XML
+        @param profile: %(doc_profile)s
+        """
+        if storage_type == "private":
+            yield self.private_plg.private_xml_store(bookmarks_elt, profile)
+        elif storage_type == "pubsub":
+            raise NotImplementedError
+        else:
+            raise ValueError("storage_type must be 'private' or 'pubsub'")
+
+    def _bookmark_elt_2_dict(self, storage_elt):
+        """Parse bookmarks to get dictionary
+        @param storage_elt (domish.Element): bookmarks storage
+        @return (dict): bookmark data (key: bookmark type, value: list) where key can be:
+            - XEP_0048.MUC_TYPE
+            - XEP_0048.URL_TYPE
+            - value (dict): data as for add_bookmark
+        """
+        conf_data = {}
+        url_data = {}
+
+        conference_elts = storage_elt.elements(NS_BOOKMARKS, "conference")
+        for conference_elt in conference_elts:
+            try:
+                room_jid = jid.JID(conference_elt[XEP_0048.MUC_KEY])
+            except KeyError:
+                log.warning(
+                    "invalid bookmark found, igoring it:\n%s" % conference_elt.toXml()
+                )
+                continue
+
+            data = conf_data[room_jid] = {}
+
+            for attr in XEP_0048.MUC_ATTRS:
+                if conference_elt.hasAttribute(attr):
+                    data[attr] = conference_elt[attr]
+            try:
+                data["nick"] = str(
+                    next(conference_elt.elements(NS_BOOKMARKS, "nick"))
+                )
+            except StopIteration:
+                pass
+            # TODO: manage password (need to be secured, see XEP-0049 §4)
+
+        url_elts = storage_elt.elements(NS_BOOKMARKS, "url")
+        for url_elt in url_elts:
+            try:
+                url = url_elt[XEP_0048.URL_KEY]
+            except KeyError:
+                log.warning("invalid bookmark found, igoring it:\n%s" % url_elt.toXml())
+                continue
+            data = url_data[url] = {}
+            for attr in XEP_0048.URL_ATTRS:
+                if url_elt.hasAttribute(attr):
+                    data[attr] = url_elt[attr]
+
+        return {XEP_0048.MUC_TYPE: conf_data, XEP_0048.URL_TYPE: url_data}
+
+    def _dict_2_bookmark_elt(self, type_, data):
+        """Construct a bookmark element from a data dict
+        @param data (dict): bookmark data (key: bookmark type, value: list) where key can be:
+            - XEP_0048.MUC_TYPE
+            - XEP_0048.URL_TYPE
+            - value (dict): data as for add_bookmark
+        @return (domish.Element): bookmark element
+        """
+        rooms_data = data.get(XEP_0048.MUC_TYPE, {})
+        urls_data = data.get(XEP_0048.URL_TYPE, {})
+        storage_elt = domish.Element((NS_BOOKMARKS, "storage"))
+        for room_jid in rooms_data:
+            conference_elt = storage_elt.addElement("conference")
+            conference_elt[XEP_0048.MUC_KEY] = room_jid.full()
+            for attr in XEP_0048.MUC_ATTRS:
+                try:
+                    conference_elt[attr] = rooms_data[room_jid][attr]
+                except KeyError:
+                    pass
+            try:
+                conference_elt.addElement("nick", content=rooms_data[room_jid]["nick"])
+            except KeyError:
+                pass
+
+        for url, url_data in urls_data.items():
+            url_elt = storage_elt.addElement("url")
+            url_elt[XEP_0048.URL_KEY] = url
+            for attr in XEP_0048.URL_ATTRS:
+                try:
+                    url_elt[attr] = url_data[attr]
+                except KeyError:
+                    pass
+
+        return storage_elt
+
+    def _bookmark_selected_cb(self, data, profile):
+        try:
+            room_jid_s, nick = data["index"].split(" ", 1)
+            room_jid = jid.JID(room_jid_s)
+        except (KeyError, RuntimeError):
+            log.warning(_("No room jid selected"))
+            return {}
+
+        client = self.host.get_client(profile)
+        d = self.host.plugins["XEP-0045"].join(client, room_jid, nick, {})
+
+        def join_eb(failure):
+            log.warning("Error while trying to join room: {}".format(failure))
+            # FIXME: failure are badly managed in plugin XEP-0045. Plugin XEP-0045 need to be fixed before managing errors correctly here
+            return {}
+
+        d.addCallbacks(lambda __: {}, join_eb)
+        return d
+
+    def _bookmarks_menu(self, data, profile):
+        """ XMLUI activated by menu: return Gateways UI
+        @param profile: %(doc_profile)s
+
+        """
+        client = self.host.get_client(profile)
+        xmlui = xml_tools.XMLUI(title=_("Bookmarks manager"))
+        adv_list = xmlui.change_container(
+            "advanced_list",
+            columns=3,
+            selectable="single",
+            callback_id=self.__selected_id,
+        )
+        for bookmarks in (
+            client.bookmarks_local,
+            client.bookmarks_private,
+            client.bookmarks_pubsub,
+        ):
+            if bookmarks is None:
+                continue
+            for (room_jid, data) in sorted(
+                list(bookmarks[XEP_0048.MUC_TYPE].items()),
+                key=lambda item: item[1].get("name", item[0].user),
+            ):
+                room_jid_s = room_jid.full()
+                adv_list.set_row_index(
+                    "%s %s" % (room_jid_s, data.get("nick") or client.jid.user)
+                )
+                xmlui.addText(data.get("name", ""))
+                xmlui.addJid(room_jid)
+                if C.bool(data.get("autojoin", C.BOOL_FALSE)):
+                    xmlui.addText("autojoin")
+                else:
+                    xmlui.addEmpty()
+        adv_list.end()
+        xmlui.addDivider("dash")
+        xmlui.addText(_("add a bookmark"))
+        xmlui.change_container("pairs")
+        xmlui.addLabel(_("Name"))
+        xmlui.addString("name")
+        xmlui.addLabel(_("jid"))
+        xmlui.addString("jid")
+        xmlui.addLabel(_("Nickname"))
+        xmlui.addString("nick", client.jid.user)
+        xmlui.addLabel(_("Autojoin"))
+        xmlui.addBool("autojoin")
+        xmlui.change_container("vertical")
+        xmlui.addButton(self.__bm_save_id, _("Save"), ("name", "jid", "nick", "autojoin"))
+        return {"xmlui": xmlui.toXml()}
+
+    def _bookmarks_save_cb(self, data, profile):
+        bm_data = xml_tools.xmlui_result_2_data_form_result(data)
+        try:
+            location = jid.JID(bm_data.pop("jid"))
+        except KeyError:
+            raise exceptions.InternalError("Can't find mandatory key")
+        d = self.add_bookmark(XEP_0048.MUC_TYPE, location, bm_data, profile_key=profile)
+        d.addCallback(lambda __: {})
+        return d
+
+    @defer.inlineCallbacks
+    def add_bookmark(
+        self, type_, location, data, storage_type="auto", profile_key=C.PROF_KEY_NONE
+    ):
+        """Store a new bookmark
+
+        @param type_: bookmark type, one of:
+            - XEP_0048.MUC_TYPE: Multi-User chat room
+            - XEP_0048.URL_TYPE: web page URL
+        @param location: dependeding on type_, can be a MUC room jid or an url
+        @param data (dict): depending on type_, can contains the following keys:
+            - name: human readable name of the bookmark
+            - nick: user preferred room nick (default to user part of profile's jid)
+            - autojoin: "true" if room must be automatically joined on connection
+            - password: unused yet TODO
+        @param storage_type: where the bookmark will be stored, can be:
+            - "auto": find best available option: pubsub, private, local in that order
+            - "pubsub": PubSub private storage (XEP-0223)
+            - "private": Private XML storage (XEP-0049)
+            - "local": Store in SàT database
+        @param profile_key: %(doc_profile_key)s
+        """
+        assert storage_type in ("auto", "pubsub", "private", "local")
+        if type_ == XEP_0048.URL_TYPE and {"autojoin", "nick"}.intersection(list(data.keys())):
+            raise ValueError("autojoin or nick can't be used with URLs")
+        client = self.host.get_client(profile_key)
+        if storage_type == "auto":
+            if client.bookmarks_pubsub is not None:
+                storage_type = "pubsub"
+            elif client.bookmarks_private is not None:
+                storage_type = "private"
+            else:
+                storage_type = "local"
+                log.warning(_("Bookmarks will be local only"))
+            log.info(_('Type selected for "auto" storage: %s') % storage_type)
+
+        if storage_type == "local":
+            client.bookmarks_local[type_][location] = data
+            yield client.bookmarks_local.force(type_)
+        else:
+            bookmarks = yield self._get_server_bookmarks(storage_type, client.profile)
+            bookmarks[type_][location] = data
+            bookmark_elt = self._dict_2_bookmark_elt(type_, bookmarks)
+            yield self._set_server_bookmarks(storage_type, bookmark_elt, client.profile)
+
+    @defer.inlineCallbacks
+    def remove_bookmark(
+        self, type_, location, storage_type="all", profile_key=C.PROF_KEY_NONE
+    ):
+        """Remove a stored bookmark
+
+        @param type_: bookmark type, one of:
+            - XEP_0048.MUC_TYPE: Multi-User chat room
+            - XEP_0048.URL_TYPE: web page URL
+        @param location: dependeding on type_, can be a MUC room jid or an url
+        @param storage_type: where the bookmark is stored, can be:
+            - "all": remove from everywhere
+            - "pubsub": PubSub private storage (XEP-0223)
+            - "private": Private XML storage (XEP-0049)
+            - "local": Store in SàT database
+        @param profile_key: %(doc_profile_key)s
+        """
+        assert storage_type in ("all", "pubsub", "private", "local")
+        client = self.host.get_client(profile_key)
+
+        if storage_type in ("all", "local"):
+            try:
+                del client.bookmarks_local[type_][location]
+                yield client.bookmarks_local.force(type_)
+            except KeyError:
+                log.debug("Bookmark is not present in local storage")
+
+        if storage_type in ("all", "private"):
+            bookmarks = yield self._get_server_bookmarks("private", client.profile)
+            try:
+                del bookmarks[type_][location]
+                bookmark_elt = self._dict_2_bookmark_elt(type_, bookmarks)
+                yield self._set_server_bookmarks("private", bookmark_elt, client.profile)
+            except KeyError:
+                log.debug("Bookmark is not present in private storage")
+
+        if storage_type == "pubsub":
+            raise NotImplementedError
+
+    def _bookmarks_list(self, type_, storage_location, profile_key=C.PROF_KEY_NONE):
+        """Return stored bookmarks
+
+        @param type_: bookmark type, one of:
+            - XEP_0048.MUC_TYPE: Multi-User chat room
+            - XEP_0048.URL_TYPE: web page URL
+        @param storage_location: can be:
+            - 'all'
+            - 'local'
+            - 'private'
+            - 'pubsub'
+        @param profile_key: %(doc_profile_key)s
+        @param return (dict): (key: storage_location, value dict) with:
+            - value (dict): (key: bookmark_location, value: bookmark data)
+        """
+        client = self.host.get_client(profile_key)
+        ret = {}
+        ret_d = defer.succeed(ret)
+
+        def fill_bookmarks(__, _storage_location):
+            bookmarks_ori = getattr(client, "bookmarks_" + _storage_location)
+            if bookmarks_ori is None:
+                return ret
+            data = bookmarks_ori[type_]
+            for bookmark in data:
+                if type_ == XEP_0048.MUC_TYPE:
+                    ret[_storage_location][bookmark.full()] = data[bookmark].copy()
+                else:
+                    ret[_storage_location][bookmark] = data[bookmark].copy()
+            return ret
+
+        for _storage_location in ("local", "private", "pubsub"):
+            if storage_location in ("all", _storage_location):
+                ret[_storage_location] = {}
+                if _storage_location in ("private",):
+                    # we update distant bookmarks, just in case an other client added something
+                    d = self._get_server_bookmarks(_storage_location, client.profile)
+                else:
+                    d = defer.succeed(None)
+                d.addCallback(fill_bookmarks, _storage_location)
+                ret_d.addCallback(lambda __: d)
+
+        return ret_d
+
+    def _bookmarks_remove(
+        self, type_, location, storage_location, profile_key=C.PROF_KEY_NONE
+    ):
+        """Return stored bookmarks
+
+        @param type_: bookmark type, one of:
+            - XEP_0048.MUC_TYPE: Multi-User chat room
+            - XEP_0048.URL_TYPE: web page URL
+        @param location: dependeding on type_, can be a MUC room jid or an url
+        @param storage_location: can be:
+            - "all": remove from everywhere
+            - "pubsub": PubSub private storage (XEP-0223)
+            - "private": Private XML storage (XEP-0049)
+            - "local": Store in SàT database
+        @param profile_key: %(doc_profile_key)s
+        """
+        if type_ == XEP_0048.MUC_TYPE:
+            location = jid.JID(location)
+        return self.remove_bookmark(type_, location, storage_location, profile_key)
+
+    def _bookmarks_add(
+        self, type_, location, data, storage_type="auto", profile_key=C.PROF_KEY_NONE
+    ):
+        if type_ == XEP_0048.MUC_TYPE:
+            location = jid.JID(location)
+        return self.add_bookmark(type_, location, data, storage_type, profile_key)
+
+    def cmd_bookmark(self, client, mess_data):
+        """(Un)bookmark a MUC room
+
+        @command (group): [autojoin | remove]
+            - autojoin: join room automatically on connection
+            - remove: remove bookmark(s) for this room
+        """
+        txt_cmd = self.host.plugins[C.TEXT_CMDS]
+
+        options = mess_data["unparsed"].strip().split()
+        if options and options[0] not in ("autojoin", "remove"):
+            txt_cmd.feed_back(client, _("Bad arguments"), mess_data)
+            return False
+
+        room_jid = mess_data["to"].userhostJID()
+
+        if "remove" in options:
+            self.remove_bookmark(XEP_0048.MUC_TYPE, room_jid, profile_key=client.profile)
+            txt_cmd.feed_back(
+                client,
+                _("All [%s] bookmarks are being removed") % room_jid.full(),
+                mess_data,
+            )
+            return False
+
+        data = {
+            "name": room_jid.user,
+            "nick": client.jid.user,
+            "autojoin": "true" if "autojoin" in options else "false",
+        }
+        self.add_bookmark(XEP_0048.MUC_TYPE, room_jid, data, profile_key=client.profile)
+        txt_cmd.feed_back(client, _("Bookmark added"), mess_data)
+
+        return False
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0049.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing xep-0049
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from wokkel import compat
+from twisted.words.xish import domish
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP-0049 Plugin",
+    C.PI_IMPORT_NAME: "XEP-0049",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0049"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "XEP_0049",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of private XML storage"""),
+}
+
+
+class XEP_0049(object):
+    NS_PRIVATE = "jabber:iq:private"
+
+    def __init__(self, host):
+        log.info(_("Plugin XEP-0049 initialization"))
+        self.host = host
+
+    def private_xml_store(self, element, profile_key):
+        """Store private data
+        @param element: domish.Element to store (must have a namespace)
+        @param profile_key: %(doc_profile_key)s
+
+        """
+        assert isinstance(element, domish.Element)
+        client = self.host.get_client(profile_key)
+        # XXX: feature announcement in disco#info is not mandatory in XEP-0049, so we have to try to use private XML, and react according to the answer
+        iq_elt = compat.IQ(client.xmlstream)
+        query_elt = iq_elt.addElement("query", XEP_0049.NS_PRIVATE)
+        query_elt.addChild(element)
+        return iq_elt.send()
+
+    def private_xml_get(self, node_name, namespace, profile_key):
+        """Store private data
+        @param node_name: name of the node to get
+        @param namespace: namespace of the node to get
+        @param profile_key: %(doc_profile_key)s
+        @return (domish.Element): a deferred which fire the stored data
+
+        """
+        client = self.host.get_client(profile_key)
+        # XXX: see private_xml_store note about feature checking
+        iq_elt = compat.IQ(client.xmlstream, "get")
+        query_elt = iq_elt.addElement("query", XEP_0049.NS_PRIVATE)
+        query_elt.addElement(node_name, namespace)
+
+        def get_cb(answer_iq_elt):
+            answer_query_elt = next(answer_iq_elt.elements(XEP_0049.NS_PRIVATE, "query"))
+            return answer_query_elt.firstChildElement()
+
+        d = iq_elt.send()
+        d.addCallback(get_cb)
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0050.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,835 @@
+#!/usr/bin/env python3
+
+# SàT plugin for Ad-Hoc Commands (XEP-0050)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from collections import namedtuple
+from uuid import uuid4
+from typing import List, Optional
+
+from zope.interface import implementer
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols import jabber
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from twisted.internet import defer
+from wokkel import disco, iwokkel, data_form
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.core import exceptions
+from libervia.backend.memory.memory import Sessions
+from libervia.backend.tools import xml_tools, utils
+from libervia.backend.tools.common import data_format
+
+
+log = getLogger(__name__)
+
+
+IQ_SET = '/iq[@type="set"]'
+NS_COMMANDS = "http://jabber.org/protocol/commands"
+ID_CMD_LIST = disco.DiscoIdentity("automation", "command-list")
+ID_CMD_NODE = disco.DiscoIdentity("automation", "command-node")
+CMD_REQUEST = IQ_SET + '/command[@xmlns="' + NS_COMMANDS + '"]'
+
+SHOWS = {
+    "default": _("Online"),
+    "away": _("Away"),
+    "chat": _("Free for chat"),
+    "dnd": _("Do not disturb"),
+    "xa": _("Left"),
+    "disconnect": _("Disconnect"),
+}
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Ad-Hoc Commands",
+    C.PI_IMPORT_NAME: "XEP-0050",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0050"],
+    C.PI_MAIN: "XEP_0050",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Ad-Hoc Commands"""),
+}
+
+
+class AdHocError(Exception):
+    def __init__(self, error_const):
+        """ Error to be used from callback
+        @param error_const: one of XEP_0050.ERROR
+        """
+        assert error_const in XEP_0050.ERROR
+        self.callback_error = error_const
+
+
+@implementer(iwokkel.IDisco)
+class AdHocCommand(XMPPHandler):
+
+    def __init__(self, callback, label, node, features, timeout,
+                 allowed_jids, allowed_groups, allowed_magics, forbidden_jids,
+                forbidden_groups):
+        XMPPHandler.__init__(self)
+        self.callback = callback
+        self.label = label
+        self.node = node
+        self.features = [disco.DiscoFeature(feature) for feature in features]
+        self.allowed_jids = allowed_jids
+        self.allowed_groups = allowed_groups
+        self.allowed_magics = allowed_magics
+        self.forbidden_jids = forbidden_jids
+        self.forbidden_groups = forbidden_groups
+        self.sessions = Sessions(timeout=timeout)
+
+    @property
+    def client(self):
+        return self.parent
+
+    def getName(self, xml_lang=None):
+        return self.label
+
+    def is_authorised(self, requestor):
+        if "@ALL@" in self.allowed_magics:
+            return True
+        forbidden = set(self.forbidden_jids)
+        for group in self.forbidden_groups:
+            forbidden.update(self.client.roster.get_jids_from_group(group))
+        if requestor.userhostJID() in forbidden:
+            return False
+        allowed = set(self.allowed_jids)
+        for group in self.allowed_groups:
+            try:
+                allowed.update(self.client.roster.get_jids_from_group(group))
+            except exceptions.UnknownGroupError:
+                log.warning(_("The groups [{group}] is unknown for profile [{profile}])")
+                            .format(group=group, profile=self.client.profile))
+        if requestor.userhostJID() in allowed:
+            return True
+        return False
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        if (
+            nodeIdentifier != NS_COMMANDS
+        ):  # FIXME: we should manage other disco nodes here
+            return []
+        # identities = [ID_CMD_LIST if self.node == NS_COMMANDS else ID_CMD_NODE] # FIXME
+        return [disco.DiscoFeature(NS_COMMANDS)] + self.features
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
+
+    def _sendAnswer(self, callback_data, session_id, request):
+        """ Send result of the command
+
+        @param callback_data: tuple (payload, status, actions, note) with:
+            - payload (domish.Element, None) usualy containing data form
+            - status: current status, see XEP_0050.STATUS
+            - actions(list[str], None): list of allowed actions (see XEP_0050.ACTION).
+                       First action is the default one. Default to EXECUTE
+            - note(tuple[str, unicode]): optional additional note: either None or a
+                tuple with (note type, human readable string), "note type" being in
+                XEP_0050.NOTE
+        @param session_id: current session id
+        @param request: original request (domish.Element)
+        @return: deferred
+        """
+        payload, status, actions, note = callback_data
+        assert isinstance(payload, domish.Element) or payload is None
+        assert status in XEP_0050.STATUS
+        if not actions:
+            actions = [XEP_0050.ACTION.EXECUTE]
+        result = domish.Element((None, "iq"))
+        result["type"] = "result"
+        result["id"] = request["id"]
+        result["to"] = request["from"]
+        command_elt = result.addElement("command", NS_COMMANDS)
+        command_elt["sessionid"] = session_id
+        command_elt["node"] = self.node
+        command_elt["status"] = status
+
+        if status != XEP_0050.STATUS.CANCELED:
+            if status != XEP_0050.STATUS.COMPLETED:
+                actions_elt = command_elt.addElement("actions")
+                actions_elt["execute"] = actions[0]
+                for action in actions:
+                    actions_elt.addElement(action)
+
+            if note is not None:
+                note_type, note_mess = note
+                note_elt = command_elt.addElement("note", content=note_mess)
+                note_elt["type"] = note_type
+
+            if payload is not None:
+                command_elt.addChild(payload)
+
+        self.client.send(result)
+        if status in (XEP_0050.STATUS.COMPLETED, XEP_0050.STATUS.CANCELED):
+            del self.sessions[session_id]
+
+    def _sendError(self, error_constant, session_id, request):
+        """ Send error stanza
+
+        @param error_constant: one of XEP_OO50.ERROR
+        @param request: original request (domish.Element)
+        """
+        xmpp_condition, cmd_condition = error_constant
+        iq_elt = jabber.error.StanzaError(xmpp_condition).toResponse(request)
+        if cmd_condition:
+            error_elt = next(iq_elt.elements(None, "error"))
+            error_elt.addElement(cmd_condition, NS_COMMANDS)
+        self.client.send(iq_elt)
+        del self.sessions[session_id]
+
+    def _request_eb(self, failure_, request, session_id):
+        if failure_.check(AdHocError):
+            error_constant = failure_.value.callback_error
+        else:
+            log.error(f"unexpected error while handling request: {failure_}")
+            error_constant = XEP_0050.ERROR.INTERNAL
+
+        self._sendError(error_constant, session_id, request)
+
+    def on_request(self, command_elt, requestor, action, session_id):
+        if not self.is_authorised(requestor):
+            return self._sendError(
+                XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent
+            )
+        if session_id:
+            try:
+                session_data = self.sessions[session_id]
+            except KeyError:
+                return self._sendError(
+                    XEP_0050.ERROR.SESSION_EXPIRED, session_id, command_elt.parent
+                )
+            if session_data["requestor"] != requestor:
+                return self._sendError(
+                    XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent
+                )
+        else:
+            session_id, session_data = self.sessions.new_session()
+            session_data["requestor"] = requestor
+        if action == XEP_0050.ACTION.CANCEL:
+            d = defer.succeed((None, XEP_0050.STATUS.CANCELED, None, None))
+        else:
+            d = utils.as_deferred(
+                self.callback,
+                self.client,
+                command_elt,
+                session_data,
+                action,
+                self.node,
+            )
+        d.addCallback(self._sendAnswer, session_id, command_elt.parent)
+        d.addErrback(self._request_eb, command_elt.parent, session_id)
+
+
+class XEP_0050(object):
+    STATUS = namedtuple("Status", ("EXECUTING", "COMPLETED", "CANCELED"))(
+        "executing", "completed", "canceled"
+    )
+    ACTION = namedtuple("Action", ("EXECUTE", "CANCEL", "NEXT", "PREV"))(
+        "execute", "cancel", "next", "prev"
+    )
+    NOTE = namedtuple("Note", ("INFO", "WARN", "ERROR"))("info", "warn", "error")
+    ERROR = namedtuple(
+        "Error",
+        (
+            "MALFORMED_ACTION",
+            "BAD_ACTION",
+            "BAD_LOCALE",
+            "BAD_PAYLOAD",
+            "BAD_SESSIONID",
+            "SESSION_EXPIRED",
+            "FORBIDDEN",
+            "ITEM_NOT_FOUND",
+            "FEATURE_NOT_IMPLEMENTED",
+            "INTERNAL",
+        ),
+    )(
+        ("bad-request", "malformed-action"),
+        ("bad-request", "bad-action"),
+        ("bad-request", "bad-locale"),
+        ("bad-request", "bad-payload"),
+        ("bad-request", "bad-sessionid"),
+        ("not-allowed", "session-expired"),
+        ("forbidden", None),
+        ("item-not-found", None),
+        ("feature-not-implemented", None),
+        ("internal-server-error", None),
+    )  # XEP-0050 §4.4 Table 5
+
+    def __init__(self, host):
+        log.info(_("plugin XEP-0050 initialization"))
+        self.host = host
+        self.requesting = Sessions()
+        host.bridge.add_method(
+            "ad_hoc_run",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._run,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ad_hoc_list",
+            ".plugin",
+            in_sign="ss",
+            out_sign="s",
+            method=self._list_ui,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ad_hoc_sequence",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="s",
+            method=self._sequence,
+            async_=True,
+        )
+        self.__requesting_id = host.register_callback(
+            self._requesting_entity, with_data=True
+        )
+        host.import_menu(
+            (D_("Service"), D_("Commands")),
+            self._commands_menu,
+            security_limit=2,
+            help_string=D_("Execute ad-hoc commands"),
+        )
+        host.register_namespace('commands', NS_COMMANDS)
+
+    def get_handler(self, client):
+        return XEP_0050_handler(self)
+
+    def profile_connected(self, client):
+        # map from node to AdHocCommand instance
+        client._XEP_0050_commands = {}
+        if not client.is_component:
+            self.add_ad_hoc_command(client, self._status_callback, _("Status"))
+
+    def do(self, client, entity, node, action=ACTION.EXECUTE, session_id=None,
+           form_values=None, timeout=30):
+        """Do an Ad-Hoc Command
+
+        @param entity(jid.JID): entity which will execture the command
+        @param node(unicode): node of the command
+        @param action(unicode): one of XEP_0050.ACTION
+        @param session_id(unicode, None): id of the ad-hoc session
+            None if no session is involved
+        @param form_values(dict, None): values to use to create command form
+            values will be passed to data_form.Form.makeFields
+        @return: iq result element
+        """
+        iq_elt = client.IQ(timeout=timeout)
+        iq_elt["to"] = entity.full()
+        command_elt = iq_elt.addElement("command", NS_COMMANDS)
+        command_elt["node"] = node
+        command_elt["action"] = action
+        if session_id is not None:
+            command_elt["sessionid"] = session_id
+
+        if form_values:
+            # We add the XMLUI result to the command payload
+            form = data_form.Form("submit")
+            form.makeFields(form_values)
+            command_elt.addChild(form.toElement())
+        d = iq_elt.send()
+        return d
+
+    def get_command_elt(self, iq_elt):
+        try:
+            return next(iq_elt.elements(NS_COMMANDS, "command"))
+        except StopIteration:
+            raise exceptions.NotFound(_("Missing command element"))
+
+    def ad_hoc_error(self, error_type):
+        """Shortcut to raise an AdHocError
+
+        @param error_type(unicode): one of XEP_0050.ERROR
+        """
+        raise AdHocError(error_type)
+
+    def _items_2_xmlui(self, items, no_instructions):
+        """Convert discovery items to XMLUI dialog """
+        # TODO: manage items on different jids
+        form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id)
+
+        if not no_instructions:
+            form_ui.addText(_("Please select a command"), "instructions")
+
+        options = [(item.nodeIdentifier, item.name) for item in items]
+        form_ui.addList("node", options)
+        return form_ui
+
+    def _get_data_lvl(self, type_):
+        """Return the constant corresponding to <note/> type attribute value
+
+        @param type_: note type (see XEP-0050 §4.3)
+        @return: a C.XMLUI_DATA_LVL_* constant
+        """
+        if type_ == "error":
+            return C.XMLUI_DATA_LVL_ERROR
+        elif type_ == "warn":
+            return C.XMLUI_DATA_LVL_WARNING
+        else:
+            if type_ != "info":
+                log.warning(_("Invalid note type [%s], using info") % type_)
+            return C.XMLUI_DATA_LVL_INFO
+
+    def _merge_notes(self, notes):
+        """Merge notes with level prefix (e.g. "ERROR: the message")
+
+        @param notes (list): list of tuple (level, message)
+        @return: list of messages
+        """
+        lvl_map = {
+            C.XMLUI_DATA_LVL_INFO: "",
+            C.XMLUI_DATA_LVL_WARNING: "%s: " % _("WARNING"),
+            C.XMLUI_DATA_LVL_ERROR: "%s: " % _("ERROR"),
+        }
+        return ["%s%s" % (lvl_map[lvl], msg) for lvl, msg in notes]
+
+    def parse_command_answer(self, iq_elt):
+        command_elt = self.get_command_elt(iq_elt)
+        data = {}
+        data["status"] = command_elt.getAttribute("status", XEP_0050.STATUS.EXECUTING)
+        data["session_id"] = command_elt.getAttribute("sessionid")
+        data["notes"] = notes = []
+        for note_elt in command_elt.elements(NS_COMMANDS, "note"):
+            notes.append(
+                (
+                    self._get_data_lvl(note_elt.getAttribute("type", "info")),
+                    str(note_elt),
+                )
+            )
+
+        return command_elt, data
+
+
+    def _commands_answer_2_xmlui(self, iq_elt, session_id, session_data):
+        """Convert command answer to an ui for frontend
+
+        @param iq_elt: command result
+        @param session_id: id of the session used with the frontend
+        @param profile_key: %(doc_profile_key)s
+        """
+        command_elt, answer_data = self.parse_command_answer(iq_elt)
+        status = answer_data["status"]
+        if status in [XEP_0050.STATUS.COMPLETED, XEP_0050.STATUS.CANCELED]:
+            # the command session is finished, we purge our session
+            del self.requesting[session_id]
+            if status == XEP_0050.STATUS.COMPLETED:
+                session_id = None
+            else:
+                return None
+        remote_session_id = answer_data["session_id"]
+        if remote_session_id:
+            session_data["remote_id"] = remote_session_id
+        notes = answer_data["notes"]
+        for data_elt in command_elt.elements(data_form.NS_X_DATA, "x"):
+            if data_elt["type"] in ("form", "result"):
+                break
+        else:
+            # no matching data element found
+            if status != XEP_0050.STATUS.COMPLETED:
+                log.warning(
+                    _("No known payload found in ad-hoc command result, aborting")
+                )
+                del self.requesting[session_id]
+                return xml_tools.XMLUI(
+                    C.XMLUI_DIALOG,
+                    dialog_opt={
+                        C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE,
+                        C.XMLUI_DATA_MESS: _("No payload found"),
+                        C.XMLUI_DATA_LVL: C.XMLUI_DATA_LVL_ERROR,
+                    },
+                )
+            if not notes:
+                # the status is completed, and we have no note to show
+                return None
+
+            # if we have only one note, we show a dialog with the level of the note
+            # if we have more, we show a dialog with "info" level, and all notes merged
+            dlg_level = notes[0][0] if len(notes) == 1 else C.XMLUI_DATA_LVL_INFO
+            return xml_tools.XMLUI(
+                C.XMLUI_DIALOG,
+                dialog_opt={
+                    C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE,
+                    C.XMLUI_DATA_MESS: "\n".join(self._merge_notes(notes)),
+                    C.XMLUI_DATA_LVL: dlg_level,
+                },
+                session_id=session_id,
+            )
+
+        if session_id is None:
+            xmlui = xml_tools.data_form_elt_result_2_xmlui(data_elt)
+            if notes:
+                for level, note in notes:
+                    if level != "info":
+                        note = f"[{level}] {note}"
+                    xmlui.add_widget("text", note)
+            return xmlui
+
+        form = data_form.Form.fromElement(data_elt)
+        # we add any present note to the instructions
+        form.instructions.extend(self._merge_notes(notes))
+        return xml_tools.data_form_2_xmlui(form, self.__requesting_id, session_id=session_id)
+
+    def _requesting_entity(self, data, profile):
+        def serialise(ret_data):
+            if "xmlui" in ret_data:
+                ret_data["xmlui"] = ret_data["xmlui"].toXml()
+            return ret_data
+
+        d = self.requesting_entity(data, profile)
+        d.addCallback(serialise)
+        return d
+
+    def requesting_entity(self, data, profile):
+        """Request and entity and create XMLUI accordingly.
+
+        @param data: data returned by previous XMLUI (first one must come from
+                     self._commands_menu)
+        @param profile: %(doc_profile)s
+        @return: callback dict result (with "xmlui" corresponding to the answering
+                 dialog, or empty if it's finished without error)
+        """
+        if C.bool(data.get("cancelled", C.BOOL_FALSE)):
+            return defer.succeed({})
+        data_form_values = xml_tools.xmlui_result_2_data_form_result(data)
+        client = self.host.get_client(profile)
+        # TODO: cancel, prev and next are not managed
+        # TODO: managed answerer errors
+        # TODO: manage nodes with a non data form payload
+        if "session_id" not in data:
+            # we just had the jid, we now request it for the available commands
+            session_id, session_data = self.requesting.new_session(profile=client.profile)
+            entity = jid.JID(data[xml_tools.SAT_FORM_PREFIX + "jid"])
+            session_data["jid"] = entity
+            d = self.list_ui(client, entity)
+
+            def send_items(xmlui):
+                xmlui.session_id = session_id  # we need to keep track of the session
+                return {"xmlui": xmlui}
+
+            d.addCallback(send_items)
+        else:
+            # we have started a several forms sessions
+            try:
+                session_data = self.requesting.profile_get(
+                    data["session_id"], client.profile
+                )
+            except KeyError:
+                log.warning("session id doesn't exist, session has probably expired")
+                # TODO: send error dialog
+                return defer.succeed({})
+            session_id = data["session_id"]
+            entity = session_data["jid"]
+            try:
+                session_data["node"]
+                # node has already been received
+            except KeyError:
+                # it's the first time we know the node, we save it in session data
+                session_data["node"] = data_form_values.pop("node")
+
+            # remote_id is the XEP_0050 sessionid used by answering command
+            # while session_id is our own session id used with the frontend
+            remote_id = session_data.get("remote_id")
+
+            # we request execute node's command
+            d = self.do(client, entity, session_data["node"], action=XEP_0050.ACTION.EXECUTE,
+                        session_id=remote_id, form_values=data_form_values)
+            d.addCallback(self._commands_answer_2_xmlui, session_id, session_data)
+            d.addCallback(lambda xmlui: {"xmlui": xmlui} if xmlui is not None else {})
+
+        return d
+
+    def _commands_menu(self, menu_data, profile):
+        """First XMLUI activated by menu: ask for target jid
+
+        @param profile: %(doc_profile)s
+        """
+        form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id)
+        form_ui.addText(_("Please enter target jid"), "instructions")
+        form_ui.change_container("pairs")
+        form_ui.addLabel("jid")
+        form_ui.addString("jid", value=self.host.get_client(profile).jid.host)
+        return {"xmlui": form_ui.toXml()}
+
+    def _status_callback(self, client, command_elt, session_data, action, node):
+        """Ad-hoc command used to change the "show" part of status"""
+        actions = session_data.setdefault("actions", [])
+        actions.append(action)
+
+        if len(actions) == 1:
+            # it's our first request, we ask the desired new status
+            status = XEP_0050.STATUS.EXECUTING
+            form = data_form.Form("form", title=_("status selection"))
+            show_options = [
+                data_form.Option(name, label) for name, label in list(SHOWS.items())
+            ]
+            field = data_form.Field(
+                "list-single", "show", options=show_options, required=True
+            )
+            form.addField(field)
+
+            payload = form.toElement()
+            note = None
+
+        elif len(actions) == 2:
+            # we should have the answer here
+            try:
+                x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
+                answer_form = data_form.Form.fromElement(x_elt)
+                show = answer_form["show"]
+            except (KeyError, StopIteration):
+                self.ad_hoc_error(XEP_0050.ERROR.BAD_PAYLOAD)
+            if show not in SHOWS:
+                self.ad_hoc_error(XEP_0050.ERROR.BAD_PAYLOAD)
+            if show == "disconnect":
+                self.host.disconnect(client.profile)
+            else:
+                self.host.presence_set(show=show, profile_key=client.profile)
+
+            # job done, we can end the session
+            status = XEP_0050.STATUS.COMPLETED
+            payload = None
+            note = (self.NOTE.INFO, _("Status updated"))
+        else:
+            self.ad_hoc_error(XEP_0050.ERROR.INTERNAL)
+
+        return (payload, status, None, note)
+
+    def _run(self, service_jid_s="", node="", profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        service_jid = jid.JID(service_jid_s) if service_jid_s else None
+        d = defer.ensureDeferred(self.run(client, service_jid, node or None))
+        d.addCallback(lambda xmlui: xmlui.toXml())
+        return d
+
+    async def run(self, client, service_jid=None, node=None):
+        """Run an ad-hoc command
+
+        @param service_jid(jid.JID, None): jid of the ad-hoc service
+            None to use profile's server
+        @param node(unicode, None): node of the ad-hoc commnad
+            None to get initial list
+        @return(unicode): command page XMLUI
+        """
+        if service_jid is None:
+            service_jid = jid.JID(client.jid.host)
+        session_id, session_data = self.requesting.new_session(profile=client.profile)
+        session_data["jid"] = service_jid
+        if node is None:
+            xmlui = await self.list_ui(client, service_jid)
+        else:
+            session_data["node"] = node
+            cb_data = await self.requesting_entity(
+                {"session_id": session_id}, client.profile
+            )
+            xmlui = cb_data["xmlui"]
+
+        xmlui.session_id = session_id
+        return xmlui
+
+    def list(self, client, to_jid):
+        """Request available commands
+
+        @param to_jid(jid.JID, None): the entity answering the commands
+            None to use profile's server
+        @return D(disco.DiscoItems): found commands
+        """
+        d = self.host.getDiscoItems(client, to_jid, NS_COMMANDS)
+        return d
+
+    def _list_ui(self, to_jid_s, profile_key):
+        client = self.host.get_client(profile_key)
+        to_jid = jid.JID(to_jid_s) if to_jid_s else None
+        d = self.list_ui(client, to_jid, no_instructions=True)
+        d.addCallback(lambda xmlui: xmlui.toXml())
+        return d
+
+    def list_ui(self, client, to_jid, no_instructions=False):
+        """Request available commands and generate XMLUI
+
+        @param to_jid(jid.JID, None): the entity answering the commands
+            None to use profile's server
+        @param no_instructions(bool): if True, don't add instructions widget
+        @return D(xml_tools.XMLUI): UI with the commands
+        """
+        d = self.list(client, to_jid)
+        d.addCallback(self._items_2_xmlui, no_instructions)
+        return d
+
+    def _sequence(self, sequence, node, service_jid_s="", profile_key=C.PROF_KEY_NONE):
+        sequence = data_format.deserialise(sequence, type_check=list)
+        client = self.host.get_client(profile_key)
+        service_jid = jid.JID(service_jid_s) if service_jid_s else None
+        d = defer.ensureDeferred(self.sequence(client, sequence, node, service_jid))
+        d.addCallback(lambda data: data_format.serialise(data))
+        return d
+
+    async def sequence(
+        self,
+        client: SatXMPPEntity,
+        sequence: List[dict],
+        node: str,
+        service_jid: Optional[jid.JID] = None,
+    ) -> dict:
+        """Send a series of data to an ad-hoc service
+
+        @param sequence: list of values to send
+            value are specified by a dict mapping var name to value.
+        @param node: node of the ad-hoc commnad
+        @param service_jid: jid of the ad-hoc service
+            None to use profile's server
+        @return: data received in final answer
+        """
+        if service_jid is None:
+            service_jid = jid.JID(client.jid.host)
+
+        session_id = None
+
+        for data_to_send in sequence:
+            iq_result_elt = await self.do(
+                client,
+                service_jid,
+                node,
+                session_id=session_id,
+                form_values=data_to_send,
+            )
+            __, answer_data = self.parse_command_answer(iq_result_elt)
+            session_id = answer_data.pop("session_id")
+
+        return answer_data
+
+    def add_ad_hoc_command(self, client, callback, label, node=None, features=None,
+                        timeout=600, allowed_jids=None, allowed_groups=None,
+                        allowed_magics=None, forbidden_jids=None, forbidden_groups=None,
+                        ):
+        """Add an ad-hoc command for the current profile
+
+        @param callback: method associated with this ad-hoc command which return the
+                         payload data (see AdHocCommand._sendAnswer), can return a
+                         deferred
+        @param label: label associated with this command on the main menu
+        @param node: disco item node associated with this command. None to use
+                     autogenerated node
+        @param features: features associated with the payload (list of strings), usualy
+                         data form
+        @param timeout: delay between two requests before canceling the session (in
+                        seconds)
+        @param allowed_jids: list of allowed entities
+        @param allowed_groups: list of allowed roster groups
+        @param allowed_magics: list of allowed magic keys, can be:
+                               @ALL@: allow everybody
+                               @PROFILE_BAREJID@: allow only the jid of the profile
+        @param forbidden_jids: black list of entities which can't access this command
+        @param forbidden_groups: black list of groups which can't access this command
+        @return: node of the added command, useful to remove the command later
+        """
+        # FIXME: "@ALL@" for profile_key seems useless and dangerous
+
+        if node is None:
+            node = "%s_%s" % ("COMMANDS", uuid4())
+
+        if features is None:
+            features = [data_form.NS_X_DATA]
+
+        if allowed_jids is None:
+            allowed_jids = []
+        if allowed_groups is None:
+            allowed_groups = []
+        if allowed_magics is None:
+            allowed_magics = ["@PROFILE_BAREJID@"]
+        if forbidden_jids is None:
+            forbidden_jids = []
+        if forbidden_groups is None:
+            forbidden_groups = []
+
+        # TODO: manage newly created/removed profiles
+        _allowed_jids = (
+            (allowed_jids + [client.jid.userhostJID()])
+            if "@PROFILE_BAREJID@" in allowed_magics
+            else allowed_jids
+        )
+        ad_hoc_command = AdHocCommand(
+            callback,
+            label,
+            node,
+            features,
+            timeout,
+            _allowed_jids,
+            allowed_groups,
+            allowed_magics,
+            forbidden_jids,
+            forbidden_groups,
+        )
+        ad_hoc_command.setHandlerParent(client)
+        commands = client._XEP_0050_commands
+        commands[node] = ad_hoc_command
+
+    def on_cmd_request(self, request, client):
+        request.handled = True
+        requestor = jid.JID(request["from"])
+        command_elt = next(request.elements(NS_COMMANDS, "command"))
+        action = command_elt.getAttribute("action", self.ACTION.EXECUTE)
+        node = command_elt.getAttribute("node")
+        if not node:
+            client.sendError(request, "bad-request")
+            return
+        sessionid = command_elt.getAttribute("sessionid")
+        commands = client._XEP_0050_commands
+        try:
+            command = commands[node]
+        except KeyError:
+            client.sendError(request, "item-not-found")
+            return
+        command.on_request(command_elt, requestor, action, sessionid)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0050_handler(XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+
+    @property
+    def client(self):
+        return self.parent
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            CMD_REQUEST, self.plugin_parent.on_cmd_request, client=self.parent
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        identities = []
+        if nodeIdentifier == NS_COMMANDS and self.client._XEP_0050_commands:
+            # we only add the identity if we have registred commands
+            identities.append(ID_CMD_LIST)
+        return [disco.DiscoFeature(NS_COMMANDS)] + identities
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        ret = []
+        if nodeIdentifier == NS_COMMANDS:
+            commands = self.client._XEP_0050_commands
+            for command in list(commands.values()):
+                if command.is_authorised(requestor):
+                    ret.append(
+                        disco.DiscoItem(self.parent.jid, command.node, command.getName())
+                    )  # TODO: manage name language
+        return ret
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0054.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,475 @@
+#!/usr/bin/env python3
+
+# SAT plugin for managing xep-0054
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import io
+from base64 import b64decode, b64encode
+from hashlib import sha1
+from pathlib import Path
+from typing import Optional
+from zope.interface import implementer
+from twisted.internet import threads, defer
+from twisted.words.protocols.jabber import jid, error
+from twisted.words.xish import domish
+from twisted.python.failure import Failure
+from wokkel import disco, iwokkel
+from libervia.backend.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.memory import persistent
+from libervia.backend.tools import image
+
+log = getLogger(__name__)
+
+try:
+    from PIL import Image
+except:
+    raise exceptions.MissingModule(
+        "Missing module pillow, please download/install it from https://python-pillow.github.io"
+    )
+
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+
+IMPORT_NAME = "XEP-0054"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP 0054 Plugin",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"],
+    C.PI_DEPENDENCIES: ["IDENTITY"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "XEP_0054",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of vcard-temp"""),
+}
+
+IQ_GET = '/iq[@type="get"]'
+NS_VCARD = "vcard-temp"
+VCARD_REQUEST = IQ_GET + '/vCard[@xmlns="' + NS_VCARD + '"]'  # TODO: manage requests
+
+PRESENCE = "/presence"
+NS_VCARD_UPDATE = "vcard-temp:x:update"
+VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]'
+
+HASH_SHA1_EMPTY = "da39a3ee5e6b4b0d3255bfef95601890afd80709"
+
+
+class XEP_0054(object):
+
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0054 initialization"))
+        self.host = host
+        self._i = host.plugins['IDENTITY']
+        self._i.register(IMPORT_NAME, 'avatar', self.get_avatar, self.set_avatar)
+        self._i.register(IMPORT_NAME, 'nicknames', self.get_nicknames, self.set_nicknames)
+        host.trigger.add("presence_available", self.presence_available_trigger)
+
+    def get_handler(self, client):
+        return XEP_0054_handler(self)
+
+    def presence_available_trigger(self, presence_elt, client):
+        try:
+            avatar_hash = client._xep_0054_avatar_hashes[client.jid.userhost()]
+        except KeyError:
+            log.info(
+                _("No avatar in cache for {profile}")
+                .format(profile=client.profile))
+            return True
+        x_elt = domish.Element((NS_VCARD_UPDATE, "x"))
+        x_elt.addElement("photo", content=avatar_hash)
+        presence_elt.addChild(x_elt)
+        return True
+
+    async def profile_connecting(self, client):
+        client._xep_0054_avatar_hashes = persistent.PersistentDict(
+            NS_VCARD, client.profile)
+        await client._xep_0054_avatar_hashes.load()
+
+    def save_photo(self, client, photo_elt, entity):
+        """Parse a <PHOTO> photo_elt and save the picture"""
+        # XXX: this method is launched in a separate thread
+        try:
+            mime_type = str(next(photo_elt.elements(NS_VCARD, "TYPE")))
+        except StopIteration:
+            mime_type = None
+        else:
+            if not mime_type:
+                # MIME type not known, we'll try autodetection below
+                mime_type = None
+            elif mime_type == "image/x-png":
+                # XXX: this old MIME type is still used by some clients
+                mime_type = "image/png"
+
+        try:
+            buf = str(next(photo_elt.elements(NS_VCARD, "BINVAL")))
+        except StopIteration:
+            log.warning("BINVAL element not found")
+            raise Failure(exceptions.NotFound())
+
+        if not buf:
+            log.warning("empty avatar for {jid}".format(jid=entity.full()))
+            raise Failure(exceptions.NotFound())
+
+        log.debug(_("Decoding binary"))
+        decoded = b64decode(buf)
+        del buf
+
+        if mime_type is None:
+            log.debug(
+                f"no media type found specified for {entity}'s avatar, trying to "
+                f"guess")
+
+            try:
+                mime_type = image.guess_type(io.BytesIO(decoded))
+            except IOError as e:
+                log.warning(f"Can't open avatar buffer: {e}")
+
+            if mime_type is None:
+                msg = f"Can't find media type for {entity}'s avatar"
+                log.warning(msg)
+                raise Failure(exceptions.DataError(msg))
+
+        image_hash = sha1(decoded).hexdigest()
+        with self.host.common_cache.cache_data(
+            PLUGIN_INFO["import_name"],
+            image_hash,
+            mime_type,
+        ) as f:
+            f.write(decoded)
+        return image_hash
+
+    async def v_card_2_dict(self, client, vcard_elt, entity_jid):
+        """Convert a VCard_elt to a dict, and save binaries"""
+        log.debug(("parsing vcard_elt"))
+        vcard_dict = {}
+
+        for elem in vcard_elt.elements():
+            if elem.name == "FN":
+                vcard_dict["fullname"] = str(elem)
+            elif elem.name == "NICKNAME":
+                nickname = vcard_dict["nickname"] = str(elem)
+                await self._i.update(
+                    client,
+                    IMPORT_NAME,
+                    "nicknames",
+                    [nickname],
+                    entity_jid
+                )
+            elif elem.name == "URL":
+                vcard_dict["website"] = str(elem)
+            elif elem.name == "EMAIL":
+                vcard_dict["email"] = str(elem)
+            elif elem.name == "BDAY":
+                vcard_dict["birthday"] = str(elem)
+            elif elem.name == "PHOTO":
+                # TODO: handle EXTVAL
+                try:
+                    avatar_hash = await threads.deferToThread(
+                        self.save_photo, client, elem, entity_jid
+                    )
+                except (exceptions.DataError, exceptions.NotFound):
+                    avatar_hash = ""
+                    vcard_dict["avatar"] = avatar_hash
+                except Exception as e:
+                    log.error(f"avatar saving error: {e}")
+                    avatar_hash = None
+                else:
+                    vcard_dict["avatar"] = avatar_hash
+                if avatar_hash is not None:
+                    await client._xep_0054_avatar_hashes.aset(
+                        entity_jid.full(), avatar_hash)
+
+                    if avatar_hash:
+                        avatar_cache = self.host.common_cache.get_metadata(avatar_hash)
+                        await self._i.update(
+                            client,
+                            IMPORT_NAME,
+                            "avatar",
+                            {
+                                'path': avatar_cache['path'],
+                                'filename': avatar_cache['filename'],
+                                'media_type': avatar_cache['mime_type'],
+                                'cache_uid': avatar_hash
+                            },
+                            entity_jid
+                        )
+                    else:
+                        await self._i.update(
+                            client, IMPORT_NAME, "avatar", None, entity_jid)
+            else:
+                log.debug("FIXME: [{}] VCard_elt tag is not managed yet".format(elem.name))
+
+        return vcard_dict
+
+    async def get_vcard_element(self, client, entity_jid):
+        """Retrieve domish.Element of a VCard
+
+        @param entity_jid(jid.JID): entity from who we need the vCard
+        @raise DataError: we got an invalid answer
+        """
+        iq_elt = client.IQ("get")
+        iq_elt["from"] = client.jid.full()
+        iq_elt["to"] = entity_jid.full()
+        iq_elt.addElement("vCard", NS_VCARD)
+        iq_ret_elt = await iq_elt.send(entity_jid.full())
+        try:
+            return next(iq_ret_elt.elements(NS_VCARD, "vCard"))
+        except StopIteration:
+            log.warning(_(
+                "vCard element not found for {entity_jid}: {xml}"
+                ).format(entity_jid=entity_jid, xml=iq_ret_elt.toXml()))
+            raise exceptions.DataError(f"no vCard element found for {entity_jid}")
+
+    async def update_vcard_elt(self, client, entity_jid, to_replace):
+        """Create a vcard element to replace some metadata
+
+        @param to_replace(list[str]): list of vcard element names to remove
+        """
+        try:
+            # we first check if a vcard already exists, to keep data
+            vcard_elt = await self.get_vcard_element(client, entity_jid)
+        except error.StanzaError as e:
+            if e.condition == "item-not-found":
+                vcard_elt = domish.Element((NS_VCARD, "vCard"))
+            else:
+                raise e
+        except exceptions.DataError:
+            vcard_elt = domish.Element((NS_VCARD, "vCard"))
+        else:
+            # the vcard exists, we need to remove elements that we'll replace
+            for elt_name in to_replace:
+                try:
+                    elt = next(vcard_elt.elements(NS_VCARD, elt_name))
+                except StopIteration:
+                    pass
+                else:
+                    vcard_elt.children.remove(elt)
+
+        return vcard_elt
+
+    async def get_card(self, client, entity_jid):
+        """Ask server for VCard
+
+        @param entity_jid(jid.JID): jid from which we want the VCard
+        @result(dict): vCard data
+        """
+        entity_jid = self._i.get_identity_jid(client, entity_jid)
+        log.debug(f"Asking for {entity_jid}'s VCard")
+        try:
+            vcard_elt = await self.get_vcard_element(client, entity_jid)
+        except exceptions.DataError:
+            self._i.update(client, IMPORT_NAME, "avatar", None, entity_jid)
+        except Exception as e:
+            log.warning(_(
+                "Can't get vCard for {entity_jid}: {e}"
+                ).format(entity_jid=entity_jid, e=e))
+        else:
+            log.debug(_("VCard found"))
+            return await self.v_card_2_dict(client, vcard_elt, entity_jid)
+
+    async def get_avatar(
+            self,
+            client: SatXMPPEntity,
+            entity_jid: jid.JID
+        ) -> Optional[dict]:
+        """Get avatar data
+
+        @param entity: entity to get avatar from
+        @return: avatar metadata, or None if no avatar has been found
+        """
+        entity_jid = self._i.get_identity_jid(client, entity_jid)
+        hashes_cache = client._xep_0054_avatar_hashes
+        vcard = await self.get_card(client, entity_jid)
+        if vcard is None:
+            return None
+        try:
+            avatar_hash = hashes_cache[entity_jid.full()]
+        except KeyError:
+            if 'avatar' in vcard:
+                raise exceptions.InternalError(
+                    "No avatar hash while avatar is found in vcard")
+            return None
+
+        if not avatar_hash:
+            return None
+
+        avatar_cache = self.host.common_cache.get_metadata(avatar_hash)
+        return self._i.avatar_build_metadata(
+                avatar_cache['path'], avatar_cache['mime_type'], avatar_hash)
+
+    async def set_avatar(self, client, avatar_data, entity):
+        """Set avatar of the profile
+
+        @param avatar_data(dict): data of the image to use as avatar, as built by
+            IDENTITY plugin.
+        @param entity(jid.JID): entity whose avatar must be changed
+        """
+        vcard_elt = await self.update_vcard_elt(client, entity, ['PHOTO'])
+
+        iq_elt = client.IQ()
+        iq_elt.addChild(vcard_elt)
+        # metadata with encoded image are now filled at the right size/format
+        photo_elt = vcard_elt.addElement("PHOTO")
+        photo_elt.addElement("TYPE", content=avatar_data["media_type"])
+        photo_elt.addElement("BINVAL", content=avatar_data["base64"])
+
+        await iq_elt.send()
+
+        # FIXME: should send the current presence, not always "available" !
+        await client.presence.available()
+
+    async def get_nicknames(self, client, entity):
+        """get nick from cache, or check vCard
+
+        @param entity(jid.JID): entity to get nick from
+        @return(list[str]): nicknames found
+        """
+        vcard_data = await self.get_card(client, entity)
+        try:
+            return [vcard_data['nickname']]
+        except (KeyError, TypeError):
+            return []
+
+    async def set_nicknames(self, client, nicknames, entity):
+        """Update our vCard and set a nickname
+
+        @param nicknames(list[str]): new nicknames to use
+            only first item of this list will be used here
+        """
+        nick = nicknames[0].strip()
+
+        vcard_elt = await self.update_vcard_elt(client, entity, ['NICKNAME'])
+
+        if nick:
+            vcard_elt.addElement((NS_VCARD, "NICKNAME"), content=nick)
+        iq_elt = client.IQ()
+        iq_elt.addChild(vcard_elt)
+        await iq_elt.send()
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0054_handler(XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(VCARD_UPDATE, self._update)
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_VCARD)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
+
+    async def update(self, presence):
+        """Called on <presence/> stanza with vcard data
+
+        Check for avatar information, and get VCard if needed
+        @param presence(domish.Element): <presence/> stanza
+        """
+        client = self.parent
+        entity_jid = self.plugin_parent._i.get_identity_jid(
+            client, jid.JID(presence["from"]))
+
+        try:
+            x_elt = next(presence.elements(NS_VCARD_UPDATE, "x"))
+        except StopIteration:
+            return
+
+        try:
+            photo_elt = next(x_elt.elements(NS_VCARD_UPDATE, "photo"))
+        except StopIteration:
+            return
+
+        given_hash = str(photo_elt).strip()
+        if given_hash == HASH_SHA1_EMPTY:
+            given_hash = ""
+
+        hashes_cache = client._xep_0054_avatar_hashes
+
+        old_hash = hashes_cache.get(entity_jid.full())
+
+        if old_hash == given_hash:
+            # no change, we can return…
+            if given_hash:
+                # …but we double check that avatar is in cache
+                avatar_cache = self.host.common_cache.get_metadata(given_hash)
+                if avatar_cache is None:
+                    log.debug(
+                        f"Avatar for [{entity_jid}] is known but not in cache, we get "
+                        f"it"
+                    )
+                    # get_card will put the avatar in cache
+                    await self.plugin_parent.get_card(client, entity_jid)
+                else:
+                    log.debug(f"avatar for {entity_jid} is already in cache")
+            return
+
+        if given_hash is None:
+            # XXX: we use empty string to indicate that there is no avatar
+            given_hash = ""
+
+        await hashes_cache.aset(entity_jid.full(), given_hash)
+
+        if not given_hash:
+            await self.plugin_parent._i.update(
+                client, IMPORT_NAME, "avatar", None, entity_jid)
+            # the avatar has been removed, no need to go further
+            return
+
+        avatar_cache = self.host.common_cache.get_metadata(given_hash)
+        if avatar_cache is not None:
+            log.debug(
+                f"New avatar found for [{entity_jid}], it's already in cache, we use it"
+            )
+            await self.plugin_parent._i.update(
+                client,
+                IMPORT_NAME, "avatar",
+                {
+                    'path': avatar_cache['path'],
+                    'filename': avatar_cache['filename'],
+                    'media_type': avatar_cache['mime_type'],
+                    'cache_uid': given_hash,
+                },
+                entity_jid
+            )
+        else:
+            log.debug(
+                "New avatar found for [{entity_jid}], requesting vcard"
+            )
+            vcard = await self.plugin_parent.get_card(client, entity_jid)
+            if vcard is None:
+                log.warning(f"Unexpected empty vCard for {entity_jid}")
+                return
+            computed_hash = client._xep_0054_avatar_hashes[entity_jid.full()]
+            if computed_hash != given_hash:
+                log.warning(
+                    "computed hash differs from given hash for {entity}:\n"
+                    "computed: {computed}\ngiven: {given}".format(
+                        entity=entity_jid, computed=computed_hash, given=given_hash
+                    )
+                )
+
+    def _update(self, presence):
+        defer.ensureDeferred(self.update(presence))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0055.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,526 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Jabber Search (xep-0055)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+from twisted.words.protocols.jabber.xmlstream import IQ
+from twisted.words.protocols.jabber import jid
+from twisted.internet import defer
+from wokkel import data_form
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.exceptions import DataError
+from libervia.backend.tools import xml_tools
+
+from wokkel import disco, iwokkel
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+from zope.interface import implementer
+
+
+NS_SEARCH = "jabber:iq:search"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Jabber Search",
+    C.PI_IMPORT_NAME: "XEP-0055",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0055"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_RECOMMENDATIONS: ["XEP-0059"],
+    C.PI_MAIN: "XEP_0055",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of Jabber Search"""),
+}
+
+# config file parameters
+CONFIG_SECTION = "plugin search"
+CONFIG_SERVICE_LIST = "service_list"
+
+DEFAULT_SERVICE_LIST = ["salut.libervia.org"]
+
+FIELD_SINGLE = "field_single"  # single text field for the simple search
+FIELD_CURRENT_SERVICE = (
+    "current_service_jid"
+)  # read-only text field for the advanced search
+
+
+class XEP_0055(object):
+    def __init__(self, host):
+        log.info(_("Jabber search plugin initialization"))
+        self.host = host
+
+        # default search services (config file + hard-coded lists)
+        self.services = [
+            jid.JID(entry)
+            for entry in host.memory.config_get(
+                CONFIG_SECTION, CONFIG_SERVICE_LIST, DEFAULT_SERVICE_LIST
+            )
+        ]
+
+        host.bridge.add_method(
+            "search_fields_ui_get",
+            ".plugin",
+            in_sign="ss",
+            out_sign="s",
+            method=self._get_fields_ui,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "search_request",
+            ".plugin",
+            in_sign="sa{ss}s",
+            out_sign="s",
+            method=self._search_request,
+            async_=True,
+        )
+
+        self.__search_menu_id = host.register_callback(self._get_main_ui, with_data=True)
+        host.import_menu(
+            (D_("Contacts"), D_("Search directory")),
+            self._get_main_ui,
+            security_limit=1,
+            help_string=D_("Search user directory"),
+        )
+
+    def _get_host_services(self, profile):
+        """Return the jabber search services associated to the user host.
+
+        @param profile (unicode): %(doc_profile)s
+        @return: list[jid.JID]
+        """
+        client = self.host.get_client(profile)
+        d = self.host.find_features_set(client, [NS_SEARCH])
+        return d.addCallback(lambda set_: list(set_))
+
+    ## Main search UI (menu item callback) ##
+
+    def _get_main_ui(self, raw_data, profile):
+        """Get the XMLUI for selecting a service and searching the directory.
+
+        @param raw_data (dict): data received from the frontend
+        @param profile (unicode): %(doc_profile)s
+        @return: a deferred XMLUI string representation
+        """
+        # check if the user's server offers some search services
+        d = self._get_host_services(profile)
+        return d.addCallback(lambda services: self.get_main_ui(services, raw_data, profile))
+
+    def get_main_ui(self, services, raw_data, profile):
+        """Get the XMLUI for selecting a service and searching the directory.
+
+        @param services (list[jid.JID]): search services offered by the user server
+        @param raw_data (dict): data received from the frontend
+        @param profile (unicode): %(doc_profile)s
+        @return: a deferred XMLUI string representation
+        """
+        # extend services offered by user's server with the default services
+        services.extend([service for service in self.services if service not in services])
+        data = xml_tools.xmlui_result_2_data_form_result(raw_data)
+        main_ui = xml_tools.XMLUI(
+            C.XMLUI_WINDOW,
+            container="tabs",
+            title=_("Search users"),
+            submit_id=self.__search_menu_id,
+        )
+
+        d = self._add_simple_search_ui(services, main_ui, data, profile)
+        d.addCallback(
+            lambda __: self._add_advanced_search_ui(services, main_ui, data, profile)
+        )
+        return d.addCallback(lambda __: {"xmlui": main_ui.toXml()})
+
+    def _add_simple_search_ui(self, services, main_ui, data, profile):
+        """Add to the main UI a tab for the simple search.
+
+        Display a single input field and search on the main service (it actually does one search per search field and then compile the results).
+
+        @param services (list[jid.JID]): search services offered by the user server
+        @param main_ui (XMLUI): the main XMLUI instance
+        @param data (dict): form data without SAT_FORM_PREFIX
+        @param profile (unicode): %(doc_profile)s
+
+        @return: a __ Deferred
+        """
+        service_jid = services[
+            0
+        ]  # TODO: search on all the given services, not only the first one
+
+        form = data_form.Form("form", formNamespace=NS_SEARCH)
+        form.addField(
+            data_form.Field(
+                "text-single",
+                FIELD_SINGLE,
+                label=_("Search for"),
+                value=data.get(FIELD_SINGLE, ""),
+            )
+        )
+
+        sub_cont = main_ui.main_container.add_tab(
+            "simple_search",
+            label=_("Simple search"),
+            container=xml_tools.VerticalContainer,
+        )
+        main_ui.change_container(sub_cont.append(xml_tools.PairsContainer(main_ui)))
+        xml_tools.data_form_2_widgets(main_ui, form)
+
+        # FIXME: add colspan attribute to divider? (we are in a PairsContainer)
+        main_ui.addDivider("blank")
+        main_ui.addDivider("blank")  # here we added a blank line before the button
+        main_ui.addDivider("blank")
+        main_ui.addButton(self.__search_menu_id, _("Search"), (FIELD_SINGLE,))
+        main_ui.addDivider("blank")
+        main_ui.addDivider("blank")  # a blank line again after the button
+
+        simple_data = {
+            key: value for key, value in data.items() if key in (FIELD_SINGLE,)
+        }
+        if simple_data:
+            log.debug("Simple search with %s on %s" % (simple_data, service_jid))
+            sub_cont.parent.set_selected(True)
+            main_ui.change_container(sub_cont.append(xml_tools.VerticalContainer(main_ui)))
+            main_ui.addDivider("dash")
+            d = self.search_request(service_jid, simple_data, profile)
+            d.addCallbacks(
+                lambda elt: self._display_search_result(main_ui, elt),
+                lambda failure: main_ui.addText(failure.getErrorMessage()),
+            )
+            return d
+
+        return defer.succeed(None)
+
+    def _add_advanced_search_ui(self, services, main_ui, data, profile):
+        """Add to the main UI a tab for the advanced search.
+
+        Display a service selector and allow to search on all the fields that are implemented by the selected service.
+
+        @param services (list[jid.JID]): search services offered by the user server
+        @param main_ui (XMLUI): the main XMLUI instance
+        @param data (dict): form data without SAT_FORM_PREFIX
+        @param profile (unicode): %(doc_profile)s
+
+        @return: a __ Deferred
+        """
+        sub_cont = main_ui.main_container.add_tab(
+            "advanced_search",
+            label=_("Advanced search"),
+            container=xml_tools.VerticalContainer,
+        )
+        service_selection_fields = ["service_jid", "service_jid_extra"]
+
+        if "service_jid_extra" in data:
+            # refresh button has been pushed, select the tab
+            sub_cont.parent.set_selected(True)
+            # get the selected service
+            service_jid_s = data.get("service_jid_extra", "")
+            if not service_jid_s:
+                service_jid_s = data.get("service_jid", str(services[0]))
+            log.debug("Refreshing search fields for %s" % service_jid_s)
+        else:
+            service_jid_s = data.get(FIELD_CURRENT_SERVICE, str(services[0]))
+        services_s = [str(service) for service in services]
+        if service_jid_s not in services_s:
+            services_s.append(service_jid_s)
+
+        main_ui.change_container(sub_cont.append(xml_tools.PairsContainer(main_ui)))
+        main_ui.addLabel(_("Search on"))
+        main_ui.addList("service_jid", options=services_s, selected=service_jid_s)
+        main_ui.addLabel(_("Other service"))
+        main_ui.addString(name="service_jid_extra")
+
+        # FIXME: add colspan attribute to divider? (we are in a PairsContainer)
+        main_ui.addDivider("blank")
+        main_ui.addDivider("blank")  # here we added a blank line before the button
+        main_ui.addDivider("blank")
+        main_ui.addButton(
+            self.__search_menu_id, _("Refresh fields"), service_selection_fields
+        )
+        main_ui.addDivider("blank")
+        main_ui.addDivider("blank")  # a blank line again after the button
+        main_ui.addLabel(_("Displaying the search form for"))
+        main_ui.addString(name=FIELD_CURRENT_SERVICE, value=service_jid_s, read_only=True)
+        main_ui.addDivider("dash")
+        main_ui.addDivider("dash")
+
+        main_ui.change_container(sub_cont.append(xml_tools.VerticalContainer(main_ui)))
+        service_jid = jid.JID(service_jid_s)
+        d = self.get_fields_ui(service_jid, profile)
+        d.addCallbacks(
+            self._add_advanced_form,
+            lambda failure: main_ui.addText(failure.getErrorMessage()),
+            [service_jid, main_ui, sub_cont, data, profile],
+        )
+        return d
+
+    def _add_advanced_form(self, form_elt, service_jid, main_ui, sub_cont, data, profile):
+        """Add the search form and the search results (if there is some to display).
+
+        @param form_elt (domish.Element): form element listing the fields
+        @param service_jid (jid.JID): current search service
+        @param main_ui (XMLUI): the main XMLUI instance
+        @param sub_cont (Container): the container of the current tab
+        @param data (dict): form data without SAT_FORM_PREFIX
+        @param profile (unicode): %(doc_profile)s
+
+        @return: a __ Deferred
+        """
+        field_list = data_form.Form.fromElement(form_elt).fieldList
+        adv_fields = [field.var for field in field_list if field.var]
+        adv_data = {key: value for key, value in data.items() if key in adv_fields}
+
+        xml_tools.data_form_2_widgets(main_ui, data_form.Form.fromElement(form_elt))
+
+        # refill the submitted values
+        # FIXME: wokkel's data_form.Form.fromElement doesn't parse the values, so we do it directly in XMLUI for now
+        for widget in main_ui.current_container.elem.childNodes:
+            name = widget.getAttribute("name")
+            if adv_data.get(name):
+                widget.setAttribute("value", adv_data[name])
+
+        # FIXME: add colspan attribute to divider? (we are in a PairsContainer)
+        main_ui.addDivider("blank")
+        main_ui.addDivider("blank")  # here we added a blank line before the button
+        main_ui.addDivider("blank")
+        main_ui.addButton(
+            self.__search_menu_id, _("Search"), adv_fields + [FIELD_CURRENT_SERVICE]
+        )
+        main_ui.addDivider("blank")
+        main_ui.addDivider("blank")  # a blank line again after the button
+
+        if adv_data:  # display the search results
+            log.debug("Advanced search with %s on %s" % (adv_data, service_jid))
+            sub_cont.parent.set_selected(True)
+            main_ui.change_container(sub_cont.append(xml_tools.VerticalContainer(main_ui)))
+            main_ui.addDivider("dash")
+            d = self.search_request(service_jid, adv_data, profile)
+            d.addCallbacks(
+                lambda elt: self._display_search_result(main_ui, elt),
+                lambda failure: main_ui.addText(failure.getErrorMessage()),
+            )
+            return d
+
+        return defer.succeed(None)
+
+    def _display_search_result(self, main_ui, elt):
+        """Display the search results.
+
+        @param main_ui (XMLUI): the main XMLUI instance
+        @param elt (domish.Element):  form result element
+        """
+        if [child for child in elt.children if child.name == "item"]:
+            headers, xmlui_data = xml_tools.data_form_elt_result_2_xmlui_data(elt)
+            if "jid" in headers:  # use XMLUI JidsListWidget to display the results
+                values = {}
+                for i in range(len(xmlui_data)):
+                    header = list(headers.keys())[i % len(headers)]
+                    widget_type, widget_args, widget_kwargs = xmlui_data[i]
+                    value = widget_args[0]
+                    values.setdefault(header, []).append(
+                        jid.JID(value) if header == "jid" else value
+                    )
+                main_ui.addJidsList(jids=values["jid"], name=D_("Search results"))
+                # TODO: also display the values other than JID
+            else:
+                xml_tools.xmlui_data_2_advanced_list(main_ui, headers, xmlui_data)
+        else:
+            main_ui.addText(D_("The search gave no result"))
+
+    ## Retrieve the  search fields ##
+
+    def _get_fields_ui(self, to_jid_s, profile_key):
+        """Ask a service to send us the list of the form fields it manages.
+
+        @param to_jid_s (unicode): XEP-0055 compliant search entity
+        @param profile_key (unicode): %(doc_profile_key)s
+        @return: a deferred XMLUI instance
+        """
+        d = self.get_fields_ui(jid.JID(to_jid_s), profile_key)
+        d.addCallback(lambda form: xml_tools.data_form_elt_result_2_xmlui(form).toXml())
+        return d
+
+    def get_fields_ui(self, to_jid, profile_key):
+        """Ask a service to send us the list of the form fields it manages.
+
+        @param to_jid (jid.JID): XEP-0055 compliant search entity
+        @param profile_key (unicode): %(doc_profile_key)s
+        @return: a deferred domish.Element
+        """
+        client = self.host.get_client(profile_key)
+        fields_request = IQ(client.xmlstream, "get")
+        fields_request["from"] = client.jid.full()
+        fields_request["to"] = to_jid.full()
+        fields_request.addElement("query", NS_SEARCH)
+        d = fields_request.send(to_jid.full())
+        d.addCallbacks(self._get_fields_ui_cb, self._get_fields_ui_eb)
+        return d
+
+    def _get_fields_ui_cb(self, answer):
+        """Callback for self.get_fields_ui.
+
+        @param answer (domish.Element): search query element
+        @return: domish.Element
+        """
+        try:
+            query_elts = next(answer.elements("jabber:iq:search", "query"))
+        except StopIteration:
+            log.info(_("No query element found"))
+            raise DataError  # FIXME: StanzaError is probably more appropriate, check the RFC
+        try:
+            form_elt = next(query_elts.elements(data_form.NS_X_DATA, "x"))
+        except StopIteration:
+            log.info(_("No data form found"))
+            raise NotImplementedError(
+                "Only search through data form is implemented so far"
+            )
+        return form_elt
+
+    def _get_fields_ui_eb(self, failure):
+        """Errback to self.get_fields_ui.
+
+        @param failure (defer.failure.Failure): twisted failure
+        @raise: the unchanged defer.failure.Failure
+        """
+        log.info(_("Fields request failure: %s") % str(failure.getErrorMessage()))
+        raise failure
+
+    ## Do the search ##
+
+    def _search_request(self, to_jid_s, search_data, profile_key):
+        """Actually do a search, according to filled data.
+
+        @param to_jid_s (unicode): XEP-0055 compliant search entity
+        @param search_data (dict): filled data, corresponding to the form obtained in get_fields_ui
+        @param profile_key (unicode): %(doc_profile_key)s
+        @return: a deferred XMLUI string representation
+        """
+        d = self.search_request(jid.JID(to_jid_s), search_data, profile_key)
+        d.addCallback(lambda form: xml_tools.data_form_elt_result_2_xmlui(form).toXml())
+        return d
+
+    def search_request(self, to_jid, search_data, profile_key):
+        """Actually do a search, according to filled data.
+
+        @param to_jid (jid.JID): XEP-0055 compliant search entity
+        @param search_data (dict): filled data, corresponding to the form obtained in get_fields_ui
+        @param profile_key (unicode): %(doc_profile_key)s
+        @return: a deferred domish.Element
+        """
+        if FIELD_SINGLE in search_data:
+            value = search_data[FIELD_SINGLE]
+            d = self.get_fields_ui(to_jid, profile_key)
+            d.addCallback(
+                lambda elt: self.search_request_multi(to_jid, value, elt, profile_key)
+            )
+            return d
+
+        client = self.host.get_client(profile_key)
+        search_request = IQ(client.xmlstream, "set")
+        search_request["from"] = client.jid.full()
+        search_request["to"] = to_jid.full()
+        query_elt = search_request.addElement("query", NS_SEARCH)
+        x_form = data_form.Form("submit", formNamespace=NS_SEARCH)
+        x_form.makeFields(search_data)
+        query_elt.addChild(x_form.toElement())
+        # TODO: XEP-0059 could be used here (with the needed new method attributes)
+        d = search_request.send(to_jid.full())
+        d.addCallbacks(self._search_ok, self._search_err)
+        return d
+
+    def search_request_multi(self, to_jid, value, form_elt, profile_key):
+        """Search for a value simultaneously in all fields, returns the results compilation.
+
+        @param to_jid (jid.JID): XEP-0055 compliant search entity
+        @param value (unicode): value to search
+        @param form_elt (domish.Element): form element listing the fields
+        @param profile_key (unicode): %(doc_profile_key)s
+        @return: a deferred domish.Element
+        """
+        form = data_form.Form.fromElement(form_elt)
+        d_list = []
+
+        for field in [field.var for field in form.fieldList if field.var]:
+            d_list.append(self.search_request(to_jid, {field: value}, profile_key))
+
+        def cb(result):  # return the results compiled in one domish element
+            result_elt = None
+            for success, form_elt in result:
+                if not success:
+                    continue
+                if (
+                    result_elt is None
+                ):  # the result element is built over the first answer
+                    result_elt = form_elt
+                    continue
+                for item_elt in form_elt.elements("jabber:x:data", "item"):
+                    result_elt.addChild(item_elt)
+            if result_elt is None:
+                raise defer.failure.Failure(
+                    DataError(_("The search could not be performed"))
+                )
+            return result_elt
+
+        return defer.DeferredList(d_list).addCallback(cb)
+
+    def _search_ok(self, answer):
+        """Callback for self.search_request.
+
+        @param answer (domish.Element): search query element
+        @return: domish.Element
+        """
+        try:
+            query_elts = next(answer.elements("jabber:iq:search", "query"))
+        except StopIteration:
+            log.info(_("No query element found"))
+            raise DataError  # FIXME: StanzaError is probably more appropriate, check the RFC
+        try:
+            form_elt = next(query_elts.elements(data_form.NS_X_DATA, "x"))
+        except StopIteration:
+            log.info(_("No data form found"))
+            raise NotImplementedError(
+                "Only search through data form is implemented so far"
+            )
+        return form_elt
+
+    def _search_err(self, failure):
+        """Errback to self.search_request.
+
+        @param failure (defer.failure.Failure): twisted failure
+        @raise: the unchanged defer.failure.Failure
+        """
+        log.info(_("Search request failure: %s") % str(failure.getErrorMessage()))
+        raise failure
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0055_handler(XMPPHandler):
+
+    def __init__(self, plugin_parent, profile):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+        self.profile = profile
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_SEARCH)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0059.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+
+# Result Set Management (XEP-0059)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional
+from zope.interface import implementer
+from twisted.words.protocols.jabber import xmlstream
+from wokkel import disco
+from wokkel import iwokkel
+from wokkel import rsm
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Result Set Management",
+    C.PI_IMPORT_NAME: "XEP-0059",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0059"],
+    C.PI_MAIN: "XEP_0059",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Result Set Management"""),
+}
+
+RSM_PREFIX = "rsm_"
+
+
+class XEP_0059(object):
+    # XXX: RSM management is done directly in Wokkel.
+
+    def __init__(self, host):
+        log.info(_("Result Set Management plugin initialization"))
+
+    def get_handler(self, client):
+        return XEP_0059_handler()
+
+    def parse_extra(self, extra):
+        """Parse extra dictionnary to retrieve RSM arguments
+
+        @param extra(dict): data for parse
+        @return (rsm.RSMRequest, None): request with parsed arguments
+            or None if no RSM arguments have been found
+        """
+        if int(extra.get(RSM_PREFIX + 'max', 0)) < 0:
+            raise ValueError(_("rsm_max can't be negative"))
+
+        rsm_args = {}
+        for arg in ("max", "after", "before", "index"):
+            try:
+                argname = "max_" if arg == "max" else arg
+                rsm_args[argname] = extra.pop(RSM_PREFIX + arg)
+            except KeyError:
+                continue
+
+        if rsm_args:
+            return rsm.RSMRequest(**rsm_args)
+        else:
+            return None
+
+    def response2dict(self, rsm_response, data=None):
+        """Return a dict with RSM response
+
+        Key set in data can be:
+            - rsm_first: first item id in the page
+            - rsm_last: last item id in the page
+            - rsm_index: position of the first item in the full set (may be approximate)
+            - rsm_count: total number of items in the full set (may be approximage)
+        If a value doesn't exists, it's not set.
+        All values are set as strings.
+        @param rsm_response(rsm.RSMResponse): response to parse
+        @param data(dict, None): dict to update with rsm_* data.
+            If None, a new dict is created
+        @return (dict): data dict
+        """
+        if data is None:
+            data = {}
+        if rsm_response.first is not None:
+            data["first"] = rsm_response.first
+        if rsm_response.last is not None:
+            data["last"] = rsm_response.last
+        if rsm_response.index is not None:
+            data["index"] = rsm_response.index
+        return data
+
+    def get_next_request(
+        self,
+        rsm_request: rsm.RSMRequest,
+        rsm_response: rsm.RSMResponse,
+        log_progress: bool = True,
+    ) -> Optional[rsm.RSMRequest]:
+        """Generate next request to paginate through all items
+
+        Page will be retrieved forward
+        @param rsm_request: last request used
+        @param rsm_response: response from the last request
+        @return: request to retrive next page, or None if we are at the end
+            or if pagination is not possible
+        """
+        if rsm_request.max == 0:
+            log.warning("Can't do pagination if max is 0")
+            return None
+        if rsm_response is None:
+            # may happen if result set it empty, or we are at the end
+            return None
+        if (
+            rsm_response.count is not None
+            and rsm_response.index is not None
+        ):
+            next_index = rsm_response.index + rsm_request.max
+            if next_index >= rsm_response.count:
+                # we have reached the last page
+                return None
+
+            if log_progress:
+                log.debug(
+                    f"retrieving items {next_index} to "
+                    f"{min(next_index+rsm_request.max, rsm_response.count)} on "
+                    f"{rsm_response.count} ({next_index/rsm_response.count*100:.2f}%)"
+                )
+
+        if rsm_response.last is None:
+            if rsm_response.count:
+                log.warning("Can't do pagination, no \"last\" received")
+            return None
+
+        return rsm.RSMRequest(
+            max_=rsm_request.max,
+            after=rsm_response.last
+        )
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0059_handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(rsm.NS_RSM)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0060.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1820 @@
+#!/usr/bin/env python3
+
+# SàT plugin for Publish-Subscribe (xep-0060)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from collections import namedtuple
+from functools import reduce
+from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union
+import urllib.error
+import urllib.parse
+import urllib.request
+
+from twisted.internet import defer, reactor
+from twisted.words.protocols.jabber import error, jid
+from twisted.words.xish import domish
+from wokkel import disco
+from wokkel import data_form
+from wokkel import pubsub
+from wokkel import rsm
+from wokkel import mam
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.xmpp import SatXMPPClient
+from libervia.backend.tools import utils
+from libervia.backend.tools import sat_defer
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools.common import data_format
+
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Publish-Subscribe",
+    C.PI_IMPORT_NAME: "XEP-0060",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0060"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_RECOMMENDATIONS: ["XEP-0059", "XEP-0313"],
+    C.PI_MAIN: "XEP_0060",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of PubSub Protocol"""),
+}
+
+UNSPECIFIED = "unspecified error"
+
+
+Extra = namedtuple("Extra", ("rsm_request", "extra"))
+# rsm_request is the rsm.RSMRequest build with rsm_ prefixed keys, or None
+# extra is a potentially empty dict
+TIMEOUT = 30
+# minimum features that a pubsub service must have to be selectable as default
+DEFAULT_PUBSUB_MIN_FEAT = {
+ 'http://jabber.org/protocol/pubsub#persistent-items',
+ 'http://jabber.org/protocol/pubsub#publish',
+ 'http://jabber.org/protocol/pubsub#retract-items',
+}
+
+class XEP_0060(object):
+    OPT_ACCESS_MODEL = "pubsub#access_model"
+    OPT_PERSIST_ITEMS = "pubsub#persist_items"
+    OPT_MAX_ITEMS = "pubsub#max_items"
+    OPT_DELIVER_PAYLOADS = "pubsub#deliver_payloads"
+    OPT_SEND_ITEM_SUBSCRIBE = "pubsub#send_item_subscribe"
+    OPT_NODE_TYPE = "pubsub#node_type"
+    OPT_SUBSCRIPTION_TYPE = "pubsub#subscription_type"
+    OPT_SUBSCRIPTION_DEPTH = "pubsub#subscription_depth"
+    OPT_ROSTER_GROUPS_ALLOWED = "pubsub#roster_groups_allowed"
+    OPT_PUBLISH_MODEL = "pubsub#publish_model"
+    OPT_OVERWRITE_POLICY = "pubsub#overwrite_policy"
+    ACCESS_OPEN = "open"
+    ACCESS_PRESENCE = "presence"
+    ACCESS_ROSTER = "roster"
+    ACCESS_PUBLISHER_ROSTER = "publisher-roster"
+    ACCESS_AUTHORIZE = "authorize"
+    ACCESS_WHITELIST = "whitelist"
+    PUBLISH_MODEL_PUBLISHERS = "publishers"
+    PUBLISH_MODEL_SUBSCRIBERS = "subscribers"
+    PUBLISH_MODEL_OPEN = "open"
+    OWPOL_ORIGINAL = "original_publisher"
+    OWPOL_ANY_PUB = "any_publisher"
+    ID_SINGLETON = "current"
+    EXTRA_PUBLISH_OPTIONS = "publish_options"
+    EXTRA_ON_PRECOND_NOT_MET = "on_precondition_not_met"
+    # extra disco needed for RSM, cf. XEP-0060 § 6.5.4
+    DISCO_RSM = "http://jabber.org/protocol/pubsub#rsm"
+
+    def __init__(self, host):
+        log.info(_("PubSub plugin initialization"))
+        self.host = host
+        self._rsm = host.plugins.get("XEP-0059")
+        self._mam = host.plugins.get("XEP-0313")
+        self._node_cb = {}  # dictionnary of callbacks for node (key: node, value: list of callbacks)
+        self.rt_sessions = sat_defer.RTDeferredSessions()
+        host.bridge.add_method(
+            "ps_node_create",
+            ".plugin",
+            in_sign="ssa{ss}s",
+            out_sign="s",
+            method=self._create_node,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_node_configuration_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="a{ss}",
+            method=self._get_node_configuration,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_node_configuration_set",
+            ".plugin",
+            in_sign="ssa{ss}s",
+            out_sign="",
+            method=self._set_node_configuration,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_node_affiliations_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="a{ss}",
+            method=self._get_node_affiliations,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_node_affiliations_set",
+            ".plugin",
+            in_sign="ssa{ss}s",
+            out_sign="",
+            method=self._set_node_affiliations,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_node_subscriptions_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="a{ss}",
+            method=self._get_node_subscriptions,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_node_subscriptions_set",
+            ".plugin",
+            in_sign="ssa{ss}s",
+            out_sign="",
+            method=self._set_node_subscriptions,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_node_purge",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._purge_node,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_node_delete",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._delete_node,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_node_watch_add",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._addWatch,
+            async_=False,
+        )
+        host.bridge.add_method(
+            "ps_node_watch_remove",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._remove_watch,
+            async_=False,
+        )
+        host.bridge.add_method(
+            "ps_affiliations_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="a{ss}",
+            method=self._get_affiliations,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_items_get",
+            ".plugin",
+            in_sign="ssiassss",
+            out_sign="s",
+            method=self._get_items,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_item_send",
+            ".plugin",
+            in_sign="ssssss",
+            out_sign="s",
+            method=self._send_item,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_items_send",
+            ".plugin",
+            in_sign="ssasss",
+            out_sign="as",
+            method=self._send_items,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_item_retract",
+            ".plugin",
+            in_sign="sssbs",
+            out_sign="",
+            method=self._retract_item,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_items_retract",
+            ".plugin",
+            in_sign="ssasbs",
+            out_sign="",
+            method=self._retract_items,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_item_rename",
+            ".plugin",
+            in_sign="sssss",
+            out_sign="",
+            method=self._rename_item,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_subscribe",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="s",
+            method=self._subscribe,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_unsubscribe",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._unsubscribe,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_subscriptions_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._subscriptions,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_subscribe_to_many",
+            ".plugin",
+            in_sign="a(ss)sa{ss}s",
+            out_sign="s",
+            method=self._subscribe_to_many,
+        )
+        host.bridge.add_method(
+            "ps_get_subscribe_rt_result",
+            ".plugin",
+            in_sign="ss",
+            out_sign="(ua(sss))",
+            method=self._many_subscribe_rt_result,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_get_from_many",
+            ".plugin",
+            in_sign="a(ss)iss",
+            out_sign="s",
+            method=self._get_from_many,
+        )
+        host.bridge.add_method(
+            "ps_get_from_many_rt_result",
+            ".plugin",
+            in_sign="ss",
+            out_sign="(ua(sssasa{ss}))",
+            method=self._get_from_many_rt_result,
+            async_=True,
+        )
+
+        #  high level observer method
+        host.bridge.add_signal(
+            "ps_event", ".plugin", signature="ssssss"
+        )  # args: category, service(jid), node, type (C.PS_ITEMS, C.PS_DELETE), data, profile
+
+        # low level observer method, used if service/node is in watching list (see psNodeWatch* methods)
+        host.bridge.add_signal(
+            "ps_event_raw", ".plugin", signature="sssass"
+        )  # args: service(jid), node, type (C.PS_ITEMS, C.PS_DELETE), list of item_xml, profile
+
+    def get_handler(self, client):
+        client.pubsub_client = SatPubSubClient(self.host, self)
+        return client.pubsub_client
+
+    async def profile_connected(self, client):
+        client.pubsub_watching = set()
+        try:
+            client.pubsub_service = jid.JID(
+                self.host.memory.config_get("", "pubsub_service")
+            )
+        except RuntimeError:
+            log.info(
+                _(
+                    "Can't retrieve pubsub_service from conf, we'll use first one that "
+                    "we find"
+                )
+            )
+            pubsub_services = await self.host.find_service_entities(
+                client, "pubsub", "service"
+            )
+            for service_jid in pubsub_services:
+                infos = await self.host.memory.disco.get_infos(client, service_jid)
+                if not DEFAULT_PUBSUB_MIN_FEAT.issubset(infos.features):
+                    continue
+                names = {(n or "").lower() for n in infos.identities.values()}
+                if "libervia pubsub service" in names:
+                    # this is the name of Libervia's side project pubsub service, we know
+                    # that it is a suitable default pubsub service
+                    client.pubsub_service = service_jid
+                    break
+                categories = {(i[0] or "").lower() for i in infos.identities.keys()}
+                if "gateway" in categories or "gateway" in names:
+                    # we don't want to use a gateway as default pubsub service
+                    continue
+                if "jabber:iq:register" in infos.features:
+                    # may be present on gateways, and we don't want a service
+                    # where registration is needed
+                    continue
+                client.pubsub_service = service_jid
+                break
+            else:
+                client.pubsub_service = None
+            pubsub_service_str = (
+                client.pubsub_service.full() if client.pubsub_service else "PEP"
+            )
+            log.info(f"default pubsub service: {pubsub_service_str}")
+
+    def features_get(self, profile):
+        try:
+            client = self.host.get_client(profile)
+        except exceptions.ProfileNotSetError:
+            return {}
+        try:
+            return {
+                "service": client.pubsub_service.full()
+                if client.pubsub_service is not None
+                else ""
+            }
+        except AttributeError:
+            if self.host.is_connected(profile):
+                log.debug("Profile is not connected, service is not checked yet")
+            else:
+                log.error("Service should be available !")
+            return {}
+
+    def parse_extra(self, extra):
+        """Parse extra dictionnary
+
+        used bridge's extra dictionnaries
+        @param extra(dict): extra data used to configure request
+        @return(Extra): filled Extra instance
+        """
+        if extra is None:
+            rsm_request = None
+            extra = {}
+        else:
+            # order-by
+            if C.KEY_ORDER_BY in extra:
+                # FIXME: we temporarily manage only one level of ordering
+                #        we need to switch to a fully serialised extra data
+                #        to be able to encode a whole ordered list
+                extra[C.KEY_ORDER_BY] = [extra.pop(C.KEY_ORDER_BY)]
+
+            # rsm
+            if self._rsm is None:
+                rsm_request = None
+            else:
+                rsm_request = self._rsm.parse_extra(extra)
+
+            # mam
+            if self._mam is None:
+                mam_request = None
+            else:
+                mam_request = self._mam.parse_extra(extra, with_rsm=False)
+
+            if mam_request is not None:
+                assert "mam" not in extra
+                extra["mam"] = mam_request
+
+        return Extra(rsm_request, extra)
+
+    def add_managed_node(
+        self,
+        node: str,
+        priority: int = 0,
+        **kwargs: Callable
+    ):
+        """Add a handler for a node
+
+        @param node: node to monitor
+            all node *prefixed* with this one will be triggered
+        @param priority: priority of the callback. Callbacks with higher priority will be
+            called first.
+        @param **kwargs: method(s) to call when the node is found
+            the method must be named after PubSub constants in lower case
+            and suffixed with "_cb"
+            e.g.: "items_cb" for C.PS_ITEMS, "delete_cb" for C.PS_DELETE
+            note: only C.PS_ITEMS and C.PS_DELETE are implemented so far
+        """
+        assert node is not None
+        assert kwargs
+        callbacks = self._node_cb.setdefault(node, {})
+        for event, cb in kwargs.items():
+            event_name = event[:-3]
+            assert event_name in C.PS_EVENTS
+            cb_list = callbacks.setdefault(event_name, [])
+            cb_list.append((cb, priority))
+            cb_list.sort(key=lambda c: c[1], reverse=True)
+
+    def remove_managed_node(self, node, *args):
+        """Add a handler for a node
+
+        @param node(unicode): node to monitor
+        @param *args: callback(s) to remove
+        """
+        assert args
+        try:
+            registred_cb = self._node_cb[node]
+        except KeyError:
+            pass
+        else:
+            removed = False
+            for callback in args:
+                for event, cb_list in registred_cb.items():
+                    to_remove = []
+                    for cb in cb_list:
+                        if cb[0] == callback:
+                            to_remove.append(cb)
+                            for cb in to_remove:
+                                cb_list.remove(cb)
+                            if not cb_list:
+                                del registred_cb[event]
+                            if not registred_cb:
+                                del self._node_cb[node]
+                            removed = True
+                            break
+
+            if not removed:
+                log.error(
+                    f"Trying to remove inexistant callback {callback} for node {node}"
+                )
+
+    # def listNodes(self, service, nodeIdentifier='', profile=C.PROF_KEY_NONE):
+    #     """Retrieve the name of the nodes that are accessible on the target service.
+
+    #     @param service (JID): target service
+    #     @param nodeIdentifier (str): the parent node name (leave empty to retrieve first-level nodes)
+    #     @param profile (str): %(doc_profile)s
+    #     @return: deferred which fire a list of nodes
+    #     """
+    #     client = self.host.get_client(profile)
+    #     d = self.host.getDiscoItems(client, service, nodeIdentifier)
+    #     d.addCallback(lambda result: [item.getAttribute('node') for item in result.toElement().children if item.hasAttribute('node')])
+    #     return d
+
+    # def listSubscribedNodes(self, service, nodeIdentifier='', filter_='subscribed', profile=C.PROF_KEY_NONE):
+    #     """Retrieve the name of the nodes to which the profile is subscribed on the target service.
+
+    #     @param service (JID): target service
+    #     @param nodeIdentifier (str): the parent node name (leave empty to retrieve all subscriptions)
+    #     @param filter_ (str): filter the result according to the given subscription type:
+    #         - None: do not filter
+    #         - 'pending': subscription has not been approved yet by the node owner
+    #         - 'unconfigured': subscription options have not been configured yet
+    #         - 'subscribed': subscription is complete
+    #     @param profile (str): %(doc_profile)s
+    #     @return: Deferred list[str]
+    #     """
+    #     d = self.subscriptions(service, nodeIdentifier, profile_key=profile)
+    #     d.addCallback(lambda subs: [sub.getAttribute('node') for sub in subs if sub.getAttribute('subscription') == filter_])
+    #     return d
+
+    def _send_item(self, service, nodeIdentifier, payload, item_id=None, extra_ser="",
+                  profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        service = None if not service else jid.JID(service)
+        payload = xml_tools.parse(payload)
+        extra = data_format.deserialise(extra_ser)
+        d = defer.ensureDeferred(self.send_item(
+            client, service, nodeIdentifier, payload, item_id or None, extra
+        ))
+        d.addCallback(lambda ret: ret or "")
+        return d
+
+    def _send_items(self, service, nodeIdentifier, items, extra_ser=None,
+                  profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        service = None if not service else jid.JID(service)
+        try:
+            items = [xml_tools.parse(item) for item in items]
+        except Exception as e:
+            raise exceptions.DataError(_("Can't parse items: {msg}").format(
+                msg=e))
+        extra = data_format.deserialise(extra_ser)
+        return defer.ensureDeferred(self.send_items(
+            client, service, nodeIdentifier, items, extra=extra
+        ))
+
+    async def send_item(
+        self,
+        client: SatXMPPClient,
+        service: Union[jid.JID, None],
+        nodeIdentifier: str,
+        payload: domish.Element,
+        item_id: Optional[str] = None,
+        extra: Optional[Dict[str, Any]] = None
+    ) -> Optional[str]:
+        """High level method to send one item
+
+        @param service: service to send the item to None to use PEP
+        @param NodeIdentifier: PubSub node to use
+        @param payload: payload of the item to send
+        @param item_id: id to use or None to create one
+        @param extra: extra options
+        @return: id of the created item
+        """
+        assert isinstance(payload, domish.Element)
+        item_elt = domish.Element((pubsub.NS_PUBSUB, 'item'))
+        if item_id is not None:
+            item_elt['id'] = item_id
+        item_elt.addChild(payload)
+        published_ids = await self.send_items(
+            client,
+            service,
+            nodeIdentifier,
+            [item_elt],
+            extra=extra
+        )
+        try:
+            return published_ids[0]
+        except IndexError:
+            return item_id
+
+    async def send_items(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        nodeIdentifier: str,
+        items: List[domish.Element],
+        sender: Optional[jid.JID] = None,
+        extra: Optional[Dict[str, Any]] = None
+    ) -> List[str]:
+        """High level method to send several items at once
+
+        @param service: service to send the item to
+            None to use PEP
+        @param NodeIdentifier: PubSub node to use
+        @param items: whole item elements to send,
+            "id" will be used if set
+        @param extra: extra options. Key can be:
+            - self.EXTRA_PUBLISH_OPTIONS(dict): publish options, cf. XEP-0060 § 7.1.5
+                the dict maps option name to value(s)
+            - self.EXTRA_ON_PRECOND_NOT_MET(str): policy to have when publishing is
+                failing du to failing precondition. Value can be:
+                * raise (default): raise the exception
+                * publish_without_options: re-publish without the publish-options.
+                    A warning will be logged showing that the publish-options could not
+                    be used
+        @return: ids of the created items
+        """
+        if extra is None:
+            extra = {}
+        if service is None:
+            service = client.jid.userhostJID()
+        parsed_items = []
+        for item in items:
+            if item.name != 'item':
+                raise exceptions.DataError(_("Invalid item: {xml}").format(item.toXml()))
+            item_id = item.getAttribute("id")
+            parsed_items.append(pubsub.Item(id=item_id, payload=item.firstChildElement()))
+        publish_options = extra.get(self.EXTRA_PUBLISH_OPTIONS)
+        try:
+            iq_result = await self.publish(
+                client, service, nodeIdentifier, parsed_items, options=publish_options,
+                sender=sender
+            )
+        except error.StanzaError as e:
+            if ((e.condition == 'conflict' and e.appCondition
+                 and e.appCondition.name == 'precondition-not-met'
+                 and publish_options is not None)):
+                # this usually happens when publish-options can't be set
+                policy = extra.get(self.EXTRA_ON_PRECOND_NOT_MET, 'raise')
+                if policy == 'raise':
+                    raise e
+                elif policy == 'publish_without_options':
+                    log.warning(_(
+                        "Can't use publish-options ({options}) on node {node}, "
+                        "re-publishing without them: {reason}").format(
+                            options=', '.join(f'{k} = {v}'
+                                    for k,v in publish_options.items()),
+                            node=nodeIdentifier,
+                            reason=e,
+                        )
+                    )
+                    iq_result = await self.publish(
+                        client, service, nodeIdentifier, parsed_items)
+                else:
+                    raise exceptions.InternalError(
+                        f"Invalid policy in extra's {self.EXTRA_ON_PRECOND_NOT_MET!r}: "
+                        f"{policy}"
+                    )
+            else:
+                raise e
+        try:
+            return [
+                item['id']
+                for item in iq_result.pubsub.publish.elements(pubsub.NS_PUBSUB, 'item')
+            ]
+        except AttributeError:
+            return []
+
+    async def publish(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        nodeIdentifier: str,
+        items: Optional[List[domish.Element]] = None,
+        options: Optional[dict] = None,
+        sender: Optional[jid.JID] = None,
+        extra: Optional[Dict[str, Any]] = None
+    ) -> domish.Element:
+        """Publish pubsub items
+
+        @param sender: sender of the request,
+            client.jid will be used if nto set
+        @param extra: extra data
+            not used directly by ``publish``, but may be used in triggers
+        @return: IQ result stanza
+        @trigger XEP-0060_publish: called just before publication.
+            if it returns False, extra must have a "iq_result_elt" key set with
+            domish.Element to return.
+        """
+        if sender is None:
+            sender = client.jid
+        if extra is None:
+            extra = {}
+        if not await self.host.trigger.async_point(
+            "XEP-0060_publish", client, service, nodeIdentifier, items, options, sender,
+            extra
+        ):
+            return extra["iq_result_elt"]
+        iq_result_elt = await client.pubsub_client.publish(
+            service, nodeIdentifier, items, sender,
+            options=options
+        )
+        return iq_result_elt
+
+    def _unwrap_mam_message(self, message_elt):
+        try:
+            item_elt = reduce(
+                lambda elt, ns_name: next(elt.elements(*ns_name)),
+                (message_elt,
+                 (mam.NS_MAM, "result"),
+                 (C.NS_FORWARD, "forwarded"),
+                 (C.NS_CLIENT, "message"),
+                 ("http://jabber.org/protocol/pubsub#event", "event"),
+                 ("http://jabber.org/protocol/pubsub#event", "items"),
+                 ("http://jabber.org/protocol/pubsub#event", "item"),
+                ))
+        except StopIteration:
+            raise exceptions.DataError("Can't find Item in MAM message element")
+        return item_elt
+
+    def serialise_items(self, items_data):
+        items, metadata = items_data
+        metadata['items'] = items
+        return data_format.serialise(metadata)
+
+    def _get_items(self, service="", node="", max_items=10, item_ids=None, sub_id=None,
+                  extra="", profile_key=C.PROF_KEY_NONE):
+        """Get items from pubsub node
+
+        @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
+        """
+        client = self.host.get_client(profile_key)
+        service = jid.JID(service) if service else None
+        max_items = None if max_items == C.NO_LIMIT else max_items
+        extra = self.parse_extra(data_format.deserialise(extra))
+        d = defer.ensureDeferred(self.get_items(
+            client,
+            service,
+            node,
+            max_items,
+            item_ids,
+            sub_id or None,
+            extra.rsm_request,
+            extra.extra,
+        ))
+        d.addCallback(self.trans_items_data)
+        d.addCallback(self.serialise_items)
+        return d
+
+    async def get_items(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+        max_items: Optional[int] = None,
+        item_ids: Optional[List[str]] = None,
+        sub_id: Optional[str] = None,
+        rsm_request: Optional[rsm.RSMRequest] = None,
+        extra: Optional[dict] = None
+    ) -> Tuple[List[dict], dict]:
+        """Retrieve pubsub items from a node.
+
+        @param service (JID, None): pubsub service.
+        @param node (str): node id.
+        @param max_items (int): optional limit on the number of retrieved items.
+        @param item_ids (list[str]): identifiers of the items to be retrieved (can't be
+             used with rsm_request). If requested items don't exist, they won't be
+             returned, meaning that we can have an empty list as result (NotFound
+             exception is NOT raised).
+        @param sub_id (str): optional subscription identifier.
+        @param rsm_request (rsm.RSMRequest): RSM request data
+        @return: a deferred couple (list[dict], dict) containing:
+            - list of items
+            - metadata with the following keys:
+                - rsm_first, rsm_last, rsm_count, rsm_index: first, last, count and index
+                    value of RSMResponse
+                - service, node: service and node used
+        """
+        if item_ids and max_items is not None:
+            max_items = None
+        if rsm_request and item_ids:
+            raise ValueError("items_id can't be used with rsm")
+        if extra is None:
+            extra = {}
+        cont, ret = await self.host.trigger.async_return_point(
+            "XEP-0060_getItems", client, service, node, max_items, item_ids, sub_id,
+            rsm_request, extra
+        )
+        if not cont:
+            return ret
+        try:
+            mam_query = extra["mam"]
+        except KeyError:
+            d = defer.ensureDeferred(client.pubsub_client.items(
+                service = service,
+                nodeIdentifier = node,
+                maxItems = max_items,
+                subscriptionIdentifier = sub_id,
+                sender = None,
+                itemIdentifiers = item_ids,
+                orderBy = extra.get(C.KEY_ORDER_BY),
+                rsm_request = rsm_request,
+                extra = extra
+            ))
+            # we have no MAM data here, so we add None
+            d.addErrback(sat_defer.stanza_2_not_found)
+            d.addTimeout(TIMEOUT, reactor)
+            items, rsm_response = await d
+            mam_response = None
+        else:
+            # if mam is requested, we have to do a totally different query
+            if self._mam is None:
+                raise exceptions.NotFound("MAM (XEP-0313) plugin is not available")
+            if max_items is not None:
+                raise exceptions.DataError("max_items parameter can't be used with MAM")
+            if item_ids:
+                raise exceptions.DataError("items_ids parameter can't be used with MAM")
+            if mam_query.node is None:
+                mam_query.node = node
+            elif mam_query.node != node:
+                raise exceptions.DataError(
+                    "MAM query node is incoherent with get_items's node"
+                )
+            if mam_query.rsm is None:
+                mam_query.rsm = rsm_request
+            else:
+                if mam_query.rsm != rsm_request:
+                    raise exceptions.DataError(
+                        "Conflict between RSM request and MAM's RSM request"
+                    )
+            items, rsm_response, mam_response = await self._mam.get_archives(
+                client, mam_query, service, self._unwrap_mam_message
+            )
+
+        try:
+            subscribe = C.bool(extra["subscribe"])
+        except KeyError:
+            subscribe = False
+
+        if subscribe:
+            try:
+                await self.subscribe(client, service, node)
+            except error.StanzaError as e:
+                log.warning(
+                    f"Could not subscribe to node {node} on service {service}: {e}"
+                )
+
+        # TODO: handle mam_response
+        service_jid = service if service else client.jid.userhostJID()
+        metadata = {
+            "service": service_jid,
+            "node": node,
+            "uri": self.get_node_uri(service_jid, node),
+        }
+        if mam_response is not None:
+            # mam_response is a dict with "complete" and "stable" keys
+            # we can put them directly in metadata
+            metadata.update(mam_response)
+        if rsm_request is not None and rsm_response is not None:
+            metadata['rsm'] = rsm_response.toDict()
+            if mam_response is None:
+                index = rsm_response.index
+                count = rsm_response.count
+                if index is None or count is None:
+                    # we don't have enough information to know if the data is complete
+                    # or not
+                    metadata["complete"] = None
+                else:
+                    # normally we have a strict equality here but XEP-0059 states
+                    # that index MAY be approximative, so just in case…
+                    metadata["complete"] = index + len(items) >= count
+        # encrypted metadata can be added by plugins in XEP-0060_items trigger
+        if "encrypted" in extra:
+            metadata["encrypted"] = extra["encrypted"]
+
+        return (items, metadata)
+
+    # @defer.inlineCallbacks
+    # def getItemsFromMany(self, service, data, max_items=None, sub_id=None, rsm=None, profile_key=C.PROF_KEY_NONE):
+    #     """Massively retrieve pubsub items from many nodes.
+    #     @param service (JID): target service.
+    #     @param data (dict): dictionnary binding some arbitrary keys to the node identifiers.
+    #     @param max_items (int): optional limit on the number of retrieved items *per node*.
+    #     @param sub_id (str): optional subscription identifier.
+    #     @param rsm (dict): RSM request data
+    #     @param profile_key (str): %(doc_profile_key)s
+    #     @return: a deferred dict with:
+    #         - key: a value in (a subset of) data.keys()
+    #         - couple (list[dict], dict) containing:
+    #             - list of items
+    #             - RSM response data
+    #     """
+    #     client = self.host.get_client(profile_key)
+    #     found_nodes = yield self.listNodes(service, profile=client.profile)
+    #     d_dict = {}
+    #     for publisher, node in data.items():
+    #         if node not in found_nodes:
+    #             log.debug(u"Skip the items retrieval for [{node}]: node doesn't exist".format(node=node))
+    #             continue  # avoid pubsub "item-not-found" error
+    #         d_dict[publisher] = self.get_items(service, node, max_items, None, sub_id, rsm, client.profile)
+    #     defer.returnValue(d_dict)
+
+    def getOptions(self, service, nodeIdentifier, subscriber, subscriptionIdentifier=None,
+                   profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        return client.pubsub_client.getOptions(
+            service, nodeIdentifier, subscriber, subscriptionIdentifier
+        )
+
+    def setOptions(self, service, nodeIdentifier, subscriber, options,
+                   subscriptionIdentifier=None, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        return client.pubsub_client.setOptions(
+            service, nodeIdentifier, subscriber, options, subscriptionIdentifier
+        )
+
+    def _create_node(self, service_s, nodeIdentifier, options, profile_key):
+        client = self.host.get_client(profile_key)
+        return self.createNode(
+            client, jid.JID(service_s) if service_s else None, nodeIdentifier, options
+        )
+
+    def createNode(
+        self,
+        client: SatXMPPClient,
+        service: jid.JID,
+        nodeIdentifier: Optional[str] = None,
+        options: Optional[Dict[str, str]] = None
+    ) -> str:
+        """Create a new node
+
+        @param service: PubSub service,
+        @param NodeIdentifier: node name use None to create instant node (identifier will
+            be returned by this method)
+        @param option: node configuration options
+        @return: identifier of the created node (may be different from requested name)
+        """
+        # TODO: if pubsub service doesn't hande publish-options, configure it in a second time
+        return client.pubsub_client.createNode(service, nodeIdentifier, options)
+
+    @defer.inlineCallbacks
+    def create_if_new_node(self, client, service, nodeIdentifier, options=None):
+        """Helper method similar to createNode, but will not fail in case of conflict"""
+        try:
+            yield self.createNode(client, service, nodeIdentifier, options)
+        except error.StanzaError as e:
+            if e.condition == "conflict":
+                pass
+            else:
+                raise e
+
+    def _get_node_configuration(self, service_s, nodeIdentifier, profile_key):
+        client = self.host.get_client(profile_key)
+        d = self.getConfiguration(
+            client, jid.JID(service_s) if service_s else None, nodeIdentifier
+        )
+
+        def serialize(form):
+            # FIXME: better more generic dataform serialisation should be available in SàT
+            return {f.var: str(f.value) for f in list(form.fields.values())}
+
+        d.addCallback(serialize)
+        return d
+
+    def getConfiguration(self, client, service, nodeIdentifier):
+        request = pubsub.PubSubRequest("configureGet")
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+
+        def cb(iq):
+            form = data_form.findForm(iq.pubsub.configure, pubsub.NS_PUBSUB_NODE_CONFIG)
+            form.typeCheck()
+            return form
+
+        d = request.send(client.xmlstream)
+        d.addCallback(cb)
+        return d
+
+    def make_configuration_form(self, options: dict) -> data_form.Form:
+        """Build a configuration form"""
+        form = data_form.Form(
+            formType="submit", formNamespace=pubsub.NS_PUBSUB_NODE_CONFIG
+        )
+        form.makeFields(options)
+        return form
+
+    def _set_node_configuration(self, service_s, nodeIdentifier, options, profile_key):
+        client = self.host.get_client(profile_key)
+        d = self.setConfiguration(
+            client, jid.JID(service_s) if service_s else None, nodeIdentifier, options
+        )
+        return d
+
+    def setConfiguration(self, client, service, nodeIdentifier, options):
+        request = pubsub.PubSubRequest("configureSet")
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+
+        form = self.make_configuration_form(options)
+        request.options = form
+
+        d = request.send(client.xmlstream)
+        return d
+
+    def _get_affiliations(self, service_s, nodeIdentifier, profile_key):
+        client = self.host.get_client(profile_key)
+        d = self.get_affiliations(
+            client, jid.JID(service_s) if service_s else None, nodeIdentifier or None
+        )
+        return d
+
+    def get_affiliations(self, client, service, nodeIdentifier=None):
+        """Retrieve affiliations of an entity
+
+        @param nodeIdentifier(unicode, None): node to get affiliation from
+            None to get all nodes affiliations for this service
+        """
+        request = pubsub.PubSubRequest("affiliations")
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+
+        def cb(iq_elt):
+            try:
+                affiliations_elt = next(
+                    iq_elt.pubsub.elements(pubsub.NS_PUBSUB, "affiliations")
+                )
+            except StopIteration:
+                raise ValueError(
+                    _("Invalid result: missing <affiliations> element: {}").format(
+                        iq_elt.toXml
+                    )
+                )
+            try:
+                return {
+                    e["node"]: e["affiliation"]
+                    for e in affiliations_elt.elements(pubsub.NS_PUBSUB, "affiliation")
+                }
+            except KeyError:
+                raise ValueError(
+                    _("Invalid result: bad <affiliation> element: {}").format(
+                        iq_elt.toXml
+                    )
+                )
+
+        d = request.send(client.xmlstream)
+        d.addCallback(cb)
+        return d
+
+    def _get_node_affiliations(self, service_s, nodeIdentifier, profile_key):
+        client = self.host.get_client(profile_key)
+        d = self.get_node_affiliations(
+            client, jid.JID(service_s) if service_s else None, nodeIdentifier
+        )
+        d.addCallback(
+            lambda affiliations: {j.full(): a for j, a in affiliations.items()}
+        )
+        return d
+
+    def get_node_affiliations(self, client, service, nodeIdentifier):
+        """Retrieve affiliations of a node owned by profile"""
+        request = pubsub.PubSubRequest("affiliationsGet")
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+
+        def cb(iq_elt):
+            try:
+                affiliations_elt = next(
+                    iq_elt.pubsub.elements(pubsub.NS_PUBSUB_OWNER, "affiliations")
+                )
+            except StopIteration:
+                raise ValueError(
+                    _("Invalid result: missing <affiliations> element: {}").format(
+                        iq_elt.toXml
+                    )
+                )
+            try:
+                return {
+                    jid.JID(e["jid"]): e["affiliation"]
+                    for e in affiliations_elt.elements(
+                        (pubsub.NS_PUBSUB_OWNER, "affiliation")
+                    )
+                }
+            except KeyError:
+                raise ValueError(
+                    _("Invalid result: bad <affiliation> element: {}").format(
+                        iq_elt.toXml
+                    )
+                )
+
+        d = request.send(client.xmlstream)
+        d.addCallback(cb)
+        return d
+
+    def _set_node_affiliations(
+        self, service_s, nodeIdentifier, affiliations, profile_key=C.PROF_KEY_NONE
+    ):
+        client = self.host.get_client(profile_key)
+        affiliations = {
+            jid.JID(jid_): affiliation for jid_, affiliation in affiliations.items()
+        }
+        d = self.set_node_affiliations(
+            client,
+            jid.JID(service_s) if service_s else None,
+            nodeIdentifier,
+            affiliations,
+        )
+        return d
+
+    def set_node_affiliations(self, client, service, nodeIdentifier, affiliations):
+        """Update affiliations of a node owned by profile
+
+        @param affiliations(dict[jid.JID, unicode]): affiliations to set
+            check https://xmpp.org/extensions/xep-0060.html#affiliations for a list of possible affiliations
+        """
+        request = pubsub.PubSubRequest("affiliationsSet")
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        request.affiliations = affiliations
+        d = request.send(client.xmlstream)
+        return d
+
+    def _purge_node(self, service_s, nodeIdentifier, profile_key):
+        client = self.host.get_client(profile_key)
+        return self.purge_node(
+            client, jid.JID(service_s) if service_s else None, nodeIdentifier
+        )
+
+    def purge_node(self, client, service, nodeIdentifier):
+        return client.pubsub_client.purge_node(service, nodeIdentifier)
+
+    def _delete_node(self, service_s, nodeIdentifier, profile_key):
+        client = self.host.get_client(profile_key)
+        return self.deleteNode(
+            client, jid.JID(service_s) if service_s else None, nodeIdentifier
+        )
+
+    def deleteNode(
+        self,
+        client: SatXMPPClient,
+        service: jid.JID,
+        nodeIdentifier: str
+    ) -> defer.Deferred:
+        return client.pubsub_client.deleteNode(service, nodeIdentifier)
+
+    def _addWatch(self, service_s, node, profile_key):
+        """watch modifications on a node
+
+        This method should only be called from bridge
+        """
+        client = self.host.get_client(profile_key)
+        service = jid.JID(service_s) if service_s else client.jid.userhostJID()
+        client.pubsub_watching.add((service, node))
+
+    def _remove_watch(self, service_s, node, profile_key):
+        """remove a node watch
+
+        This method should only be called from bridge
+        """
+        client = self.host.get_client(profile_key)
+        service = jid.JID(service_s) if service_s else client.jid.userhostJID()
+        client.pubsub_watching.remove((service, node))
+
+    def _retract_item(
+        self, service_s, nodeIdentifier, itemIdentifier, notify, profile_key
+    ):
+        return self._retract_items(
+            service_s, nodeIdentifier, (itemIdentifier,), notify, profile_key
+        )
+
+    def _retract_items(
+        self, service_s, nodeIdentifier, itemIdentifiers, notify, profile_key
+    ):
+        client = self.host.get_client(profile_key)
+        return self.retract_items(
+            client,
+            jid.JID(service_s) if service_s else None,
+            nodeIdentifier,
+            itemIdentifiers,
+            notify,
+        )
+
+    def retract_items(
+        self,
+        client: SatXMPPClient,
+        service: jid.JID,
+        nodeIdentifier: str,
+        itemIdentifiers: Iterable[str],
+        notify: bool = True,
+    ) -> defer.Deferred:
+        return client.pubsub_client.retractItems(
+            service, nodeIdentifier, itemIdentifiers, notify=notify
+        )
+
+    def _rename_item(
+        self,
+        service,
+        node,
+        item_id,
+        new_id,
+        profile_key=C.PROF_KEY_NONE,
+    ):
+        client = self.host.get_client(profile_key)
+        service = jid.JID(service) if service else None
+        return defer.ensureDeferred(self.rename_item(
+            client, service, node, item_id, new_id
+        ))
+
+    async def rename_item(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+        item_id: str,
+        new_id: str
+    ) -> None:
+        """Rename an item by recreating it then deleting it
+
+        we have to recreate then delete because there is currently no rename operation
+        with PubSub
+        """
+        if not item_id or not new_id:
+            raise ValueError("item_id and new_id must not be empty")
+        # retract must be done last, so if something goes wrong, the exception will stop
+        # the workflow and no accidental delete should happen
+        item_elt = (await self.get_items(client, service, node, item_ids=[item_id]))[0][0]
+        await self.send_item(client, service, node, item_elt.firstChildElement(), new_id)
+        await self.retract_items(client, service, node, [item_id])
+
+    def _subscribe(self, service, nodeIdentifier, options, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        service = None if not service else jid.JID(service)
+        d = defer.ensureDeferred(
+            self.subscribe(
+                client,
+                service,
+                nodeIdentifier,
+                options=data_format.deserialise(options)
+            )
+        )
+        d.addCallback(lambda subscription: subscription.subscriptionIdentifier or "")
+        return d
+
+    async def subscribe(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        nodeIdentifier: str,
+        sub_jid: Optional[jid.JID] = None,
+        options: Optional[dict] = None
+    ) -> pubsub.Subscription:
+        # TODO: reimplement a subscribtion cache, checking that we have not subscription before trying to subscribe
+        if service is None:
+            service = client.jid.userhostJID()
+        cont, trigger_sub = await self.host.trigger.async_return_point(
+            "XEP-0060_subscribe", client, service, nodeIdentifier, sub_jid, options,
+        )
+        if not cont:
+            return trigger_sub
+        try:
+            subscription = await client.pubsub_client.subscribe(
+                service, nodeIdentifier, sub_jid or client.jid.userhostJID(),
+                options=options, sender=client.jid.userhostJID()
+            )
+        except error.StanzaError as e:
+            if e.condition == 'item-not-found':
+                raise exceptions.NotFound(e.text or e.condition)
+            else:
+                raise e
+        return subscription
+
+    def _unsubscribe(self, service, nodeIdentifier, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        service = None if not service else jid.JID(service)
+        return defer.ensureDeferred(self.unsubscribe(client, service, nodeIdentifier))
+
+    async def unsubscribe(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        nodeIdentifier: str,
+        sub_jid: Optional[jid.JID] = None,
+        subscriptionIdentifier: Optional[str] = None,
+        sender: Optional[jid.JID] = None,
+    ) -> None:
+        if not await self.host.trigger.async_point(
+            "XEP-0060_unsubscribe", client, service, nodeIdentifier, sub_jid,
+            subscriptionIdentifier, sender
+        ):
+            return
+        try:
+            await client.pubsub_client.unsubscribe(
+            service,
+            nodeIdentifier,
+            sub_jid or client.jid.userhostJID(),
+            subscriptionIdentifier,
+            sender,
+        )
+        except error.StanzaError as e:
+            try:
+                next(e.getElement().elements(pubsub.NS_PUBSUB_ERRORS, "not-subscribed"))
+            except StopIteration:
+                raise e
+            else:
+                log.info(
+                    f"{sender.full() if sender else client.jid.full()} was not "
+                    f"subscribed to node {nodeIdentifier!s} at {service.full()}"
+                )
+
+    @utils.ensure_deferred
+    async def _subscriptions(
+        self,
+        service="",
+        nodeIdentifier="",
+        profile_key=C.PROF_KEY_NONE
+    ) -> str:
+        client = self.host.get_client(profile_key)
+        service = None if not service else jid.JID(service)
+        subs = await self.subscriptions(client, service, nodeIdentifier or None)
+        return data_format.serialise(subs)
+
+    async def subscriptions(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID] = None,
+        node: Optional[str] = None
+    ) -> List[Dict[str, Union[str, bool]]]:
+        """Retrieve subscriptions from a service
+
+        @param service(jid.JID): PubSub service
+        @param nodeIdentifier(unicode, None): node to check
+            None to get all subscriptions
+        """
+        cont, ret = await self.host.trigger.async_return_point(
+            "XEP-0060_subscriptions", client, service, node
+        )
+        if not cont:
+            return ret
+        subs = await client.pubsub_client.subscriptions(service, node)
+        ret = []
+        for sub in subs:
+            sub_dict = {
+                "service": service.host if service else client.jid.host,
+                "node": sub.nodeIdentifier,
+                "subscriber": sub.subscriber.full(),
+                "state": sub.state,
+            }
+            if sub.subscriptionIdentifier is not None:
+                sub_dict["id"] = sub.subscriptionIdentifier
+            ret.append(sub_dict)
+        return ret
+
+    ## misc tools ##
+
+    def get_node_uri(self, service, node, item=None):
+        """Return XMPP URI of a PubSub node
+
+        @param service(jid.JID): PubSub service
+        @param node(unicode): node
+        @return (unicode): URI of the node
+        """
+        # FIXME: deprecated, use sat.tools.common.uri instead
+        assert service is not None
+        # XXX: urllib.urlencode use "&" to separate value, while XMPP URL (cf. RFC 5122)
+        #      use ";" as a separator. So if more than one value is used in query_data,
+        #      urlencode MUST NOT BE USED.
+        query_data = [("node", node.encode("utf-8"))]
+        if item is not None:
+            query_data.append(("item", item.encode("utf-8")))
+        return "xmpp:{service}?;{query}".format(
+            service=service.userhost(), query=urllib.parse.urlencode(query_data)
+        )
+
+    ## methods to manage several stanzas/jids at once ##
+
+    # generic #
+
+    def get_rt_results(
+        self, session_id, on_success=None, on_error=None, profile=C.PROF_KEY_NONE
+    ):
+        return self.rt_sessions.get_results(session_id, on_success, on_error, profile)
+
+    def trans_items_data(self, items_data, item_cb=lambda item: item.toXml()):
+        """Helper method to transform result from [get_items]
+
+        the items_data must be a tuple(list[domish.Element], dict[unicode, unicode])
+        as returned by [get_items].
+        @param items_data(tuple): tuple returned by [get_items]
+        @param item_cb(callable): method to transform each item
+        @return (tuple): a serialised form ready to go throught bridge
+        """
+        items, metadata = items_data
+        items = [item_cb(item) for item in items]
+
+        return (items, metadata)
+
+    def trans_items_data_d(self, items_data, item_cb):
+        """Helper method to transform result from [get_items], deferred version
+
+        the items_data must be a tuple(list[domish.Element], dict[unicode, unicode])
+        as returned by [get_items]. metadata values are then casted to unicode and
+        each item is passed to items_cb.
+        An errback is added to item_cb, and when it is fired the value is filtered from
+            final items
+        @param items_data(tuple): tuple returned by [get_items]
+        @param item_cb(callable): method to transform each item (must return a deferred)
+        @return (tuple): a deferred which fire a dict which can be serialised to go
+            throught bridge
+        """
+        items, metadata = items_data
+
+        def eb(failure_):
+            log.warning(f"Error while parsing item: {failure_.value}")
+
+        d = defer.gatherResults([item_cb(item).addErrback(eb) for item in items])
+        d.addCallback(lambda parsed_items: (
+            [i for i in parsed_items if i is not None],
+            metadata
+        ))
+        return d
+
+    def ser_d_list(self, results, failure_result=None):
+        """Serialise a DeferredList result
+
+        @param results: DeferredList results
+        @param failure_result: value to use as value for failed Deferred
+            (default: empty tuple)
+        @return (list): list with:
+            - failure: empty in case of success, else error message
+            - result
+        """
+        if failure_result is None:
+            failure_result = ()
+        return [
+            ("", result)
+            if success
+            else (str(result.result) or UNSPECIFIED, failure_result)
+            for success, result in results
+        ]
+
+    # subscribe #
+
+    @utils.ensure_deferred
+    async def _get_node_subscriptions(
+        self,
+        service: str,
+        node: str,
+        profile_key: str
+    ) -> Dict[str, str]:
+        client = self.host.get_client(profile_key)
+        subs = await self.get_node_subscriptions(
+            client, jid.JID(service) if service else None, node
+        )
+        return {j.full(): a for j, a in subs.items()}
+
+    async def get_node_subscriptions(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        nodeIdentifier: str
+    ) -> Dict[jid.JID, str]:
+        """Retrieve subscriptions to a node
+
+        @param nodeIdentifier(unicode): node to get subscriptions from
+        """
+        if not nodeIdentifier:
+            raise exceptions.DataError("node identifier can't be empty")
+        request = pubsub.PubSubRequest("subscriptionsGet")
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+
+        iq_elt = await request.send(client.xmlstream)
+        try:
+            subscriptions_elt = next(
+                iq_elt.pubsub.elements(pubsub.NS_PUBSUB_OWNER, "subscriptions")
+            )
+        except StopIteration:
+            raise ValueError(
+                _("Invalid result: missing <subscriptions> element: {}").format(
+                    iq_elt.toXml
+                )
+            )
+        except AttributeError as e:
+            raise ValueError(_("Invalid result: {}").format(e))
+        try:
+            return {
+                jid.JID(s["jid"]): s["subscription"]
+                for s in subscriptions_elt.elements(
+                    (pubsub.NS_PUBSUB, "subscription")
+                )
+            }
+        except KeyError:
+            raise ValueError(
+                _("Invalid result: bad <subscription> element: {}").format(
+                    iq_elt.toXml
+                )
+            )
+
+    def _set_node_subscriptions(
+        self, service_s, nodeIdentifier, subscriptions, profile_key=C.PROF_KEY_NONE
+    ):
+        client = self.host.get_client(profile_key)
+        subscriptions = {
+            jid.JID(jid_): subscription
+            for jid_, subscription in subscriptions.items()
+        }
+        d = self.set_node_subscriptions(
+            client,
+            jid.JID(service_s) if service_s else None,
+            nodeIdentifier,
+            subscriptions,
+        )
+        return d
+
+    def set_node_subscriptions(self, client, service, nodeIdentifier, subscriptions):
+        """Set or update subscriptions of a node owned by profile
+
+        @param subscriptions(dict[jid.JID, unicode]): subscriptions to set
+            check https://xmpp.org/extensions/xep-0060.html#substates for a list of possible subscriptions
+        """
+        request = pubsub.PubSubRequest("subscriptionsSet")
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        request.subscriptions = {
+            pubsub.Subscription(nodeIdentifier, jid_, state)
+            for jid_, state in subscriptions.items()
+        }
+        d = request.send(client.xmlstream)
+        return d
+
+    def _many_subscribe_rt_result(self, session_id, profile_key=C.PROF_KEY_DEFAULT):
+        """Get real-time results for subcribeToManu session
+
+        @param session_id: id of the real-time deferred session
+        @param return (tuple): (remaining, results) where:
+            - remaining is the number of still expected results
+            - results is a list of tuple(unicode, unicode, bool, unicode) with:
+                - service: pubsub service
+                - and node: pubsub node
+                - failure(unicode): empty string in case of success, error message else
+        @param profile_key: %(doc_profile_key)s
+        """
+        profile = self.host.get_client(profile_key).profile
+        d = self.rt_sessions.get_results(
+            session_id,
+            on_success=lambda result: "",
+            on_error=lambda failure: str(failure.value),
+            profile=profile,
+        )
+        # we need to convert jid.JID to unicode with full() to serialise it for the bridge
+        d.addCallback(
+            lambda ret: (
+                ret[0],
+                [
+                    (service.full(), node, "" if success else failure or UNSPECIFIED)
+                    for (service, node), (success, failure) in ret[1].items()
+                ],
+            )
+        )
+        return d
+
+    def _subscribe_to_many(
+        self, node_data, subscriber=None, options=None, profile_key=C.PROF_KEY_NONE
+    ):
+        return self.subscribe_to_many(
+            [(jid.JID(service), str(node)) for service, node in node_data],
+            jid.JID(subscriber),
+            options,
+            profile_key,
+        )
+
+    def subscribe_to_many(
+        self, node_data, subscriber, options=None, profile_key=C.PROF_KEY_NONE
+    ):
+        """Subscribe to several nodes at once.
+
+        @param node_data (iterable[tuple]): iterable of tuple (service, node) where:
+            - service (jid.JID) is the pubsub service
+            - node (unicode) is the node to subscribe to
+        @param subscriber (jid.JID): optional subscription identifier.
+        @param options (dict): subscription options
+        @param profile_key (str): %(doc_profile_key)s
+        @return (str): RT Deferred session id
+        """
+        client = self.host.get_client(profile_key)
+        deferreds = {}
+        for service, node in node_data:
+            deferreds[(service, node)] = defer.ensureDeferred(
+                client.pubsub_client.subscribe(
+                    service, node, subscriber, options=options
+                )
+            )
+        return self.rt_sessions.new_session(deferreds, client.profile)
+        # found_nodes = yield self.listNodes(service, profile=client.profile)
+        # subscribed_nodes = yield self.listSubscribedNodes(service, profile=client.profile)
+        # d_list = []
+        # for nodeIdentifier in (set(nodeIdentifiers) - set(subscribed_nodes)):
+        #     if nodeIdentifier not in found_nodes:
+        #         log.debug(u"Skip the subscription to [{node}]: node doesn't exist".format(node=nodeIdentifier))
+        #         continue  # avoid sat-pubsub "SubscriptionExists" error
+        #     d_list.append(client.pubsub_client.subscribe(service, nodeIdentifier, sub_jid or client.pubsub_client.parent.jid.userhostJID(), options=options))
+        # defer.returnValue(d_list)
+
+    # get #
+
+    def _get_from_many_rt_result(self, session_id, profile_key=C.PROF_KEY_DEFAULT):
+        """Get real-time results for get_from_many session
+
+        @param session_id: id of the real-time deferred session
+        @param profile_key: %(doc_profile_key)s
+        @param return (tuple): (remaining, results) where:
+            - remaining is the number of still expected results
+            - results is a list of tuple with
+                - service (unicode): pubsub service
+                - node (unicode): pubsub node
+                - failure (unicode): empty string in case of success, error message else
+                - items (list[s]): raw XML of items
+                - metadata(dict): serialised metadata
+        """
+        profile = self.host.get_client(profile_key).profile
+        d = self.rt_sessions.get_results(
+            session_id,
+            on_success=lambda result: ("", self.trans_items_data(result)),
+            on_error=lambda failure: (str(failure.value) or UNSPECIFIED, ([], {})),
+            profile=profile,
+        )
+        d.addCallback(
+            lambda ret: (
+                ret[0],
+                [
+                    (service.full(), node, failure, items, metadata)
+                    for (service, node), (success, (failure, (items, metadata))) in ret[
+                        1
+                    ].items()
+                ],
+            )
+        )
+        return d
+
+    def _get_from_many(
+        self, node_data, max_item=10, extra="", profile_key=C.PROF_KEY_NONE
+    ):
+        """
+        @param max_item(int): maximum number of item to get, C.NO_LIMIT for no limit
+        """
+        max_item = None if max_item == C.NO_LIMIT else max_item
+        extra = self.parse_extra(data_format.deserialise(extra))
+        return self.get_from_many(
+            [(jid.JID(service), str(node)) for service, node in node_data],
+            max_item,
+            extra.rsm_request,
+            extra.extra,
+            profile_key,
+        )
+
+    def get_from_many(self, node_data, max_item=None, rsm_request=None, extra=None,
+                    profile_key=C.PROF_KEY_NONE):
+        """Get items from many nodes at once
+
+        @param node_data (iterable[tuple]): iterable of tuple (service, node) where:
+            - service (jid.JID) is the pubsub service
+            - node (unicode) is the node to get items from
+        @param max_items (int): optional limit on the number of retrieved items.
+        @param rsm_request (RSMRequest): RSM request data
+        @param profile_key (unicode): %(doc_profile_key)s
+        @return (str): RT Deferred session id
+        """
+        client = self.host.get_client(profile_key)
+        deferreds = {}
+        for service, node in node_data:
+            deferreds[(service, node)] = defer.ensureDeferred(self.get_items(
+                client, service, node, max_item, rsm_request=rsm_request, extra=extra
+            ))
+        return self.rt_sessions.new_session(deferreds, client.profile)
+
+
+@implementer(disco.IDisco)
+class SatPubSubClient(rsm.PubSubClient):
+
+    def __init__(self, host, parent_plugin):
+        self.host = host
+        self.parent_plugin = parent_plugin
+        rsm.PubSubClient.__init__(self)
+
+    def connectionInitialized(self):
+        rsm.PubSubClient.connectionInitialized(self)
+
+    async def items(
+        self,
+        service: Optional[jid.JID],
+        nodeIdentifier: str,
+        maxItems: Optional[int] = None,
+        subscriptionIdentifier: Optional[str] = None,
+        sender: Optional[jid.JID] = None,
+        itemIdentifiers: Optional[Set[str]] = None,
+        orderBy: Optional[List[str]] = None,
+        rsm_request: Optional[rsm.RSMRequest] = None,
+        extra: Optional[Dict[str, Any]] = None,
+    ):
+        if extra is None:
+            extra = {}
+        items, rsm_response = await super().items(
+            service, nodeIdentifier, maxItems, subscriptionIdentifier, sender,
+            itemIdentifiers, orderBy, rsm_request
+        )
+        # items must be returned, thus this async point can't stop the workflow (but it
+        # can modify returned items)
+        await self.host.trigger.async_point(
+            "XEP-0060_items", self.parent, service, nodeIdentifier, items, rsm_response,
+            extra
+        )
+        return items, rsm_response
+
+    def _get_node_callbacks(self, node, event):
+        """Generate callbacks from given node and event
+
+        @param node(unicode): node used for the item
+            any registered node which prefix the node will match
+        @param event(unicode): one of C.PS_ITEMS, C.PS_RETRACT, C.PS_DELETE
+        @return (iterator[callable]): callbacks for this node/event
+        """
+        for registered_node, callbacks_dict in self.parent_plugin._node_cb.items():
+            if not node.startswith(registered_node):
+                continue
+            try:
+                for callback_data in callbacks_dict[event]:
+                    yield callback_data[0]
+            except KeyError:
+                continue
+
+    async def _call_node_callbacks(self, client, event: pubsub.ItemsEvent) -> None:
+        """Call sequencially event callbacks of a node
+
+        Callbacks are called sequencially and not in parallel to be sure to respect
+        priority (notably for plugin needing to get old items before they are modified or
+        deleted from cache).
+        """
+        for callback in self._get_node_callbacks(event.nodeIdentifier, C.PS_ITEMS):
+            try:
+                await utils.as_deferred(callback, client, event)
+            except Exception as e:
+                log.error(
+                    f"Error while running items event callback {callback}: {e}"
+                )
+
+    def itemsReceived(self, event):
+        log.debug("Pubsub items received")
+        client = self.parent
+        defer.ensureDeferred(self._call_node_callbacks(client, event))
+        if (event.sender, event.nodeIdentifier) in client.pubsub_watching:
+            raw_items = [i.toXml() for i in event.items]
+            self.host.bridge.ps_event_raw(
+                event.sender.full(),
+                event.nodeIdentifier,
+                C.PS_ITEMS,
+                raw_items,
+                client.profile,
+            )
+
+    def deleteReceived(self, event):
+        log.debug(("Publish node deleted"))
+        for callback in self._get_node_callbacks(event.nodeIdentifier, C.PS_DELETE):
+            d = utils.as_deferred(callback, self.parent, event)
+            d.addErrback(lambda f: log.error(
+                f"Error while running delete event callback {callback}: {f}"
+            ))
+        client = self.parent
+        if (event.sender, event.nodeIdentifier) in client.pubsub_watching:
+            self.host.bridge.ps_event_raw(
+                event.sender.full(), event.nodeIdentifier, C.PS_DELETE, [], client.profile
+            )
+
+    def purgeReceived(self, event):
+        log.debug(("Publish node purged"))
+        for callback in self._get_node_callbacks(event.nodeIdentifier, C.PS_PURGE):
+            d = utils.as_deferred(callback, self.parent, event)
+            d.addErrback(lambda f: log.error(
+                f"Error while running purge event callback {callback}: {f}"
+            ))
+        client = self.parent
+        if (event.sender, event.nodeIdentifier) in client.pubsub_watching:
+            self.host.bridge.ps_event_raw(
+                event.sender.full(), event.nodeIdentifier, C.PS_PURGE, [], client.profile
+            )
+
+    def subscriptions(self, service, nodeIdentifier, sender=None):
+        """Return the list of subscriptions to the given service and node.
+
+        @param service: The publish subscribe service to retrieve the subscriptions from.
+        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
+        @param nodeIdentifier: The identifier of the node (leave empty to retrieve all subscriptions).
+        @type nodeIdentifier: C{unicode}
+        @return (list[pubsub.Subscription]): list of subscriptions
+        """
+        request = pubsub.PubSubRequest("subscriptions")
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        request.sender = sender
+        d = request.send(self.xmlstream)
+
+        def cb(iq):
+            subs = []
+            for subscription_elt in iq.pubsub.subscriptions.elements(
+                pubsub.NS_PUBSUB, "subscription"
+            ):
+                subscription = pubsub.Subscription(
+                    subscription_elt["node"],
+                    jid.JID(subscription_elt["jid"]),
+                    subscription_elt["subscription"],
+                    subscriptionIdentifier=subscription_elt.getAttribute("subid"),
+                )
+                subs.append(subscription)
+            return subs
+
+        return d.addCallback(cb)
+
+    def purge_node(self, service, nodeIdentifier):
+        """Purge a node (i.e. delete all items from it)
+
+        @param service(jid.JID, None): service to send the item to
+            None to use PEP
+        @param NodeIdentifier(unicode): PubSub node to use
+        """
+        # TODO: propose this upstream and remove it once merged
+        request = pubsub.PubSubRequest('purge')
+        request.recipient = service
+        request.nodeIdentifier = nodeIdentifier
+        return request.send(self.xmlstream)
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        disco_info = []
+        self.host.trigger.point("PubSub Disco Info", disco_info, self.parent.profile)
+        return disco_info
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0065.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1396 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing xep-0065
+
+# Copyright (C)
+# 2002, 2003, 2004   Dave Smith (dizzyd@jabber.org)
+# 2007, 2008         Fabio Forno (xmpp:ff@jabber.bluendo.com)
+# 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+# --
+
+# This module is based on proxy65 (http://code.google.com/p/proxy65),
+# originaly written by David Smith and modified by Fabio Forno.
+# It is sublicensed under AGPL v3 (or any later version) as allowed by the original
+# license.
+
+# --
+
+# Here is a copy of the original license:
+
+# Copyright (C)
+# 2002-2004   Dave Smith (dizzyd@jabber.org)
+# 2007-2008   Fabio Forno (xmpp:ff@jabber.bluendo.com)
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+import struct
+import hashlib
+import uuid
+from collections import namedtuple
+from zope.interface import implementer
+from twisted.internet import protocol
+from twisted.internet import reactor
+from twisted.internet import error as internet_error
+from twisted.words.protocols.jabber import error as jabber_error
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import xmlstream
+from twisted.internet import defer
+from wokkel import disco, iwokkel
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.tools import sat_defer
+
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP 0065 Plugin",
+    C.PI_IMPORT_NAME: "XEP-0065",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0065"],
+    C.PI_DEPENDENCIES: ["IP"],
+    C.PI_RECOMMENDATIONS: ["NAT-PORT"],
+    C.PI_MAIN: "XEP_0065",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of SOCKS5 Bytestreams"""),
+}
+
+IQ_SET = '/iq[@type="set"]'
+NS_BS = "http://jabber.org/protocol/bytestreams"
+BS_REQUEST = IQ_SET + '/query[@xmlns="' + NS_BS + '"]'
+TIMER_KEY = "timer"
+DEFER_KEY = "finished"  # key of the deferred used to track session end
+SERVER_STARTING_PORT = (
+    0
+)  # starting number for server port search (0 to ask automatic attribution)
+
+# priorities are candidates local priorities, must be a int between 0 and 65535
+PRIORITY_BEST_DIRECT = 10000
+PRIORITY_DIRECT = 5000
+PRIORITY_ASSISTED = 1000
+PRIORITY_PROXY = 0.2  # proxy is the last option for s5b
+CANDIDATE_DELAY = 0.2  # see XEP-0260 §4
+CANDIDATE_DELAY_PROXY = 0.2  # additional time for proxy types (see XEP-0260 §4 note 3)
+
+TIMEOUT = 300  # maxium time between session creation and stream start
+
+# XXX: by default eveything is automatic
+# TODO: use these params to force use of specific proxy/port/IP
+# PARAMS = """
+#     <params>
+#     <general>
+#     <category name="File Transfer">
+#         <param name="Force IP" type="string" />
+#         <param name="Force Port" type="int" constraint="1;65535" />
+#     </category>
+#     </general>
+#     <individual>
+#     <category name="File Transfer">
+#         <param name="Force Proxy" value="" type="string" />
+#         <param name="Force Proxy host" value="" type="string" />
+#         <param name="Force Proxy port" value="" type="int" constraint="1;65535" />
+#     </category>
+#     </individual>
+#     </params>
+#     """
+
+(
+    STATE_INITIAL,
+    STATE_AUTH,
+    STATE_REQUEST,
+    STATE_READY,
+    STATE_AUTH_USERPASS,
+    STATE_CLIENT_INITIAL,
+    STATE_CLIENT_AUTH,
+    STATE_CLIENT_REQUEST,
+) = range(8)
+
+SOCKS5_VER = 0x05
+
+ADDR_IPV4 = 0x01
+ADDR_DOMAINNAME = 0x03
+ADDR_IPV6 = 0x04
+
+CMD_CONNECT = 0x01
+CMD_BIND = 0x02
+CMD_UDPASSOC = 0x03
+
+AUTHMECH_ANON = 0x00
+AUTHMECH_USERPASS = 0x02
+AUTHMECH_INVALID = 0xFF
+
+REPLY_SUCCESS = 0x00
+REPLY_GENERAL_FAILUR = 0x01
+REPLY_CONN_NOT_ALLOWED = 0x02
+REPLY_NETWORK_UNREACHABLE = 0x03
+REPLY_HOST_UNREACHABLE = 0x04
+REPLY_CONN_REFUSED = 0x05
+REPLY_TTL_EXPIRED = 0x06
+REPLY_CMD_NOT_SUPPORTED = 0x07
+REPLY_ADDR_NOT_SUPPORTED = 0x08
+
+
+ProxyInfos = namedtuple("ProxyInfos", ["host", "jid", "port"])
+
+
+class Candidate(object):
+    def __init__(self, host, port, type_, priority, jid_, id_=None, priority_local=False,
+                 factory=None,):
+        """
+        @param host(unicode): host IP or domain
+        @param port(int): port
+        @param type_(unicode): stream type (one of XEP_0065.TYPE_*)
+        @param priority(int): priority
+        @param jid_(jid.JID): jid
+        @param id_(None, id_): Candidate ID, or None to generate
+        @param priority_local(bool): if True, priority is used as local priority,
+            else priority is used as global one (and local priority is set to 0)
+        """
+        assert isinstance(jid_, jid.JID)
+        self.host, self.port, self.type, self.jid = (host, int(port), type_, jid_)
+        self.id = id_ if id_ is not None else str(uuid.uuid4())
+        if priority_local:
+            self._local_priority = int(priority)
+            self._priority = self.calculate_priority()
+        else:
+            self._local_priority = 0
+            self._priority = int(priority)
+        self.factory = factory
+
+    def discard(self):
+        """Disconnect a candidate if it is connected
+
+        Used to disconnect tryed client when they are discarded
+        """
+        log.debug("Discarding {}".format(self))
+        try:
+            self.factory.discard()
+        except AttributeError:
+            pass  # no discard for Socks5ServerFactory
+
+    @property
+    def local_priority(self):
+        return self._local_priority
+
+    @property
+    def priority(self):
+        return self._priority
+
+    def __str__(self):
+        return "Candidate ({0.priority}): host={0.host} port={0.port} jid={0.jid} type={0.type}{id}".format(
+            self, id=" id={}".format(self.id if self.id is not None else "")
+        )
+
+    def __eq__(self, other):
+        # self.id is is not used in __eq__ as the same candidate can have
+        # different ids if proposed by initiator or responder
+        try:
+            return (
+                self.host == other.host
+                and self.port == other.port
+                and self.jid == other.jid
+            )
+        except (AttributeError, TypeError):
+            return False
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def calculate_priority(self):
+        """Calculate candidate priority according to XEP-0260 §2.2
+
+
+        @return (int): priority
+        """
+        if self.type == XEP_0065.TYPE_DIRECT:
+            multiplier = 126
+        elif self.type == XEP_0065.TYPE_ASSISTED:
+            multiplier = 120
+        elif self.type == XEP_0065.TYPE_TUNEL:
+            multiplier = 110
+        elif self.type == XEP_0065.TYPE_PROXY:
+            multiplier = 10
+        else:
+            raise exceptions.InternalError("Unknown {} type !".format(self.type))
+        return 2 ** 16 * multiplier + self._local_priority
+
+    def activate(self, client, sid, peer_jid, local_jid):
+        """Activate the proxy candidate
+
+        Send activation request as explained in XEP-0065 § 6.3.5
+        Must only be used with proxy candidates
+        @param sid(unicode): session id (same as for get_session_hash)
+        @param peer_jid(jid.JID): jid of the other peer
+        @return (D(domish.Element)): IQ result (or error)
+        """
+        assert self.type == XEP_0065.TYPE_PROXY
+        iq_elt = client.IQ()
+        iq_elt["from"] = local_jid.full()
+        iq_elt["to"] = self.jid.full()
+        query_elt = iq_elt.addElement((NS_BS, "query"))
+        query_elt["sid"] = sid
+        query_elt.addElement("activate", content=peer_jid.full())
+        return iq_elt.send()
+
+    def start_transfer(self, session_hash=None):
+        if self.type == XEP_0065.TYPE_PROXY:
+            chunk_size = 4096  # Prosody's proxy reject bigger chunks by default
+        else:
+            chunk_size = None
+        self.factory.start_transfer(session_hash, chunk_size=chunk_size)
+
+
+def get_session_hash(requester_jid, target_jid, sid):
+    """Calculate SHA1 Hash according to XEP-0065 §5.3.2
+
+    @param requester_jid(jid.JID): jid of the requester (the one which activate the proxy)
+    @param target_jid(jid.JID): jid of the target
+    @param sid(unicode): session id
+    @return (str): hash
+    """
+    return hashlib.sha1(
+        (sid + requester_jid.full() + target_jid.full()).encode("utf-8")
+    ).hexdigest()
+
+
+class SOCKSv5(protocol.Protocol):
+    CHUNK_SIZE = 2 ** 16
+
+    def __init__(self, session_hash=None):
+        """
+        @param session_hash(str): hash of the session
+            must only be used in client mode
+        """
+        self.connection = defer.Deferred()  # called when connection/auth is done
+        if session_hash is not None:
+            assert isinstance(session_hash, str)
+            self.server_mode = False
+            self._session_hash = session_hash
+            self.state = STATE_CLIENT_INITIAL
+        else:
+            self.server_mode = True
+            self.state = STATE_INITIAL
+        self.buf = b""
+        self.supportedAuthMechs = [AUTHMECH_ANON]
+        self.supportedAddrs = [ADDR_DOMAINNAME]
+        self.enabledCommands = [CMD_CONNECT]
+        self.peersock = None
+        self.addressType = 0
+        self.requestType = 0
+        self._stream_object = None
+        self.active = False  # set to True when protocol is actually used for transfer
+        # used by factories to know when the finished Deferred can be triggered
+
+    @property
+    def stream_object(self):
+        if self._stream_object is None:
+            self._stream_object = self.getSession()["stream_object"]
+            if self.server_mode:
+                self._stream_object.registerProducer(self.transport, True)
+        return self._stream_object
+
+    def getSession(self):
+        """Return session associated with this candidate
+
+        @return (dict): session data
+        """
+        if self.server_mode:
+            return self.factory.getSession(self._session_hash)
+        else:
+            return self.factory.getSession()
+
+    def _start_negotiation(self):
+        log.debug("starting negotiation (client mode)")
+        self.state = STATE_CLIENT_AUTH
+        self.transport.write(struct.pack("!3B", SOCKS5_VER, 1, AUTHMECH_ANON))
+
+    def _parse_negotiation(self):
+        try:
+            # Parse out data
+            ver, nmethod = struct.unpack("!BB", self.buf[:2])
+            methods = struct.unpack("%dB" % nmethod, self.buf[2 : nmethod + 2])
+
+            # Ensure version is correct
+            if ver != 5:
+                self.transport.write(struct.pack("!BB", SOCKS5_VER, AUTHMECH_INVALID))
+                self.transport.loseConnection()
+                return
+
+            # Trim off front of the buffer
+            self.buf = self.buf[nmethod + 2 :]
+
+            # Check for supported auth mechs
+            for m in self.supportedAuthMechs:
+                if m in methods:
+                    # Update internal state, according to selected method
+                    if m == AUTHMECH_ANON:
+                        self.state = STATE_REQUEST
+                    elif m == AUTHMECH_USERPASS:
+                        self.state = STATE_AUTH_USERPASS
+                    # Complete negotiation w/ this method
+                    self.transport.write(struct.pack("!BB", SOCKS5_VER, m))
+                    return
+
+            # No supported mechs found, notify client and close the connection
+            log.warning("Unsupported authentication mechanism")
+            self.transport.write(struct.pack("!BB", SOCKS5_VER, AUTHMECH_INVALID))
+            self.transport.loseConnection()
+        except struct.error:
+            pass
+
+    def _parse_user_pass(self):
+        try:
+            # Parse out data
+            ver, ulen = struct.unpack("BB", self.buf[:2])
+            uname, = struct.unpack("%ds" % ulen, self.buf[2 : ulen + 2])
+            plen, = struct.unpack("B", self.buf[ulen + 2])
+            password, = struct.unpack("%ds" % plen, self.buf[ulen + 3 : ulen + 3 + plen])
+            # Trim off fron of the buffer
+            self.buf = self.buf[3 + ulen + plen :]
+            # Fire event to authenticate user
+            if self.authenticate_user_pass(uname, password):
+                # Signal success
+                self.state = STATE_REQUEST
+                self.transport.write(struct.pack("!BB", SOCKS5_VER, 0x00))
+            else:
+                # Signal failure
+                self.transport.write(struct.pack("!BB", SOCKS5_VER, 0x01))
+                self.transport.loseConnection()
+        except struct.error:
+            pass
+
+    def send_error_reply(self, errorcode):
+        # Any other address types are not supported
+        result = struct.pack("!BBBBIH", SOCKS5_VER, errorcode, 0, 1, 0, 0)
+        self.transport.write(result)
+        self.transport.loseConnection()
+
+    def _parseRequest(self):
+        try:
+            # Parse out data and trim buffer accordingly
+            ver, cmd, rsvd, self.addressType = struct.unpack("!BBBB", self.buf[:4])
+
+            # Ensure we actually support the requested address type
+            if self.addressType not in self.supportedAddrs:
+                self.send_error_reply(REPLY_ADDR_NOT_SUPPORTED)
+                return
+
+            # Deal with addresses
+            if self.addressType == ADDR_IPV4:
+                addr, port = struct.unpack("!IH", self.buf[4:10])
+                self.buf = self.buf[10:]
+            elif self.addressType == ADDR_DOMAINNAME:
+                nlen = self.buf[4]
+                addr, port = struct.unpack("!%dsH" % nlen, self.buf[5:])
+                self.buf = self.buf[7 + len(addr) :]
+            else:
+                # Any other address types are not supported
+                self.send_error_reply(REPLY_ADDR_NOT_SUPPORTED)
+                return
+
+            # Ensure command is supported
+            if cmd not in self.enabledCommands:
+                # Send a not supported error
+                self.send_error_reply(REPLY_CMD_NOT_SUPPORTED)
+                return
+
+            # Process the command
+            if cmd == CMD_CONNECT:
+                self.connect_requested(addr, port)
+            elif cmd == CMD_BIND:
+                self.bind_requested(addr, port)
+            else:
+                # Any other command is not supported
+                self.send_error_reply(REPLY_CMD_NOT_SUPPORTED)
+
+        except struct.error:
+            # The buffer is probably not complete, we need to wait more
+            return None
+
+    def _make_request(self):
+        hash_ = self._session_hash.encode('utf-8')
+        request = struct.pack(
+            "!5B%dsH" % len(hash_),
+            SOCKS5_VER,
+            CMD_CONNECT,
+            0,
+            ADDR_DOMAINNAME,
+            len(hash_),
+            hash_,
+            0,
+        )
+        self.transport.write(request)
+        self.state = STATE_CLIENT_REQUEST
+
+    def _parse_request_reply(self):
+        try:
+            ver, rep, rsvd, self.addressType = struct.unpack("!BBBB", self.buf[:4])
+            # Ensure we actually support the requested address type
+            if self.addressType not in self.supportedAddrs:
+                self.send_error_reply(REPLY_ADDR_NOT_SUPPORTED)
+                return
+
+            # Deal with addresses
+            if self.addressType == ADDR_IPV4:
+                addr, port = struct.unpack("!IH", self.buf[4:10])
+                self.buf = self.buf[10:]
+            elif self.addressType == ADDR_DOMAINNAME:
+                nlen = self.buf[4]
+                addr, port = struct.unpack("!%dsH" % nlen, self.buf[5:])
+                self.buf = self.buf[7 + len(addr) :]
+            else:
+                # Any other address types are not supported
+                self.send_error_reply(REPLY_ADDR_NOT_SUPPORTED)
+                return
+
+            # Ensure reply is OK
+            if rep != REPLY_SUCCESS:
+                self.loseConnection()
+                return
+
+            self.state = STATE_READY
+            self.connection.callback(None)
+
+        except struct.error:
+            # The buffer is probably not complete, we need to wait more
+            return None
+
+    def connectionMade(self):
+        log.debug(
+            "Socks5 connectionMade (mode = {})".format(
+                "server" if self.state == STATE_INITIAL else "client"
+            )
+        )
+        if self.state == STATE_CLIENT_INITIAL:
+            self._start_negotiation()
+
+    def connect_requested(self, addr, port):
+        # Check that this session is expected
+        if not self.factory.add_to_session(addr.decode('utf-8'), self):
+            log.warning(
+                "Unexpected connection request received from {host}".format(
+                    host=self.transport.getPeer().host
+                )
+            )
+            self.send_error_reply(REPLY_CONN_REFUSED)
+            return
+        self._session_hash = addr.decode('utf-8')
+        self.connect_completed(addr, 0)
+
+    def start_transfer(self, chunk_size):
+        """Callback called when the result iq is received
+
+        @param chunk_size(None, int): size of the buffer, or None for default
+        """
+        self.active = True
+        if chunk_size is not None:
+            self.CHUNK_SIZE = chunk_size
+        log.debug("Starting file transfer")
+        d = self.stream_object.start_stream(self.transport)
+        d.addCallback(self.stream_finished)
+
+    def stream_finished(self, d):
+        log.info(_("File transfer completed, closing connection"))
+        self.transport.loseConnection()
+
+    def connect_completed(self, remotehost, remoteport):
+        if self.addressType == ADDR_IPV4:
+            result = struct.pack(
+                "!BBBBIH", SOCKS5_VER, REPLY_SUCCESS, 0, 1, remotehost, remoteport
+            )
+        elif self.addressType == ADDR_DOMAINNAME:
+            result = struct.pack(
+                "!BBBBB%dsH" % len(remotehost),
+                SOCKS5_VER,
+                REPLY_SUCCESS,
+                0,
+                ADDR_DOMAINNAME,
+                len(remotehost),
+                remotehost,
+                remoteport,
+            )
+        self.transport.write(result)
+        self.state = STATE_READY
+
+    def bind_requested(self, addr, port):
+        pass
+
+    def authenticate_user_pass(self, user, passwd):
+        # FIXME: implement authentication and remove the debug printing a password
+        log.debug("User/pass: %s/%s" % (user, passwd))
+        return True
+
+    def dataReceived(self, buf):
+        if self.state == STATE_READY:
+            # Everything is set, we just have to write the incoming data
+            self.stream_object.write(buf)
+            if not self.active:
+                self.active = True
+                self.getSession()[TIMER_KEY].cancel()
+            return
+
+        self.buf = self.buf + buf
+        if self.state == STATE_INITIAL:
+            self._parse_negotiation()
+        if self.state == STATE_AUTH_USERPASS:
+            self._parse_user_pass()
+        if self.state == STATE_REQUEST:
+            self._parseRequest()
+        if self.state == STATE_CLIENT_REQUEST:
+            self._parse_request_reply()
+        if self.state == STATE_CLIENT_AUTH:
+            ver, method = struct.unpack("!BB", buf)
+            self.buf = self.buf[2:]
+            if ver != SOCKS5_VER or method != AUTHMECH_ANON:
+                self.transport.loseConnection()
+            else:
+                self._make_request()
+
+    def connectionLost(self, reason):
+        log.debug("Socks5 connection lost: {}".format(reason.value))
+        if self.state != STATE_READY:
+            self.connection.errback(reason)
+        if self.server_mode:
+            try:
+                session_hash = self._session_hash
+            except AttributeError:
+                log.debug("no session has been received yet")
+            else:
+                self.factory.remove_from_session(session_hash, self, reason)
+
+
+class Socks5ServerFactory(protocol.ServerFactory):
+    protocol = SOCKSv5
+
+    def __init__(self, parent):
+        """
+        @param parent(XEP_0065): XEP_0065 parent instance
+        """
+        self.parent = parent
+
+    def getSession(self, session_hash):
+        return self.parent.getSession(None, session_hash)
+
+    def start_transfer(self, session_hash, chunk_size=None):
+        session = self.getSession(session_hash)
+        try:
+            protocol = session["protocols"][0]
+        except (KeyError, IndexError):
+            log.error("Can't start file transfer, can't find protocol")
+        else:
+            session[TIMER_KEY].cancel()
+            protocol.start_transfer(chunk_size)
+
+    def add_to_session(self, session_hash, protocol):
+        """Check is session_hash is valid, and associate protocol with it
+
+        the session will be associated to the corresponding candidate
+        @param session_hash(str): hash of the session
+        @param protocol(SOCKSv5): protocol instance
+        @param return(bool): True if hash was valid (i.e. expected), False else
+        """
+        assert isinstance(session_hash, str)
+        try:
+            session_data = self.getSession(session_hash)
+        except KeyError:
+            return False
+        else:
+            session_data.setdefault("protocols", []).append(protocol)
+            return True
+
+    def remove_from_session(self, session_hash, protocol, reason):
+        """Remove a protocol from session_data
+
+        There can be several protocol instances while candidates are tried, they
+        have removed when candidate connection is closed
+        @param session_hash(str): hash of the session
+        @param protocol(SOCKSv5): protocol instance
+        @param reason(failure.Failure): reason of the removal
+        """
+        try:
+            protocols = self.getSession(session_hash)["protocols"]
+            protocols.remove(protocol)
+        except (KeyError, ValueError):
+            log.error("Protocol not found in session while it should be there")
+        else:
+            if protocol.active:
+                # The active protocol has been removed, session is finished
+                if reason.check(internet_error.ConnectionDone):
+                    self.getSession(session_hash)[DEFER_KEY].callback(None)
+                else:
+                    self.getSession(session_hash)[DEFER_KEY].errback(reason)
+
+
+class Socks5ClientFactory(protocol.ClientFactory):
+    protocol = SOCKSv5
+
+    def __init__(self, client, parent, session, session_hash):
+        """Init the Client Factory
+
+        @param session(dict): session data
+        @param session_hash(unicode): hash used for peer_connection
+            hash is the same as hostname computed in XEP-0065 § 5.3.2 #1
+        """
+        self.session = session
+        self.session_hash = session_hash
+        self.client = client
+        self.connection = defer.Deferred()
+        self._protocol_instance = None
+        self.connector = None
+
+    def discard(self):
+        """Disconnect the client
+
+        Also set a discarded flag, which avoid to call the session Deferred
+        """
+        self.connector.disconnect()
+
+    def getSession(self):
+        return self.session
+
+    def start_transfer(self, __=None, chunk_size=None):
+        self.session[TIMER_KEY].cancel()
+        self._protocol_instance.start_transfer(chunk_size)
+
+    def clientConnectionFailed(self, connector, reason):
+        log.debug("Connection failed")
+        self.connection.errback(reason)
+
+    def clientConnectionLost(self, connector, reason):
+        log.debug(_("Socks 5 client connection lost (reason: %s)") % reason.value)
+        if self._protocol_instance.active:
+            # This one was used for the transfer, than mean that
+            # the Socks5 session is finished
+            if reason.check(internet_error.ConnectionDone):
+                self.getSession()[DEFER_KEY].callback(None)
+            else:
+                self.getSession()[DEFER_KEY].errback(reason)
+        self._protocol_instance = None
+
+    def buildProtocol(self, addr):
+        log.debug(("Socks 5 client connection started"))
+        p = self.protocol(session_hash=self.session_hash)
+        p.factory = self
+        p.connection.chainDeferred(self.connection)
+        self._protocol_instance = p
+        return p
+
+
+class XEP_0065(object):
+    NAMESPACE = NS_BS
+    TYPE_DIRECT = "direct"
+    TYPE_ASSISTED = "assisted"
+    TYPE_TUNEL = "tunel"
+    TYPE_PROXY = "proxy"
+    Candidate = Candidate
+
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0065 initialization"))
+        self.host = host
+
+        # session data
+        self.hash_clients_map = {}  # key: hash of the transfer session, value: session data
+        self._cache_proxies = {}  # key: server jid, value: proxy data
+
+        # misc data
+        self._server_factory = None
+        self._external_port = None
+
+        # plugins shortcuts
+        self._ip = self.host.plugins["IP"]
+        try:
+            self._np = self.host.plugins["NAT-PORT"]
+        except KeyError:
+            log.debug("NAT Port plugin not available")
+            self._np = None
+
+        # parameters
+        # XXX: params are not used for now, but they may be used in the futur to force proxy/IP
+        # host.memory.update_params(PARAMS)
+
+    def get_handler(self, client):
+        return XEP_0065_handler(self)
+
+    def profile_connected(self, client):
+        client.xep_0065_sid_session = {}  # key: stream_id, value: session_data(dict)
+        client._s5b_sessions = {}
+
+    def get_session_hash(self, from_jid, to_jid, sid):
+        return get_session_hash(from_jid, to_jid, sid)
+
+    def get_socks_5_server_factory(self):
+        """Return server factory
+
+        The server is created if it doesn't exists yet
+        self._server_factory_port is set on server creation
+        """
+
+        if self._server_factory is None:
+            self._server_factory = Socks5ServerFactory(self)
+            for port in range(SERVER_STARTING_PORT, 65356):
+                try:
+                    listening_port = reactor.listenTCP(port, self._server_factory)
+                except internet_error.CannotListenError as e:
+                    log.debug(
+                        "Cannot listen on port {port}: {err_msg}{err_num}".format(
+                            port=port,
+                            err_msg=e.socketError.strerror,
+                            err_num=" (error code: {})".format(e.socketError.errno),
+                        )
+                    )
+                else:
+                    self._server_factory_port = listening_port.getHost().port
+                    break
+
+            log.info(
+                _("Socks5 Stream server launched on port {}").format(
+                    self._server_factory_port
+                )
+            )
+        return self._server_factory
+
+    @defer.inlineCallbacks
+    def get_proxy(self, client, local_jid):
+        """Return the proxy available for this profile
+
+        cache is used between clients using the same server
+        @param local_jid(jid.JID): same as for [get_candidates]
+        @return ((D)(ProxyInfos, None)): Found proxy infos,
+            or None if not acceptable proxy is found
+        @raise exceptions.NotFound: no Proxy found
+        """
+
+        def notFound(server):
+            log.info("No proxy found on this server")
+            self._cache_proxies[server] = None
+            raise exceptions.NotFound
+
+        server = client.host if client.is_component else client.jid.host
+        try:
+            defer.returnValue(self._cache_proxies[server])
+        except KeyError:
+            pass
+        try:
+            proxy = (
+                yield self.host.find_service_entities(client, "proxy", "bytestreams")
+            ).pop()
+        except (defer.CancelledError, StopIteration, KeyError):
+            notFound(server)
+        iq_elt = client.IQ("get")
+        iq_elt["from"] = local_jid.full()
+        iq_elt["to"] = proxy.full()
+        iq_elt.addElement((NS_BS, "query"))
+
+        try:
+            result_elt = yield iq_elt.send()
+        except jabber_error.StanzaError as failure:
+            log.warning(
+                "Error while requesting proxy info on {jid}: {error}".format(
+                    jid=proxy.full(), error=failure
+                )
+            )
+            notFound(server)
+
+        try:
+            query_elt = next(result_elt.elements(NS_BS, "query"))
+            streamhost_elt = next(query_elt.elements(NS_BS, "streamhost"))
+            host = streamhost_elt["host"]
+            jid_ = streamhost_elt["jid"]
+            port = streamhost_elt["port"]
+            if not all((host, jid, port)):
+                raise KeyError
+            jid_ = jid.JID(jid_)
+        except (StopIteration, KeyError, RuntimeError, jid.InvalidFormat, AttributeError):
+            log.warning("Invalid proxy data received from {}".format(proxy.full()))
+            notFound(server)
+
+        proxy_infos = self._cache_proxies[server] = ProxyInfos(host, jid_, port)
+        log.info("Proxy found: {}".format(proxy_infos))
+        defer.returnValue(proxy_infos)
+
+    @defer.inlineCallbacks
+    def _get_network_data(self, client):
+        """Retrieve information about network
+
+        @param client: %(doc_client)s
+        @return (D(tuple[local_port, external_port, local_ips, external_ip])): network data
+        """
+        self.get_socks_5_server_factory()
+        local_port = self._server_factory_port
+        external_ip = yield self._ip.get_external_ip(client)
+        local_ips = yield self._ip.get_local_i_ps(client)
+
+        if external_ip is not None and self._external_port is None:
+            if external_ip != local_ips[0]:
+                log.info("We are probably behind a NAT")
+                if self._np is None:
+                    log.warning("NAT port plugin not available, we can't map port")
+                else:
+                    ext_port = yield self._np.map_port(
+                        local_port, desc="SaT socks5 stream"
+                    )
+                    if ext_port is None:
+                        log.warning("Can't map NAT port")
+                    else:
+                        self._external_port = ext_port
+
+        defer.returnValue((local_port, self._external_port, local_ips, external_ip))
+
+    @defer.inlineCallbacks
+    def get_candidates(self, client, local_jid):
+        """Return a list of our stream candidates
+
+        @param local_jid(jid.JID): jid to use as local jid
+            This is needed for client which can be addressed with a different jid than
+            client.jid if a local part is used (e.g. piotr@file.example.net where
+            client.jid would be file.example.net)
+        @return (D(list[Candidate])): list of candidates, ordered by priority
+        """
+        server_factory = yield self.get_socks_5_server_factory()
+        local_port, ext_port, local_ips, external_ip = yield self._get_network_data(client)
+        try:
+            proxy = yield self.get_proxy(client, local_jid)
+        except exceptions.NotFound:
+            proxy = None
+
+        # its time to gather the candidates
+        candidates = []
+
+        # first the direct ones
+
+        # the preferred direct connection
+        ip = local_ips.pop(0)
+        candidates.append(
+            Candidate(
+                ip,
+                local_port,
+                XEP_0065.TYPE_DIRECT,
+                PRIORITY_BEST_DIRECT,
+                local_jid,
+                priority_local=True,
+                factory=server_factory,
+            )
+        )
+        for ip in local_ips:
+            candidates.append(
+                Candidate(
+                    ip,
+                    local_port,
+                    XEP_0065.TYPE_DIRECT,
+                    PRIORITY_DIRECT,
+                    local_jid,
+                    priority_local=True,
+                    factory=server_factory,
+                )
+            )
+
+        # then the assisted one
+        if ext_port is not None:
+            candidates.append(
+                Candidate(
+                    external_ip,
+                    ext_port,
+                    XEP_0065.TYPE_ASSISTED,
+                    PRIORITY_ASSISTED,
+                    local_jid,
+                    priority_local=True,
+                    factory=server_factory,
+                )
+            )
+
+        # finally the proxy
+        if proxy:
+            candidates.append(
+                Candidate(
+                    proxy.host,
+                    proxy.port,
+                    XEP_0065.TYPE_PROXY,
+                    PRIORITY_PROXY,
+                    proxy.jid,
+                    priority_local=True,
+                )
+            )
+
+        # should be already sorted, but just in case the priorities get weird
+        candidates.sort(key=lambda c: c.priority, reverse=True)
+        defer.returnValue(candidates)
+
+    def _add_connector(self, connector, candidate):
+        """Add connector used to connect to candidate, and return client factory's connection Deferred
+
+        the connector can be used to disconnect the candidate, and returning the factory's connection Deferred allow to wait for connection completion
+        @param connector: a connector implementing IConnector
+        @param candidate(Candidate): candidate linked to the connector
+        @return (D): Deferred fired when factory connection is done or has failed
+        """
+        candidate.factory.connector = connector
+        return candidate.factory.connection
+
+    def connect_candidate(
+        self, client, candidate, session_hash, peer_session_hash=None, delay=None
+    ):
+        """Connect to a candidate
+
+        Connection will be done with a Socks5ClientFactory
+        @param candidate(Candidate): candidate to connect to
+        @param session_hash(unicode): hash of the session
+            hash is the same as hostname computed in XEP-0065 § 5.3.2 #1
+        @param peer_session_hash(unicode, None): hash used with the peer
+            None to use session_hash.
+            None must be used in 2 cases:
+                - when XEP-0065 is used with XEP-0096
+                - when a peer connect to a proxy *he proposed himself*
+            in practice, peer_session_hash is only used by try_candidates
+        @param delay(None, float): optional delay to wait before connection, in seconds
+        @return (D): Deferred launched when TCP connection + Socks5 connection is done
+        """
+        if peer_session_hash is None:
+            # for XEP-0065, only one hash is needed
+            peer_session_hash = session_hash
+        session = self.getSession(client, session_hash)
+        factory = Socks5ClientFactory(client, self, session, peer_session_hash)
+        candidate.factory = factory
+        if delay is None:
+            d = defer.succeed(candidate.host)
+        else:
+            d = sat_defer.DelayedDeferred(delay, candidate.host)
+        d.addCallback(reactor.connectTCP, candidate.port, factory)
+        d.addCallback(self._add_connector, candidate)
+        return d
+
+    def try_candidates(
+        self,
+        client,
+        candidates,
+        session_hash,
+        peer_session_hash,
+        connection_cb=None,
+        connection_eb=None,
+    ):
+        defers_list = []
+
+        for candidate in candidates:
+            delay = CANDIDATE_DELAY * len(defers_list)
+            if candidate.type == XEP_0065.TYPE_PROXY:
+                delay += CANDIDATE_DELAY_PROXY
+            d = self.connect_candidate(
+                client, candidate, session_hash, peer_session_hash, delay
+            )
+            if connection_cb is not None:
+                d.addCallback(
+                    lambda __, candidate=candidate, client=client: connection_cb(
+                        client, candidate
+                    )
+                )
+            if connection_eb is not None:
+                d.addErrback(connection_eb, client, candidate)
+            defers_list.append(d)
+
+        return defers_list
+
+    def get_best_candidate(self, client, candidates, session_hash, peer_session_hash=None):
+        """Get best candidate (according to priority) which can connect
+
+        @param candidates(iterable[Candidate]): candidates to test
+        @param session_hash(unicode): hash of the session
+            hash is the same as hostname computed in XEP-0065 § 5.3.2 #1
+        @param peer_session_hash(unicode, None): hash of the other peer
+            only useful for XEP-0260, must be None for XEP-0065 streamhost candidates
+        @return (D(None, Candidate)): best candidate or None if none can connect
+        """
+        defer_candidates = None
+
+        def connection_cb(client, candidate):
+            log.info("Connection of {} successful".format(str(candidate)))
+            for idx, other_candidate in enumerate(candidates):
+                try:
+                    if other_candidate.priority < candidate.priority:
+                        log.debug("Cancelling {}".format(other_candidate))
+                        defer_candidates[idx].cancel()
+                except AttributeError:
+                    assert other_candidate is None
+
+        def connection_eb(failure, client, candidate):
+            if failure.check(defer.CancelledError):
+                log.debug("Connection of {} has been cancelled".format(candidate))
+            else:
+                log.info(
+                    "Connection of {candidate} Failed: {error}".format(
+                        candidate=candidate, error=failure.value
+                    )
+                )
+            candidates[candidates.index(candidate)] = None
+
+        def all_tested(__):
+            log.debug("All candidates have been tested")
+            good_candidates = [c for c in candidates if c]
+            return good_candidates[0] if good_candidates else None
+
+        defer_candidates = self.try_candidates(
+            client,
+            candidates,
+            session_hash,
+            peer_session_hash,
+            connection_cb,
+            connection_eb,
+        )
+        d_list = defer.DeferredList(defer_candidates)
+        d_list.addCallback(all_tested)
+        return d_list
+
+    def _time_out(self, session_hash, client):
+        """Called when stream was not started quickly enough
+
+        @param session_hash(str): hash as returned by get_session_hash
+        @param client: %(doc_client)s
+        """
+        log.info("Socks5 Bytestream: TimeOut reached")
+        session = self.getSession(client, session_hash)
+        session[DEFER_KEY].errback(exceptions.TimeOutError())
+
+    def kill_session(self, failure_, session_hash, sid, client):
+        """Clean the current session
+
+        @param session_hash(str): hash as returned by get_session_hash
+        @param sid(None, unicode): session id
+            or None if self.xep_0065_sid_session was not used
+        @param client: %(doc_client)s
+        @param failure_(None, failure.Failure): None if eveything was fine, a failure else
+        @return (None, failure.Failure): failure_ is returned
+        """
+        log.debug(
+            "Cleaning session with hash {hash}{id}: {reason}".format(
+                hash=session_hash,
+                reason="" if failure_ is None else failure_.value,
+                id="" if sid is None else " (id: {})".format(sid),
+            )
+        )
+
+        try:
+            assert self.hash_clients_map[session_hash] == client
+            del self.hash_clients_map[session_hash]
+        except KeyError:
+            pass
+
+        if sid is not None:
+            try:
+                del client.xep_0065_sid_session[sid]
+            except KeyError:
+                log.warning("Session id {} is unknown".format(sid))
+
+        try:
+            session_data = client._s5b_sessions[session_hash]
+        except KeyError:
+            log.warning("There is no session with this hash")
+            return
+        else:
+            del client._s5b_sessions[session_hash]
+
+        try:
+            session_data["timer"].cancel()
+        except (internet_error.AlreadyCalled, internet_error.AlreadyCancelled):
+            pass
+
+        return failure_
+
+    def start_stream(self, client, stream_object, local_jid, to_jid, sid):
+        """Launch the stream workflow
+
+        @param streamProducer: stream_object to use
+        @param local_jid(jid.JID): same as for [get_candidates]
+        @param to_jid: JID of the recipient
+        @param sid: Stream session id
+        @param successCb: method to call when stream successfuly finished
+        @param failureCb: method to call when something goes wrong
+        @return (D): Deferred fired when session is finished
+        """
+        session_data = self._create_session(
+            client, stream_object, local_jid, to_jid, sid, True)
+
+        session_data[client] = client
+
+        def got_candidates(candidates):
+            session_data["candidates"] = candidates
+            iq_elt = client.IQ()
+            iq_elt["from"] = local_jid.full()
+            iq_elt["to"] = to_jid.full()
+            query_elt = iq_elt.addElement((NS_BS, "query"))
+            query_elt["mode"] = "tcp"
+            query_elt["sid"] = sid
+
+            for candidate in candidates:
+                streamhost = query_elt.addElement("streamhost")
+                streamhost["host"] = candidate.host
+                streamhost["port"] = str(candidate.port)
+                streamhost["jid"] = candidate.jid.full()
+                log.debug("Candidate proposed: {}".format(candidate))
+
+            d = iq_elt.send()
+            args = [client, session_data, local_jid]
+            d.addCallbacks(self._iq_negotiation_cb, self._iq_negotiation_eb, args, None, args)
+
+        self.get_candidates(client, local_jid).addCallback(got_candidates)
+        return session_data[DEFER_KEY]
+
+    def _iq_negotiation_cb(self, iq_elt, client, session_data, local_jid):
+        """Called when the result of open iq is received
+
+        @param session_data(dict): data of the session
+        @param client: %(doc_client)s
+        @param iq_elt(domish.Element): <iq> result
+        """
+        try:
+            query_elt = next(iq_elt.elements(NS_BS, "query"))
+            streamhost_used_elt = next(query_elt.elements(NS_BS, "streamhost-used"))
+        except StopIteration:
+            log.warning("No streamhost found in stream query")
+            # FIXME: must clean session
+            return
+
+        streamhost_jid = jid.JID(streamhost_used_elt["jid"])
+        try:
+            candidate = next((
+                c for c in session_data["candidates"] if c.jid == streamhost_jid
+            ))
+        except StopIteration:
+            log.warning(
+                "Candidate [{jid}] is unknown !".format(jid=streamhost_jid.full())
+            )
+            return
+        else:
+            log.info("Candidate choosed by target: {}".format(candidate))
+
+        if candidate.type == XEP_0065.TYPE_PROXY:
+            log.info("A Socks5 proxy is used")
+            d = self.connect_candidate(client, candidate, session_data["hash"])
+            d.addCallback(
+                lambda __: candidate.activate(
+                    client, session_data["id"], session_data["peer_jid"], local_jid
+                )
+            )
+            d.addErrback(self._activation_eb)
+        else:
+            d = defer.succeed(None)
+
+        d.addCallback(lambda __: candidate.start_transfer(session_data["hash"]))
+
+    def _activation_eb(self, failure):
+        log.warning("Proxy activation error: {}".format(failure.value))
+
+    def _iq_negotiation_eb(self, stanza_err, client, session_data, local_jid):
+        log.warning("Socks5 transfer failed: {}".format(stanza_err.value))
+        # FIXME: must clean session
+
+    def create_session(self, *args, **kwargs):
+        """like [_create_session] but return the session deferred instead of the whole session
+
+        session deferred is fired when transfer is finished
+        """
+        return self._create_session(*args, **kwargs)[DEFER_KEY]
+
+    def _create_session(self, client, stream_object, local_jid, to_jid, sid,
+                       requester=False):
+        """Called when a bytestream is imminent
+
+        @param stream_object(iface.IStreamProducer): File object where data will be
+            written
+        @param to_jid(jid.JId): jid of the other peer
+        @param sid(unicode): session id
+        @param initiator(bool): if True, this session is create by initiator
+        @return (dict): session data
+        """
+        if sid in client.xep_0065_sid_session:
+            raise exceptions.ConflictError("A session with this id already exists !")
+        if requester:
+            session_hash = get_session_hash(local_jid, to_jid, sid)
+            session_data = self._register_hash(client, session_hash, stream_object)
+        else:
+            session_hash = get_session_hash(to_jid, local_jid, sid)
+            session_d = defer.Deferred()
+            session_d.addBoth(self.kill_session, session_hash, sid, client)
+            session_data = client._s5b_sessions[session_hash] = {
+                DEFER_KEY: session_d,
+                TIMER_KEY: reactor.callLater(
+                    TIMEOUT, self._time_out, session_hash, client
+                ),
+            }
+        client.xep_0065_sid_session[sid] = session_data
+        session_data.update(
+            {
+                "id": sid,
+                "local_jid": local_jid,
+                "peer_jid": to_jid,
+                "stream_object": stream_object,
+                "hash": session_hash,
+            }
+        )
+
+        return session_data
+
+    def getSession(self, client, session_hash):
+        """Return session data
+
+        @param session_hash(unicode): hash of the session
+            hash is the same as hostname computed in XEP-0065 § 5.3.2 #1
+        @param client(None, SatXMPPClient): client of the peer
+            None is used only if client is unknown (this is only the case
+            for incoming request received by Socks5ServerFactory). None must
+            only be used by Socks5ServerFactory.
+            See comments below for details
+        @return (dict): session data
+        """
+        assert isinstance(session_hash, str)
+        if client is None:
+            try:
+                client = self.hash_clients_map[session_hash]
+            except KeyError as e:
+                log.warning("The requested session doesn't exists !")
+                raise e
+        return client._s5b_sessions[session_hash]
+
+    def register_hash(self, *args, **kwargs):
+        """like [_register_hash] but return the session deferred instead of the whole session
+        session deferred is fired when transfer is finished
+        """
+        return self._register_hash(*args, **kwargs)[DEFER_KEY]
+
+    def _register_hash(self, client, session_hash, stream_object):
+        """Create a session_data associated to hash
+
+        @param session_hash(str): hash of the session
+        @param stream_object(iface.IStreamProducer, IConsumer, None): file-like object
+            None if it will be filled later
+        return (dict): session data
+        """
+        assert session_hash not in client._s5b_sessions
+        session_d = defer.Deferred()
+        session_d.addBoth(self.kill_session, session_hash, None, client)
+        session_data = client._s5b_sessions[session_hash] = {
+            DEFER_KEY: session_d,
+            TIMER_KEY: reactor.callLater(TIMEOUT, self._time_out, session_hash, client),
+        }
+
+        if stream_object is not None:
+            session_data["stream_object"] = stream_object
+
+        assert session_hash not in self.hash_clients_map
+        self.hash_clients_map[session_hash] = client
+
+        return session_data
+
+    def associate_stream_object(self, client, session_hash, stream_object):
+        """Associate a stream object with  a session"""
+        session_data = self.getSession(client, session_hash)
+        assert "stream_object" not in session_data
+        session_data["stream_object"] = stream_object
+
+    def stream_query(self, iq_elt, client):
+        log.debug("BS stream query")
+
+        iq_elt.handled = True
+
+        query_elt = next(iq_elt.elements(NS_BS, "query"))
+        try:
+            sid = query_elt["sid"]
+        except KeyError:
+            log.warning("Invalid bystreams request received")
+            return client.sendError(iq_elt, "bad-request")
+
+        streamhost_elts = list(query_elt.elements(NS_BS, "streamhost"))
+        if not streamhost_elts:
+            return client.sendError(iq_elt, "bad-request")
+
+        try:
+            session_data = client.xep_0065_sid_session[sid]
+        except KeyError:
+            log.warning("Ignoring unexpected BS transfer: {}".format(sid))
+            return client.sendError(iq_elt, "not-acceptable")
+
+        peer_jid = session_data["peer_jid"] = jid.JID(iq_elt["from"])
+
+        candidates = []
+        nb_sh = len(streamhost_elts)
+        for idx, sh_elt in enumerate(streamhost_elts):
+            try:
+                host, port, jid_ = sh_elt["host"], sh_elt["port"], jid.JID(sh_elt["jid"])
+            except KeyError:
+                log.warning("malformed streamhost element")
+                return client.sendError(iq_elt, "bad-request")
+            priority = nb_sh - idx
+            if jid_.userhostJID() != peer_jid.userhostJID():
+                type_ = XEP_0065.TYPE_PROXY
+            else:
+                type_ = XEP_0065.TYPE_DIRECT
+            candidates.append(Candidate(host, port, type_, priority, jid_))
+
+        for candidate in candidates:
+            log.info("Candidate proposed: {}".format(candidate))
+
+        d = self.get_best_candidate(client, candidates, session_data["hash"])
+        d.addCallback(self._ack_stream, iq_elt, session_data, client)
+
+    def _ack_stream(self, candidate, iq_elt, session_data, client):
+        if candidate is None:
+            log.info("No streamhost candidate worked, we have to end negotiation")
+            return client.sendError(iq_elt, "item-not-found")
+        log.info("We choose: {}".format(candidate))
+        result_elt = xmlstream.toResponse(iq_elt, "result")
+        query_elt = result_elt.addElement((NS_BS, "query"))
+        query_elt["sid"] = session_data["id"]
+        streamhost_used_elt = query_elt.addElement("streamhost-used")
+        streamhost_used_elt["jid"] = candidate.jid.full()
+        client.send(result_elt)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0065_handler(xmlstream.XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            BS_REQUEST, self.plugin_parent.stream_query, client=self.parent
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_BS)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0070.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing xep-0070
+# Copyright (C) 2009-2016 Geoffrey POUZET (chteufleur@kingpenguin.tk)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.protocols import jabber
+
+log = getLogger(__name__)
+from libervia.backend.tools import xml_tools
+
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+
+NS_HTTP_AUTH = "http://jabber.org/protocol/http-auth"
+
+IQ = "iq"
+IQ_GET = "/" + IQ + '[@type="get"]'
+IQ_HTTP_AUTH_REQUEST = IQ_GET + '/confirm[@xmlns="' + NS_HTTP_AUTH + '"]'
+
+MSG = "message"
+MSG_GET = "/" + MSG + '[@type="normal"]'
+MSG_HTTP_AUTH_REQUEST = MSG_GET + '/confirm[@xmlns="' + NS_HTTP_AUTH + '"]'
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP-0070 Plugin",
+    C.PI_IMPORT_NAME: "XEP-0070",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0070"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "XEP_0070",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of HTTP Requests via XMPP"""),
+}
+
+
+class XEP_0070(object):
+    """
+    Implementation for XEP 0070.
+    """
+
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0070 initialization"))
+        self.host = host
+        self._dictRequest = dict()
+
+    def get_handler(self, client):
+        return XEP_0070_handler(self, client.profile)
+
+    def on_http_auth_request_iq(self, iq_elt, client):
+        """This method is called on confirmation request received (XEP-0070 #4.5)
+
+        @param iq_elt: IQ element
+        @param client: %(doc_client)s
+        """
+        log.info(_("XEP-0070 Verifying HTTP Requests via XMPP (iq)"))
+        self._treat_http_auth_request(iq_elt, IQ, client)
+
+    def on_http_auth_request_msg(self, msg_elt, client):
+        """This method is called on confirmation request received (XEP-0070 #4.5)
+
+        @param msg_elt: message element
+        @param client: %(doc_client)s
+        """
+        log.info(_("XEP-0070 Verifying HTTP Requests via XMPP (message)"))
+        self._treat_http_auth_request(msg_elt, MSG, client)
+
+    def _treat_http_auth_request(self, elt, stanzaType, client):
+        elt.handled = True
+        auth_elt = next(elt.elements(NS_HTTP_AUTH, "confirm"))
+        auth_id = auth_elt["id"]
+        auth_method = auth_elt["method"]
+        auth_url = auth_elt["url"]
+        self._dictRequest[client] = (auth_id, auth_method, auth_url, stanzaType, elt)
+        title = D_("Auth confirmation")
+        message = D_("{auth_url} needs to validate your identity, do you agree?\n"
+                     "Validation code : {auth_id}\n\n"
+                     "Please check that this code is the same as on {auth_url}"
+                    ).format(auth_url=auth_url, auth_id=auth_id)
+        d = xml_tools.defer_confirm(self.host, message=message, title=title,
+            profile=client.profile)
+        d.addCallback(self._auth_request_callback, client)
+
+    def _auth_request_callback(self, authorized, client):
+        try:
+            auth_id, auth_method, auth_url, stanzaType, elt = self._dictRequest.pop(
+                client)
+        except KeyError:
+            authorized = False
+
+        if authorized:
+            if stanzaType == IQ:
+                # iq
+                log.debug(_("XEP-0070 reply iq"))
+                iq_result_elt = xmlstream.toResponse(elt, "result")
+                client.send(iq_result_elt)
+            elif stanzaType == MSG:
+                # message
+                log.debug(_("XEP-0070 reply message"))
+                msg_result_elt = xmlstream.toResponse(elt, "result")
+                msg_result_elt.addChild(next(elt.elements(NS_HTTP_AUTH, "confirm")))
+                client.send(msg_result_elt)
+        else:
+            log.debug(_("XEP-0070 reply error"))
+            result_elt = jabber.error.StanzaError("not-authorized").toResponse(elt)
+            client.send(result_elt)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0070_handler(XMPPHandler):
+
+    def __init__(self, plugin_parent, profile):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+        self.profile = profile
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            IQ_HTTP_AUTH_REQUEST,
+            self.plugin_parent.on_http_auth_request_iq,
+            client=self.parent,
+        )
+        self.xmlstream.addObserver(
+            MSG_HTTP_AUTH_REQUEST,
+            self.plugin_parent.on_http_auth_request_msg,
+            client=self.parent,
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_HTTP_AUTH)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0071.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,309 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Publish-Subscribe (xep-0071)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.tools.common import data_format
+
+from twisted.internet import defer
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+# from lxml import etree
+try:
+    from lxml import html
+except ImportError:
+    raise exceptions.MissingModule(
+        "Missing module lxml, please download/install it from http://lxml.de/"
+    )
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+NS_XHTML_IM = "http://jabber.org/protocol/xhtml-im"
+NS_XHTML = "http://www.w3.org/1999/xhtml"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XHTML-IM Plugin",
+    C.PI_IMPORT_NAME: "XEP-0071",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0071"],
+    C.PI_DEPENDENCIES: ["TEXT_SYNTAXES"],
+    C.PI_MAIN: "XEP_0071",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of XHTML-IM"""),
+}
+
+allowed = {
+    "a": set(["href", "style", "type"]),
+    "blockquote": set(["style"]),
+    "body": set(["style"]),
+    "br": set([]),
+    "cite": set(["style"]),
+    "em": set([]),
+    "img": set(["alt", "height", "src", "style", "width"]),
+    "li": set(["style"]),
+    "ol": set(["style"]),
+    "p": set(["style"]),
+    "span": set(["style"]),
+    "strong": set([]),
+    "ul": set(["style"]),
+}
+
+styles_allowed = [
+    "background-color",
+    "color",
+    "font-family",
+    "font-size",
+    "font-style",
+    "font-weight",
+    "margin-left",
+    "margin-right",
+    "text-align",
+    "text-decoration",
+]
+
+blacklist = ["script"]  # tag that we have to kill (we don't keep content)
+
+
+class XEP_0071(object):
+    SYNTAX_XHTML_IM = "XHTML-IM"
+
+    def __init__(self, host):
+        log.info(_("XHTML-IM plugin initialization"))
+        self.host = host
+        self._s = self.host.plugins["TEXT_SYNTAXES"]
+        self._s.add_syntax(
+            self.SYNTAX_XHTML_IM,
+            lambda xhtml: xhtml,
+            self.XHTML2XHTML_IM,
+            [self._s.OPT_HIDDEN],
+        )
+        host.trigger.add("message_received", self.message_received_trigger)
+        host.trigger.add("sendMessage", self.send_message_trigger)
+
+    def get_handler(self, client):
+        return XEP_0071_handler(self)
+
+    def _message_post_treat(self, data, message_elt, body_elts, client):
+        """Callback which manage the post treatment of the message in case of XHTML-IM found
+
+        @param data: data send by message_received trigger through post_treat deferred
+        @param message_elt: whole <message> stanza
+        @param body_elts: XHTML-IM body elements found
+        @return: the data with the extra parameter updated
+        """
+        # TODO: check if text only body is empty, then try to convert XHTML-IM to pure text and show a warning message
+        def converted(xhtml, lang):
+            if lang:
+                data["extra"]["xhtml_{}".format(lang)] = xhtml
+            else:
+                data["extra"]["xhtml"] = xhtml
+
+        defers = []
+        for body_elt in body_elts:
+            lang = body_elt.getAttribute((C.NS_XML, "lang"), "")
+            treat_d = defer.succeed(None)  #  deferred used for treatments
+            if self.host.trigger.point(
+                "xhtml_post_treat", client, message_elt, body_elt, lang, treat_d
+            ):
+                continue
+            treat_d.addCallback(
+                lambda __: self._s.convert(
+                    body_elt.toXml(), self.SYNTAX_XHTML_IM, safe=True
+                )
+            )
+            treat_d.addCallback(converted, lang)
+            defers.append(treat_d)
+
+        d_list = defer.DeferredList(defers)
+        d_list.addCallback(lambda __: data)
+        return d_list
+
+    def _fill_body_text(self, text, data, lang):
+        data["message"][lang or ""] = text
+        message_elt = data["xml"]
+        body_elt = message_elt.addElement("body", content=text)
+        if lang:
+            body_elt[(C.NS_XML, "lang")] = lang
+
+    def _check_body_text(self, data, lang, markup, syntax, defers):
+        """check if simple text message exists, and fill if needed"""
+        if not (lang or "") in data["message"]:
+            d = self._s.convert(markup, syntax, self._s.SYNTAX_TEXT)
+            d.addCallback(self._fill_body_text, data, lang)
+            defers.append(d)
+
+    def _send_message_add_rich(self, data, client):
+        """ Construct XHTML-IM node and add it XML element
+
+        @param data: message data as sended by sendMessage callback
+        """
+        # at this point, either ['extra']['rich'] or ['extra']['xhtml'] exists
+        # but both can't exist at the same time
+        message_elt = data["xml"]
+        html_elt = message_elt.addElement((NS_XHTML_IM, "html"))
+
+        def syntax_converted(xhtml_im, lang):
+            body_elt = html_elt.addElement((NS_XHTML, "body"))
+            if lang:
+                body_elt[(C.NS_XML, "lang")] = lang
+                data["extra"]["xhtml_{}".format(lang)] = xhtml_im
+            else:
+                data["extra"]["xhtml"] = xhtml_im
+            body_elt.addRawXml(xhtml_im)
+
+        syntax = self._s.get_current_syntax(client.profile)
+        defers = []
+        if "xhtml" in data["extra"]:
+            # we have directly XHTML
+            for lang, xhtml in data_format.get_sub_dict("xhtml", data["extra"]):
+                self._check_body_text(data, lang, xhtml, self._s.SYNTAX_XHTML, defers)
+                d = self._s.convert(xhtml, self._s.SYNTAX_XHTML, self.SYNTAX_XHTML_IM)
+                d.addCallback(syntax_converted, lang)
+                defers.append(d)
+        elif "rich" in data["extra"]:
+            # we have rich syntax to convert
+            for lang, rich_data in data_format.get_sub_dict("rich", data["extra"]):
+                self._check_body_text(data, lang, rich_data, syntax, defers)
+                d = self._s.convert(rich_data, syntax, self.SYNTAX_XHTML_IM)
+                d.addCallback(syntax_converted, lang)
+                defers.append(d)
+        else:
+            exceptions.InternalError("xhtml or rich should be present at this point")
+        d_list = defer.DeferredList(defers)
+        d_list.addCallback(lambda __: data)
+        return d_list
+
+    def message_received_trigger(self, client, message, post_treat):
+        """ Check presence of XHTML-IM in message
+        """
+        try:
+            html_elt = next(message.elements(NS_XHTML_IM, "html"))
+        except StopIteration:
+            # No XHTML-IM
+            pass
+        else:
+            body_elts = html_elt.elements(NS_XHTML, "body")
+            post_treat.addCallback(self._message_post_treat, message, body_elts, client)
+        return True
+
+    def send_message_trigger(self, client, data, pre_xml_treatments, post_xml_treatments):
+        """ Check presence of rich text in extra """
+        rich = {}
+        xhtml = {}
+        for key, value in data["extra"].items():
+            if key.startswith("rich"):
+                rich[key[5:]] = value
+            elif key.startswith("xhtml"):
+                xhtml[key[6:]] = value
+        if rich and xhtml:
+            raise exceptions.DataError(
+                _("Can't have XHTML and rich content at the same time")
+            )
+        if rich or xhtml:
+            if rich:
+                data["rich"] = rich
+            else:
+                data["xhtml"] = xhtml
+            post_xml_treatments.addCallback(self._send_message_add_rich, client)
+        return True
+
+    def _purge_style(self, styles_raw):
+        """ Remove unauthorised styles according to the XEP-0071
+        @param styles_raw: raw styles (value of the style attribute)
+        """
+        purged = []
+
+        styles = [style.strip().split(":") for style in styles_raw.split(";")]
+
+        for style_tuple in styles:
+            if len(style_tuple) != 2:
+                continue
+            name, value = style_tuple
+            name = name.strip()
+            if name not in styles_allowed:
+                continue
+            purged.append((name, value.strip()))
+
+        return "; ".join(["%s: %s" % data for data in purged])
+
+    def XHTML2XHTML_IM(self, xhtml):
+        """ Convert XHTML document to XHTML_IM subset
+        @param xhtml: raw xhtml to convert
+        """
+        # TODO: more clever tag replacement (replace forbidden tags with equivalents when possible)
+
+        parser = html.HTMLParser(remove_comments=True, encoding="utf-8")
+        root = html.fromstring(xhtml, parser=parser)
+        body_elt = root.find("body")
+        if body_elt is None:
+            # we use the whole XML as body if no body element is found
+            body_elt = html.Element("body")
+            body_elt.append(root)
+        else:
+            body_elt.attrib.clear()
+
+        allowed_tags = list(allowed.keys())
+        to_strip = []
+        for elem in body_elt.iter():
+            if elem.tag not in allowed_tags:
+                to_strip.append(elem)
+            else:
+                # we remove unallowed attributes
+                attrib = elem.attrib
+                att_to_remove = set(attrib).difference(allowed[elem.tag])
+                for att in att_to_remove:
+                    del (attrib[att])
+                if "style" in attrib:
+                    attrib["style"] = self._purge_style(attrib["style"])
+
+        for elem in to_strip:
+            if elem.tag in blacklist:
+                # we need to remove the element and all descendants
+                log.debug("removing black listed tag: %s" % (elem.tag))
+                elem.drop_tree()
+            else:
+                elem.drop_tag()
+        if len(body_elt) != 1:
+            root_elt = body_elt
+            body_elt.tag = "p"
+        else:
+            root_elt = body_elt[0]
+
+        return html.tostring(root_elt, encoding="unicode", method="xml")
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0071_handler(XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_XHTML_IM)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0077.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,312 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing xep-0077
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from twisted.words.protocols.jabber import jid, xmlstream, client, error as jabber_error
+from twisted.internet import defer, reactor, ssl
+from wokkel import data_form
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.tools import xml_tools
+
+log = getLogger(__name__)
+
+NS_REG = "jabber:iq:register"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP 0077 Plugin",
+    C.PI_IMPORT_NAME: "XEP-0077",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0077"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "XEP_0077",
+    C.PI_DESCRIPTION: _("""Implementation of in-band registration"""),
+}
+
+# FIXME: this implementation is incomplete
+
+
+class RegisteringAuthenticator(xmlstream.ConnectAuthenticator):
+    # FIXME: request IQ is not send to check available fields,
+    #        while XEP recommand to use it
+    # FIXME: doesn't handle data form or oob
+    namespace = 'jabber:client'
+
+    def __init__(self, jid_, password, email=None, check_certificate=True):
+        log.debug(_("Registration asked for {jid}").format(jid=jid_))
+        xmlstream.ConnectAuthenticator.__init__(self, jid_.host)
+        self.jid = jid_
+        self.password = password
+        self.email = email
+        self.check_certificate = check_certificate
+        self.registered = defer.Deferred()
+
+    def associateWithStream(self, xs):
+        xmlstream.ConnectAuthenticator.associateWithStream(self, xs)
+        xs.addObserver(xmlstream.STREAM_AUTHD_EVENT, self.register)
+
+        xs.initializers = [client.CheckVersionInitializer(xs)]
+        if self.check_certificate:
+            tls_required, configurationForTLS = True, None
+        else:
+            tls_required = False
+            configurationForTLS = ssl.CertificateOptions(trustRoot=None)
+        tls_init = xmlstream.TLSInitiatingInitializer(
+            xs, required=tls_required, configurationForTLS=configurationForTLS)
+
+        xs.initializers.append(tls_init)
+
+    def register(self, xmlstream):
+        log.debug(_("Stream started with {server}, now registering"
+                    .format(server=self.jid.host)))
+        iq = XEP_0077.build_register_iq(self.xmlstream, self.jid, self.password, self.email)
+        d = iq.send(self.jid.host).addCallbacks(self.registration_cb, self.registration_eb)
+        d.chainDeferred(self.registered)
+
+    def registration_cb(self, answer):
+        log.debug(_("Registration answer: {}").format(answer.toXml()))
+        self.xmlstream.sendFooter()
+
+    def registration_eb(self, failure_):
+        log.info(_("Registration failure: {}").format(str(failure_.value)))
+        self.xmlstream.sendFooter()
+        raise failure_
+
+
+class ServerRegister(xmlstream.XmlStreamFactory):
+
+    def __init__(self, *args, **kwargs):
+        xmlstream.XmlStreamFactory.__init__(self, *args, **kwargs)
+        self.addBootstrap(xmlstream.STREAM_END_EVENT, self._disconnected)
+
+    def clientConnectionLost(self, connector, reason):
+        connector.disconnect()
+
+    def _disconnected(self, reason):
+        if not self.authenticator.registered.called:
+            err = jabber_error.StreamError("Server unexpectedly closed the connection")
+            try:
+                if reason.value.args[0][0][2] == "certificate verify failed":
+                    err = exceptions.InvalidCertificate()
+            except (IndexError, TypeError):
+                pass
+            self.authenticator.registered.errback(err)
+
+
+class XEP_0077(object):
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0077 initialization"))
+        self.host = host
+        host.bridge.add_method(
+            "in_band_register",
+            ".plugin",
+            in_sign="ss",
+            out_sign="",
+            method=self._in_band_register,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "in_band_account_new",
+            ".plugin",
+            in_sign="ssssi",
+            out_sign="",
+            method=self._register_new_account,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "in_band_unregister",
+            ".plugin",
+            in_sign="ss",
+            out_sign="",
+            method=self._unregister,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "in_band_password_change",
+            ".plugin",
+            in_sign="ss",
+            out_sign="",
+            method=self._change_password,
+            async_=True,
+        )
+
+    @staticmethod
+    def build_register_iq(xmlstream_, jid_, password, email=None):
+        iq_elt = xmlstream.IQ(xmlstream_, "set")
+        iq_elt["to"] = jid_.host
+        query_elt = iq_elt.addElement(("jabber:iq:register", "query"))
+        username_elt = query_elt.addElement("username")
+        username_elt.addContent(jid_.user)
+        password_elt = query_elt.addElement("password")
+        password_elt.addContent(password)
+        if email is not None:
+            email_elt = query_elt.addElement("email")
+            email_elt.addContent(email)
+        return iq_elt
+
+    def _reg_cb(self, answer, client, post_treat_cb):
+        """Called after the first get IQ"""
+        try:
+            query_elt = next(answer.elements(NS_REG, "query"))
+        except StopIteration:
+            raise exceptions.DataError("Can't find expected query element")
+
+        try:
+            x_elem = next(query_elt.elements(data_form.NS_X_DATA, "x"))
+        except StopIteration:
+            # XXX: it seems we have an old service which doesn't manage data forms
+            log.warning(_("Can't find data form"))
+            raise exceptions.DataError(
+                _("This gateway can't be managed by SàT, sorry :(")
+            )
+
+        def submit_form(data, profile):
+            form_elt = xml_tools.xmlui_result_to_elt(data)
+
+            iq_elt = client.IQ()
+            iq_elt["id"] = answer["id"]
+            iq_elt["to"] = answer["from"]
+            query_elt = iq_elt.addElement("query", NS_REG)
+            query_elt.addChild(form_elt)
+            d = iq_elt.send()
+            d.addCallback(self._reg_success, client, post_treat_cb)
+            d.addErrback(self._reg_failure, client)
+            return d
+
+        form = data_form.Form.fromElement(x_elem)
+        submit_reg_id = self.host.register_callback(
+            submit_form, with_data=True, one_shot=True
+        )
+        return xml_tools.data_form_2_xmlui(form, submit_reg_id)
+
+    def _reg_eb(self, failure, client):
+        """Called when something is wrong with registration"""
+        log.info(_("Registration failure: %s") % str(failure.value))
+        raise failure
+
+    def _reg_success(self, answer, client, post_treat_cb):
+        log.debug(_("registration answer: %s") % answer.toXml())
+        if post_treat_cb is not None:
+            post_treat_cb(jid.JID(answer["from"]), client.profile)
+        return {}
+
+    def _reg_failure(self, failure, client):
+        log.info(_("Registration failure: %s") % str(failure.value))
+        if failure.value.condition == "conflict":
+            raise exceptions.ConflictError(
+                _("Username already exists, please choose an other one")
+            )
+        raise failure
+
+    def _in_band_register(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
+        return self.in_band_register, jid.JID(to_jid_s, profile_key)
+
+    def in_band_register(self, to_jid, post_treat_cb=None, profile_key=C.PROF_KEY_NONE):
+        """register to a service
+
+        @param to_jid(jid.JID): jid of the service to register to
+        """
+        # FIXME: this post_treat_cb arguments seems wrong, check it
+        client = self.host.get_client(profile_key)
+        log.debug(_("Asking registration for {}").format(to_jid.full()))
+        reg_request = client.IQ("get")
+        reg_request["from"] = client.jid.full()
+        reg_request["to"] = to_jid.full()
+        reg_request.addElement("query", NS_REG)
+        d = reg_request.send(to_jid.full()).addCallbacks(
+            self._reg_cb,
+            self._reg_eb,
+            callbackArgs=[client, post_treat_cb],
+            errbackArgs=[client],
+        )
+        return d
+
+    def _register_new_account(self, jid_, password, email, host, port):
+        kwargs = {}
+        if email:
+            kwargs["email"] = email
+        if host:
+            kwargs["host"] = host
+        if port:
+            kwargs["port"] = port
+        return self.register_new_account(jid.JID(jid_), password, **kwargs)
+
+    def register_new_account(
+        self, jid_, password, email=None, host=None, port=C.XMPP_C2S_PORT
+    ):
+        """register a new account on a XMPP server
+
+        @param jid_(jid.JID): request jid to register
+        @param password(unicode): password of the account
+        @param email(unicode): email of the account
+        @param host(None, unicode): host of the server to register to
+        @param port(int): port of the server to register to
+        """
+        if host is None:
+           host = self.host.memory.config_get("", "xmpp_domain", "127.0.0.1")
+        check_certificate = host != "127.0.0.1"
+        authenticator = RegisteringAuthenticator(
+            jid_, password, email, check_certificate=check_certificate)
+        registered_d = authenticator.registered
+        server_register = ServerRegister(authenticator)
+        reactor.connectTCP(host, port, server_register)
+        return registered_d
+
+    def _change_password(self, new_password, profile_key):
+        client = self.host.get_client(profile_key)
+        return self.change_password(client, new_password)
+
+    def change_password(self, client, new_password):
+        iq_elt = self.build_register_iq(client.xmlstream, client.jid, new_password)
+        d = iq_elt.send(client.jid.host)
+        d.addCallback(
+            lambda __: self.host.memory.param_set(
+                "Password", new_password, "Connection", profile_key=client.profile
+            )
+        )
+        return d
+
+    def _unregister(self, to_jid_s, profile_key):
+        client = self.host.get_client(profile_key)
+        return self.unregister(client, jid.JID(to_jid_s))
+
+    def unregister(
+            self,
+            client: SatXMPPEntity,
+            to_jid: jid.JID
+    ) -> defer.Deferred:
+        """remove registration from a server/service
+
+        BEWARE! if you remove registration from profile own server, this will
+        DELETE THE XMPP ACCOUNT WITHOUT WARNING
+        @param to_jid: jid of the service or server
+            None to delete client's account (DANGEROUS!)
+        """
+        iq_elt = client.IQ()
+        if to_jid is not None:
+            iq_elt["to"] = to_jid.full()
+        query_elt = iq_elt.addElement((NS_REG, "query"))
+        query_elt.addElement("remove")
+        d = iq_elt.send()
+        if not to_jid or to_jid == jid.JID(client.jid.host):
+            d.addCallback(lambda __: client.entity_disconnect())
+        return d
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0080.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Dict, Any
+
+from twisted.words.xish import domish
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.tools import utils
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "User Location",
+    C.PI_IMPORT_NAME: "XEP-0080",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0080"],
+    C.PI_MAIN: "XEP_0080",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of XEP-0080 (User Location)"""),
+}
+
+NS_GEOLOC = "http://jabber.org/protocol/geoloc"
+KEYS_TYPES = {
+    "accuracy": float,
+    "alt": float,
+    "altaccuracy": float,
+    "area": str,
+    "bearing": float,
+    "building": str,
+    "country": str,
+    "countrycode": str,
+    "datum": str,
+    "description": str,
+    "error": float,
+    "floor": str,
+    "lat": float,
+    "locality": str,
+    "lon": float,
+    "postalcode": str,
+    "region": str,
+    "room": str,
+    "speed": float,
+    "street": str,
+    "text": str,
+    "timestamp": "datetime",
+    "tzo": str,
+    "uri": str
+}
+
+
+class XEP_0080:
+
+    def __init__(self, host):
+        log.info(_("XEP-0080 (User Location) plugin initialization"))
+        host.register_namespace("geoloc", NS_GEOLOC)
+
+    def get_geoloc_elt(
+        self,
+        location_data: Dict[str, Any],
+    ) -> domish.Element:
+        """Generate the element describing the location
+
+        @param geoloc: metadata description the location
+            Keys correspond to ones found at
+            https://xmpp.org/extensions/xep-0080.html#format, with following additional
+            keys:
+                - id (str): Identifier for this location
+                - language (str): language of the human readable texts
+            All keys are optional.
+        @return: ``<geoloc/>`` element
+        """
+        geoloc_elt = domish.Element((NS_GEOLOC, "geoloc"))
+        for key, value in location_data.items():
+            try:
+                key_type = KEYS_TYPES[key]
+            except KeyError:
+                if key == "id":
+                    # "id" attribute is not specified for XEP-0080's <geoloc/> element,
+                    # but it may be used in a parent element (that's the case for events)
+                    pass
+                elif key == "language":
+                    geoloc_elt["xml:lang"] = value
+                else:
+                    log.warning(f"Unknown location key {key}: {location_data}")
+                continue
+            if key_type == "datetime":
+                content = utils.xmpp_date(value)
+            else:
+                content = str(value)
+            geoloc_elt.addElement(key, content=content)
+
+        return geoloc_elt
+
+    def parse_geoloc_elt(
+        self,
+        geoloc_elt: domish.Element
+    ) -> Dict[str, Any]:
+        """Parse <geoloc/> element
+
+        @param geoloc_elt: <geoloc/> element
+            a parent element can also be used
+        @return: geoloc data. It's a dict whose keys correspond to
+            [get_geoloc_elt] parameters
+        @raise exceptions.NotFound: no <geoloc/> element has been found
+        """
+
+        if geoloc_elt.name != "geoloc":
+            try:
+                geoloc_elt = next(geoloc_elt.elements(NS_GEOLOC, "geoloc"))
+            except StopIteration:
+                raise exceptions.NotFound
+        data: Dict[str, Any] = {}
+        for elt in geoloc_elt.elements():
+            if elt.uri != NS_GEOLOC:
+                log.warning(f"unmanaged geoloc element: {elt.toXml()}")
+                continue
+            try:
+                data_type = KEYS_TYPES[elt.name]
+            except KeyError:
+                log.warning(f"unknown geoloc element: {elt.toXml()}")
+                continue
+            try:
+                if data_type == "datetime":
+                    data[elt.name] = utils.parse_xmpp_date(str(elt))
+                else:
+                    data[elt.name] = data_type(str(elt))
+            except Exception as e:
+                log.warning(f"can't parse element: {elt.toXml()}")
+                continue
+
+        return data
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0082.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for XMPP Date and Time Profile formatting and parsing with Python's
+# datetime package
+# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import D_
+from libervia.backend.core.sat_main import SAT
+from libervia.backend.tools import xmpp_datetime
+
+
+__all__ = [  # pylint: disable=unused-variable
+    "PLUGIN_INFO",
+    "XEP_0082"
+]
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XMPP Date and Time Profiles",
+    C.PI_IMPORT_NAME: "XEP-0082",
+    C.PI_TYPE: C.PLUG_TYPE_MISC,
+    C.PI_PROTOCOLS: [ "XEP-0082" ],
+    C.PI_DEPENDENCIES: [],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "XEP_0082",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: D_("Date and Time Profiles for XMPP"),
+}
+
+
+class XEP_0082:  # pylint: disable=invalid-name
+    """
+    Implementation of the date and time profiles specified in XEP-0082 using Python's
+    datetime module. The legacy format described in XEP-0082 section "4. Migration" is not
+    supported. Reexports of the functions in :mod:`sat.tools.xmpp_datetime`.
+
+    This is a passive plugin, i.e. it doesn't hook into any triggers to process stanzas
+    actively, but offers API for other plugins to use.
+    """
+
+    def __init__(self, sat: SAT) -> None:
+        """
+        @param sat: The SAT instance.
+        """
+
+    format_date = staticmethod(xmpp_datetime.format_date)
+    parse_date = staticmethod(xmpp_datetime.parse_date)
+    format_datetime = staticmethod(xmpp_datetime.format_datetime)
+    parse_datetime = staticmethod(xmpp_datetime.parse_datetime)
+    format_time = staticmethod(xmpp_datetime.format_time)
+    parse_time = staticmethod(xmpp_datetime.parse_time)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0084.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,268 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for XEP-0084
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional, Dict, Any
+from pathlib import Path
+from base64 import b64decode, b64encode
+
+from twisted.internet import defer
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.protocols.jabber import jid, error
+from twisted.words.xish import domish
+from zope.interface import implementer
+from wokkel import disco, iwokkel, pubsub
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core import exceptions
+
+
+log = getLogger(__name__)
+
+IMPORT_NAME = "XEP-0084"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "User Avatar",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0084"],
+    C.PI_DEPENDENCIES: ["IDENTITY", "XEP-0060", "XEP-0163"],
+    C.PI_MAIN: "XEP_0084",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""XEP-0084 (User Avatar) implementation"""),
+}
+
+NS_AVATAR = "urn:xmpp:avatar"
+NS_AVATAR_METADATA = f"{NS_AVATAR}:metadata"
+NS_AVATAR_DATA = f"{NS_AVATAR}:data"
+
+
+class XEP_0084:
+    namespace_metadata = NS_AVATAR_METADATA
+    namespace_data = NS_AVATAR_DATA
+
+    def __init__(self, host):
+        log.info(_("XEP-0084 (User Avatar) plugin initialization"))
+        host.register_namespace("avatar_metadata", NS_AVATAR_METADATA)
+        host.register_namespace("avatar_data", NS_AVATAR_DATA)
+        self.host = host
+        self._p = host.plugins["XEP-0060"]
+        self._i = host.plugins['IDENTITY']
+        self._i.register(
+            IMPORT_NAME,
+            "avatar",
+            self.get_avatar,
+            self.set_avatar,
+            priority=2000
+        )
+        host.plugins["XEP-0163"].add_pep_event(
+            None, NS_AVATAR_METADATA, self._on_metadata_update
+        )
+
+    def get_handler(self, client):
+        return XEP_0084_Handler()
+
+    def _on_metadata_update(self, itemsEvent, profile):
+        client = self.host.get_client(profile)
+        defer.ensureDeferred(self.on_metadata_update(client, itemsEvent))
+
+    async def on_metadata_update(
+        self,
+        client: SatXMPPEntity,
+        itemsEvent: pubsub.ItemsEvent
+    ) -> None:
+        entity = client.jid.userhostJID()
+        avatar_metadata = await self.get_avatar(client, entity)
+        await self._i.update(client, IMPORT_NAME, "avatar", avatar_metadata, entity)
+
+    async def get_avatar(
+            self,
+            client: SatXMPPEntity,
+            entity_jid: jid.JID
+        ) -> Optional[dict]:
+        """Get avatar data
+
+        @param entity: entity to get avatar from
+        @return: avatar metadata, or None if no avatar has been found
+        """
+        service = entity_jid.userhostJID()
+        # metadata
+        try:
+            items, __ = await self._p.get_items(
+                client,
+                service,
+                NS_AVATAR_METADATA,
+                max_items=1
+            )
+        except exceptions.NotFound:
+            return None
+
+        if not items:
+            return None
+
+        item_elt = items[0]
+        try:
+            metadata_elt = next(item_elt.elements(NS_AVATAR_METADATA, "metadata"))
+        except StopIteration:
+            log.warning(f"missing metadata element: {item_elt.toXml()}")
+            return None
+
+        for info_elt in metadata_elt.elements(NS_AVATAR_METADATA, "info"):
+            try:
+                metadata = {
+                    "id": str(info_elt["id"]),
+                    "size": int(info_elt["bytes"]),
+                    "media_type": str(info_elt["type"])
+                }
+                avatar_id = metadata["id"]
+                if not avatar_id:
+                    raise ValueError
+            except (KeyError, ValueError):
+                log.warning(f"invalid <info> element: {item_elt.toXml()}")
+                return None
+            # FIXME: to simplify, we only handle image/png for now
+            if metadata["media_type"] == "image/png":
+                break
+        else:
+            # mandatory image/png is missing, or avatar is disabled
+            # (https://xmpp.org/extensions/xep-0084.html#pub-disable)
+            return None
+
+        cache_data = self.host.common_cache.get_metadata(avatar_id)
+        if not cache_data:
+            try:
+                data_items, __ = await self._p.get_items(
+                    client,
+                    service,
+                    NS_AVATAR_DATA,
+                    item_ids=[avatar_id]
+                )
+                data_item_elt = data_items[0]
+            except (error.StanzaError, IndexError) as e:
+                log.warning(
+                    f"Can't retrieve avatar of {service.full()} with ID {avatar_id!r}: "
+                    f"{e}"
+                )
+                return None
+            try:
+                avatar_buf = b64decode(
+                    str(next(data_item_elt.elements(NS_AVATAR_DATA, "data")))
+                )
+            except Exception as e:
+                log.warning(
+                    f"invalid data element for {service.full()} with avatar ID "
+                    f"{avatar_id!r}: {e}\n{data_item_elt.toXml()}"
+                )
+                return None
+            with self.host.common_cache.cache_data(
+                IMPORT_NAME,
+                avatar_id,
+                metadata["media_type"]
+            ) as f:
+                f.write(avatar_buf)
+                cache_data = {
+                    "path": Path(f.name),
+                    "mime_type": metadata["media_type"]
+                }
+
+        return self._i.avatar_build_metadata(
+                cache_data['path'], cache_data['mime_type'], avatar_id
+        )
+
+    def build_item_data_elt(self, avatar_data: Dict[str, Any]) -> domish.Element:
+        """Generate the item for the data node
+
+        @param avatar_data: data as build by identity plugin (need to be filled with
+            "cache_uid" and "base64" keys)
+        """
+        data_elt = domish.Element((NS_AVATAR_DATA, "data"))
+        data_elt.addContent(avatar_data["base64"])
+        return pubsub.Item(id=avatar_data["cache_uid"], payload=data_elt)
+
+    def build_item_metadata_elt(self, avatar_data: Dict[str, Any]) -> domish.Element:
+        """Generate the item for the metadata node
+
+        @param avatar_data: data as build by identity plugin (need to be filled with
+            "cache_uid", "path", and "media_type" keys)
+        """
+        metadata_elt = domish.Element((NS_AVATAR_METADATA, "metadata"))
+        info_elt = metadata_elt.addElement("info")
+        # FIXME: we only fill required elements for now (see
+        #        https://xmpp.org/extensions/xep-0084.html#table-1)
+        info_elt["id"] = avatar_data["cache_uid"]
+        info_elt["type"] = avatar_data["media_type"]
+        info_elt["bytes"] = str(avatar_data["path"].stat().st_size)
+        return pubsub.Item(id=self._p.ID_SINGLETON, payload=metadata_elt)
+
+    async def set_avatar(
+        self,
+        client: SatXMPPEntity,
+        avatar_data: Dict[str, Any],
+        entity: jid.JID
+    ) -> None:
+        """Set avatar of the profile
+
+        @param avatar_data(dict): data of the image to use as avatar, as built by
+            IDENTITY plugin.
+        @param entity(jid.JID): entity whose avatar must be changed
+        """
+        service = entity.userhostJID()
+
+        # Data
+        await self._p.create_if_new_node(
+            client,
+            service,
+            NS_AVATAR_DATA,
+            options={
+                self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
+                self._p.OPT_PERSIST_ITEMS: 1,
+                self._p.OPT_MAX_ITEMS: 1,
+            }
+        )
+        item_data_elt = self.build_item_data_elt(avatar_data)
+        await self._p.send_items(client, service, NS_AVATAR_DATA, [item_data_elt])
+
+        # Metadata
+        await self._p.create_if_new_node(
+            client,
+            service,
+            NS_AVATAR_METADATA,
+            options={
+                self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
+                self._p.OPT_PERSIST_ITEMS: 1,
+                self._p.OPT_MAX_ITEMS: 1,
+            }
+        )
+        item_metadata_elt = self.build_item_metadata_elt(avatar_data)
+        await self._p.send_items(client, service, NS_AVATAR_METADATA, [item_metadata_elt])
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0084_Handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [
+            disco.DiscoFeature(NS_AVATAR_METADATA),
+            disco.DiscoFeature(NS_AVATAR_DATA)
+        ]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0085.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,433 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Chat State Notifications Protocol (xep-0085)
+# Copyright (C) 2009-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+from twisted.words.protocols.jabber.jid import JID
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+from twisted.words.xish import domish
+from twisted.internet import reactor
+from twisted.internet import error as internet_error
+
+NS_XMPP_CLIENT = "jabber:client"
+NS_CHAT_STATES = "http://jabber.org/protocol/chatstates"
+CHAT_STATES = ["active", "inactive", "gone", "composing", "paused"]
+MESSAGE_TYPES = ["chat", "groupchat"]
+PARAM_KEY = "Notifications"
+PARAM_NAME = "Enable chat state notifications"
+ENTITY_KEY = PARAM_KEY + "_" + PARAM_NAME
+DELETE_VALUE = "DELETE"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Chat State Notifications Protocol Plugin",
+    C.PI_IMPORT_NAME: "XEP-0085",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0085"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "XEP_0085",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Chat State Notifications Protocol"""),
+}
+
+
+# Describe the internal transitions that are triggered
+# by a timer. Beside that, external transitions can be
+# runned to target the states "active" or "composing".
+# Delay is specified here in seconds.
+TRANSITIONS = {
+    "active": {"next_state": "inactive", "delay": 120},
+    "inactive": {"next_state": "gone", "delay": 480},
+    "gone": {"next_state": "", "delay": 0},
+    "composing": {"next_state": "paused", "delay": 30},
+    "paused": {"next_state": "inactive", "delay": 450},
+}
+
+
+class UnknownChatStateException(Exception):
+    """
+    This error is raised when an unknown chat state is used.
+    """
+
+    pass
+
+
+class XEP_0085(object):
+    """
+    Implementation for XEP 0085
+    """
+
+    params = """
+    <params>
+    <individual>
+    <category name="%(category_name)s" label="%(category_label)s">
+        <param name="%(param_name)s" label="%(param_label)s" value="true" type="bool" security="0"/>
+     </category>
+    </individual>
+    </params>
+    """ % {
+        "category_name": PARAM_KEY,
+        "category_label": _(PARAM_KEY),
+        "param_name": PARAM_NAME,
+        "param_label": _("Enable chat state notifications"),
+    }
+
+    def __init__(self, host):
+        log.info(_("Chat State Notifications plugin initialization"))
+        self.host = host
+        self.map = {}  # FIXME: would be better to use client instead of mapping profile to data
+
+        # parameter value is retrieved before each use
+        host.memory.update_params(self.params)
+
+        # triggers from core
+        host.trigger.add("message_received", self.message_received_trigger)
+        host.trigger.add("sendMessage", self.send_message_trigger)
+        host.trigger.add("param_update_trigger", self.param_update_trigger)
+
+        # args: to_s (jid as string), profile
+        host.bridge.add_method(
+            "chat_state_composing",
+            ".plugin",
+            in_sign="ss",
+            out_sign="",
+            method=self.chat_state_composing,
+        )
+
+        # args: from (jid as string), state in CHAT_STATES, profile
+        host.bridge.add_signal("chat_state_received", ".plugin", signature="sss")
+
+    def get_handler(self, client):
+        return XEP_0085_handler(self, client.profile)
+
+    def profile_disconnected(self, client):
+        """Eventually send a 'gone' state to all one2one contacts."""
+        profile = client.profile
+        if profile not in self.map:
+            return
+        for to_jid in self.map[profile]:
+            # FIXME: the "unavailable" presence stanza is received by to_jid
+            # before the chat state, so it will be ignored... find a way to
+            # actually defer the disconnection
+            self.map[profile][to_jid]._onEvent("gone")
+        del self.map[profile]
+
+    def update_cache(self, entity_jid, value, profile):
+        """Update the entity data of the given profile for one or all contacts.
+        Reset the chat state(s) display if the notification has been disabled.
+
+        @param entity_jid: contact's JID, or C.ENTITY_ALL to update all contacts.
+        @param value: True, False or DELETE_VALUE to delete the entity data
+        @param profile: current profile
+        """
+        client = self.host.get_client(profile)
+        if value == DELETE_VALUE:
+            self.host.memory.del_entity_datum(client, entity_jid, ENTITY_KEY)
+        else:
+            self.host.memory.update_entity_data(
+                client, entity_jid, ENTITY_KEY, value
+            )
+        if not value or value == DELETE_VALUE:
+            # reinit chat state UI for this or these contact(s)
+            self.host.bridge.chat_state_received(entity_jid.full(), "", profile)
+
+    def param_update_trigger(self, name, value, category, type_, profile):
+        """Reset all the existing chat state entity data associated with this profile after a parameter modification.
+
+        @param name: parameter name
+        @param value: "true" to activate the notifications, or any other value to delete it
+        @param category: parameter category
+        @param type_: parameter type
+        """
+        if (category, name) == (PARAM_KEY, PARAM_NAME):
+            self.update_cache(
+                C.ENTITY_ALL, True if C.bool(value) else DELETE_VALUE, profile=profile
+            )
+            return False
+        return True
+
+    def message_received_trigger(self, client, message, post_treat):
+        """
+        Update the entity cache when we receive a message with body.
+        Check for a chat state in the message and signal frontends.
+        """
+        profile = client.profile
+        if not self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile):
+            return True
+
+        from_jid = JID(message.getAttribute("from"))
+        if self._is_muc(from_jid, profile):
+            from_jid = from_jid.userhostJID()
+        else:  # update entity data for one2one chat
+            # assert from_jid.resource # FIXME: assert doesn't work on normal message from server (e.g. server announce), because there is no resource
+            try:
+                next(domish.generateElementsNamed(message.elements(), name="body"))
+                try:
+                    next(domish.generateElementsNamed(message.elements(), name="active"))
+                    # contact enabled Chat State Notifications
+                    self.update_cache(from_jid, True, profile=profile)
+                except StopIteration:
+                    if message.getAttribute("type") == "chat":
+                        # contact didn't enable Chat State Notifications
+                        self.update_cache(from_jid, False, profile=profile)
+                        return True
+            except StopIteration:
+                pass
+
+        # send our next "composing" states to any MUC and to the contacts who enabled the feature
+        self._chat_state_init(from_jid, message.getAttribute("type"), profile)
+
+        state_list = [
+            child.name
+            for child in message.elements()
+            if message.getAttribute("type") in MESSAGE_TYPES
+            and child.name in CHAT_STATES
+            and child.defaultUri == NS_CHAT_STATES
+        ]
+        for state in state_list:
+            # there must be only one state according to the XEP
+            if state != "gone" or message.getAttribute("type") != "groupchat":
+                self.host.bridge.chat_state_received(
+                    message.getAttribute("from"), state, profile
+                )
+            break
+        return True
+
+    def send_message_trigger(
+        self, client, mess_data, pre_xml_treatments, post_xml_treatments
+    ):
+        """
+        Eventually add the chat state to the message and initiate
+        the state machine when sending an "active" state.
+        """
+        profile = client.profile
+
+        def treatment(mess_data):
+            message = mess_data["xml"]
+            to_jid = JID(message.getAttribute("to"))
+            if not self._check_activation(to_jid, forceEntityData=True, profile=profile):
+                return mess_data
+            try:
+                # message with a body always mean active state
+                next(domish.generateElementsNamed(message.elements(), name="body"))
+                message.addElement("active", NS_CHAT_STATES)
+                # launch the chat state machine (init the timer)
+                if self._is_muc(to_jid, profile):
+                    to_jid = to_jid.userhostJID()
+                self._chat_state_active(to_jid, mess_data["type"], profile)
+            except StopIteration:
+                if "chat_state" in mess_data["extra"]:
+                    state = mess_data["extra"].pop("chat_state")
+                    assert state in CHAT_STATES
+                    message.addElement(state, NS_CHAT_STATES)
+            return mess_data
+
+        post_xml_treatments.addCallback(treatment)
+        return True
+
+    def _is_muc(self, to_jid, profile):
+        """Tell if that JID is a MUC or not
+
+        @param to_jid (JID): full or bare JID to check
+        @param profile (str): %(doc_profile)s
+        @return: bool
+        """
+        client = self.host.get_client(profile)
+        try:
+            type_ = self.host.memory.get_entity_datum(
+                client, to_jid.userhostJID(), C.ENTITY_TYPE)
+            if type_ == C.ENTITY_TYPE_MUC:
+                return True
+        except (exceptions.UnknownEntityError, KeyError):
+            pass
+        return False
+
+    def _check_activation(self, to_jid, forceEntityData, profile):
+        """
+        @param to_jid: the contact's full JID (or bare if you know it's a MUC)
+        @param forceEntityData: if set to True, a non-existing
+        entity data will be considered to be True (and initialized)
+        @param: current profile
+        @return: True if the notifications should be sent to this JID.
+        """
+        client = self.host.get_client(profile)
+        # check if the parameter is active
+        if not self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile):
+            return False
+        # check if notifications should be sent to this contact
+        if self._is_muc(to_jid, profile):
+            return True
+        # FIXME: this assertion crash when we want to send a message to an online bare jid
+        # assert to_jid.resource or not self.host.memory.is_entity_available(to_jid, profile) # must either have a resource, or talk to an offline contact
+        try:
+            return self.host.memory.get_entity_datum(client, to_jid, ENTITY_KEY)
+        except (exceptions.UnknownEntityError, KeyError):
+            if forceEntityData:
+                # enable it for the first time
+                self.update_cache(to_jid, True, profile=profile)
+                return True
+        # wait for the first message before sending states
+        return False
+
+    def _chat_state_init(self, to_jid, mess_type, profile):
+        """
+        Data initialization for the chat state machine.
+
+        @param to_jid (JID): full JID for one2one, bare JID for MUC
+        @param mess_type (str): "one2one" or "groupchat"
+        @param profile (str): %(doc_profile)s
+        """
+        if mess_type is None:
+            return
+        profile_map = self.map.setdefault(profile, {})
+        if to_jid not in profile_map:
+            machine = ChatStateMachine(self.host, to_jid, mess_type, profile)
+            self.map[profile][to_jid] = machine
+
+    def _chat_state_active(self, to_jid, mess_type, profile_key):
+        """
+        Launch the chat state machine on "active" state.
+
+        @param to_jid (JID): full JID for one2one, bare JID for MUC
+        @param mess_type (str): "one2one" or "groupchat"
+        @param profile (str): %(doc_profile)s
+        """
+        profile = self.host.memory.get_profile_name(profile_key)
+        if profile is None:
+            raise exceptions.ProfileUnknownError
+        self._chat_state_init(to_jid, mess_type, profile)
+        self.map[profile][to_jid]._onEvent("active")
+
+    def chat_state_composing(self, to_jid_s, profile_key):
+        """Move to the "composing" state when required.
+
+        Since this method is called from the front-end, it needs to check the
+        values of the parameter "Send chat state notifications" and the entity
+        data associated to the target JID.
+
+        @param to_jid_s (str): contact full JID as a string
+        @param profile_key (str): %(doc_profile_key)s
+        """
+        # TODO: try to optimize this method which is called often
+        client = self.host.get_client(profile_key)
+        to_jid = JID(to_jid_s)
+        if self._is_muc(to_jid, client.profile):
+            to_jid = to_jid.userhostJID()
+        elif not to_jid.resource:
+            to_jid.resource = self.host.memory.main_resource_get(client, to_jid)
+        if not self._check_activation(
+            to_jid, forceEntityData=False, profile=client.profile
+        ):
+            return
+        try:
+            self.map[client.profile][to_jid]._onEvent("composing")
+        except (KeyError, AttributeError):
+            # no message has been sent/received since the notifications
+            # have been enabled, it's better to wait for a first one
+            pass
+
+
+class ChatStateMachine(object):
+    """
+    This class represents a chat state, between one profile and
+    one target contact. A timer is used to move from one state
+    to the other. The initialization is done through the "active"
+    state which is internally set when a message is sent. The state
+    "composing" can be set externally (through the bridge by a
+    frontend). Other states are automatically set with the timer.
+    """
+
+    def __init__(self, host, to_jid, mess_type, profile):
+        """
+        Initialization need to store the target, message type
+        and a profile for sending later messages.
+        """
+        self.host = host
+        self.to_jid = to_jid
+        self.mess_type = mess_type
+        self.profile = profile
+        self.state = None
+        self.timer = None
+
+    def _onEvent(self, state):
+        """
+        Move to the specified state, eventually send the
+        notification to the contact (the "active" state is
+        automatically sent with each message) and set the timer.
+        """
+        assert state in TRANSITIONS
+        transition = TRANSITIONS[state]
+        assert "next_state" in transition and "delay" in transition
+
+        if state != self.state and state != "active":
+            if state != "gone" or self.mess_type != "groupchat":
+                # send a new message without body
+                log.debug(
+                    "sending state '{state}' to {jid}".format(
+                        state=state, jid=self.to_jid.full()
+                    )
+                )
+                client = self.host.get_client(self.profile)
+                mess_data = {
+                    "from": client.jid,
+                    "to": self.to_jid,
+                    "uid": "",
+                    "message": {},
+                    "type": self.mess_type,
+                    "subject": {},
+                    "extra": {},
+                }
+                client.generate_message_xml(mess_data)
+                mess_data["xml"].addElement(state, NS_CHAT_STATES)
+                client.send(mess_data["xml"])
+
+        self.state = state
+        try:
+            self.timer.cancel()
+        except (internet_error.AlreadyCalled, AttributeError):
+            pass
+
+        if transition["next_state"] and transition["delay"] > 0:
+            self.timer = reactor.callLater(
+                transition["delay"], self._onEvent, transition["next_state"]
+            )
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0085_handler(XMPPHandler):
+
+    def __init__(self, plugin_parent, profile):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+        self.profile = profile
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_CHAT_STATES)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0092.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,142 @@
+#!/usr/bin/env python3
+
+
+# SàT plugin for Software Version (XEP-0092)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Tuple
+
+from twisted.internet import defer, reactor
+from twisted.words.protocols.jabber import jid
+from wokkel import compat
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+NS_VERSION = "jabber:iq:version"
+TIMEOUT = 10
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Software Version Plugin",
+    C.PI_IMPORT_NAME: "XEP-0092",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0092"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_RECOMMENDATIONS: [C.TEXT_CMDS],
+    C.PI_MAIN: "XEP_0092",
+    C.PI_HANDLER: "no",  # version is already handler in core.xmpp module
+    C.PI_DESCRIPTION: _("""Implementation of Software Version"""),
+}
+
+
+class XEP_0092(object):
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0092 initialization"))
+        self.host = host
+        host.bridge.add_method(
+            "software_version_get",
+            ".plugin",
+            in_sign="ss",
+            out_sign="(sss)",
+            method=self._get_version,
+            async_=True,
+        )
+        try:
+            self.host.plugins[C.TEXT_CMDS].add_who_is_cb(self._whois, 50)
+        except KeyError:
+            log.info(_("Text commands not available"))
+
+    def _get_version(self, entity_jid_s, profile_key):
+        def prepare_for_bridge(data):
+            name, version, os = data
+            return (name or "", version or "", os or "")
+
+        client = self.host.get_client(profile_key)
+        d = self.version_get(client, jid.JID(entity_jid_s))
+        d.addCallback(prepare_for_bridge)
+        return d
+
+    def version_get(
+        self,
+        client: SatXMPPEntity,
+        jid_: jid.JID,
+    ) -> Tuple[str, str, str]:
+        """Ask version of the client that jid_ is running
+
+        @param jid_: jid from who we want to know client's version
+        @return: a defered which fire a tuple with the following data (None if not available):
+            - name: Natural language name of the software
+            - version: specific version of the software
+            - os: operating system of the queried entity
+        """
+
+        def do_version_get(__):
+            iq_elt = compat.IQ(client.xmlstream, "get")
+            iq_elt["to"] = jid_.full()
+            iq_elt.addElement("query", NS_VERSION)
+            d = iq_elt.send()
+            d.addCallback(self._got_version)
+            return d
+
+        d = self.host.check_feature(client, NS_VERSION, jid_)
+        d.addCallback(do_version_get)
+        reactor.callLater(
+            TIMEOUT, d.cancel
+        )  # XXX: timeout needed because some clients don't answer the IQ
+        return d
+
+    def _got_version(self, iq_elt):
+        try:
+            query_elt = next(iq_elt.elements(NS_VERSION, "query"))
+        except StopIteration:
+            raise exceptions.DataError
+        ret = []
+        for name in ("name", "version", "os"):
+            try:
+                data_elt = next(query_elt.elements(NS_VERSION, name))
+                ret.append(str(data_elt))
+            except StopIteration:
+                ret.append(None)
+
+        return tuple(ret)
+
+    def _whois(self, client, whois_msg, mess_data, target_jid):
+        """Add software/OS information to whois"""
+
+        def version_cb(version_data):
+            name, version, os = version_data
+            if name:
+                whois_msg.append(_("Client name: %s") % name)
+            if version:
+                whois_msg.append(_("Client version: %s") % version)
+            if os:
+                whois_msg.append(_("Operating system: %s") % os)
+
+        def version_eb(failure):
+            failure.trap(exceptions.FeatureNotFound, defer.CancelledError)
+            if failure.check(failure, exceptions.FeatureNotFound):
+                whois_msg.append(_("Software version not available"))
+            else:
+                whois_msg.append(_("Client software version request timeout"))
+
+        d = self.version_get(client, target_jid)
+        d.addCallbacks(version_cb, version_eb)
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0095.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing xep-0095
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.protocols.jabber import error
+from zope.interface import implementer
+from wokkel import disco
+from wokkel import iwokkel
+import uuid
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP 0095 Plugin",
+    C.PI_IMPORT_NAME: "XEP-0095",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0095"],
+    C.PI_MAIN: "XEP_0095",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Stream Initiation"""),
+}
+
+
+IQ_SET = '/iq[@type="set"]'
+NS_SI = "http://jabber.org/protocol/si"
+SI_REQUEST = IQ_SET + '/si[@xmlns="' + NS_SI + '"]'
+SI_PROFILE_HEADER = "http://jabber.org/protocol/si/profile/"
+SI_ERROR_CONDITIONS = ("bad-profile", "no-valid-streams")
+
+
+class XEP_0095(object):
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0095 initialization"))
+        self.host = host
+        self.si_profiles = {}  # key: SI profile, value: callback
+
+    def get_handler(self, client):
+        return XEP_0095_handler(self)
+
+    def register_si_profile(self, si_profile, callback):
+        """Add a callback for a SI Profile
+
+        @param si_profile(unicode): SI profile name (e.g. file-transfer)
+        @param callback(callable): method to call when the profile name is asked
+        """
+        self.si_profiles[si_profile] = callback
+
+    def unregister_si_profile(self, si_profile):
+        try:
+            del self.si_profiles[si_profile]
+        except KeyError:
+            log.error(
+                "Trying to unregister SI profile [{}] which was not registered".format(
+                    si_profile
+                )
+            )
+
+    def stream_init(self, iq_elt, client):
+        """This method is called on stream initiation (XEP-0095 #3.2)
+
+        @param iq_elt: IQ element
+        """
+        log.info(_("XEP-0095 Stream initiation"))
+        iq_elt.handled = True
+        si_elt = next(iq_elt.elements(NS_SI, "si"))
+        si_id = si_elt["id"]
+        si_mime_type = iq_elt.getAttribute("mime-type", "application/octet-stream")
+        si_profile = si_elt["profile"]
+        si_profile_key = (
+            si_profile[len(SI_PROFILE_HEADER) :]
+            if si_profile.startswith(SI_PROFILE_HEADER)
+            else si_profile
+        )
+        if si_profile_key in self.si_profiles:
+            # We know this SI profile, we call the callback
+            self.si_profiles[si_profile_key](client, iq_elt, si_id, si_mime_type, si_elt)
+        else:
+            # We don't know this profile, we send an error
+            self.sendError(client, iq_elt, "bad-profile")
+
+    def sendError(self, client, request, condition):
+        """Send IQ error as a result
+
+        @param request(domish.Element): original IQ request
+        @param condition(str): error condition
+        """
+        if condition in SI_ERROR_CONDITIONS:
+            si_condition = condition
+            condition = "bad-request"
+        else:
+            si_condition = None
+
+        iq_error_elt = error.StanzaError(condition).toResponse(request)
+        if si_condition is not None:
+            iq_error_elt.error.addElement((NS_SI, si_condition))
+
+        client.send(iq_error_elt)
+
+    def accept_stream(self, client, iq_elt, feature_elt, misc_elts=None):
+        """Send the accept stream initiation answer
+
+        @param iq_elt(domish.Element): initial SI request
+        @param feature_elt(domish.Element): 'feature' element containing stream method to use
+        @param misc_elts(list[domish.Element]): list of elements to add
+        """
+        log.info(_("sending stream initiation accept answer"))
+        if misc_elts is None:
+            misc_elts = []
+        result_elt = xmlstream.toResponse(iq_elt, "result")
+        si_elt = result_elt.addElement((NS_SI, "si"))
+        si_elt.addChild(feature_elt)
+        for elt in misc_elts:
+            si_elt.addChild(elt)
+        client.send(result_elt)
+
+    def _parse_offer_result(self, iq_elt):
+        try:
+            si_elt = next(iq_elt.elements(NS_SI, "si"))
+        except StopIteration:
+            log.warning("No <si/> element found in result while expected")
+            raise exceptions.DataError
+        return (iq_elt, si_elt)
+
+    def propose_stream(
+        self,
+        client,
+        to_jid,
+        si_profile,
+        feature_elt,
+        misc_elts,
+        mime_type="application/octet-stream",
+    ):
+        """Propose a stream initiation
+
+        @param to_jid(jid.JID): recipient
+        @param si_profile(unicode): Stream initiation profile (XEP-0095)
+        @param feature_elt(domish.Element): feature element, according to XEP-0020
+        @param misc_elts(list[domish.Element]): list of elements to add
+        @param mime_type(unicode): stream mime type
+        @return (tuple): tuple with:
+            - session id (unicode)
+            - (D(domish_elt, domish_elt): offer deferred which returl a tuple
+                with iq_elt and si_elt
+        """
+        offer = client.IQ()
+        sid = str(uuid.uuid4())
+        log.debug(_("Stream Session ID: %s") % offer["id"])
+
+        offer["from"] = client.jid.full()
+        offer["to"] = to_jid.full()
+        si = offer.addElement("si", NS_SI)
+        si["id"] = sid
+        si["mime-type"] = mime_type
+        si["profile"] = si_profile
+        for elt in misc_elts:
+            si.addChild(elt)
+        si.addChild(feature_elt)
+
+        offer_d = offer.send()
+        offer_d.addCallback(self._parse_offer_result)
+        return sid, offer_d
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0095_handler(xmlstream.XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            SI_REQUEST, self.plugin_parent.stream_init, client=self.parent
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_SI)] + [
+            disco.DiscoFeature(
+                "http://jabber.org/protocol/si/profile/{}".format(profile_name)
+            )
+            for profile_name in self.plugin_parent.si_profiles
+        ]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0096.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,406 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing xep-0096
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import os
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import error
+from twisted.internet import defer
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools import stream
+
+log = getLogger(__name__)
+
+
+NS_SI_FT = "http://jabber.org/protocol/si/profile/file-transfer"
+IQ_SET = '/iq[@type="set"]'
+SI_PROFILE_NAME = "file-transfer"
+SI_PROFILE = "http://jabber.org/protocol/si/profile/" + SI_PROFILE_NAME
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP-0096 Plugin",
+    C.PI_IMPORT_NAME: "XEP-0096",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0096"],
+    C.PI_DEPENDENCIES: ["XEP-0020", "XEP-0095", "XEP-0065", "XEP-0047", "FILE"],
+    C.PI_MAIN: "XEP_0096",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of SI File Transfer"""),
+}
+
+
+class XEP_0096(object):
+    # TODO: call self._f.unregister when unloading order will be managing (i.e. when depenencies will be unloaded at the end)
+    name = PLUGIN_INFO[C.PI_NAME]
+    human_name = D_("Stream Initiation")
+
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0096 initialization"))
+        self.host = host
+        self.managed_stream_m = [
+            self.host.plugins["XEP-0065"].NAMESPACE,
+            self.host.plugins["XEP-0047"].NAMESPACE,
+        ]  # Stream methods managed
+        self._f = self.host.plugins["FILE"]
+        self._f.register(self)
+        self._si = self.host.plugins["XEP-0095"]
+        self._si.register_si_profile(SI_PROFILE_NAME, self._transfer_request)
+        host.bridge.add_method(
+            "si_file_send", ".plugin", in_sign="sssss", out_sign="s", method=self._file_send
+        )
+
+    async def can_handle_file_send(self, client, peer_jid, filepath):
+        return await self.host.hasFeature(client, NS_SI_FT, peer_jid)
+
+    def unload(self):
+        self._si.unregister_si_profile(SI_PROFILE_NAME)
+
+    def _bad_request(self, client, iq_elt, message=None):
+        """Send a bad-request error
+
+        @param iq_elt(domish.Element): initial <IQ> element of the SI request
+        @param message(None, unicode): informational message to display in the logs
+        """
+        if message is not None:
+            log.warning(message)
+        self._si.sendError(client, iq_elt, "bad-request")
+
+    def _parse_range(self, parent_elt, file_size):
+        """find and parse <range/> element
+
+        @param parent_elt(domish.Element): direct parent of the <range/> element
+        @return (tuple[bool, int, int]): a tuple with
+            - True if range is required
+            - range_offset
+            - range_length
+        """
+        try:
+            range_elt = next(parent_elt.elements(NS_SI_FT, "range"))
+        except StopIteration:
+            range_ = False
+            range_offset = None
+            range_length = None
+        else:
+            range_ = True
+
+            try:
+                range_offset = int(range_elt["offset"])
+            except KeyError:
+                range_offset = 0
+
+            try:
+                range_length = int(range_elt["length"])
+            except KeyError:
+                range_length = file_size
+
+            if range_offset != 0 or range_length != file_size:
+                raise NotImplementedError  # FIXME
+
+        return range_, range_offset, range_length
+
+    def _transfer_request(self, client, iq_elt, si_id, si_mime_type, si_elt):
+        """Called when a file transfer is requested
+
+        @param iq_elt(domish.Element): initial <IQ> element of the SI request
+        @param si_id(unicode): Stream Initiation session id
+        @param si_mime_type("unicode"): Mime type of the file (or default "application/octet-stream" if unknown)
+        @param si_elt(domish.Element): request
+        """
+        log.info(_("XEP-0096 file transfer requested"))
+        peer_jid = jid.JID(iq_elt["from"])
+
+        try:
+            file_elt = next(si_elt.elements(NS_SI_FT, "file"))
+        except StopIteration:
+            return self._bad_request(
+                client, iq_elt, "No <file/> element found in SI File Transfer request"
+            )
+
+        try:
+            feature_elt = self.host.plugins["XEP-0020"].get_feature_elt(si_elt)
+        except exceptions.NotFound:
+            return self._bad_request(
+                client, iq_elt, "No <feature/> element found in SI File Transfer request"
+            )
+
+        try:
+            filename = file_elt["name"]
+            file_size = int(file_elt["size"])
+        except (KeyError, ValueError):
+            return self._bad_request(client, iq_elt, "Malformed SI File Transfer request")
+
+        file_date = file_elt.getAttribute("date")
+        file_hash = file_elt.getAttribute("hash")
+
+        log.info(
+            "File proposed: name=[{name}] size={size}".format(
+                name=filename, size=file_size
+            )
+        )
+
+        try:
+            file_desc = str(next(file_elt.elements(NS_SI_FT, "desc")))
+        except StopIteration:
+            file_desc = ""
+
+        try:
+            range_, range_offset, range_length = self._parse_range(file_elt, file_size)
+        except ValueError:
+            return self._bad_request(client, iq_elt, "Malformed SI File Transfer request")
+
+        try:
+            stream_method = self.host.plugins["XEP-0020"].negotiate(
+                feature_elt, "stream-method", self.managed_stream_m, namespace=None
+            )
+        except KeyError:
+            return self._bad_request(client, iq_elt, "No stream method found")
+
+        if stream_method:
+            if stream_method == self.host.plugins["XEP-0065"].NAMESPACE:
+                plugin = self.host.plugins["XEP-0065"]
+            elif stream_method == self.host.plugins["XEP-0047"].NAMESPACE:
+                plugin = self.host.plugins["XEP-0047"]
+            else:
+                log.error(
+                    "Unknown stream method, this should not happen at this stage, cancelling transfer"
+                )
+        else:
+            log.warning("Can't find a valid stream method")
+            self._si.sendError(client, iq_elt, "not-acceptable")
+            return
+
+        # if we are here, the transfer can start, we just need user's agreement
+        data = {
+            "name": filename,
+            "peer_jid": peer_jid,
+            "size": file_size,
+            "date": file_date,
+            "hash": file_hash,
+            "desc": file_desc,
+            "range": range_,
+            "range_offset": range_offset,
+            "range_length": range_length,
+            "si_id": si_id,
+            "progress_id": si_id,
+            "stream_method": stream_method,
+            "stream_plugin": plugin,
+        }
+
+        d = defer.ensureDeferred(
+            self._f.get_dest_dir(client, peer_jid, data, data, stream_object=True)
+        )
+        d.addCallback(self.confirmation_cb, client, iq_elt, data)
+
+    def confirmation_cb(self, accepted, client, iq_elt, data):
+        """Called on confirmation answer
+
+        @param accepted(bool): True if file transfer is accepted
+        @param iq_elt(domish.Element): initial SI request
+        @param data(dict): session data
+        """
+        if not accepted:
+            log.info("File transfer declined")
+            self._si.sendError(client, iq_elt, "forbidden")
+            return
+        # data, timeout, stream_method, failed_methods = client._xep_0096_waiting_for_approval[sid]
+        # can_range = data['can_range'] == "True"
+        # range_offset = 0
+        # if timeout.active():
+        #     timeout.cancel()
+        # try:
+        #     dest_path = frontend_data['dest_path']
+        # except KeyError:
+        #     log.error(_('dest path not found in frontend_data'))
+        #     del client._xep_0096_waiting_for_approval[sid]
+        #     return
+        # if stream_method == self.host.plugins["XEP-0065"].NAMESPACE:
+        #     plugin = self.host.plugins["XEP-0065"]
+        # elif stream_method == self.host.plugins["XEP-0047"].NAMESPACE:
+        #     plugin = self.host.plugins["XEP-0047"]
+        # else:
+        #     log.error(_("Unknown stream method, this should not happen at this stage, cancelling transfer"))
+        #     del client._xep_0096_waiting_for_approval[sid]
+        #     return
+
+        # file_obj = self._getFileObject(dest_path, can_range)
+        # range_offset = file_obj.tell()
+        d = data["stream_plugin"].create_session(
+            client, data["stream_object"], client.jid, data["peer_jid"], data["si_id"]
+        )
+        d.addCallback(self._transfer_cb, client, data)
+        d.addErrback(self._transfer_eb, client, data)
+
+        # we can send the iq result
+        feature_elt = self.host.plugins["XEP-0020"].choose_option(
+            {"stream-method": data["stream_method"]}, namespace=None
+        )
+        misc_elts = []
+        misc_elts.append(domish.Element((SI_PROFILE, "file")))
+        # if can_range:
+        #     range_elt = domish.Element((None, "range"))
+        #     range_elt['offset'] = str(range_offset)
+        #     #TODO: manage range length
+        #     misc_elts.append(range_elt)
+        self._si.accept_stream(client, iq_elt, feature_elt, misc_elts)
+
+    def _transfer_cb(self, __, client, data):
+        """Called by the stream method when transfer successfuly finished
+
+        @param data: session data
+        """
+        # TODO: check hash
+        data["stream_object"].close()
+        log.info("Transfer {si_id} successfuly finished".format(**data))
+
+    def _transfer_eb(self, failure, client, data):
+        """Called when something went wrong with the transfer
+
+        @param id: stream id
+        @param data: session data
+        """
+        log.warning(
+            "Transfer {si_id} failed: {reason}".format(
+                reason=str(failure.value), **data
+            )
+        )
+        data["stream_object"].close()
+
+    def _file_send(self, peer_jid_s, filepath, name, desc, profile=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile)
+        return self.file_send(
+            client, jid.JID(peer_jid_s), filepath, name or None, desc or None
+        )
+
+    def file_send(self, client, peer_jid, filepath, name=None, desc=None, extra=None):
+        """Send a file using XEP-0096
+
+        @param peer_jid(jid.JID): recipient
+        @param filepath(str): absolute path to the file to send
+        @param name(unicode): name of the file to send
+            name must not contain "/" characters
+        @param desc: description of the file
+        @param extra: not used here
+        @return: an unique id to identify the transfer
+        """
+        feature_elt = self.host.plugins["XEP-0020"].propose_features(
+            {"stream-method": self.managed_stream_m}, namespace=None
+        )
+
+        file_transfer_elts = []
+
+        statinfo = os.stat(filepath)
+        file_elt = domish.Element((SI_PROFILE, "file"))
+        file_elt["name"] = name or os.path.basename(filepath)
+        assert "/" not in file_elt["name"]
+        size = statinfo.st_size
+        file_elt["size"] = str(size)
+        if desc:
+            file_elt.addElement("desc", content=desc)
+        file_transfer_elts.append(file_elt)
+
+        file_transfer_elts.append(domish.Element((None, "range")))
+
+        sid, offer_d = self._si.propose_stream(
+            client, peer_jid, SI_PROFILE, feature_elt, file_transfer_elts
+        )
+        args = [filepath, sid, size, client]
+        offer_d.addCallbacks(self._file_cb, self._file_eb, args, None, args)
+        return sid
+
+    def _file_cb(self, result_tuple, filepath, sid, size, client):
+        iq_elt, si_elt = result_tuple
+
+        try:
+            feature_elt = self.host.plugins["XEP-0020"].get_feature_elt(si_elt)
+        except exceptions.NotFound:
+            log.warning("No <feature/> element found in result while expected")
+            return
+
+        choosed_options = self.host.plugins["XEP-0020"].get_choosed_options(
+            feature_elt, namespace=None
+        )
+        try:
+            stream_method = choosed_options["stream-method"]
+        except KeyError:
+            log.warning("No stream method choosed")
+            return
+
+        try:
+            file_elt = next(si_elt.elements(NS_SI_FT, "file"))
+        except StopIteration:
+            pass
+        else:
+            range_, range_offset, range_length = self._parse_range(file_elt, size)
+
+        if stream_method == self.host.plugins["XEP-0065"].NAMESPACE:
+            plugin = self.host.plugins["XEP-0065"]
+        elif stream_method == self.host.plugins["XEP-0047"].NAMESPACE:
+            plugin = self.host.plugins["XEP-0047"]
+        else:
+            log.warning("Invalid stream method received")
+            return
+
+        stream_object = stream.FileStreamObject(
+            self.host, client, filepath, uid=sid, size=size
+        )
+        d = plugin.start_stream(client, stream_object, client.jid,
+                               jid.JID(iq_elt["from"]), sid)
+        d.addCallback(self._send_cb, client, sid, stream_object)
+        d.addErrback(self._send_eb, client, sid, stream_object)
+
+    def _file_eb(self, failure, filepath, sid, size, client):
+        if failure.check(error.StanzaError):
+            stanza_err = failure.value
+            if stanza_err.code == "403" and stanza_err.condition == "forbidden":
+                from_s = stanza_err.stanza["from"]
+                log.info("File transfer refused by {}".format(from_s))
+                msg = D_("The contact {} has refused your file").format(from_s)
+                title = D_("File refused")
+                xml_tools.quick_note(self.host, client, msg, title, C.XMLUI_DATA_LVL_INFO)
+            else:
+                log.warning(_("Error during file transfer"))
+                msg = D_(
+                    "Something went wrong during the file transfer session initialisation: {reason}"
+                ).format(reason=str(stanza_err))
+                title = D_("File transfer error")
+                xml_tools.quick_note(self.host, client, msg, title, C.XMLUI_DATA_LVL_ERROR)
+        elif failure.check(exceptions.DataError):
+            log.warning("Invalid stanza received")
+        else:
+            log.error("Error while proposing stream: {}".format(failure))
+
+    def _send_cb(self, __, client, sid, stream_object):
+        log.info(
+            _("transfer {sid} successfuly finished [{profile}]").format(
+                sid=sid, profile=client.profile
+            )
+        )
+        stream_object.close()
+
+    def _send_eb(self, failure, client, sid, stream_object):
+        log.warning(
+            _("transfer {sid} failed [{profile}]: {reason}").format(
+                sid=sid, profile=client.profile, reason=str(failure.value)
+            )
+        )
+        stream_object.close()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0100.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing gateways (xep-0100)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.tools import xml_tools
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.words.protocols.jabber import jid
+from twisted.internet import reactor, defer
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Gateways Plugin",
+    C.PI_IMPORT_NAME: "XEP-0100",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0100"],
+    C.PI_DEPENDENCIES: ["XEP-0077"],
+    C.PI_MAIN: "XEP_0100",
+    C.PI_DESCRIPTION: _("""Implementation of Gateways protocol"""),
+}
+
+WARNING_MSG = D_(
+    """Be careful ! Gateways allow you to use an external IM (legacy IM), so you can see your contact as XMPP contacts.
+But when you do this, all your messages go throught the external legacy IM server, it is a huge privacy issue (i.e.: all your messages throught the gateway can be monitored, recorded, analysed by the external server, most of time a private company)."""
+)
+
+GATEWAY_TIMEOUT = 10  # time to wait before cancelling a gateway disco info, in seconds
+
+TYPE_DESCRIPTIONS = {
+    "irc": D_("Internet Relay Chat"),
+    "xmpp": D_("XMPP"),
+    "qq": D_("Tencent QQ"),
+    "simple": D_("SIP/SIMPLE"),
+    "icq": D_("ICQ"),
+    "yahoo": D_("Yahoo! Messenger"),
+    "gadu-gadu": D_("Gadu-Gadu"),
+    "aim": D_("AOL Instant Messenger"),
+    "msn": D_("Windows Live Messenger"),
+}
+
+
+class XEP_0100(object):
+    def __init__(self, host):
+        log.info(_("Gateways plugin initialization"))
+        self.host = host
+        self.__gateways = {}  # dict used to construct the answer to gateways_find. Key = target jid
+        host.bridge.add_method(
+            "gateways_find",
+            ".plugin",
+            in_sign="ss",
+            out_sign="s",
+            method=self._find_gateways,
+        )
+        host.bridge.add_method(
+            "gateway_register",
+            ".plugin",
+            in_sign="ss",
+            out_sign="s",
+            method=self._gateway_register,
+        )
+        self.__menu_id = host.register_callback(self._gateways_menu, with_data=True)
+        self.__selected_id = host.register_callback(
+            self._gateway_selected_cb, with_data=True
+        )
+        host.import_menu(
+            (D_("Service"), D_("Gateways")),
+            self._gateways_menu,
+            security_limit=1,
+            help_string=D_("Find gateways"),
+        )
+
+    def _gateways_menu(self, data, profile):
+        """ XMLUI activated by menu: return Gateways UI
+
+        @param profile: %(doc_profile)s
+        """
+        client = self.host.get_client(profile)
+        try:
+            jid_ = jid.JID(
+                data.get(xml_tools.form_escape("external_jid"), client.jid.host)
+            )
+        except RuntimeError:
+            raise exceptions.DataError(_("Invalid JID"))
+        d = self.gateways_find(jid_, profile)
+        d.addCallback(self._gateways_result_2_xmlui, jid_)
+        d.addCallback(lambda xmlui: {"xmlui": xmlui.toXml()})
+        return d
+
+    def _gateways_result_2_xmlui(self, result, entity):
+        xmlui = xml_tools.XMLUI(title=_("Gateways manager (%s)") % entity.full())
+        xmlui.addText(_(WARNING_MSG))
+        xmlui.addDivider("dash")
+        adv_list = xmlui.change_container(
+            "advanced_list",
+            columns=3,
+            selectable="single",
+            callback_id=self.__selected_id,
+        )
+        for success, gateway_data in result:
+            if not success:
+                fail_cond, disco_item = gateway_data
+                xmlui.addJid(disco_item.entity)
+                xmlui.addText(_("Failed (%s)") % fail_cond)
+                xmlui.addEmpty()
+            else:
+                jid_, data = gateway_data
+                for datum in data:
+                    identity, name = datum
+                    adv_list.set_row_index(jid_.full())
+                    xmlui.addJid(jid_)
+                    xmlui.addText(name)
+                    xmlui.addText(self._get_identity_desc(identity))
+        adv_list.end()
+        xmlui.addDivider("blank")
+        xmlui.change_container("advanced_list", columns=3)
+        xmlui.addLabel(_("Use external XMPP server"))
+        xmlui.addString("external_jid")
+        xmlui.addButton(self.__menu_id, _("Go !"), fields_back=("external_jid",))
+        return xmlui
+
+    def _gateway_selected_cb(self, data, profile):
+        try:
+            target_jid = jid.JID(data["index"])
+        except (KeyError, RuntimeError):
+            log.warning(_("No gateway index selected"))
+            return {}
+
+        d = self.gateway_register(target_jid, profile)
+        d.addCallback(lambda xmlui: {"xmlui": xmlui.toXml()})
+        return d
+
+    def _get_identity_desc(self, identity):
+        """ Return a human readable description of identity
+        @param identity: tuple as returned by Disco identities (category, type)
+
+        """
+        category, type_ = identity
+        if category != "gateway":
+            log.error(
+                _(
+                    'INTERNAL ERROR: identity category should always be "gateway" in _getTypeString, got "%s"'
+                )
+                % category
+            )
+        try:
+            return _(TYPE_DESCRIPTIONS[type_])
+        except KeyError:
+            return _("Unknown IM")
+
+    def _registration_successful(self, jid_, profile):
+        """Called when in_band registration is ok, we must now follow the rest of procedure"""
+        log.debug(_("Registration successful, doing the rest"))
+        self.host.contact_add(jid_, profile_key=profile)
+        self.host.presence_set(jid_, profile_key=profile)
+
+    def _gateway_register(self, target_jid_s, profile_key=C.PROF_KEY_NONE):
+        d = self.gateway_register(jid.JID(target_jid_s), profile_key)
+        d.addCallback(lambda xmlui: xmlui.toXml())
+        return d
+
+    def gateway_register(self, target_jid, profile_key=C.PROF_KEY_NONE):
+        """Register gateway using in-band registration, then log-in to gateway"""
+        profile = self.host.memory.get_profile_name(profile_key)
+        assert profile
+        d = self.host.plugins["XEP-0077"].in_band_register(
+            target_jid, self._registration_successful, profile
+        )
+        return d
+
+    def _infos_received(self, dl_result, items, target, client):
+        """Find disco infos about entity, to check if it is a gateway"""
+
+        ret = []
+        for idx, (success, result) in enumerate(dl_result):
+            if not success:
+                if isinstance(result.value, defer.CancelledError):
+                    msg = _("Timeout")
+                else:
+                    try:
+                        msg = result.value.condition
+                    except AttributeError:
+                        msg = str(result)
+                ret.append((success, (msg, items[idx])))
+            else:
+                entity = items[idx].entity
+                gateways = [
+                    (identity, result.identities[identity])
+                    for identity in result.identities
+                    if identity[0] == "gateway"
+                ]
+                if gateways:
+                    log.info(
+                        _("Found gateway [%(jid)s]: %(identity_name)s")
+                        % {
+                            "jid": entity.full(),
+                            "identity_name": " - ".join(
+                                [gateway[1] for gateway in gateways]
+                            ),
+                        }
+                    )
+                    ret.append((success, (entity, gateways)))
+                else:
+                    log.info(
+                        _("Skipping [%(jid)s] which is not a gateway")
+                        % {"jid": entity.full()}
+                    )
+        return ret
+
+    def _items_received(self, disco, target, client):
+        """Look for items with disco protocol, and ask infos for each one"""
+
+        if len(disco._items) == 0:
+            log.debug(_("No gateway found"))
+            return []
+
+        _defers = []
+        for item in disco._items:
+            log.debug(_("item found: %s") % item.entity)
+            _defers.append(client.disco.requestInfo(item.entity))
+        dl = defer.DeferredList(_defers)
+        dl.addCallback(
+            self._infos_received, items=disco._items, target=target, client=client
+        )
+        reactor.callLater(GATEWAY_TIMEOUT, dl.cancel)
+        return dl
+
+    def _find_gateways(self, target_jid_s, profile_key):
+        target_jid = jid.JID(target_jid_s)
+        profile = self.host.memory.get_profile_name(profile_key)
+        if not profile:
+            raise exceptions.ProfileUnknownError
+        d = self.gateways_find(target_jid, profile)
+        d.addCallback(self._gateways_result_2_xmlui, target_jid)
+        d.addCallback(lambda xmlui: xmlui.toXml())
+        return d
+
+    def gateways_find(self, target, profile):
+        """Find gateways in the target JID, using discovery protocol
+        """
+        client = self.host.get_client(profile)
+        log.debug(
+            _("find gateways (target = %(target)s, profile = %(profile)s)")
+            % {"target": target.full(), "profile": profile}
+        )
+        d = client.disco.requestItems(target)
+        d.addCallback(self._items_received, target=target, client=client)
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0103.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Dict, Any
+
+from twisted.words.xish import domish
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "URL Address Information",
+    C.PI_IMPORT_NAME: "XEP-0103",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0103"],
+    C.PI_MAIN: "XEP_0103",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of XEP-0103 (URL Address Information)"""),
+}
+
+NS_URL_DATA = "http://jabber.org/protocol/url-data"
+
+
+class XEP_0103:
+    namespace = NS_URL_DATA
+
+    def __init__(self, host):
+        log.info(_("XEP-0103 (URL Address Information) plugin initialization"))
+        host.register_namespace("url-data", NS_URL_DATA)
+
+    def get_url_data_elt(
+        self,
+        url: str,
+        **kwargs
+    ) -> domish.Element:
+        """Generate the element describing the URL
+
+        @param url: URL to use
+        @param extra: extra metadata describing how to access the URL
+        @return: ``<url-data/>`` element
+        """
+        url_data_elt = domish.Element((NS_URL_DATA, "url-data"))
+        url_data_elt["target"] = url
+        return url_data_elt
+
+    def parse_url_data_elt(
+        self,
+        url_data_elt: domish.Element
+    ) -> Dict[str, Any]:
+        """Parse <url-data/> element
+
+        @param url_data_elt: <url-data/> element
+            a parent element can also be used
+        @return: url-data data. It's a dict whose keys correspond to
+            [get_url_data_elt] parameters
+        @raise exceptions.NotFound: no <url-data/> element has been found
+        """
+        if url_data_elt.name != "url-data":
+            try:
+                url_data_elt = next(
+                    url_data_elt.elements(NS_URL_DATA, "url-data")
+                )
+            except StopIteration:
+                raise exceptions.NotFound
+        try:
+            data: Dict[str, Any] = {"url": url_data_elt["target"]}
+        except KeyError:
+            raise ValueError(f'"target" attribute is missing: {url_data_elt.toXml}')
+
+        return data
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0106.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Explicit Message Encryption
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from twisted.words.protocols.jabber import xmlstream
+from zope.interface import implementer
+from wokkel import disco
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "JID Escaping",
+    C.PI_IMPORT_NAME: "XEP-0106",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0106"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "XEP_0106",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""(Un)escape JID to use disallowed chars in local parts"""),
+}
+
+NS_JID_ESCAPING = r"jid\20escaping"
+ESCAPE_MAP = {
+    ' ': r'\20',
+    '"': r'\22',
+    '&': r'\26',
+    "'": r'\27',
+    '/': r'\2f',
+    ':': r'\3a',
+    '<': r'\3c',
+    '>': r'\3e',
+    '@': r'\40',
+    '\\': r'\5c',
+}
+
+
+class XEP_0106(object):
+
+    def __init__(self, host):
+        self.reverse_map = {v:k for k,v in ESCAPE_MAP.items()}
+
+    def get_handler(self, client):
+        return XEP_0106_handler()
+
+    def escape(self, text):
+        """Escape text
+
+        @param text(unicode): text to escape
+        @return (unicode): escaped text
+        @raise ValueError: text can't be escaped
+        """
+        if not text or text[0] == ' ' or text[-1] == ' ':
+            raise ValueError("text must not be empty, or start or end with a whitespace")
+        escaped = []
+        for c in text:
+            if c in ESCAPE_MAP:
+                escaped.append(ESCAPE_MAP[c])
+            else:
+                escaped.append(c)
+        return ''.join(escaped)
+
+    def unescape(self, escaped):
+        """Unescape text
+
+        @param escaped(unicode): text to unescape
+        @return (unicode): unescaped text
+        @raise ValueError: text can't be unescaped
+        """
+        if not escaped or escaped.startswith(r'\27') or escaped.endswith(r'\27'):
+            raise ValueError("escaped value must not be empty, or start or end with a "
+                             f"whitespace: rejected value is {escaped!r}")
+        unescaped = []
+        idx = 0
+        while idx < len(escaped):
+            char_seq = escaped[idx:idx+3]
+            if char_seq in self.reverse_map:
+                unescaped.append(self.reverse_map[char_seq])
+                idx += 3
+            else:
+                unescaped.append(escaped[idx])
+                idx += 1
+        return ''.join(unescaped)
+
+
+@implementer(disco.IDisco)
+class XEP_0106_handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_JID_ESCAPING)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0115.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,212 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing xep-0115
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber import jid
+from twisted.internet import defer, error
+from zope.interface import implementer
+from wokkel import disco, iwokkel
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+PRESENCE = "/presence"
+NS_ENTITY_CAPABILITY = "http://jabber.org/protocol/caps"
+NS_CAPS_OPTIMIZE = "http://jabber.org/protocol/caps#optimize"
+CAPABILITY_UPDATE = PRESENCE + '/c[@xmlns="' + NS_ENTITY_CAPABILITY + '"]'
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP 0115 Plugin",
+    C.PI_IMPORT_NAME: "XEP-0115",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0115"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "XEP_0115",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of entity capabilities"""),
+}
+
+
+class XEP_0115(object):
+    cap_hash = None  # capabilities hash is class variable as it is common to all profiles
+
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0115 initialization"))
+        self.host = host
+        host.trigger.add("Presence send", self._presence_trigger)
+
+    def get_handler(self, client):
+        return XEP_0115_handler(self)
+
+    @defer.inlineCallbacks
+    def _prepare_caps(self, client):
+        # we have to calculate hash for client
+        # because disco infos/identities may change between clients
+
+        # optimize check
+        client._caps_optimize = yield self.host.hasFeature(client, NS_CAPS_OPTIMIZE)
+        if client._caps_optimize:
+            log.info(_("Caps optimisation enabled"))
+            client._caps_sent = False
+        else:
+            log.warning(_("Caps optimisation not available"))
+
+        # hash generation
+        _infos = yield client.discoHandler.info(client.jid, client.jid, "")
+        disco_infos = disco.DiscoInfo()
+        for item in _infos:
+            disco_infos.append(item)
+        cap_hash = client._caps_hash = self.host.memory.disco.generate_hash(disco_infos)
+        log.info(
+            "Our capability hash has been generated: [{cap_hash}]".format(
+                cap_hash=cap_hash
+            )
+        )
+        log.debug("Generating capability domish.Element")
+        c_elt = domish.Element((NS_ENTITY_CAPABILITY, "c"))
+        c_elt["hash"] = "sha-1"
+        c_elt["node"] = C.APP_URL
+        c_elt["ver"] = cap_hash
+        client._caps_elt = c_elt
+        if client._caps_optimize:
+            client._caps_sent = False
+        if cap_hash not in self.host.memory.disco.hashes:
+            self.host.memory.disco.hashes[cap_hash] = disco_infos
+            self.host.memory.update_entity_data(
+                client, client.jid, C.ENTITY_CAP_HASH, cap_hash
+            )
+
+    def _presence_add_elt(self, client, obj):
+        if client._caps_optimize:
+            if client._caps_sent:
+                return
+            client.caps_sent = True
+        obj.addChild(client._caps_elt)
+
+    def _presence_trigger(self, client, obj, presence_d):
+        if not hasattr(client, "_caps_optimize"):
+            presence_d.addCallback(lambda __: self._prepare_caps(client))
+
+        presence_d.addCallback(lambda __: self._presence_add_elt(client, obj))
+        return True
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0115_handler(XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+
+    @property
+    def client(self):
+        return self.parent
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(CAPABILITY_UPDATE, self.update)
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [
+            disco.DiscoFeature(NS_ENTITY_CAPABILITY),
+            disco.DiscoFeature(NS_CAPS_OPTIMIZE),
+        ]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
+
+    def update(self, presence):
+        """
+        Manage the capabilities of the entity
+
+        Check if we know the version of this capabilities and get the capabilities if necessary
+        """
+        from_jid = jid.JID(presence["from"])
+        c_elem = next(presence.elements(NS_ENTITY_CAPABILITY, "c"))
+        try:
+            c_ver = c_elem["ver"]
+            c_hash = c_elem["hash"]
+            c_node = c_elem["node"]
+        except KeyError:
+            log.warning(_("Received invalid capabilities tag: %s") % c_elem.toXml())
+            return
+
+        if c_ver in self.host.memory.disco.hashes:
+            # we already know the hash, we update the jid entity
+            log.debug(
+                "hash [%(hash)s] already in cache, updating entity [%(jid)s]"
+                % {"hash": c_ver, "jid": from_jid.full()}
+            )
+            self.host.memory.update_entity_data(
+                self.client, from_jid, C.ENTITY_CAP_HASH, c_ver
+            )
+            return
+
+        if c_hash != "sha-1":  # unknown hash method
+            log.warning(
+                _(
+                    "Unknown hash method for entity capabilities: [{hash_method}] "
+                    "(entity: {entity_jid}, node: {node})"
+                )
+                .format(hash_method = c_hash, entity_jid = from_jid, node = c_node)
+            )
+
+        def cb(__):
+            computed_hash = self.host.memory.get_entity_datum(
+                self.client, from_jid, C.ENTITY_CAP_HASH
+            )
+            if computed_hash != c_ver:
+                log.warning(
+                    _(
+                        "Computed hash differ from given hash:\n"
+                        "given: [{given}]\n"
+                        "computed: [{computed}]\n"
+                        "(entity: {entity_jid}, node: {node})"
+                    ).format(
+                        given = c_ver,
+                        computed = computed_hash,
+                        entity_jid = from_jid,
+                        node = c_node,
+                    )
+                )
+
+        def eb(failure):
+            if isinstance(failure.value, error.ConnectionDone):
+                return
+            msg = (
+                failure.value.condition
+                if hasattr(failure.value, "condition")
+                else failure.getErrorMessage()
+            )
+            log.error(
+                _("Couldn't retrieve disco info for {jid}: {error}").format(
+                    jid=from_jid.full(), error=msg
+                )
+            )
+
+        d = self.host.get_disco_infos(self.parent, from_jid)
+        d.addCallbacks(cb, eb)
+        # TODO: me must manage the full algorithm described at XEP-0115 #5.4 part 3
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0163.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,203 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Personal Eventing Protocol (xep-0163)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional, Callable
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+from twisted.words.xish import domish
+
+from wokkel import disco, pubsub
+from wokkel.formats import Mood
+from libervia.backend.tools.common import data_format
+
+
+log = getLogger(__name__)
+
+NS_USER_MOOD = "http://jabber.org/protocol/mood"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Personal Eventing Protocol Plugin",
+    C.PI_IMPORT_NAME: "XEP-0163",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0163", "XEP-0107"],
+    C.PI_DEPENDENCIES: ["XEP-0060"],
+    C.PI_MAIN: "XEP_0163",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of Personal Eventing Protocol"""),
+}
+
+
+class XEP_0163(object):
+    def __init__(self, host):
+        log.info(_("PEP plugin initialization"))
+        self.host = host
+        self.pep_events = set()
+        self.pep_out_cb = {}
+        host.trigger.add("PubSub Disco Info", self.diso_info_trigger)
+        host.bridge.add_method(
+            "pep_send",
+            ".plugin",
+            in_sign="sa{ss}s",
+            out_sign="",
+            method=self.pep_send,
+            async_=True,
+        )  # args: type(MOOD, TUNE, etc), data, profile_key;
+        self.add_pep_event("MOOD", NS_USER_MOOD, self.user_mood_cb, self.send_mood)
+
+    def diso_info_trigger(self, disco_info, profile):
+        """Add info from managed PEP
+
+        @param disco_info: list of disco feature as returned by PubSub,
+            will be filled with PEP features
+        @param profile: profile we are handling
+        """
+        disco_info.extend(list(map(disco.DiscoFeature, self.pep_events)))
+        return True
+
+    def add_pep_event(
+        self,
+        event_type: Optional[str],
+        node: str,
+        in_callback: Callable,
+        out_callback: Optional[Callable] = None,
+        notify: bool = True
+    ) -> None:
+        """Add a Personal Eventing Protocol event manager
+
+        @param event_type: type of the event (stored uppercase),
+            only used when out_callback is set.
+            Can be MOOD, TUNE, etc.
+        @param node: namespace of the node (e.g. http://jabber.org/protocol/mood
+            for User Mood)
+        @param in_callback: method to call when this event occur
+            the callable will be called with (itemsEvent, profile) as arguments
+        @param out_callback: method to call when we want to publish this
+            event (must return a deferred)
+            the callable will be called when send_pep_event is called
+        @param notify: add autosubscribe (+notify) if True
+        """
+        if event_type and out_callback:
+            event_type = event_type.upper()
+            if event_type in self.pep_out_cb:
+                raise exceptions.ConflictError(
+                    f"event_type {event_type!r} already exists"
+                )
+            self.pep_out_cb[event_type] = out_callback
+        self.pep_events.add(node)
+        if notify:
+            self.pep_events.add(node + "+notify")
+
+        def filter_pep_event(client, itemsEvent):
+            """Ignore messages which are not coming from PEP (i.e. a bare jid)
+
+            @param itemsEvent(pubsub.ItemsEvent): pubsub event
+            """
+            if not itemsEvent.sender.user or itemsEvent.sender.resource:
+                log.debug(
+                    "ignoring non PEP event from {} (profile={})".format(
+                        itemsEvent.sender.full(), client.profile
+                    )
+                )
+                return
+            in_callback(itemsEvent, client.profile)
+
+        self.host.plugins["XEP-0060"].add_managed_node(node, items_cb=filter_pep_event)
+
+    def send_pep_event(self, node, data, profile):
+        """Publish the event data
+
+        @param node(unicode): node namespace
+        @param data: domish.Element to use as payload
+        @param profile: profile which send the data
+        """
+        client = self.host.get_client(profile)
+        item = pubsub.Item(payload=data)
+        return self.host.plugins["XEP-0060"].publish(client, None, node, [item])
+
+    def pep_send(self, event_type, data, profile_key=C.PROF_KEY_NONE):
+        """Send personal event after checking the data is alright
+
+        @param event_type: type of event (eg: MOOD, TUNE),
+            must be in self.pep_out_cb.keys()
+        @param data: dict of {string:string} of event_type dependant data
+        @param profile_key: profile who send the event
+        """
+        profile = self.host.memory.get_profile_name(profile_key)
+        if not profile:
+            log.error(
+                _("Trying to send personal event with an unknown profile key [%s]")
+                % profile_key
+            )
+            raise exceptions.ProfileUnknownError
+        if not event_type in list(self.pep_out_cb.keys()):
+            log.error(_("Trying to send personal event for an unknown type"))
+            raise exceptions.DataError("Type unknown")
+        return self.pep_out_cb[event_type](data, profile)
+
+    def user_mood_cb(self, itemsEvent, profile):
+        if not itemsEvent.items:
+            log.debug(_("No item found"))
+            return
+        try:
+            mood_elt = [
+                child for child in itemsEvent.items[0].elements() if child.name == "mood"
+            ][0]
+        except IndexError:
+            log.error(_("Can't find mood element in mood event"))
+            return
+        mood = Mood.fromXml(mood_elt)
+        if not mood:
+            log.debug(_("No mood found"))
+            return
+        self.host.bridge.ps_event(
+            C.PS_PEP,
+            itemsEvent.sender.full(),
+            itemsEvent.nodeIdentifier,
+            "MOOD",
+            data_format.serialise({"mood": mood.value or "", "text": mood.text or ""}),
+            profile,
+        )
+
+    def send_mood(self, data, profile):
+        """Send XEP-0107's User Mood
+
+        @param data: must include mood and text
+        @param profile: profile which send the mood"""
+        try:
+            value = data["mood"].lower()
+            text = data["text"] if "text" in data else ""
+        except KeyError:
+            raise exceptions.DataError("Mood data must contain at least 'mood' key")
+        mood = UserMood(value, text)
+        return self.send_pep_event(NS_USER_MOOD, mood, profile)
+
+
+class UserMood(Mood, domish.Element):
+    """Improved wokkel Mood which is also a domish.Element"""
+
+    def __init__(self, value, text=None):
+        Mood.__init__(self, value, text)
+        domish.Element.__init__(self, (NS_USER_MOOD, "mood"))
+        self.addElement(value)
+        if text:
+            self.addElement("text", content=text)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0166/__init__.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1409 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Jingle (XEP-0166)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import time
+from typing import Any, Callable, Dict, Final, List, Optional, Tuple
+import uuid
+
+from twisted.internet import defer
+from twisted.internet import reactor
+from twisted.python import failure
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import error
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools import utils
+
+from .models import (
+    ApplicationData,
+    BaseApplicationHandler,
+    BaseTransportHandler,
+    ContentData,
+    TransportData,
+)
+
+
+log = getLogger(__name__)
+
+
+IQ_SET : Final = '/iq[@type="set"]'
+NS_JINGLE : Final = "urn:xmpp:jingle:1"
+NS_JINGLE_ERROR : Final = "urn:xmpp:jingle:errors:1"
+JINGLE_REQUEST : Final = f'{IQ_SET}/jingle[@xmlns="{NS_JINGLE}"]'
+STATE_PENDING : Final = "PENDING"
+STATE_ACTIVE : Final = "ACTIVE"
+STATE_ENDED : Final = "ENDED"
+CONFIRM_TXT : Final = D_(
+    "{entity} want to start a jingle session with you, do you accept ?"
+)
+
+PLUGIN_INFO : Final = {
+    C.PI_NAME: "Jingle",
+    C.PI_IMPORT_NAME: "XEP-0166",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0166"],
+    C.PI_MAIN: "XEP_0166",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Jingle"""),
+}
+
+
+class XEP_0166:
+    namespace : Final = NS_JINGLE
+
+    ROLE_INITIATOR : Final = "initiator"
+    ROLE_RESPONDER : Final = "responder"
+
+    TRANSPORT_DATAGRAM : Final = "UDP"
+    TRANSPORT_STREAMING : Final = "TCP"
+
+    REASON_SUCCESS : Final = "success"
+    REASON_DECLINE : Final = "decline"
+    REASON_FAILED_APPLICATION : Final = "failed-application"
+    REASON_FAILED_TRANSPORT : Final = "failed-transport"
+    REASON_CONNECTIVITY_ERROR : Final = "connectivity-error"
+
+    # standard actions
+
+    A_SESSION_INITIATE : Final = "session-initiate"
+    A_SESSION_ACCEPT : Final = "session-accept"
+    A_SESSION_TERMINATE : Final = "session-terminate"
+    A_SESSION_INFO : Final = "session-info"
+    A_TRANSPORT_REPLACE : Final = "transport-replace"
+    A_TRANSPORT_ACCEPT : Final = "transport-accept"
+    A_TRANSPORT_REJECT : Final = "transport-reject"
+    A_TRANSPORT_INFO : Final = "transport-info"
+
+    # non standard actions
+
+    #: called before the confirmation request, first event for responder, useful for
+    #: parsing
+    A_PREPARE_CONFIRMATION : Final = "prepare-confirmation"
+    #: initiator must prepare tranfer
+    A_PREPARE_INITIATOR : Final = "prepare-initiator"
+    #: responder must prepare tranfer
+    A_PREPARE_RESPONDER : Final = "prepare-responder"
+    #; session accepted ack has been received from initiator
+    A_ACCEPTED_ACK : Final = (
+        "accepted-ack"
+    )
+    A_START : Final = "start"  # application can start
+    #: called when a transport is destroyed (e.g. because it is remplaced). Used to do
+    #: cleaning operations
+    A_DESTROY : Final = (
+        "destroy"
+    )
+
+    def __init__(self, host):
+        log.info(_("plugin Jingle initialization"))
+        self.host = host
+        self._applications = {}  # key: namespace, value: application data
+        self._transports = {}  # key: namespace, value: transport data
+        # we also keep transports by type, they are then sorted by priority
+        self._type_transports = {
+            XEP_0166.TRANSPORT_DATAGRAM: [],
+            XEP_0166.TRANSPORT_STREAMING: [],
+        }
+
+    def profile_connected(self, client):
+        client.jingle_sessions = {}  # key = sid, value = session_data
+
+    def get_handler(self, client):
+        return XEP_0166_handler(self)
+
+    def get_session(self, client: SatXMPPEntity, session_id: str) -> dict:
+        """Retrieve session from its SID
+
+        @param session_id: session ID
+        @return: found session
+
+        @raise exceptions.NotFound: no session with this SID has been found
+        """
+        try:
+            return client.jingle_sessions[session_id]
+        except KeyError:
+            raise exceptions.NotFound(
+                f"No session with SID {session_id} found"
+            )
+
+
+    def _del_session(self, client, sid):
+        try:
+            del client.jingle_sessions[sid]
+        except KeyError:
+            log.debug(
+                f"Jingle session id {sid!r} is unknown, nothing to delete "
+                f"[{client.profile}]")
+        else:
+            log.debug(f"Jingle session id {sid!r} deleted [{client.profile}]")
+
+    ## helpers methods to build stanzas ##
+
+    def _build_jingle_elt(
+        self,
+        client: SatXMPPEntity,
+        session: dict,
+        action: str
+    ) -> Tuple[xmlstream.IQ, domish.Element]:
+        iq_elt = client.IQ("set")
+        iq_elt["from"] = session['local_jid'].full()
+        iq_elt["to"] = session["peer_jid"].full()
+        jingle_elt = iq_elt.addElement("jingle", NS_JINGLE)
+        jingle_elt["sid"] = session["id"]
+        jingle_elt["action"] = action
+        return iq_elt, jingle_elt
+
+    def sendError(self, client, error_condition, sid, request, jingle_condition=None):
+        """Send error stanza
+
+        @param error_condition: one of twisted.words.protocols.jabber.error.STANZA_CONDITIONS keys
+        @param sid(unicode,None): jingle session id, or None, if session must not be destroyed
+        @param request(domish.Element): original request
+        @param jingle_condition(None, unicode): if not None, additional jingle-specific error information
+        """
+        iq_elt = error.StanzaError(error_condition).toResponse(request)
+        if jingle_condition is not None:
+            iq_elt.error.addElement((NS_JINGLE_ERROR, jingle_condition))
+        if error.STANZA_CONDITIONS[error_condition]["type"] == "cancel" and sid:
+            self._del_session(client, sid)
+            log.warning(
+                "Error while managing jingle session, cancelling: {condition}".format(
+                    condition=error_condition
+                )
+            )
+        return client.send(iq_elt)
+
+    def _terminate_eb(self, failure_):
+        log.warning(_("Error while terminating session: {msg}").format(msg=failure_))
+
+    def terminate(self, client, reason, session, text=None):
+        """Terminate the session
+
+        send the session-terminate action, and delete the session data
+        @param reason(unicode, list[domish.Element]): if unicode, will be transformed to an element
+            if a list of element, add them as children of the <reason/> element
+        @param session(dict): data of the session
+        """
+        iq_elt, jingle_elt = self._build_jingle_elt(
+            client, session, XEP_0166.A_SESSION_TERMINATE
+        )
+        reason_elt = jingle_elt.addElement("reason")
+        if isinstance(reason, str):
+            reason_elt.addElement(reason)
+        else:
+            for elt in reason:
+                reason_elt.addChild(elt)
+        if text is not None:
+            reason_elt.addElement("text", content=text)
+        self._del_session(client, session["id"])
+        d = iq_elt.send()
+        d.addErrback(self._terminate_eb)
+        return d
+
+    ## errors which doesn't imply a stanza sending ##
+
+    def _iq_error(self, failure_, sid, client):
+        """Called when we got an <iq/> error
+
+        @param failure_(failure.Failure): the exceptions raised
+        @param sid(unicode): jingle session id
+        """
+        log.warning(
+            "Error while sending jingle <iq/> stanza: {failure_}".format(
+                failure_=failure_.value
+            )
+        )
+        self._del_session(client, sid)
+
+    def _jingle_error_cb(self, failure_, session, request, client):
+        """Called when something is going wrong while parsing jingle request
+
+        The error condition depend of the exceptions raised:
+            exceptions.DataError raise a bad-request condition
+        @param fail(failure.Failure): the exceptions raised
+        @param session(dict): data of the session
+        @param request(domsih.Element): jingle request
+        @param client: %(doc_client)s
+        """
+        del session["jingle_elt"]
+        log.warning(f"Error while processing jingle request [{client.profile}]")
+        if isinstance(failure_.value, defer.FirstError):
+            failure_ = failure_.value.subFailure.value
+        if isinstance(failure_, exceptions.DataError):
+            return self.sendError(client, "bad-request", session["id"], request)
+        elif isinstance(failure_, error.StanzaError):
+            return self.terminate(client, self.REASON_FAILED_APPLICATION, session,
+                                  text=str(failure_))
+        else:
+            log.error(f"Unmanaged jingle exception: {failure_}")
+            return self.terminate(client, self.REASON_FAILED_APPLICATION, session,
+                                  text=str(failure_))
+
+    ## methods used by other plugins ##
+
+    def register_application(
+        self,
+        namespace: str,
+        handler: BaseApplicationHandler
+    ) -> None:
+        """Register an application plugin
+
+        @param namespace(unicode): application namespace managed by the plugin
+        @param handler(object): instance of a class which manage the application.
+            May have the following methods:
+                - request_confirmation(session, desc_elt, client):
+                    - if present, it is called on when session must be accepted.
+                    - if it return True the session is accepted, else rejected.
+                        A Deferred can be returned
+                    - if not present, a generic accept dialog will be used
+                - jingle_session_init(
+                        client, self, session, content_name[, *args, **kwargs]
+                    ): must return the domish.Element used for initial content
+                - jingle_handler(
+                        client, self, action, session, content_name, transport_elt
+                    ):
+                    called on several action to negociate the application or transport
+                - jingle_terminate: called on session terminate, with reason_elt
+                    May be used to clean session
+        """
+        if namespace in self._applications:
+            raise exceptions.ConflictError(
+                f"Trying to register already registered namespace {namespace}"
+            )
+        self._applications[namespace] = ApplicationData(
+            namespace=namespace, handler=handler
+        )
+        log.debug("new jingle application registered")
+
+    def register_transport(
+            self,
+            namespace: str,
+            transport_type: str,
+            handler: BaseTransportHandler,
+            priority: int = 0
+    ) -> None:
+        """Register a transport plugin
+
+        @param namespace: the XML namespace used for this transport
+        @param transport_type: type of transport to use (see XEP-0166 §8)
+        @param handler: instance of a class which manage the application.
+        @param priority: priority of this transport
+        """
+        assert transport_type in (
+            XEP_0166.TRANSPORT_DATAGRAM,
+            XEP_0166.TRANSPORT_STREAMING,
+        )
+        if namespace in self._transports:
+            raise exceptions.ConflictError(
+                "Trying to register already registered namespace {}".format(namespace)
+            )
+        transport_data = TransportData(
+            namespace=namespace, handler=handler, priority=priority
+        )
+        self._type_transports[transport_type].append(transport_data)
+        self._type_transports[transport_type].sort(
+            key=lambda transport_data: transport_data.priority, reverse=True
+        )
+        self._transports[namespace] = transport_data
+        log.debug("new jingle transport registered")
+
+    @defer.inlineCallbacks
+    def transport_replace(self, client, transport_ns, session, content_name):
+        """Replace a transport
+
+        @param transport_ns(unicode): namespace of the new transport to use
+        @param session(dict): jingle session data
+        @param content_name(unicode): name of the content
+        """
+        # XXX: for now we replace the transport before receiving confirmation from other peer
+        #      this is acceptable because we terminate the session if transport is rejected.
+        #      this behavious may change in the future.
+        content_data = session["contents"][content_name]
+        transport_data = content_data["transport_data"]
+        try:
+            transport = self._transports[transport_ns]
+        except KeyError:
+            raise exceptions.InternalError("Unkown transport")
+        yield content_data["transport"].handler.jingle_handler(
+            client, XEP_0166.A_DESTROY, session, content_name, None
+        )
+        content_data["transport"] = transport
+        transport_data.clear()
+
+        iq_elt, jingle_elt = self._build_jingle_elt(
+            client, session, XEP_0166.A_TRANSPORT_REPLACE
+        )
+        content_elt = jingle_elt.addElement("content")
+        content_elt["name"] = content_name
+        content_elt["creator"] = content_data["creator"]
+
+        transport_elt = transport.handler.jingle_session_init(client, session, content_name)
+        content_elt.addChild(transport_elt)
+        iq_elt.send()
+
+    def build_action(
+        self,
+        client: SatXMPPEntity,
+        action: str,
+        session: dict,
+        content_name: str,
+        iq_elt: Optional[xmlstream.IQ] = None,
+        context_elt: Optional[domish.Element] = None
+    ) -> Tuple[xmlstream.IQ, domish.Element]:
+        """Build an element according to requested action
+
+        @param action: a jingle action (see XEP-0166 §7.2),
+            session-* actions are not managed here
+            transport-replace is managed in the dedicated [transport_replace] method
+        @param session: jingle session data
+        @param content_name: name of the content
+        @param iq_elt: use this IQ instead of creating a new one if provided
+        @param context_elt: use this element instead of creating a new one if provided
+        @return: parent <iq> element, <transport> or <description> element, according to action
+        """
+        # we first build iq, jingle and content element which are the same in every cases
+        if iq_elt is not None:
+            try:
+                jingle_elt = next(iq_elt.elements(NS_JINGLE, "jingle"))
+            except StopIteration:
+                raise exceptions.InternalError(
+                    "The <iq> element provided doesn't have a <jingle> element"
+                )
+        else:
+            iq_elt, jingle_elt = self._build_jingle_elt(client, session, action)
+        # FIXME: XEP-0260 § 2.3 Ex 5 has an initiator attribute, but it should not according to XEP-0166 §7.1 table 1, must be checked
+        content_data = session["contents"][content_name]
+        content_elt = jingle_elt.addElement("content")
+        content_elt["name"] = content_name
+        content_elt["creator"] = content_data["creator"]
+
+        if context_elt is not None:
+            pass
+        elif action == XEP_0166.A_TRANSPORT_INFO:
+            context_elt = transport_elt = content_elt.addElement(
+                "transport", content_data["transport"].namespace
+            )
+        else:
+            raise exceptions.InternalError(f"unmanaged action {action}")
+
+        return iq_elt, context_elt
+
+    def build_session_info(self, client, session):
+        """Build a session-info action
+
+        @param session(dict): jingle session data
+        @return (tuple[domish.Element, domish.Element]): parent <iq> element, <jingle> element
+        """
+        return self._build_jingle_elt(client, session, XEP_0166.A_SESSION_INFO)
+
+    def get_application(self, namespace: str) -> ApplicationData:
+        """Retreive application corresponding to a namespace
+
+        @raise exceptions.NotFound if application can't be found
+        """
+        try:
+            return self._applications[namespace]
+        except KeyError:
+            raise exceptions.NotFound(
+                f"No application registered for {namespace}"
+            )
+
+    def get_content_data(self, content: dict) -> ContentData:
+        """"Retrieve application and its argument from content"""
+        app_ns = content["app_ns"]
+        try:
+            application = self.get_application(app_ns)
+        except exceptions.NotFound as e:
+            raise exceptions.InternalError(str(e))
+        app_args = content.get("app_args", [])
+        app_kwargs = content.get("app_kwargs", {})
+        transport_data = content.get("transport_data", {})
+        try:
+            content_name = content["name"]
+        except KeyError:
+            content_name = content["name"] = str(uuid.uuid4())
+        return ContentData(
+            application,
+            app_args,
+            app_kwargs,
+            transport_data,
+            content_name
+        )
+
+    async def initiate(
+        self,
+        client: SatXMPPEntity,
+        peer_jid: jid.JID,
+        contents: List[dict],
+        encrypted: bool = False,
+        **extra_data: Any
+    ) -> str:
+        """Send a session initiation request
+
+        @param peer_jid: jid to establith session with
+        @param contents: list of contents to use:
+            The dict must have the following keys:
+                - app_ns(str): namespace of the application
+            the following keys are optional:
+                - transport_type(str): type of transport to use (see XEP-0166 §8)
+                    default to TRANSPORT_STREAMING
+                - name(str): name of the content
+                - senders(str): One of XEP_0166.ROLE_INITIATOR, XEP_0166.ROLE_RESPONDER, both or none
+                    default to BOTH (see XEP-0166 §7.3)
+                - app_args(list): args to pass to the application plugin
+                - app_kwargs(dict): keyword args to pass to the application plugin
+        @param encrypted: if True, session must be encrypted and "encryption" must be set
+            to all content data of session
+        @return: jingle session id
+        """
+        assert contents  # there must be at least one content
+        if (peer_jid == client.jid
+            or client.is_component and peer_jid.host == client.jid.host):
+            raise ValueError(_("You can't do a jingle session with yourself"))
+        initiator = client.jid
+        sid = str(uuid.uuid4())
+        # TODO: session cleaning after timeout ?
+        session = client.jingle_sessions[sid] = {
+            "id": sid,
+            "state": STATE_PENDING,
+            "initiator": initiator,
+            "role": XEP_0166.ROLE_INITIATOR,
+            "local_jid": client.jid,
+            "peer_jid": peer_jid,
+            "started": time.time(),
+            "contents": {},
+            **extra_data,
+        }
+
+        if not await self.host.trigger.async_point(
+            "XEP-0166_initiate",
+            client, session, contents
+        ):
+            return sid
+
+        iq_elt, jingle_elt = self._build_jingle_elt(
+            client, session, XEP_0166.A_SESSION_INITIATE
+        )
+        jingle_elt["initiator"] = initiator.full()
+        session["jingle_elt"] = jingle_elt
+
+        session_contents = session["contents"]
+
+        for content in contents:
+            # we get the application plugin
+            content_data = self.get_content_data(content)
+
+            # and the transport plugin
+            transport_type = content.get("transport_type", XEP_0166.TRANSPORT_STREAMING)
+            try:
+                transport = self._type_transports[transport_type][0]
+            except IndexError:
+                raise exceptions.InternalError(
+                    "No transport registered for {}".format(transport_type)
+                )
+
+            # we build the session data for this content
+            application_data = {}
+            transport_data = content_data.transport_data
+            session_content = {
+                "application": content_data.application,
+                "application_data": application_data,
+                "transport": transport,
+                "transport_data": transport_data,
+                "creator": XEP_0166.ROLE_INITIATOR,
+                "senders": content.get("senders", "both"),
+            }
+            if content_data.content_name in session_contents:
+                raise exceptions.InternalError(
+                    "There is already a content with this name"
+                )
+            session_contents[content_data.content_name] = session_content
+
+            # we construct the content element
+            content_elt = jingle_elt.addElement("content")
+            content_elt["creator"] = session_content["creator"]
+            content_elt["name"] = content_data.content_name
+            try:
+                content_elt["senders"] = content["senders"]
+            except KeyError:
+                pass
+
+            # then the description element
+            application_data["desc_elt"] = desc_elt = await utils.as_deferred(
+                content_data.application.handler.jingle_session_init,
+                client, session, content_data.content_name,
+                *content_data.app_args, **content_data.app_kwargs
+            )
+            content_elt.addChild(desc_elt)
+
+            # and the transport one
+            transport_data["transport_elt"] = transport_elt = await utils.as_deferred(
+                transport.handler.jingle_session_init,
+                client, session, content_data.content_name,
+            )
+            content_elt.addChild(transport_elt)
+
+        if not await self.host.trigger.async_point(
+            "XEP-0166_initiate_elt_built",
+            client, session, iq_elt, jingle_elt
+        ):
+            return sid
+
+        # processing is done, we can remove elements
+        for content_data in session_contents.values():
+            del content_data["application_data"]["desc_elt"]
+            del content_data["transport_data"]["transport_elt"]
+        del session["jingle_elt"]
+
+        if encrypted:
+            for content in session["contents"].values():
+                if "encryption" not in content:
+                    raise exceptions.EncryptionError(
+                        "Encryption is requested, but no encryption has been set"
+                    )
+
+        try:
+            await iq_elt.send()
+        except Exception as e:
+            failure_ = failure.Failure(e)
+            self._iq_error(failure_, sid, client)
+            raise failure_
+        return sid
+
+    def delayed_content_terminate(self, *args, **kwargs):
+        """Put content_terminate in queue but don't execute immediately
+
+        This is used to terminate a content inside a handler, to avoid modifying contents
+        """
+        reactor.callLater(0, self.content_terminate, *args, **kwargs)
+
+    def content_terminate(self, client, session, content_name, reason=REASON_SUCCESS):
+        """Terminate and remove a content
+
+        if there is no more content, then session is terminated
+        @param session(dict): jingle session
+        @param content_name(unicode): name of the content terminated
+        @param reason(unicode): reason of the termination
+        """
+        contents = session["contents"]
+        del contents[content_name]
+        if not contents:
+            self.terminate(client, reason, session)
+
+    ## defaults methods called when plugin doesn't have them ##
+
+    def jingle_request_confirmation_default(
+        self, client, action, session, content_name, desc_elt
+    ):
+        """This method request confirmation for a jingle session"""
+        log.debug("Using generic jingle confirmation method")
+        return xml_tools.defer_confirm(
+            self.host,
+            _(CONFIRM_TXT).format(entity=session["peer_jid"].full()),
+            _("Confirm Jingle session"),
+            profile=client.profile,
+        )
+
+    ## jingle events ##
+
+    def _on_jingle_request(self, request: domish.Element, client: SatXMPPEntity) -> None:
+        defer.ensureDeferred(self.on_jingle_request(client, request))
+
+    async def on_jingle_request(
+        self,
+        client: SatXMPPEntity,
+        request: domish.Element
+    ) -> None:
+        """Called when any jingle request is received
+
+        The request will then be dispatched to appropriate method
+        according to current state
+        @param request(domish.Element): received IQ request
+        """
+        request.handled = True
+        jingle_elt = next(request.elements(NS_JINGLE, "jingle"))
+
+        # first we need the session id
+        try:
+            sid = jingle_elt["sid"]
+            if not sid:
+                raise KeyError
+        except KeyError:
+            log.warning("Received jingle request has no sid attribute")
+            self.sendError(client, "bad-request", None, request)
+            return
+
+        # then the action
+        try:
+            action = jingle_elt["action"]
+            if not action:
+                raise KeyError
+        except KeyError:
+            log.warning("Received jingle request has no action")
+            self.sendError(client, "bad-request", None, request)
+            return
+
+        peer_jid = jid.JID(request["from"])
+
+        # we get or create the session
+        try:
+            session = client.jingle_sessions[sid]
+        except KeyError:
+            if action == XEP_0166.A_SESSION_INITIATE:
+                pass
+            elif action == XEP_0166.A_SESSION_TERMINATE:
+                log.debug(
+                    "ignoring session terminate action (inexisting session id): {request_id} [{profile}]".format(
+                        request_id=sid, profile=client.profile
+                    )
+                )
+                return
+            else:
+                log.warning(
+                    "Received request for an unknown session id: {request_id} [{profile}]".format(
+                        request_id=sid, profile=client.profile
+                    )
+                )
+                self.sendError(client, "item-not-found", None, request, "unknown-session")
+                return
+
+            session = client.jingle_sessions[sid] = {
+                "id": sid,
+                "state": STATE_PENDING,
+                "initiator": peer_jid,
+                "role": XEP_0166.ROLE_RESPONDER,
+                # we store local_jid using request['to'] because for a component the jid
+                # used may not be client.jid (if a local part is used).
+                "local_jid": jid.JID(request['to']),
+                "peer_jid": peer_jid,
+                "started": time.time(),
+            }
+        else:
+            if session["peer_jid"] != peer_jid:
+                log.warning(
+                    "sid conflict ({}), the jid doesn't match. Can be a collision, a hack attempt, or a bad sid generation".format(
+                        sid
+                    )
+                )
+                self.sendError(client, "service-unavailable", sid, request)
+                return
+            if session["id"] != sid:
+                log.error("session id doesn't match")
+                self.sendError(client, "service-unavailable", sid, request)
+                raise exceptions.InternalError
+
+        if action == XEP_0166.A_SESSION_INITIATE:
+            await self.on_session_initiate(client, request, jingle_elt, session)
+        elif action == XEP_0166.A_SESSION_TERMINATE:
+            self.on_session_terminate(client, request, jingle_elt, session)
+        elif action == XEP_0166.A_SESSION_ACCEPT:
+            await self.on_session_accept(client, request, jingle_elt, session)
+        elif action == XEP_0166.A_SESSION_INFO:
+            self.on_session_info(client, request, jingle_elt, session)
+        elif action == XEP_0166.A_TRANSPORT_INFO:
+            self.on_transport_info(client, request, jingle_elt, session)
+        elif action == XEP_0166.A_TRANSPORT_REPLACE:
+            await self.on_transport_replace(client, request, jingle_elt, session)
+        elif action == XEP_0166.A_TRANSPORT_ACCEPT:
+            self.on_transport_accept(client, request, jingle_elt, session)
+        elif action == XEP_0166.A_TRANSPORT_REJECT:
+            self.on_transport_reject(client, request, jingle_elt, session)
+        else:
+            raise exceptions.InternalError(f"Unknown action {action}")
+
+    ## Actions callbacks ##
+
+    def _parse_elements(
+        self,
+        jingle_elt: domish.Element,
+        session: dict,
+        request: domish.Element,
+        client: SatXMPPEntity,
+        new: bool = False,
+        creator: str = ROLE_INITIATOR,
+        with_application: bool =True,
+        with_transport: bool = True,
+        store_in_session: bool = True,
+    ) -> Dict[str, dict]:
+        """Parse contents elements and fill contents_dict accordingly
+
+        after the parsing, contents_dict will containt handlers, "desc_elt" and
+        "transport_elt"
+        @param jingle_elt: parent <jingle> element, containing one or more <content>
+        @param session: session data
+        @param request: the whole request
+        @param client: %(doc_client)s
+        @param new: True if the content is new and must be created,
+            else the content must exists, and session data will be filled
+        @param creator: only used if new is True: creating pear (see § 7.3)
+        @param with_application: if True, raise an error if there is no <description>
+            element else ignore it
+        @param with_transport: if True, raise an error if there is no <transport> element
+            else ignore it
+        @param store_in_session: if True, the ``session`` contents will be updated with
+        the parsed elements.
+            Use False when you parse an action which can happen at any time (e.g.
+            transport-info) and meaning that a parsed element may already be present in
+            the session (e.g. if an authorisation request is waiting for user answer),
+            This can't be used when ``new`` is set.
+        @return: contents_dict (from session, or a new one if "store_in_session" is False)
+        @raise exceptions.CancelError: the error is treated and the calling method can
+            cancel the treatment (i.e. return)
+        """
+        if store_in_session:
+            contents_dict = session["contents"]
+        else:
+            if new:
+                raise exceptions.InternalError(
+                    '"store_in_session" must not be used when "new" is set'
+                )
+            contents_dict = {n: {} for n in session["contents"]}
+        content_elts = jingle_elt.elements(NS_JINGLE, "content")
+
+        for content_elt in content_elts:
+            name = content_elt["name"]
+
+            if new:
+                # the content must not exist, we check it
+                if not name or name in contents_dict:
+                    self.sendError(client, "bad-request", session["id"], request)
+                    raise exceptions.CancelError
+                content_data = contents_dict[name] = {
+                    "creator": creator,
+                    "senders": content_elt.attributes.get("senders", "both"),
+                }
+            else:
+                # the content must exist, we check it
+                try:
+                    content_data = contents_dict[name]
+                except KeyError:
+                    log.warning("Other peer try to access an unknown content")
+                    self.sendError(client, "bad-request", session["id"], request)
+                    raise exceptions.CancelError
+
+            # application
+            if with_application:
+                desc_elt = content_elt.description
+                if not desc_elt:
+                    self.sendError(client, "bad-request", session["id"], request)
+                    raise exceptions.CancelError
+
+                if new:
+                    # the content is new, we need to check and link the application
+                    app_ns = desc_elt.uri
+                    if not app_ns or app_ns == NS_JINGLE:
+                        self.sendError(client, "bad-request", session["id"], request)
+                        raise exceptions.CancelError
+
+                    try:
+                        application = self._applications[app_ns]
+                    except KeyError:
+                        log.warning(
+                            "Unmanaged application namespace [{}]".format(app_ns)
+                        )
+                        self.sendError(
+                            client, "service-unavailable", session["id"], request
+                        )
+                        raise exceptions.CancelError
+
+                    content_data["application"] = application
+                    content_data["application_data"] = {}
+                else:
+                    # the content exists, we check that we have not a former desc_elt
+                    if "desc_elt" in content_data:
+                        raise exceptions.InternalError(
+                            "desc_elt should not exist at this point"
+                        )
+
+                content_data["desc_elt"] = desc_elt
+
+            # transport
+            if with_transport:
+                transport_elt = content_elt.transport
+                if not transport_elt:
+                    self.sendError(client, "bad-request", session["id"], request)
+                    raise exceptions.CancelError
+
+                if new:
+                    # the content is new, we need to check and link the transport
+                    transport_ns = transport_elt.uri
+                    if not app_ns or app_ns == NS_JINGLE:
+                        self.sendError(client, "bad-request", session["id"], request)
+                        raise exceptions.CancelError
+
+                    try:
+                        transport = self._transports[transport_ns]
+                    except KeyError:
+                        raise exceptions.InternalError(
+                            "No transport registered for namespace {}".format(
+                                transport_ns
+                            )
+                        )
+                    content_data["transport"] = transport
+                    content_data["transport_data"] = {}
+                else:
+                    # the content exists, we check that we have not a former transport_elt
+                    if "transport_elt" in content_data:
+                        raise exceptions.InternalError(
+                            "transport_elt should not exist at this point"
+                        )
+
+                content_data["transport_elt"] = transport_elt
+
+        return contents_dict
+
+    def _ignore(self, client, action, session, content_name, elt):
+        """Dummy method used when not exception must be raised if a method is not implemented in _call_plugins
+
+        must be used as app_default_cb and/or transp_default_cb
+        """
+        return elt
+
+    def _call_plugins(
+        self,
+        client: SatXMPPEntity,
+        action: str,
+        session: dict,
+        app_method_name: Optional[str] = "jingle_handler",
+        transp_method_name: Optional[str] = "jingle_handler",
+        app_default_cb: Optional[Callable] = None,
+        transp_default_cb: Optional[Callable] = None,
+        delete: bool = True,
+        elements: bool = True,
+        force_element: Optional[domish.Element] = None
+    ) -> List[defer.Deferred]:
+        """Call application and transport plugin methods for all contents
+
+        @param action: jingle action name
+        @param session: jingle session data
+        @param app_method_name: name of the method to call for applications
+            None to ignore
+        @param transp_method_name: name of the method to call for transports
+            None to ignore
+        @param app_default_cb: default callback to use if plugin has not app_method_name
+            None to raise an exception instead
+        @param transp_default_cb: default callback to use if plugin has not transp_method_name
+            None to raise an exception instead
+        @param delete: if True, remove desc_elt and transport_elt from session
+            ignored if elements is False
+        @param elements: True if elements(desc_elt and tranport_elt) must be managed
+            must be True if _call_plugins is used in a request, and False if it is used
+            after a request (i.e. on <iq> result or error)
+        @param force_element: if elements is False, it is used as element parameter
+            else it is ignored
+        @return : list of launched Deferred
+        @raise exceptions.NotFound: method is not implemented
+        """
+        contents_dict = session["contents"]
+        defers_list = []
+        for content_name, content_data in contents_dict.items():
+            for method_name, handler_key, default_cb, elt_name in (
+                (app_method_name, "application", app_default_cb, "desc_elt"),
+                (transp_method_name, "transport", transp_default_cb, "transport_elt"),
+            ):
+                if method_name is None:
+                    continue
+
+                handler = content_data[handler_key].handler
+                try:
+                    method = getattr(handler, method_name)
+                except AttributeError:
+                    if default_cb is None:
+                        raise exceptions.NotFound(
+                            "{} not implemented !".format(method_name)
+                        )
+                    else:
+                        method = default_cb
+                if elements:
+                    elt = content_data.pop(elt_name) if delete else content_data[elt_name]
+                else:
+                    elt = force_element
+                d = utils.as_deferred(
+                    method, client, action, session, content_name, elt
+                )
+                defers_list.append(d)
+
+        return defers_list
+
+    async def on_session_initiate(
+        self,
+        client: SatXMPPEntity,
+        request: domish.Element,
+        jingle_elt: domish.Element,
+        session: Dict[str, Any]
+    ) -> None:
+        """Called on session-initiate action
+
+        The "jingle_request_confirmation" method of each application will be called
+        (or self.jingle_request_confirmation_default if the former doesn't exist).
+        The session is only accepted if all application are confirmed.
+        The application must manage itself multiple contents scenari (e.g. audio/video).
+        @param client: %(doc_client)s
+        @param request(domish.Element): full request
+        @param jingle_elt(domish.Element): <jingle> element
+        @param session(dict): session data
+        """
+        if "contents" in session:
+            raise exceptions.InternalError(
+                "Contents dict should not already exist at this point"
+            )
+        session["contents"] = contents_dict = {}
+
+        try:
+            self._parse_elements(
+                jingle_elt, session, request, client, True, XEP_0166.ROLE_INITIATOR
+            )
+        except exceptions.CancelError:
+            return
+
+        if not contents_dict:
+            # there MUST be at least one content
+            self.sendError(client, "bad-request", session["id"], request)
+            return
+
+        # at this point we can send the <iq/> result to confirm reception of the request
+        client.send(xmlstream.toResponse(request, "result"))
+
+
+        assert "jingle_elt" not in session
+        session["jingle_elt"] = jingle_elt
+        if not await self.host.trigger.async_point(
+            "XEP-0166_on_session_initiate",
+            client, session, request, jingle_elt
+        ):
+            return
+
+        await defer.DeferredList(self._call_plugins(
+            client,
+            XEP_0166.A_PREPARE_CONFIRMATION,
+            session,
+            delete=False
+        ))
+
+        # we now request each application plugin confirmation
+        # and if all are accepted, we can accept the session
+        confirm_defers = self._call_plugins(
+            client,
+            XEP_0166.A_SESSION_INITIATE,
+            session,
+            "jingle_request_confirmation",
+            None,
+            self.jingle_request_confirmation_default,
+            delete=False,
+        )
+
+        confirm_dlist = defer.gatherResults(confirm_defers)
+        confirm_dlist.addCallback(self._confirmation_cb, session, jingle_elt, client)
+        confirm_dlist.addErrback(self._jingle_error_cb, session, request, client)
+
+    def _confirmation_cb(self, confirm_results, session, jingle_elt, client):
+        """Method called when confirmation from user has been received
+
+        This method is only called for the responder
+        @param confirm_results(list[bool]): all True if session is accepted
+        @param session(dict): session data
+        @param jingle_elt(domish.Element): jingle data of this session
+        @param client: %(doc_client)s
+        """
+        del session["jingle_elt"]
+        confirmed = all(confirm_results)
+        if not confirmed:
+            return self.terminate(client, XEP_0166.REASON_DECLINE, session)
+
+        iq_elt, jingle_elt = self._build_jingle_elt(
+            client, session, XEP_0166.A_SESSION_ACCEPT
+        )
+        jingle_elt["responder"] = session['local_jid'].full()
+        session["jingle_elt"] = jingle_elt
+
+        # contents
+
+        def addElement(domish_elt, content_elt):
+            content_elt.addChild(domish_elt)
+
+        defers_list = []
+
+        for content_name, content_data in session["contents"].items():
+            content_elt = jingle_elt.addElement("content")
+            content_elt["creator"] = XEP_0166.ROLE_INITIATOR
+            content_elt["name"] = content_name
+
+            application = content_data["application"]
+            app_session_accept_cb = application.handler.jingle_handler
+
+            app_d = utils.as_deferred(
+                app_session_accept_cb,
+                client,
+                XEP_0166.A_SESSION_INITIATE,
+                session,
+                content_name,
+                content_data.pop("desc_elt"),
+            )
+            app_d.addCallback(addElement, content_elt)
+            defers_list.append(app_d)
+
+            transport = content_data["transport"]
+            transport_session_accept_cb = transport.handler.jingle_handler
+
+            transport_d = utils.as_deferred(
+                transport_session_accept_cb,
+                client,
+                XEP_0166.A_SESSION_INITIATE,
+                session,
+                content_name,
+                content_data.pop("transport_elt"),
+            )
+            transport_d.addCallback(addElement, content_elt)
+            defers_list.append(transport_d)
+
+        d_list = defer.DeferredList(defers_list)
+        d_list.addCallback(
+            lambda __: self._call_plugins(
+                client,
+                XEP_0166.A_PREPARE_RESPONDER,
+                session,
+                app_method_name=None,
+                elements=False,
+            )
+        )
+        d_list.addCallback(lambda __: session.pop("jingle_elt"))
+        d_list.addCallback(lambda __: iq_elt.send())
+
+        def change_state(__, session):
+            session["state"] = STATE_ACTIVE
+
+        d_list.addCallback(change_state, session)
+        d_list.addCallback(
+            lambda __: self._call_plugins(
+                client, XEP_0166.A_ACCEPTED_ACK, session, elements=False
+            )
+        )
+        d_list.addErrback(self._iq_error, session["id"], client)
+        return d_list
+
+    def on_session_terminate(self, client, request, jingle_elt, session):
+        # TODO: check reason, display a message to user if needed
+        log.debug(f"Jingle Session {session['id']} terminated")
+        try:
+            reason_elt = next(jingle_elt.elements(NS_JINGLE, "reason"))
+        except StopIteration:
+            log.warning("No reason given for session termination")
+            reason_elt = jingle_elt.addElement("reason")
+
+        terminate_defers = self._call_plugins(
+            client,
+            XEP_0166.A_SESSION_TERMINATE,
+            session,
+            "jingle_terminate",
+            "jingle_terminate",
+            self._ignore,
+            self._ignore,
+            elements=False,
+            force_element=reason_elt,
+        )
+        terminate_dlist = defer.DeferredList(terminate_defers)
+
+        terminate_dlist.addCallback(lambda __: self._del_session(client, session["id"]))
+        client.send(xmlstream.toResponse(request, "result"))
+
+    async def on_session_accept(self, client, request, jingle_elt, session):
+        """Method called once session is accepted
+
+        This method is only called for initiator
+        @param client: %(doc_client)s
+        @param request(domish.Element): full <iq> request
+        @param jingle_elt(domish.Element): the <jingle> element
+        @param session(dict): session data
+        """
+        log.debug(f"Jingle session {session['id']} has been accepted")
+
+        try:
+            self._parse_elements(jingle_elt, session, request, client)
+        except exceptions.CancelError:
+            return
+
+        # at this point we can send the <iq/> result to confirm reception of the request
+        client.send(xmlstream.toResponse(request, "result"))
+        # and change the state
+        session["state"] = STATE_ACTIVE
+        session["jingle_elt"] = jingle_elt
+
+        await defer.DeferredList(self._call_plugins(
+            client,
+            XEP_0166.A_PREPARE_INITIATOR,
+            session,
+            delete=False
+        ))
+
+        negociate_defers = []
+        negociate_defers = self._call_plugins(client, XEP_0166.A_SESSION_ACCEPT, session)
+
+        negociate_dlist = defer.gatherResults(negociate_defers)
+
+        # after negociations we start the transfer
+        negociate_dlist.addCallback(
+            lambda __: self._call_plugins(
+                client, XEP_0166.A_START, session, app_method_name=None, elements=False
+            )
+        )
+        negociate_dlist.addCallback(lambda __: session.pop("jingle_elt"))
+
+    def _on_session_cb(self, result, client, request, jingle_elt, session):
+        client.send(xmlstream.toResponse(request, "result"))
+
+    def _on_session_eb(self, failure_, client, request, jingle_elt, session):
+        log.error("Error while handling on_session_info: {}".format(failure_.value))
+        # XXX: only error managed so far, maybe some applications/transports need more
+        self.sendError(
+            client, "feature-not-implemented", None, request, "unsupported-info"
+        )
+
+    def on_session_info(self, client, request, jingle_elt, session):
+        """Method called when a session-info action is received from other peer
+
+        This method is only called for initiator
+        @param client: %(doc_client)s
+        @param request(domish.Element): full <iq> request
+        @param jingle_elt(domish.Element): the <jingle> element
+        @param session(dict): session data
+        """
+        if not jingle_elt.children:
+            # this is a session ping, see XEP-0166 §6.8
+            client.send(xmlstream.toResponse(request, "result"))
+            return
+
+        try:
+            # XXX: session-info is most likely only used for application, so we don't call transport plugins
+            #      if a future transport use it, this behaviour must be adapted
+            defers = self._call_plugins(
+                client,
+                XEP_0166.A_SESSION_INFO,
+                session,
+                "jingle_session_info",
+                None,
+                elements=False,
+                force_element=jingle_elt,
+            )
+        except exceptions.NotFound as e:
+            self._on_session_eb(failure.Failure(e), client, request, jingle_elt, session)
+            return
+
+        dlist = defer.DeferredList(defers, fireOnOneErrback=True)
+        dlist.addCallback(self._on_session_cb, client, request, jingle_elt, session)
+        dlist.addErrback(self._on_session_cb, client, request, jingle_elt, session)
+
+    async def on_transport_replace(self, client, request, jingle_elt, session):
+        """A transport change is requested
+
+        The request is parsed, and jingle_handler is called on concerned transport plugin(s)
+        @param client: %(doc_client)s
+        @param request(domish.Element): full <iq> request
+        @param jingle_elt(domish.Element): the <jingle> element
+        @param session(dict): session data
+        """
+        log.debug("Other peer wants to replace the transport")
+        try:
+            self._parse_elements(
+                jingle_elt, session, request, client, with_application=False
+            )
+        except exceptions.CancelError:
+            defer.returnValue(None)
+
+        client.send(xmlstream.toResponse(request, "result"))
+
+        content_name = None
+        to_replace = []
+
+        for content_name, content_data in session["contents"].items():
+            try:
+                transport_elt = content_data.pop("transport_elt")
+            except KeyError:
+                continue
+            transport_ns = transport_elt.uri
+            try:
+                transport = self._transports[transport_ns]
+            except KeyError:
+                log.warning(
+                    "Other peer want to replace current transport with an unknown one: {}".format(
+                        transport_ns
+                    )
+                )
+                content_name = None
+                break
+            to_replace.append((content_name, content_data, transport, transport_elt))
+
+        if content_name is None:
+            # wa can't accept the replacement
+            iq_elt, reject_jingle_elt = self._build_jingle_elt(
+                client, session, XEP_0166.A_TRANSPORT_REJECT
+            )
+            for child in jingle_elt.children:
+                reject_jingle_elt.addChild(child)
+
+            iq_elt.send()
+            defer.returnValue(None)
+
+        # at this point, everything is alright and we can replace the transport(s)
+        # this is similar to an session-accept action, but for transports only
+        iq_elt, accept_jingle_elt = self._build_jingle_elt(
+            client, session, XEP_0166.A_TRANSPORT_ACCEPT
+        )
+        for content_name, content_data, transport, transport_elt in to_replace:
+            # we can now actually replace the transport
+            await utils.as_deferred(
+                content_data["transport"].handler.jingle_handler,
+                client, XEP_0166.A_DESTROY, session, content_name, None
+            )
+            content_data["transport"] = transport
+            content_data["transport_data"].clear()
+            # and build the element
+            content_elt = accept_jingle_elt.addElement("content")
+            content_elt["name"] = content_name
+            content_elt["creator"] = content_data["creator"]
+            # we notify the transport and insert its <transport/> in the answer
+            accept_transport_elt = await utils.as_deferred(
+                transport.handler.jingle_handler,
+                client, XEP_0166.A_TRANSPORT_REPLACE, session, content_name, transport_elt
+            )
+            content_elt.addChild(accept_transport_elt)
+            # there is no confirmation needed here, so we can directly prepare it
+            await utils.as_deferred(
+                transport.handler.jingle_handler,
+                client, XEP_0166.A_PREPARE_RESPONDER, session, content_name, None
+            )
+
+        iq_elt.send()
+
+    def on_transport_accept(self, client, request, jingle_elt, session):
+        """Method called once transport replacement is accepted
+
+        @param client: %(doc_client)s
+        @param request(domish.Element): full <iq> request
+        @param jingle_elt(domish.Element): the <jingle> element
+        @param session(dict): session data
+        """
+        log.debug("new transport has been accepted")
+
+        try:
+            self._parse_elements(
+                jingle_elt, session, request, client, with_application=False
+            )
+        except exceptions.CancelError:
+            return
+
+        # at this point we can send the <iq/> result to confirm reception of the request
+        client.send(xmlstream.toResponse(request, "result"))
+
+        negociate_defers = []
+        negociate_defers = self._call_plugins(
+            client, XEP_0166.A_TRANSPORT_ACCEPT, session, app_method_name=None
+        )
+
+        negociate_dlist = defer.DeferredList(negociate_defers)
+
+        # after negociations we start the transfer
+        negociate_dlist.addCallback(
+            lambda __: self._call_plugins(
+                client, XEP_0166.A_START, session, app_method_name=None, elements=False
+            )
+        )
+
+    def on_transport_reject(self, client, request, jingle_elt, session):
+        """Method called when a transport replacement is refused
+
+        @param client: %(doc_client)s
+        @param request(domish.Element): full <iq> request
+        @param jingle_elt(domish.Element): the <jingle> element
+        @param session(dict): session data
+        """
+        # XXX: for now, we terminate the session in case of transport-reject
+        #      this behaviour may change in the future
+        self.terminate(client, "failed-transport", session)
+
+    def on_transport_info(
+        self,
+        client: SatXMPPEntity,
+        request: domish.Element,
+        jingle_elt: domish.Element,
+        session: dict
+    ) -> None:
+        """Method called when a transport-info action is received from other peer
+
+        The request is parsed, and jingle_handler is called on concerned transport
+        plugin(s)
+        @param client: %(doc_client)s
+        @param request: full <iq> request
+        @param jingle_elt: the <jingle> element
+        @param session: session data
+        """
+        log.debug(f"Jingle session {session['id']} has been accepted")
+
+        try:
+            parsed_contents = self._parse_elements(
+                jingle_elt, session, request, client, with_application=False,
+                store_in_session=False
+            )
+        except exceptions.CancelError:
+            return
+
+        # The parsing was OK, we send the <iq> result
+        client.send(xmlstream.toResponse(request, "result"))
+
+        for content_name, content_data in session["contents"].items():
+            try:
+                transport_elt = parsed_contents[content_name]["transport_elt"]
+            except KeyError:
+                continue
+            else:
+                utils.as_deferred(
+                    content_data["transport"].handler.jingle_handler,
+                    client,
+                    XEP_0166.A_TRANSPORT_INFO,
+                    session,
+                    content_name,
+                    transport_elt,
+                )
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0166_handler(xmlstream.XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            JINGLE_REQUEST, self.plugin_parent._on_jingle_request, client=self.parent
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_JINGLE)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0166/models.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,187 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Jingle (XEP-0166)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import abc
+from dataclasses import dataclass
+from typing import Awaitable, Callable, Union
+
+from twisted.internet import defer
+from twisted.words.xish import domish
+
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+
+
+class BaseApplicationHandler(abc.ABC):
+
+    @abc.abstractmethod
+    def jingle_request_confirmation(
+        self,
+        client: SatXMPPEntity,
+        action: str,
+        session: dict,
+        content_name: str,
+        desc_elt: domish.Element,
+    ) -> Union[
+        Callable[..., Union[bool, defer.Deferred]],
+        Callable[..., Awaitable[bool]]
+    ]:
+        """
+        If present, it is called on when session must be accepted.
+        If not present, a generic accept dialog will be used.
+
+        @param session: Jingle Session
+        @param desc_elt: <description> element
+        @return: True if the session is accepted.
+            A Deferred can be returned.
+        """
+        pass
+
+    @abc.abstractmethod
+    def jingle_session_init(
+        self,
+        client: SatXMPPEntity,
+        session: dict,
+        content_name: str,
+        *args, **kwargs
+    ) -> Union[
+        Callable[..., domish.Element],
+        Callable[..., Awaitable[domish.Element]]
+    ]:
+        """
+        Must return the domish.Element used for initial content.
+
+        @param client: SatXMPPEntity instance
+        @param session: Jingle Session
+        @param content_name: Name of the content
+        @return: The domish.Element used for initial content
+        """
+        pass
+
+    @abc.abstractmethod
+    def jingle_handler(
+        self,
+        client: SatXMPPEntity,
+        action: str,
+        session: dict,
+        content_name: str,
+        transport_elt: domish.Element
+    ) -> Union[
+        Callable[..., None],
+        Callable[..., Awaitable[None]]
+    ]:
+        """
+        Called on several actions to negotiate the application or transport.
+
+        @param client: SatXMPPEntity instance
+        @param action: Jingle action
+        @param session: Jingle Session
+        @param content_name: Name of the content
+        @param transport_elt: Transport element
+        """
+        pass
+
+    @abc.abstractmethod
+    def jingle_terminate(
+        self,
+        client: SatXMPPEntity,
+        action: str,
+        session: dict,
+        content_name: str,
+        reason_elt: domish.Element
+    ) -> Union[
+        Callable[..., None],
+        Callable[..., Awaitable[None]]
+    ]:
+        """
+        Called on session terminate, with reason_elt.
+        May be used to clean session.
+
+        @param reason_elt: Reason element
+        """
+        pass
+
+
+class BaseTransportHandler(abc.ABC):
+
+    @abc.abstractmethod
+    def jingle_session_init(
+        self,
+        client: SatXMPPEntity,
+        session: dict,
+        content_name: str,
+        *args, **kwargs
+    ) -> Union[
+        Callable[..., domish.Element],
+        Callable[..., Awaitable[domish.Element]]
+    ]:
+        """
+        Must return the domish.Element used for initial content.
+
+        @param client: SatXMPPEntity instance
+        @param session: Jingle Session
+        @param content_name: Name of the content
+        @return: The domish.Element used for initial content
+        """
+        pass
+
+    @abc.abstractmethod
+    def jingle_handler(
+        self,
+        client: SatXMPPEntity,
+        action: str,
+        session: dict,
+        content_name: str,
+        reason_elt: domish.Element
+    ) -> Union[
+        Callable[..., None],
+        Callable[..., Awaitable[None]]
+    ]:
+        """
+        Called on several actions to negotiate the application or transport.
+
+        @param client: SatXMPPEntity instance
+        @param action: Jingle action
+        @param session: Jingle Session
+        @param content_name: Name of the content
+        @param reason_elt: <reason> element
+        """
+        pass
+
+
+@dataclass(frozen=True)
+class ApplicationData:
+    namespace: str
+    handler: BaseApplicationHandler
+
+
+@dataclass(frozen=True)
+class TransportData:
+    namespace: str
+    handler: BaseTransportHandler
+    priority: int
+
+
+@dataclass(frozen=True)
+class ContentData:
+    application: ApplicationData
+    app_args: list
+    app_kwargs: dict
+    transport_data: dict
+    content_name: str
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0167/__init__.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,439 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional
+
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools.common import data_format
+
+from . import mapping
+from ..plugin_xep_0166 import BaseApplicationHandler
+from .constants import (
+    NS_JINGLE_RTP,
+    NS_JINGLE_RTP_INFO,
+    NS_JINGLE_RTP_AUDIO,
+    NS_JINGLE_RTP_VIDEO,
+)
+
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Jingle RTP Sessions",
+    C.PI_IMPORT_NAME: "XEP-0167",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0167"],
+    C.PI_DEPENDENCIES: ["XEP-0166"],
+    C.PI_MAIN: "XEP_0167",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Real-time Transport Protocol (RTP) is used for A/V calls"""),
+}
+
+CONFIRM = D_("{peer} wants to start a call ({call_type}) with you, do you accept?")
+CONFIRM_TITLE = D_("Incoming Call")
+SECURITY_LIMIT = 0
+
+ALLOWED_ACTIONS = (
+    "active",
+    "hold",
+    "unhold",
+    "mute",
+    "unmute",
+    "ringing",
+)
+
+
+class XEP_0167(BaseApplicationHandler):
+    def __init__(self, host):
+        log.info(f'Plugin "{PLUGIN_INFO[C.PI_NAME]}" initialization')
+        self.host = host
+        # FIXME: to be removed once host is accessible from global var
+        mapping.host = host
+        self._j = host.plugins["XEP-0166"]
+        self._j.register_application(NS_JINGLE_RTP, self)
+        host.bridge.add_method(
+            "call_start",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._call_start,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "call_end",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._call_end,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "call_info",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="",
+            method=self._call_start,
+        )
+        host.bridge.add_signal(
+            "call_accepted", ".plugin", signature="sss"
+        )  # args: session_id, answer_sdp, profile
+        host.bridge.add_signal(
+            "call_ended", ".plugin", signature="sss"
+        )  # args: session_id, data, profile
+        host.bridge.add_signal(
+            "call_info", ".plugin", signature="ssss"
+        )  # args: session_id, info_type, extra, profile
+
+    def get_handler(self, client):
+        return XEP_0167_handler()
+
+    # bridge methods
+
+    def _call_start(
+        self,
+        entity_s: str,
+        call_data_s: str,
+        profile_key: str,
+    ):
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(
+            self.call_start(
+                client, jid.JID(entity_s), data_format.deserialise(call_data_s)
+            )
+        )
+
+    async def call_start(
+        self,
+        client: SatXMPPEntity,
+        peer_jid: jid.JID,
+        call_data: dict,
+    ) -> None:
+        """Temporary method to test RTP session"""
+        contents = []
+        metadata = call_data.get("metadata") or {}
+
+        if "sdp" in call_data:
+            sdp_data = mapping.parse_sdp(call_data["sdp"])
+            for media_type in ("audio", "video"):
+                try:
+                    media_data = sdp_data.pop(media_type)
+                except KeyError:
+                    continue
+                call_data[media_type] = media_data["application_data"]
+                transport_data = media_data["transport_data"]
+                try:
+                    call_data[media_type]["fingerprint"] = transport_data["fingerprint"]
+                except KeyError:
+                    log.warning("fingerprint is missing")
+                    pass
+                try:
+                    call_data[media_type]["id"] = media_data["id"]
+                except KeyError:
+                    log.warning(f"no media ID found for {media_type}: {media_data}")
+                try:
+                    call_data[media_type]["ice-candidates"] = transport_data["candidates"]
+                    metadata["ice-ufrag"] = transport_data["ufrag"]
+                    metadata["ice-pwd"] = transport_data["pwd"]
+                except KeyError:
+                    log.warning("ICE data are missing from SDP")
+                    continue
+            metadata.update(sdp_data.get("metadata", {}))
+
+        call_type = (
+            C.META_SUBTYPE_CALL_VIDEO
+            if "video" in call_data
+            else C.META_SUBTYPE_CALL_AUDIO
+        )
+        seen_names = set()
+
+        for media in ("audio", "video"):
+            media_data = call_data.get(media)
+            if media_data is not None:
+                content = {
+                    "app_ns": NS_JINGLE_RTP,
+                    "senders": "both",
+                    "transport_type": self._j.TRANSPORT_DATAGRAM,
+                    "app_kwargs": {"media": media, "media_data": media_data},
+                    "transport_data": {
+                        "local_ice_data": {
+                            "ufrag": metadata["ice-ufrag"],
+                            "pwd": metadata["ice-pwd"],
+                            "candidates": media_data.pop("ice-candidates"),
+                            "fingerprint": media_data.pop("fingerprint", {}),
+                        }
+                    },
+                }
+                if "id" in media_data:
+                    name = media_data.pop("id")
+                    if name in seen_names:
+                        raise exceptions.DataError(
+                            f"Content name (mid) seen multiple times: {name}"
+                        )
+                    content["name"] = name
+                contents.append(content)
+        if not contents:
+            raise exceptions.DataError("no valid media data found: {call_data}")
+        return await self._j.initiate(
+            client,
+            peer_jid,
+            contents,
+            call_type=call_type,
+            metadata=metadata,
+            peer_metadata={},
+        )
+
+    def _call_end(
+        self,
+        session_id: str,
+        data_s: str,
+        profile_key: str,
+    ):
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(
+            self.call_end(
+                client, session_id, data_format.deserialise(data_s)
+            )
+        )
+
+    async def call_end(
+        self,
+        client: SatXMPPEntity,
+        session_id: str,
+        data: dict,
+    ) -> None:
+        """End a call
+
+        @param session_id: Jingle session ID of the call
+        @param data: optional extra data, may be used to indicate the reason to end the
+            call
+        """
+        session = self._j.get_session(client, session_id)
+        await self._j.terminate(client, self._j.REASON_SUCCESS, session)
+
+    # jingle callbacks
+
+    def jingle_session_init(
+        self,
+        client: SatXMPPEntity,
+        session: dict,
+        content_name: str,
+        media: str,
+        media_data: dict,
+    ) -> domish.Element:
+        if media not in ("audio", "video"):
+            raise ValueError('only "audio" and "video" media types are supported')
+        content_data = session["contents"][content_name]
+        application_data = content_data["application_data"]
+        application_data["media"] = media
+        application_data["local_data"] = media_data
+        desc_elt = mapping.build_description(media, media_data, session)
+        self.host.trigger.point(
+            "XEP-0167_jingle_session_init",
+            client,
+            session,
+            content_name,
+            media,
+            media_data,
+            desc_elt,
+            triggers_no_cancel=True,
+        )
+        return desc_elt
+
+    async def jingle_request_confirmation(
+        self,
+        client: SatXMPPEntity,
+        action: str,
+        session: dict,
+        content_name: str,
+        desc_elt: domish.Element,
+    ) -> bool:
+        if content_name != next(iter(session["contents"])):
+            # we request confirmation only for the first content, all others are
+            # automatically accepted. In practice, that means that the call confirmation
+            # is requested only once for audio and video contents.
+            return True
+        peer_jid = session["peer_jid"]
+
+        if any(
+            c["desc_elt"].getAttribute("media") == "video"
+            for c in session["contents"].values()
+        ):
+            call_type = session["call_type"] = C.META_SUBTYPE_CALL_VIDEO
+        else:
+            call_type = session["call_type"] = C.META_SUBTYPE_CALL_AUDIO
+
+        sdp = mapping.generate_sdp_from_session(session)
+
+        resp_data = await xml_tools.defer_dialog(
+            self.host,
+            _(CONFIRM).format(peer=peer_jid.userhost(), call_type=call_type),
+            _(CONFIRM_TITLE),
+            action_extra={
+                "session_id": session["id"],
+                "from_jid": peer_jid.full(),
+                "type": C.META_TYPE_CALL,
+                "sub_type": call_type,
+                "sdp": sdp,
+            },
+            security_limit=SECURITY_LIMIT,
+            profile=client.profile,
+        )
+
+        if resp_data.get("cancelled", False):
+            return False
+
+        answer_sdp = resp_data["sdp"]
+        parsed_answer = mapping.parse_sdp(answer_sdp)
+        session["peer_metadata"].update(parsed_answer["metadata"])
+        for media in ("audio", "video"):
+            for content in session["contents"].values():
+                if content["desc_elt"].getAttribute("media") == media:
+                    media_data = parsed_answer[media]
+                    application_data = content["application_data"]
+                    application_data["local_data"] = media_data["application_data"]
+                    transport_data = content["transport_data"]
+                    local_ice_data = media_data["transport_data"]
+                    transport_data["local_ice_data"] = local_ice_data
+
+        return True
+
+    async def jingle_handler(self, client, action, session, content_name, desc_elt):
+        content_data = session["contents"][content_name]
+        application_data = content_data["application_data"]
+        if action == self._j.A_PREPARE_CONFIRMATION:
+            session["metadata"] = {}
+            session["peer_metadata"] = {}
+            try:
+                media = application_data["media"] = desc_elt["media"]
+            except KeyError:
+                raise exceptions.DataError('"media" key is missing in {desc_elt.toXml()}')
+            if media not in ("audio", "video"):
+                raise exceptions.DataError(f"invalid media: {media!r}")
+            application_data["peer_data"] = mapping.parse_description(desc_elt)
+        elif action == self._j.A_SESSION_INITIATE:
+            application_data["peer_data"] = mapping.parse_description(desc_elt)
+            desc_elt = mapping.build_description(
+                application_data["media"], application_data["local_data"], session
+            )
+        elif action == self._j.A_ACCEPTED_ACK:
+            pass
+        elif action == self._j.A_PREPARE_INITIATOR:
+            application_data["peer_data"] = mapping.parse_description(desc_elt)
+        elif action == self._j.A_SESSION_ACCEPT:
+            if content_name == next(iter(session["contents"])):
+                # we only send the signal for first content, as it means that the whole
+                # session is accepted
+                answer_sdp = mapping.generate_sdp_from_session(session)
+                self.host.bridge.call_accepted(session["id"], answer_sdp, client.profile)
+        else:
+            log.warning(f"FIXME: unmanaged action {action}")
+
+        self.host.trigger.point(
+            "XEP-0167_jingle_handler",
+            client,
+            action,
+            session,
+            content_name,
+            desc_elt,
+            triggers_no_cancel=True,
+        )
+        return desc_elt
+
+    def jingle_session_info(
+        self,
+        client: SatXMPPEntity,
+        action: str,
+        session: dict,
+        content_name: str,
+        jingle_elt: domish.Element,
+    ) -> None:
+        """Informational messages"""
+        for elt in jingle_elt.elements():
+            if elt.uri == NS_JINGLE_RTP_INFO:
+                info_type = elt.name
+                if info_type not in ALLOWED_ACTIONS:
+                    log.warning("ignoring unknow info type: {info_type!r}")
+                    continue
+                extra = {}
+                if info_type in ("mute", "unmute"):
+                    name = elt.getAttribute("name")
+                    if name:
+                        extra["name"] = name
+                log.debug(f"{info_type} call info received (extra: {extra})")
+                self.host.bridge.call_info(
+                    session["id"], info_type, data_format.serialise(extra), client.profile
+                )
+
+    def _call_info(self, session_id, info_type, extra_s, profile_key):
+        client = self.host.get_client(profile_key)
+        extra = data_format.deserialise(extra_s)
+        return self.send_info(client, session_id, info_type, extra)
+
+
+    def send_info(
+        self,
+        client: SatXMPPEntity,
+        session_id: str,
+        info_type: str,
+        extra: Optional[dict],
+    ) -> None:
+        """Send information on the call"""
+        if info_type not in ALLOWED_ACTIONS:
+            raise ValueError(f"Unkown info type {info_type!r}")
+        session = self._j.get_session(client, session_id)
+        iq_elt, jingle_elt = self._j.build_session_info(client, session)
+        info_elt = jingle_elt.addElement((NS_JINGLE_RTP_INFO, info_type))
+        if extra and info_type in ("mute", "unmute") and "name" in extra:
+            info_elt["name"] = extra["name"]
+        iq_elt.send()
+
+    def jingle_terminate(
+        self,
+        client: SatXMPPEntity,
+        action: str,
+        session: dict,
+        content_name: str,
+        reason_elt: domish.Element,
+    ) -> None:
+        self.host.bridge.call_ended(session["id"], "", client.profile)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0167_handler(XMPPHandler):
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [
+            disco.DiscoFeature(NS_JINGLE_RTP),
+            disco.DiscoFeature(NS_JINGLE_RTP_AUDIO),
+            disco.DiscoFeature(NS_JINGLE_RTP_VIDEO),
+        ]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0167/constants.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for managing pipes (experimental)
+# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Final
+
+
+NS_JINGLE_RTP_BASE: Final = "urn:xmpp:jingle:apps:rtp"
+NS_JINGLE_RTP: Final = f"{NS_JINGLE_RTP_BASE}:1"
+NS_JINGLE_RTP_AUDIO: Final = f"{NS_JINGLE_RTP_BASE}:audio"
+NS_JINGLE_RTP_VIDEO: Final = f"{NS_JINGLE_RTP_BASE}:video"
+NS_JINGLE_RTP_ERRORS: Final = f"{NS_JINGLE_RTP_BASE}:errors:1"
+NS_JINGLE_RTP_INFO: Final = f"{NS_JINGLE_RTP_BASE}:info:1"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0167/mapping.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,645 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import base64
+from typing import Any, Dict, Optional
+
+from twisted.words.xish import domish
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+from .constants import NS_JINGLE_RTP
+
+log = getLogger(__name__)
+
+host = None
+
+
+def senders_to_sdp(senders: str, session: dict) -> str:
+    """Returns appropriate SDP attribute corresponding to Jingle senders attribute"""
+    if senders == "both":
+        return "a=sendrecv"
+    elif senders == "none":
+        return "a=inactive"
+    elif session["role"] == senders:
+        return "a=sendonly"
+    else:
+        return "a=recvonly"
+
+
+def generate_sdp_from_session(
+    session: dict, local: bool = False, port: int = 9999
+) -> str:
+    """Generate an SDP string from session data.
+
+    @param session: A dictionary containing the session data. It should have the
+        following structure:
+
+        {
+            "contents": {
+                "<content_id>": {
+                    "application_data": {
+                        "media": <str: "audio" or "video">,
+                        "local_data": <media_data dict>,
+                        "peer_data": <media_data dict>,
+                        ...
+                    },
+                    "transport_data": {
+                        "local_ice_data": <ice_data dict>,
+                        "peer_ice_data": <ice_data dict>,
+                        ...
+                    },
+                    ...
+                },
+                ...
+            }
+        }
+    @param local: A boolean value indicating whether to generate SDP for the local or
+        peer entity. If True, the method will generate SDP for the local entity,
+        otherwise for the peer entity. Generally the local SDP is received from frontends
+        and not needed in backend, except for debugging purpose.
+    @param port: The preferred port for communications.
+
+    @return: The generated SDP string.
+    """
+    sdp_lines = ["v=0"]
+
+    # Add originator (o=) line after the version (v=) line
+    username = base64.b64encode(session["local_jid"].full().encode()).decode()
+    session_id = "1"  # Increment this for each session
+    session_version = "1"  # Increment this when the session is updated
+    network_type = "IN"
+    address_type = "IP4"
+    connection_address = "0.0.0.0"
+    o_line = (
+        f"o={username} {session_id} {session_version} {network_type} {address_type} "
+        f"{connection_address}"
+    )
+    sdp_lines.append(o_line)
+
+    # Add the mandatory "s=" and t=" lines
+    sdp_lines.append("s=-")
+    sdp_lines.append("t=0 0")
+
+    # stream direction
+    all_senders = {c["senders"] for c in session["contents"].values()}
+    # if we don't have a common senders for all contents, we set them at media level
+    senders = all_senders.pop() if len(all_senders) == 1 else None
+    if senders is not None:
+        sdp_lines.append(senders_to_sdp(senders, session))
+
+    sdp_lines.append("a=msid-semantic:WMS *")
+
+    host.trigger.point(
+        "XEP-0167_generate_sdp_session",
+        session,
+        local,
+        sdp_lines,
+        triggers_no_cancel=True
+    )
+
+    contents = session["contents"]
+    for content_name, content_data in contents.items():
+        app_data_key = "local_data" if local else "peer_data"
+        application_data = content_data["application_data"]
+        media_data = application_data[app_data_key]
+        media = application_data["media"]
+        payload_types = media_data.get("payload_types", {})
+
+        # Generate m= line
+        transport = "UDP/TLS/RTP/SAVPF"
+        payload_type_ids = [str(pt_id) for pt_id in payload_types]
+        m_line = f"m={media} {port} {transport} {' '.join(payload_type_ids)}"
+        sdp_lines.append(m_line)
+
+        sdp_lines.append(f"c={network_type} {address_type} {connection_address}")
+
+        sdp_lines.append(f"a=mid:{content_name}")
+
+        # stream direction
+        if senders is None:
+            sdp_lines.append(senders_to_sdp(content_data["senders"], session))
+
+        # Generate a= lines for rtpmap and fmtp
+        for pt_id, pt in payload_types.items():
+            name = pt["name"]
+            clockrate = pt.get("clockrate", "")
+            sdp_lines.append(f"a=rtpmap:{pt_id} {name}/{clockrate}")
+
+            if "ptime" in pt:
+                sdp_lines.append(f"a=ptime:{pt['ptime']}")
+
+            if "parameters" in pt:
+                fmtp_params = ";".join([f"{k}={v}" for k, v in pt["parameters"].items()])
+                sdp_lines.append(f"a=fmtp:{pt_id} {fmtp_params}")
+
+        if "bandwidth" in media_data:
+            sdp_lines.append(f"a=b:{media_data['bandwidth']}")
+
+        if media_data.get("rtcp-mux"):
+            sdp_lines.append("a=rtcp-mux")
+
+        # Generate a= lines for fingerprint, ICE ufrag, pwd and candidates
+        ice_data_key = "local_ice_data" if local else "peer_ice_data"
+        ice_data = content_data["transport_data"][ice_data_key]
+
+        if "fingerprint" in ice_data:
+            fingerprint_data = ice_data["fingerprint"]
+            sdp_lines.append(
+                f"a=fingerprint:{fingerprint_data['hash']} "
+                f"{fingerprint_data['fingerprint']}"
+            )
+            sdp_lines.append(f"a=setup:{fingerprint_data['setup']}")
+
+        sdp_lines.append(f"a=ice-ufrag:{ice_data['ufrag']}")
+        sdp_lines.append(f"a=ice-pwd:{ice_data['pwd']}")
+
+        for candidate in ice_data["candidates"]:
+            foundation = candidate["foundation"]
+            component_id = candidate["component_id"]
+            transport = candidate["transport"]
+            priority = candidate["priority"]
+            address = candidate["address"]
+            candidate_port = candidate["port"]
+            candidate_type = candidate["type"]
+
+            candidate_line = (
+                f"a=candidate:{foundation} {component_id} {transport} {priority} "
+                f"{address} {candidate_port} typ {candidate_type}"
+            )
+
+            if "rel_addr" in candidate and "rel_port" in candidate:
+                candidate_line += (
+                    f" raddr {candidate['rel_addr']} rport {candidate['rel_port']}"
+                )
+
+            if "generation" in candidate:
+                candidate_line += f" generation {candidate['generation']}"
+
+            if "network" in candidate:
+                candidate_line += f" network {candidate['network']}"
+
+            sdp_lines.append(candidate_line)
+
+        # Generate a= lines for encryption
+        if "encryption" in media_data:
+            for enc_data in media_data["encryption"]:
+                crypto_suite = enc_data["crypto-suite"]
+                key_params = enc_data["key-params"]
+                session_params = enc_data.get("session-params", "")
+                tag = enc_data["tag"]
+
+                crypto_line = f"a=crypto:{tag} {crypto_suite} {key_params}"
+                if session_params:
+                    crypto_line += f" {session_params}"
+                sdp_lines.append(crypto_line)
+
+
+        host.trigger.point(
+            "XEP-0167_generate_sdp_content",
+            session,
+            local,
+            content_name,
+            content_data,
+            sdp_lines,
+            application_data,
+            app_data_key,
+            media_data,
+            media,
+            triggers_no_cancel=True
+        )
+
+    # Combine SDP lines and return the result
+    return "\r\n".join(sdp_lines) + "\r\n"
+
+
+def parse_sdp(sdp: str) -> dict:
+    """Parse SDP string.
+
+    @param sdp: The SDP string to parse.
+
+    @return: A dictionary containing parsed session data.
+    """
+    # FIXME: to be removed once host is accessible from global var
+    assert host is not None
+    lines = sdp.strip().split("\r\n")
+    # session metadata
+    metadata: Dict[str, Any] = {}
+    call_data = {"metadata": metadata}
+
+    media_type = None
+    media_data: Optional[Dict[str, Any]] = None
+    application_data: Optional[Dict[str, Any]] = None
+    transport_data: Optional[Dict[str, Any]] = None
+    fingerprint_data: Optional[Dict[str, str]] = None
+    ice_pwd: Optional[str] = None
+    ice_ufrag: Optional[str] = None
+    payload_types: Optional[Dict[int, dict]] = None
+
+    for line in lines:
+        try:
+            parts = line.split()
+            prefix = parts[0][:2]  # Extract the 'a=', 'm=', etc., prefix
+            parts[0] = parts[0][2:]  # Remove the prefix from the first element
+
+            if prefix == "m=":
+                media_type = parts[0]
+                port = int(parts[1])
+                payload_types = {}
+                for payload_type_id in [int(pt_id) for pt_id in parts[3:]]:
+                    payload_type = {"id": payload_type_id}
+                    payload_types[payload_type_id] = payload_type
+
+                application_data = {"media": media_type, "payload_types": payload_types}
+                transport_data = {"port": port}
+                if fingerprint_data is not None:
+                    transport_data["fingerprint"] = fingerprint_data
+                if ice_pwd is not None:
+                    transport_data["pwd"] = ice_pwd
+                if ice_ufrag is not None:
+                    transport_data["ufrag"] = ice_ufrag
+                media_data = call_data[media_type] = {
+                    "application_data": application_data,
+                    "transport_data": transport_data,
+                }
+
+            elif prefix == "a=":
+                if ":" in parts[0]:
+                    attribute, parts[0] = parts[0].split(":", 1)
+                else:
+                    attribute = parts[0]
+
+                if (
+                    media_type is None
+                    or application_data is None
+                    or transport_data is None
+                ) and not (
+                    attribute
+                    in (
+                        "sendrecv",
+                        "sendonly",
+                        "recvonly",
+                        "inactive",
+                        "fingerprint",
+                        "group",
+                        "ice-options",
+                        "msid-semantic",
+                        "ice-pwd",
+                        "ice-ufrag",
+                    )
+                ):
+                    log.warning(
+                        "Received attribute before media description, this is "
+                        f"invalid: {line}"
+                    )
+                    continue
+
+                if attribute == "mid":
+                    assert media_data is not None
+                    try:
+                        media_data["id"] = parts[0]
+                    except IndexError:
+                        log.warning(f"invalid media ID: {line}")
+
+                elif attribute == "rtpmap":
+                    assert application_data is not None
+                    assert payload_types is not None
+                    pt_id = int(parts[0])
+                    codec_info = parts[1].split("/")
+                    codec = codec_info[0]
+                    clockrate = int(codec_info[1])
+                    payload_type = {
+                        "id": pt_id,
+                        "name": codec,
+                        "clockrate": clockrate,
+                    }
+                    # Handle optional channel count
+                    if len(codec_info) > 2:
+                        channels = int(codec_info[2])
+                        payload_type["channels"] = channels
+
+                    payload_types.setdefault(pt_id, {}).update(payload_type)
+
+                elif attribute == "fmtp":
+                    assert payload_types is not None
+                    pt_id = int(parts[0])
+                    params = parts[1].split(";")
+                    try:
+                        payload_type = payload_types[pt_id]
+                    except KeyError:
+                        raise ValueError(
+                            f"Can find content type {pt_id}, ignoring: {line}"
+                        )
+
+                    try:
+                        payload_type["parameters"] = {
+                            name: value
+                            for name, value in (param.split("=") for param in params)
+                        }
+                    except ValueError:
+                        payload_type.setdefault("exra-parameters", []).extend(params)
+
+                elif attribute == "candidate":
+                    assert transport_data is not None
+                    candidate = {
+                        "foundation": parts[0],
+                        "component_id": int(parts[1]),
+                        "transport": parts[2],
+                        "priority": int(parts[3]),
+                        "address": parts[4],
+                        "port": int(parts[5]),
+                        "type": parts[7],
+                    }
+
+                    for part in parts[8:]:
+                        if part == "raddr":
+                            candidate["rel_addr"] = parts[parts.index(part) + 1]
+                        elif part == "rport":
+                            candidate["rel_port"] = int(parts[parts.index(part) + 1])
+                        elif part == "generation":
+                            candidate["generation"] = parts[parts.index(part) + 1]
+                        elif part == "network":
+                            candidate["network"] = parts[parts.index(part) + 1]
+
+                    transport_data.setdefault("candidates", []).append(candidate)
+
+                elif attribute == "fingerprint":
+                    algorithm, fingerprint = parts[0], parts[1]
+                    fingerprint_data = {"hash": algorithm, "fingerprint": fingerprint}
+                    if transport_data is not None:
+                        transport_data["fingerprint"] = fingerprint_data
+                elif attribute == "setup":
+                    assert transport_data is not None
+                    setup = parts[0]
+                    transport_data.setdefault("fingerprint", {})["setup"] = setup
+
+                elif attribute == "b":
+                    assert application_data is not None
+                    bandwidth = int(parts[0])
+                    application_data["bandwidth"] = bandwidth
+
+                elif attribute == "rtcp-mux":
+                    assert application_data is not None
+                    application_data["rtcp-mux"] = True
+
+                elif attribute == "ice-ufrag":
+                    if transport_data is not None:
+                        transport_data["ufrag"] = parts[0]
+
+                elif attribute == "ice-pwd":
+                    if transport_data is not None:
+                        transport_data["pwd"] = parts[0]
+
+                host.trigger.point(
+                    "XEP-0167_parse_sdp_a",
+                    attribute,
+                    parts,
+                    call_data,
+                    metadata,
+                    media_type,
+                    application_data,
+                    transport_data,
+                    triggers_no_cancel=True
+                )
+
+        except ValueError as e:
+            raise ValueError(f"Could not parse line. Invalid format ({e}): {line}") from e
+        except IndexError as e:
+            raise IndexError(f"Incomplete line. Missing data: {line}") from e
+
+    # we remove private data (data starting with _, used by some plugins (e.g. XEP-0294)
+    # to handle session data at media level))
+    for key in [k for k in call_data if k.startswith("_")]:
+        log.debug(f"cleaning remaining private data {key!r}")
+        del call_data[key]
+
+    # ICE candidates may only be specified for the first media, this
+    # duplicate the candidate for the other in this case
+    all_media = {k:v for k,v in call_data.items() if k in ("audio", "video")}
+    if len(all_media) > 1 and not all(
+        "candidates" in c["transport_data"] for c in all_media.values()
+    ):
+        first_content = next(iter(all_media.values()))
+        try:
+            ice_candidates = first_content["transport_data"]["candidates"]
+        except KeyError:
+            log.warning("missing candidates in SDP")
+        else:
+            for idx, content in enumerate(all_media.values()):
+                if idx == 0:
+                    continue
+                content["transport_data"].setdefault("candidates", ice_candidates)
+
+    return call_data
+
+
+def build_description(media: str, media_data: dict, session: dict) -> domish.Element:
+    """Generate <description> element from media data
+
+    @param media: media type ("audio" or "video")
+
+    @param media_data: A dictionary containing the media description data.
+        The keys and values are described below:
+
+        - ssrc (str, optional): The synchronization source identifier.
+        - payload_types (list): A list of dictionaries, each representing a payload
+          type.
+          Each dictionary may contain the following keys:
+            - channels (str, optional): Number of audio channels.
+            - clockrate (str, optional): Clock rate of the media.
+            - id (str): The unique identifier of the payload type.
+            - maxptime (str, optional): Maximum packet time.
+            - name (str, optional): Name of the codec.
+            - ptime (str, optional): Preferred packet time.
+            - parameters (dict, optional): A dictionary of codec-specific parameters.
+              Key-value pairs represent the parameter name and value, respectively.
+        - bandwidth (str, optional): The bandwidth type.
+        - rtcp-mux (bool, optional): Indicates whether RTCP multiplexing is enabled or
+          not.
+        - encryption (list, optional): A list of dictionaries, each representing an
+          encryption method.
+          Each dictionary may contain the following keys:
+            - tag (str): The unique identifier of the encryption method.
+            - crypto-suite (str): The encryption suite in use.
+            - key-params (str): Key parameters for the encryption suite.
+            - session-params (str, optional): Session parameters for the encryption
+              suite.
+
+    @return: A <description> element.
+    """
+    # FIXME: to be removed once host is accessible from global var
+    assert host is not None
+
+    desc_elt = domish.Element((NS_JINGLE_RTP, "description"), attribs={"media": media})
+
+    for pt_id, pt_data in media_data.get("payload_types", {}).items():
+        payload_type_elt = desc_elt.addElement("payload-type")
+        payload_type_elt["id"] = str(pt_id)
+        for attr in ["channels", "clockrate", "maxptime", "name", "ptime"]:
+            if attr in pt_data:
+                payload_type_elt[attr] = str(pt_data[attr])
+
+        if "parameters" in pt_data:
+            for param_name, param_value in pt_data["parameters"].items():
+                param_elt = payload_type_elt.addElement("parameter")
+                param_elt["name"] = param_name
+                param_elt["value"] = param_value
+        host.trigger.point(
+            "XEP-0167_build_description_payload_type",
+            desc_elt,
+            media_data,
+            pt_data,
+            payload_type_elt,
+            triggers_no_cancel=True
+        )
+
+    if "bandwidth" in media_data:
+        bandwidth_elt = desc_elt.addElement("bandwidth")
+        bandwidth_elt["type"] = media_data["bandwidth"]
+
+    if media_data.get("rtcp-mux"):
+        desc_elt.addElement("rtcp-mux")
+
+    # Add encryption element
+    if "encryption" in media_data:
+        encryption_elt = desc_elt.addElement("encryption")
+        # we always want require encryption if the `encryption` data is present
+        encryption_elt["required"] = "1"
+        for enc_data in media_data["encryption"]:
+            crypto_elt = encryption_elt.addElement("crypto")
+            for attr in ["tag", "crypto-suite", "key-params", "session-params"]:
+                if attr in enc_data:
+                    crypto_elt[attr] = enc_data[attr]
+
+    host.trigger.point(
+        "XEP-0167_build_description",
+        desc_elt,
+        media_data,
+        session,
+        triggers_no_cancel=True
+    )
+
+    return desc_elt
+
+
+def parse_description(desc_elt: domish.Element) -> dict:
+    """Parse <desciption> to a dict
+
+    @param desc_elt: <description> element
+    @return: media data as in [build_description]
+    """
+    # FIXME: to be removed once host is accessible from global var
+    assert host is not None
+
+    media_data = {}
+    if desc_elt.hasAttribute("ssrc"):
+        media_data.setdefault("ssrc", {})[desc_elt["ssrc"]] = {}
+
+    payload_types = {}
+    for payload_type_elt in desc_elt.elements(NS_JINGLE_RTP, "payload-type"):
+        payload_type_data = {
+            attr: payload_type_elt[attr]
+            for attr in [
+                "channels",
+                "clockrate",
+                "maxptime",
+                "name",
+                "ptime",
+            ]
+            if payload_type_elt.hasAttribute(attr)
+        }
+        try:
+            pt_id = int(payload_type_elt["id"])
+        except KeyError:
+            log.warning(
+                f"missing ID in payload type, ignoring: {payload_type_elt.toXml()}"
+            )
+            continue
+
+        parameters = {}
+        for param_elt in payload_type_elt.elements(NS_JINGLE_RTP, "parameter"):
+            param_name = param_elt.getAttribute("name")
+            param_value = param_elt.getAttribute("value")
+            if not param_name or param_value is None:
+                log.warning(f"invalid parameter: {param_elt.toXml()}")
+                continue
+            parameters[param_name] = param_value
+
+        if parameters:
+            payload_type_data["parameters"] = parameters
+
+        host.trigger.point(
+            "XEP-0167_parse_description_payload_type",
+            desc_elt,
+            media_data,
+            payload_type_elt,
+            payload_type_data,
+            triggers_no_cancel=True
+        )
+        payload_types[pt_id] = payload_type_data
+
+    # bandwidth
+    media_data["payload_types"] = payload_types
+    try:
+        bandwidth_elt = next(desc_elt.elements(NS_JINGLE_RTP, "bandwidth"))
+    except StopIteration:
+        pass
+    else:
+        bandwidth = bandwidth_elt.getAttribute("type")
+        if not bandwidth:
+            log.warning(f"invalid bandwidth: {bandwidth_elt.toXml}")
+        else:
+            media_data["bandwidth"] = bandwidth
+
+    # rtcp-mux
+    rtcp_mux_elt = next(desc_elt.elements(NS_JINGLE_RTP, "rtcp-mux"), None)
+    media_data["rtcp-mux"] = rtcp_mux_elt is not None
+
+    # Encryption
+    encryption_data = []
+    encryption_elt = next(desc_elt.elements(NS_JINGLE_RTP, "encryption"), None)
+    if encryption_elt:
+        media_data["encryption_required"] = C.bool(
+            encryption_elt.getAttribute("required", C.BOOL_FALSE)
+        )
+
+        for crypto_elt in encryption_elt.elements(NS_JINGLE_RTP, "crypto"):
+            crypto_data = {
+                attr: crypto_elt[attr]
+                for attr in [
+                    "crypto-suite",
+                    "key-params",
+                    "session-params",
+                    "tag",
+                ]
+                if crypto_elt.hasAttribute(attr)
+            }
+            encryption_data.append(crypto_data)
+
+    if encryption_data:
+        media_data["encryption"] = encryption_data
+
+    host.trigger.point(
+        "XEP-0167_parse_description",
+        desc_elt,
+        media_data,
+        triggers_no_cancel=True
+    )
+
+    return media_data
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0176.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,394 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Jingle (XEP-0176)
+# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Dict, List, Optional
+import uuid
+
+from twisted.internet import defer
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+
+from .plugin_xep_0166 import BaseTransportHandler
+
+log = getLogger(__name__)
+
+NS_JINGLE_ICE_UDP= "urn:xmpp:jingle:transports:ice-udp:1"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Jingle ICE-UDP Transport Method",
+    C.PI_IMPORT_NAME: "XEP-0176",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0176"],
+    C.PI_DEPENDENCIES: ["XEP-0166"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "XEP_0176",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Jingle ICE-UDP transport"""),
+}
+
+
+class XEP_0176(BaseTransportHandler):
+
+    def __init__(self, host):
+        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
+        self.host = host
+        self._j = host.plugins["XEP-0166"]  # shortcut to access jingle
+        self._j.register_transport(
+            NS_JINGLE_ICE_UDP, self._j.TRANSPORT_DATAGRAM, self, 100
+        )
+        host.bridge.add_method(
+            "ice_candidates_add",
+            ".plugin",
+            in_sign="sss",
+            out_sign="",
+            method=self._ice_candidates_add,
+            async_=True,
+        )
+        host.bridge.add_signal(
+            "ice_candidates_new", ".plugin", signature="sss"
+        )  # args: jingle_sid, candidates_serialised, profile
+        host.bridge.add_signal(
+            "ice_restart", ".plugin", signature="sss"
+        )  # args: jingle_sid, side ("local" or "peer"), profile
+
+    def get_handler(self, client):
+        return XEP_0176_handler()
+
+    def _ice_candidates_add(
+        self,
+        session_id: str,
+        media_ice_data_s: str,
+        profile_key: str,
+    ):
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(self.ice_candidates_add(
+            client,
+            session_id,
+            data_format.deserialise(media_ice_data_s),
+        ))
+
+    def build_transport(self, ice_data: dict) -> domish.Element:
+        """Generate <transport> element from ICE data
+
+        @param ice_data: a dict containing the following keys:
+            - "ufrag" (str): The ICE username fragment.
+            - "pwd" (str): The ICE password.
+            - "candidates" (List[dict]): A list of ICE candidate dictionaries, each
+              containing:
+                - "component_id" (int): The component ID.
+                - "foundation" (str): The candidate foundation.
+                - "address" (str): The candidate IP address.
+                - "port" (int): The candidate port.
+                - "priority" (int): The candidate priority.
+                - "transport" (str): The candidate transport protocol, e.g., "udp".
+                - "type" (str): The candidate type, e.g., "host", "srflx", "prflx", or
+                  "relay".
+                - "generation" (str, optional): The candidate generation. Defaults to "0".
+                - "network" (str, optional): The candidate network. Defaults to "0".
+                - "rel_addr" (str, optional): The related address for the candidate, if
+                  any.
+                - "rel_port" (int, optional): The related port for the candidate, if any.
+
+        @return: A <transport> element.
+        """
+        try:
+            ufrag: str = ice_data["ufrag"]
+            pwd: str = ice_data["pwd"]
+            candidates: List[dict] = ice_data["candidates"]
+        except KeyError as e:
+            raise exceptions.DataError(f"ICE {e} must be provided")
+
+        candidates.sort(key=lambda c: int(c.get("priority", 0)), reverse=True)
+        transport_elt = domish.Element(
+            (NS_JINGLE_ICE_UDP, "transport"),
+            attribs={"ufrag": ufrag, "pwd": pwd}
+        )
+
+        for candidate in candidates:
+            try:
+                candidate_elt = transport_elt.addElement("candidate")
+                candidate_elt["component"] = str(candidate["component_id"])
+                candidate_elt["foundation"] = candidate["foundation"]
+                candidate_elt["generation"] = str(candidate.get("generation", "0"))
+                candidate_elt["id"] = candidate.get("id") or str(uuid.uuid4())
+                candidate_elt["ip"] = candidate["address"]
+                candidate_elt["network"] = str(candidate.get("network", "0"))
+                candidate_elt["port"] = str(candidate["port"])
+                candidate_elt["priority"] = str(candidate["priority"])
+                candidate_elt["protocol"] = candidate["transport"]
+                candidate_elt["type"] = candidate["type"]
+            except KeyError as e:
+                raise exceptions.DataError(
+                    f"Mandatory ICE candidate attribute {e} is missing"
+                )
+
+            if "rel_addr" in candidate and "rel_port" in candidate:
+                candidate_elt["rel-addr"] = candidate["rel_addr"]
+                candidate_elt["rel-port"] = str(candidate["rel_port"])
+
+        self.host.trigger.point("XEP-0176_build_transport", transport_elt, ice_data)
+
+        return transport_elt
+
+    def parse_transport(self, transport_elt: domish.Element) -> dict:
+        """Parse <transport> to a dict
+
+        @param transport_elt: <transport> element
+        @return: ICE data (as in [build_transport])
+        """
+        try:
+            ice_data = {
+                "ufrag": transport_elt["ufrag"],
+                "pwd": transport_elt["pwd"]
+            }
+        except KeyError as e:
+            raise exceptions.DataError(
+                f"<transport> is missing mandatory attribute {e}: {transport_elt.toXml()}"
+            )
+        ice_data["candidates"] = ice_candidates = []
+
+        for candidate_elt in transport_elt.elements(NS_JINGLE_ICE_UDP, "candidate"):
+            try:
+                candidate = {
+                    "component_id": int(candidate_elt["component"]),
+                    "foundation": candidate_elt["foundation"],
+                    "address": candidate_elt["ip"],
+                    "port": int(candidate_elt["port"]),
+                    "priority": int(candidate_elt["priority"]),
+                    "transport": candidate_elt["protocol"],
+                    "type": candidate_elt["type"],
+                }
+            except KeyError as e:
+                raise exceptions.DataError(
+                    f"Mandatory attribute {e} is missing in candidate element"
+                )
+
+            if candidate_elt.hasAttribute("generation"):
+                candidate["generation"] = candidate_elt["generation"]
+
+            if candidate_elt.hasAttribute("network"):
+                candidate["network"] = candidate_elt["network"]
+
+            if candidate_elt.hasAttribute("rel-addr"):
+                candidate["rel_addr"] = candidate_elt["rel-addr"]
+
+            if candidate_elt.hasAttribute("rel-port"):
+                candidate["rel_port"] = int(candidate_elt["rel-port"])
+
+            ice_candidates.append(candidate)
+
+        self.host.trigger.point("XEP-0176_parse_transport", transport_elt, ice_data)
+
+        return ice_data
+
+    async def jingle_session_init(
+        self,
+        client: SatXMPPEntity,
+        session: dict,
+        content_name: str,
+    ) -> domish.Element:
+        """Create a Jingle session initiation transport element with ICE candidates.
+
+        @param client: SatXMPPEntity object representing the client.
+        @param session: Dictionary containing session data.
+        @param content_name: Name of the content.
+        @param ufrag: ICE username fragment.
+        @param pwd: ICE password.
+        @param candidates: List of ICE candidate dictionaries parsed from the
+            parse_ice_candidate method.
+
+        @return: domish.Element representing the Jingle transport element.
+
+        @raise exceptions.DataError: If mandatory data is missing from the candidates.
+        """
+        content_data = session["contents"][content_name]
+        transport_data = content_data["transport_data"]
+        ice_data = transport_data["local_ice_data"]
+        return self.build_transport(ice_data)
+
+    async def jingle_handler(
+        self,
+        client: SatXMPPEntity,
+        action: str,
+        session: dict,
+        content_name: str,
+        transport_elt: domish.Element,
+    ) -> domish.Element:
+        """Handle Jingle requests
+
+        @param client: The SatXMPPEntity instance.
+        @param action: The action to be performed with the session.
+        @param session: A dictionary containing the session information.
+        @param content_name: The name of the content.
+        @param transport_elt: The domish.Element instance representing the transport
+            element.
+
+        @return: <transport> element
+        """
+        content_data = session["contents"][content_name]
+        transport_data = content_data["transport_data"]
+        if action in (self._j.A_PREPARE_CONFIRMATION, self._j.A_PREPARE_INITIATOR):
+            peer_ice_data = self.parse_transport(transport_elt)
+            transport_data["peer_ice_data"] = peer_ice_data
+
+        elif action in (self._j.A_ACCEPTED_ACK, self._j.A_PREPARE_RESPONDER):
+            pass
+
+        elif action == self._j.A_SESSION_ACCEPT:
+            pass
+
+        elif action == self._j.A_START:
+            pass
+
+        elif action == self._j.A_SESSION_INITIATE:
+            # responder side, we give our candidates
+            transport_elt = self.build_transport(transport_data["local_ice_data"])
+        elif action == self._j.A_TRANSPORT_INFO:
+
+            media_type = content_data["application_data"].get("media")
+            new_ice_data = self.parse_transport(transport_elt)
+            restart = self.update_candidates(transport_data, new_ice_data, local=False)
+            if restart:
+                log.debug(
+                    f"Peer ICE restart detected on session {session['id']} "
+                    f"[{client.profile}]"
+                )
+                self.host.bridge.ice_restart(session["id"], "peer", client.profile)
+
+            self.host.bridge.ice_candidates_new(
+                session["id"],
+                data_format.serialise({media_type: new_ice_data}),
+                client.profile
+            )
+        elif action == self._j.A_DESTROY:
+           pass
+        else:
+            log.warning("FIXME: unmanaged action {}".format(action))
+
+        return transport_elt
+
+    def jingle_terminate(
+        self,
+        client: SatXMPPEntity,
+        action: str,
+        session: dict,
+        content_name: str,
+        reason_elt: domish.Element,
+    ) -> None:
+        log.debug("ICE-UDP session terminated")
+
+    def update_candidates(
+        self,
+        transport_data: dict,
+        new_ice_data: dict,
+        local: bool
+    ) -> bool:
+        """Update ICE candidates when new one are received
+
+        @param transport_data: transport_data of the content linked to the candidates
+        @param new_ice_data: new ICE data, in the same format as returned
+            by [self.parse_transport]
+        @param local: True if it's our candidates, False if it's peer ones
+        @return: True if there is a ICE restart
+        """
+        key = "local_ice_data" if local else "peer_ice_data"
+        try:
+            ice_data = transport_data[key]
+        except KeyError:
+            log.warning(
+                f"no {key} available"
+            )
+            transport_data[key] = new_ice_data
+        else:
+            if (
+                new_ice_data["ufrag"] != ice_data["ufrag"]
+                or new_ice_data["pwd"] != ice_data["pwd"]
+            ):
+                ice_data["ufrag"] = new_ice_data["ufrag"]
+                ice_data["pwd"] = new_ice_data["pwd"]
+                ice_data["candidates"] = new_ice_data["candidates"]
+                return True
+        return False
+
+    async def ice_candidates_add(
+        self,
+        client: SatXMPPEntity,
+        session_id: str,
+        media_ice_data: Dict[str, dict]
+    ) -> None:
+        """Called when a new ICE candidates are available for a session
+
+        @param session_id: Session ID
+        @param candidates: a map from media type (audio, video) to ICE data
+            ICE data must be in the same format as in [self.parse_transport]
+        """
+        session = self._j.get_session(client, session_id)
+        iq_elt: Optional[domish.Element] = None
+
+        for media_type, new_ice_data in media_ice_data.items():
+            for content_name, content_data in session["contents"].items():
+                if content_data["application_data"].get("media") == media_type:
+                    break
+            else:
+                log.warning(
+                    "no media of type {media_type} has been found"
+                )
+                continue
+            restart = self.update_candidates(
+                content_data["transport_data"], new_ice_data, True
+            )
+            if restart:
+                log.debug(
+                    f"Local ICE restart detected on session {session['id']} "
+                    f"[{client.profile}]"
+                )
+                self.host.bridge.ice_restart(session["id"], "local", client.profile)
+            transport_elt = self.build_transport(new_ice_data)
+            iq_elt, __ = self._j.build_action(
+                client, self._j.A_TRANSPORT_INFO, session, content_name, iq_elt=iq_elt,
+                transport_elt=transport_elt
+            )
+
+        if iq_elt is not None:
+            try:
+                await iq_elt.send()
+            except Exception as e:
+                log.warning(f"Could not send new ICE candidates: {e}")
+
+        else:
+            log.error("Could not find any content to apply new ICE candidates")
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0176_handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_JINGLE_ICE_UDP)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0184.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,237 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing xep-0184
+# Copyright (C) 2009-2016 Geoffrey POUZET (chteufleur@kingpenguin.tk)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from twisted.internet import reactor
+from twisted.words.protocols.jabber import xmlstream, jid
+from twisted.words.xish import domish
+
+log = getLogger(__name__)
+
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+
+NS_MESSAGE_DELIVERY_RECEIPTS = "urn:xmpp:receipts"
+
+MSG = "message"
+
+MSG_CHAT = "/" + MSG + '[@type="chat"]'
+MSG_CHAT_MESSAGE_DELIVERY_RECEIPTS_REQUEST = (
+    MSG_CHAT + '/request[@xmlns="' + NS_MESSAGE_DELIVERY_RECEIPTS + '"]'
+)
+MSG_CHAT_MESSAGE_DELIVERY_RECEIPTS_RECEIVED = (
+    MSG_CHAT + '/received[@xmlns="' + NS_MESSAGE_DELIVERY_RECEIPTS + '"]'
+)
+
+MSG_NORMAL = "/" + MSG + '[@type="normal"]'
+MSG_NORMAL_MESSAGE_DELIVERY_RECEIPTS_REQUEST = (
+    MSG_NORMAL + '/request[@xmlns="' + NS_MESSAGE_DELIVERY_RECEIPTS + '"]'
+)
+MSG_NORMAL_MESSAGE_DELIVERY_RECEIPTS_RECEIVED = (
+    MSG_NORMAL + '/received[@xmlns="' + NS_MESSAGE_DELIVERY_RECEIPTS + '"]'
+)
+
+
+PARAM_KEY = "Privacy"
+PARAM_NAME = "Enable message delivery receipts"
+ENTITY_KEY = PARAM_KEY + "_" + PARAM_NAME
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP-0184 Plugin",
+    C.PI_IMPORT_NAME: "XEP-0184",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0184"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "XEP_0184",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Message Delivery Receipts"""),
+}
+
+
+STATUS_MESSAGE_DELIVERY_RECEIVED = "delivered"
+TEMPO_DELETE_WAITING_ACK_S = 300  # 5 min
+
+
+class XEP_0184(object):
+    """
+    Implementation for XEP 0184.
+    """
+
+    params = """
+    <params>
+    <individual>
+    <category name="%(category_name)s" label="%(category_label)s">
+        <param name="%(param_name)s" label="%(param_label)s" value="true" type="bool" security="0"/>
+     </category>
+    </individual>
+    </params>
+    """ % {
+        "category_name": PARAM_KEY,
+        "category_label": _(PARAM_KEY),
+        "param_name": PARAM_NAME,
+        "param_label": _("Enable message delivery receipts"),
+    }
+
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0184 (message delivery receipts) initialization"))
+        self.host = host
+        self._dictRequest = dict()
+
+        # parameter value is retrieved before each use
+        host.memory.update_params(self.params)
+
+        host.trigger.add("sendMessage", self.send_message_trigger)
+        host.bridge.add_signal(
+            "message_state", ".plugin", signature="sss"
+        )  # message_uid, status, profile
+
+    def get_handler(self, client):
+        return XEP_0184_handler(self, client.profile)
+
+    def send_message_trigger(
+        self, client, mess_data, pre_xml_treatments, post_xml_treatments
+    ):
+        """Install SendMessage command hook """
+
+        def treatment(mess_data):
+            message = mess_data["xml"]
+            message_type = message.getAttribute("type")
+
+            if self._is_actif(client.profile) and (
+                message_type == "chat" or message_type == "normal"
+            ):
+                message.addElement("request", NS_MESSAGE_DELIVERY_RECEIPTS)
+                uid = mess_data["uid"]
+                msg_id = message.getAttribute("id")
+                self._dictRequest[msg_id] = uid
+                reactor.callLater(
+                    TEMPO_DELETE_WAITING_ACK_S, self._clear_dict_request, msg_id
+                )
+                log.debug(
+                    _(
+                        "[XEP-0184] Request acknowledgment for message id {}".format(
+                            msg_id
+                        )
+                    )
+                )
+
+            return mess_data
+
+        post_xml_treatments.addCallback(treatment)
+        return True
+
+    def on_message_delivery_receipts_request(self, msg_elt, client):
+        """This method is called on message delivery receipts **request** (XEP-0184 #7)
+        @param msg_elt: message element
+        @param client: %(doc_client)s"""
+        from_jid = jid.JID(msg_elt["from"])
+
+        if self._is_actif(client.profile) and client.roster.is_subscribed_from(from_jid):
+            received_elt_ret = domish.Element((NS_MESSAGE_DELIVERY_RECEIPTS, "received"))
+            try:
+                received_elt_ret["id"] = msg_elt["id"]
+            except KeyError:
+                log.warning(f"missing id for message element: {msg_elt.toXml}")
+                return
+
+            msg_result_elt = xmlstream.toResponse(msg_elt, "result")
+            msg_result_elt.addChild(received_elt_ret)
+            client.send(msg_result_elt)
+
+    def on_message_delivery_receipts_received(self, msg_elt, client):
+        """This method is called on message delivery receipts **received** (XEP-0184 #7)
+        @param msg_elt: message element
+        @param client: %(doc_client)s"""
+        msg_elt.handled = True
+        rcv_elt = next(msg_elt.elements(NS_MESSAGE_DELIVERY_RECEIPTS, "received"))
+        msg_id = rcv_elt["id"]
+
+        try:
+            uid = self._dictRequest[msg_id]
+            del self._dictRequest[msg_id]
+            self.host.bridge.message_state(
+                uid, STATUS_MESSAGE_DELIVERY_RECEIVED, client.profile
+            )
+            log.debug(
+                _("[XEP-0184] Receive acknowledgment for message id {}".format(msg_id))
+            )
+        except KeyError:
+            pass
+
+    def _clear_dict_request(self, msg_id):
+        try:
+            del self._dictRequest[msg_id]
+            log.debug(
+                _(
+                    "[XEP-0184] Delete waiting acknowledgment for message id {}".format(
+                        msg_id
+                    )
+                )
+            )
+        except KeyError:
+            pass
+
+    def _is_actif(self, profile):
+        return self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0184_handler(XMPPHandler):
+
+    def __init__(self, plugin_parent, profile):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+        self.profile = profile
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            MSG_CHAT_MESSAGE_DELIVERY_RECEIPTS_REQUEST,
+            self.plugin_parent.on_message_delivery_receipts_request,
+            client=self.parent,
+        )
+        self.xmlstream.addObserver(
+            MSG_CHAT_MESSAGE_DELIVERY_RECEIPTS_RECEIVED,
+            self.plugin_parent.on_message_delivery_receipts_received,
+            client=self.parent,
+        )
+
+        self.xmlstream.addObserver(
+            MSG_NORMAL_MESSAGE_DELIVERY_RECEIPTS_REQUEST,
+            self.plugin_parent.on_message_delivery_receipts_request,
+            client=self.parent,
+        )
+        self.xmlstream.addObserver(
+            MSG_NORMAL_MESSAGE_DELIVERY_RECEIPTS_RECEIVED,
+            self.plugin_parent.on_message_delivery_receipts_received,
+            client=self.parent,
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_MESSAGE_DELIVERY_RECEIPTS)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0191.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for XEP-0191
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import List, Set
+
+from twisted.words.protocols.jabber import xmlstream, jid
+from twisted.words.xish import domish
+from twisted.internet import defer
+from zope.interface import implementer
+from wokkel import disco, iwokkel
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.tools.utils import ensure_deferred
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Blokcing Commands",
+    C.PI_IMPORT_NAME: "XEP-0191",
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0191"],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0376"],
+    C.PI_MAIN: "XEP_0191",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implement the protocol to block users or whole domains"""),
+}
+
+NS_BLOCKING = "urn:xmpp:blocking"
+IQ_BLOCK_PUSH = f'{C.IQ_SET}/block[@xmlns="{NS_BLOCKING}"]'
+IQ_UNBLOCK_PUSH = f'{C.IQ_SET}/unblock[@xmlns="{NS_BLOCKING}"]'
+
+
+class XEP_0191:
+
+    def __init__(self, host):
+        log.info(_("Blocking Command initialization"))
+        host.register_namespace("blocking", NS_BLOCKING)
+        self.host = host
+        host.bridge.add_method(
+            "blocking_list",
+            ".plugin",
+            in_sign="s",
+            out_sign="as",
+            method=self._block_list,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "blocking_block",
+            ".plugin",
+            in_sign="ass",
+            out_sign="",
+            method=self._block,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "blocking_unblock",
+            ".plugin",
+            in_sign="ass",
+            out_sign="",
+            method=self._unblock,
+            async_=True,
+        )
+
+    def get_handler(self, client):
+        return XEP_0191_Handler(self)
+
+    @ensure_deferred
+    async def _block_list(
+        self,
+        profile_key=C.PROF_KEY_NONE
+    ) -> List[str]:
+        client = self.host.get_client(profile_key)
+        blocked_jids = await self.block_list(client)
+        return [j.full() for j in blocked_jids]
+
+    async def block_list(self, client: SatXMPPEntity) -> Set[jid.JID]:
+        await self.host.check_feature(client, NS_BLOCKING)
+        iq_elt = client.IQ("get")
+        iq_elt.addElement((NS_BLOCKING, "blocklist"))
+        iq_result_elt = await iq_elt.send()
+        try:
+            blocklist_elt = next(iq_result_elt.elements(NS_BLOCKING, "blocklist"))
+        except StopIteration:
+            log.warning(f"missing <blocklist> element: {iq_result_elt.toXml()}")
+            return []
+        blocked_jids = set()
+        for item_elt in blocklist_elt.elements(NS_BLOCKING, "item"):
+            try:
+                blocked_jid = jid.JID(item_elt["jid"])
+            except (RuntimeError, AttributeError):
+                log.warning(f"Invalid <item> element in block list: {item_elt.toXml()}")
+            else:
+                blocked_jids.add(blocked_jid)
+
+        return blocked_jids
+
+    def _block(
+        self,
+        entities: List[str],
+        profile_key: str = C.PROF_KEY_NONE
+    ) -> str:
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(
+            self.block(client, [jid.JID(entity) for entity in entities])
+        )
+
+    async def block(self, client: SatXMPPEntity, entities: List[jid.JID]) -> None:
+        await self.host.check_feature(client, NS_BLOCKING)
+        iq_elt = client.IQ("set")
+        block_elt = iq_elt.addElement((NS_BLOCKING, "block"))
+        for entity in entities:
+            item_elt = block_elt.addElement("item")
+            item_elt["jid"] = entity.full()
+        await iq_elt.send()
+
+    def _unblock(
+        self,
+        entities: List[str],
+        profile_key: str = C.PROF_KEY_NONE
+    ) -> None:
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(
+            self.unblock(client, [jid.JID(e) for e in entities])
+        )
+
+    async def unblock(self, client: SatXMPPEntity, entities: List[jid.JID]) -> None:
+        await self.host.check_feature(client, NS_BLOCKING)
+        iq_elt = client.IQ("set")
+        unblock_elt = iq_elt.addElement((NS_BLOCKING, "unblock"))
+        for entity in entities:
+            item_elt = unblock_elt.addElement("item")
+            item_elt["jid"] = entity.full()
+        await iq_elt.send()
+
+    def on_block_push(self, iq_elt: domish.Element, client: SatXMPPEntity) -> None:
+        # TODO: send notification to user
+        iq_elt.handled = True
+        for item_elt in iq_elt.block.elements(NS_BLOCKING, "item"):
+            try:
+                entity = jid.JID(item_elt["jid"])
+            except (KeyError, RuntimeError):
+                log.warning(f"invalid item received in block push: {item_elt.toXml()}")
+            else:
+                log.info(f"{entity.full()} has been blocked for {client.profile}")
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        client.send(iq_result_elt)
+
+    def on_unblock_push(self, iq_elt: domish.Element, client: SatXMPPEntity) -> None:
+        # TODO: send notification to user
+        iq_elt.handled = True
+        items = list(iq_elt.unblock.elements(NS_BLOCKING, "item"))
+        if not items:
+            log.info(f"All entities have been unblocked for {client.profile}")
+        else:
+            for item_elt in items:
+                try:
+                    entity = jid.JID(item_elt["jid"])
+                except (KeyError, RuntimeError):
+                    log.warning(
+                        f"invalid item received in unblock push: {item_elt.toXml()}"
+                    )
+                else:
+                    log.info(f"{entity.full()} has been unblocked for {client.profile}")
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        client.send(iq_result_elt)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0191_Handler(xmlstream.XMPPHandler):
+
+    def __init__(self, plugin_parent: XEP_0191):
+        self.plugin_parent = plugin_parent
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            IQ_BLOCK_PUSH,
+            self.plugin_parent.on_block_push,
+            client=self.parent
+
+        )
+        self.xmlstream.addObserver(
+            IQ_UNBLOCK_PUSH,
+            self.plugin_parent.on_unblock_push,
+            client=self.parent
+        )
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_BLOCKING)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0198.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,555 @@
+#!/usr/bin/env python3
+
+# SàT plugin for managing Stream-Management
+# Copyright (C) 2009-2021  Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from twisted.words.protocols.jabber import client as jabber_client
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.xish import domish
+from twisted.internet import defer
+from twisted.internet import task, reactor
+from functools import partial
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+import collections
+import time
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Stream Management",
+    C.PI_IMPORT_NAME: "XEP-0198",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0198"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_RECOMMENDATIONS: ["XEP-0045", "XEP-0313"],
+    C.PI_MAIN: "XEP_0198",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Stream Management"""),
+}
+
+NS_SM = "urn:xmpp:sm:3"
+SM_ENABLED = '/enabled[@xmlns="' + NS_SM + '"]'
+SM_RESUMED = '/resumed[@xmlns="' + NS_SM + '"]'
+SM_FAILED = '/failed[@xmlns="' + NS_SM + '"]'
+SM_R_REQUEST = '/r[@xmlns="' + NS_SM + '"]'
+SM_A_REQUEST = '/a[@xmlns="' + NS_SM + '"]'
+SM_H_REQUEST = '/h[@xmlns="' + NS_SM + '"]'
+# Max number of stanza to send before requesting ack
+MAX_STANZA_ACK_R = 5
+# Max number of seconds before requesting ack
+MAX_DELAY_ACK_R = 30
+MAX_COUNTER = 2**32
+RESUME_MAX = 5*60
+# if we don't have an answer to ACK REQUEST after this delay, connection is aborted
+ACK_TIMEOUT = 35
+
+
+class ProfileSessionData(object):
+    out_counter = 0
+    in_counter = 0
+    session_id = None
+    location = None
+    session_max = None
+    # True when an ack answer is expected
+    ack_requested = False
+    last_ack_r = 0
+    disconnected_time = None
+
+    def __init__(self, callback, **kw):
+        self.buffer = collections.deque()
+        self.buffer_idx = 0
+        self._enabled = False
+        self.timer = None
+        # time used when doing a ack request
+        # when it times out, connection is aborted
+        self.req_timer = None
+        self.callback_data = (callback, kw)
+
+    @property
+    def enabled(self):
+        return self._enabled
+
+    @enabled.setter
+    def enabled(self, enabled):
+        if enabled:
+            if self._enabled:
+                raise exceptions.InternalError(
+                    "Stream Management can't be enabled twice")
+            self._enabled = True
+            callback, kw = self.callback_data
+            self.timer = task.LoopingCall(callback, **kw)
+            self.timer.start(MAX_DELAY_ACK_R, now=False)
+        else:
+            self._enabled = False
+            if self.timer is not None:
+                self.timer.stop()
+                self.timer = None
+
+    @property
+    def resume_enabled(self):
+        return self.session_id is not None
+
+    def reset(self):
+        self.enabled = False
+        self.buffer.clear()
+        self.buffer_idx = 0
+        self.in_counter = self.out_counter = 0
+        self.session_id = self.location = None
+        self.ack_requested = False
+        self.last_ack_r = 0
+        if self.req_timer is not None:
+            if self.req_timer.active():
+                log.error("req_timer has been called/cancelled but not reset")
+            else:
+                self.req_timer.cancel()
+            self.req_timer = None
+
+    def get_buffer_copy(self):
+        return list(self.buffer)
+
+
+class XEP_0198(object):
+    # FIXME: location is not handled yet
+
+    def __init__(self, host):
+        log.info(_("Plugin Stream Management initialization"))
+        self.host = host
+        host.register_namespace('sm', NS_SM)
+        host.trigger.add("stream_hooks", self.add_hooks)
+        host.trigger.add("xml_init", self._xml_init_trigger)
+        host.trigger.add("disconnecting", self._disconnecting_trigger)
+        host.trigger.add("disconnected", self._disconnected_trigger)
+        try:
+            self._ack_timeout = int(host.memory.config_get("", "ack_timeout", ACK_TIMEOUT))
+        except ValueError:
+            log.error(_("Invalid ack_timeout value, please check your configuration"))
+            self._ack_timeout = ACK_TIMEOUT
+        if not self._ack_timeout:
+            log.info(_("Ack timeout disabled"))
+        else:
+            log.info(_("Ack timeout set to {timeout}s").format(
+                timeout=self._ack_timeout))
+
+    def profile_connecting(self, client):
+        client._xep_0198_session = ProfileSessionData(callback=self.check_acks,
+                                                      client=client)
+
+    def get_handler(self, client):
+        return XEP_0198_handler(self)
+
+    def add_hooks(self, client, receive_hooks, send_hooks):
+        """Add hooks to handle in/out stanzas counters"""
+        receive_hooks.append(partial(self.on_receive, client=client))
+        send_hooks.append(partial(self.on_send, client=client))
+        return True
+
+    def _xml_init_trigger(self, client):
+        """Enable or resume a stream mangement"""
+        if not (NS_SM, 'sm') in client.xmlstream.features:
+            log.warning(_(
+                "Your server doesn't support stream management ({namespace}), this is "
+                "used to improve connection problems detection (like network outages). "
+                "Please ask your server administrator to enable this feature.".format(
+                namespace=NS_SM)))
+            return True
+        session = client._xep_0198_session
+
+        # a disconnect timer from a previous disconnection may still be active
+        try:
+            disconnect_timer = session.disconnect_timer
+        except AttributeError:
+            pass
+        else:
+            if disconnect_timer.active():
+                disconnect_timer.cancel()
+            del session.disconnect_timer
+
+        if session.resume_enabled:
+            # we are resuming a session
+            resume_elt = domish.Element((NS_SM, 'resume'))
+            resume_elt['h'] = str(session.in_counter)
+            resume_elt['previd'] = session.session_id
+            client.send(resume_elt)
+            session.resuming = True
+            # session.enabled will be set on <resumed/> reception
+            return False
+        else:
+            # we start a new session
+            assert session.out_counter == 0
+            enable_elt = domish.Element((NS_SM, 'enable'))
+            enable_elt['resume'] = 'true'
+            client.send(enable_elt)
+            session.enabled = True
+            return True
+
+    def _disconnecting_trigger(self, client):
+        session = client._xep_0198_session
+        if session.enabled:
+            self.send_ack(client)
+        # This is a requested disconnection, so we can reset the session
+        # to disable resuming and close normally the stream
+        session.reset()
+        return True
+
+    def _disconnected_trigger(self, client, reason):
+        if client.is_component:
+            return True
+        session = client._xep_0198_session
+        session.enabled = False
+        if session.resume_enabled:
+            session.disconnected_time = time.time()
+            session.disconnect_timer = reactor.callLater(session.session_max,
+                                                         client.disconnect_profile,
+                                                         reason)
+            # disconnect_profile must not be called at this point
+            # because session can be resumed
+            return False
+        else:
+            return True
+
+    def check_acks(self, client):
+        """Request ack if needed"""
+        session = client._xep_0198_session
+        # log.debug("check_acks (in_counter={}, out_counter={}, buf len={}, buf idx={})"
+        #     .format(session.in_counter, session.out_counter, len(session.buffer),
+        #             session.buffer_idx))
+        if session.ack_requested or not session.buffer:
+            return
+        if (session.out_counter - session.buffer_idx >= MAX_STANZA_ACK_R
+            or time.time() - session.last_ack_r >= MAX_DELAY_ACK_R):
+            self.request_ack(client)
+            session.ack_requested = True
+            session.last_ack_r = time.time()
+
+    def update_buffer(self, session, server_acked):
+        """Update buffer and buffer_index"""
+        if server_acked > session.buffer_idx:
+            diff = server_acked - session.buffer_idx
+            try:
+                for i in range(diff):
+                    session.buffer.pop()
+            except IndexError:
+                log.error(
+                    "error while cleaning buffer, invalid index (buffer is empty):\n"
+                    "diff = {diff}\n"
+                    "server_acked = {server_acked}\n"
+                    "buffer_idx = {buffer_id}".format(
+                        diff=diff, server_acked=server_acked,
+                        buffer_id=session.buffer_idx))
+            session.buffer_idx += diff
+
+    def replay_buffer(self, client, buffer_, discard_results=False):
+        """Resend all stanza in buffer
+
+        @param buffer_(collection.deque, list): buffer to replay
+            the buffer will be cleared by this method
+        @param discard_results(bool): if True, don't replay IQ result stanzas
+        """
+        while True:
+            try:
+                stanza = buffer_.pop()
+            except IndexError:
+                break
+            else:
+                if ((discard_results
+                     and stanza.name == 'iq'
+                     and stanza.getAttribute('type') == 'result')):
+                    continue
+                client.send(stanza)
+
+    def send_ack(self, client):
+        """Send an answer element with current IN counter"""
+        a_elt = domish.Element((NS_SM, 'a'))
+        a_elt['h'] = str(client._xep_0198_session.in_counter)
+        client.send(a_elt)
+
+    def request_ack(self, client):
+        """Send a request element"""
+        session = client._xep_0198_session
+        r_elt = domish.Element((NS_SM, 'r'))
+        client.send(r_elt)
+        if session.req_timer is not None:
+            raise exceptions.InternalError("req_timer should not be set")
+        if self._ack_timeout:
+            session.req_timer = reactor.callLater(self._ack_timeout, self.on_ack_time_out,
+                                                  client)
+
+    def _connectionFailed(self, failure_, connector):
+        normal_host, normal_port = connector.normal_location
+        del connector.normal_location
+        log.warning(_(
+            "Connection failed using location given by server (host: {host}, port: "
+            "{port}), switching to normal host and port (host: {normal_host}, port: "
+            "{normal_port})".format(host=connector.host, port=connector.port,
+                                     normal_host=normal_host, normal_port=normal_port)))
+        connector.host, connector.port = normal_host, normal_port
+        connector.connectionFailed = connector.connectionFailed_ori
+        del connector.connectionFailed_ori
+        return connector.connectionFailed(failure_)
+
+    def on_enabled(self, enabled_elt, client):
+        session = client._xep_0198_session
+        session.in_counter = 0
+
+        # we check that resuming is possible and that we have a session id
+        resume = C.bool(enabled_elt.getAttribute('resume'))
+        session_id = enabled_elt.getAttribute('id')
+        if not session_id:
+            log.warning(_('Incorrect <enabled/> element received, no "id" attribute'))
+        if not resume or not session_id:
+            log.warning(_(
+                "You're server doesn't support session resuming with stream management, "
+                "please contact your server administrator to enable it"))
+            return
+
+        session.session_id = session_id
+
+        # XXX: we disable resource binding, which must not be done
+        #      when we resume the session.
+        client.factory.authenticator.res_binding = False
+
+        # location, in case server want resuming session to be elsewhere
+        try:
+            location = enabled_elt['location']
+        except KeyError:
+            pass
+        else:
+            # TODO: handle IPv6 here (in brackets, cf. XEP)
+            try:
+                domain, port = location.split(':', 1)
+                port = int(port)
+            except ValueError:
+                log.warning(_("Invalid location received: {location}")
+                    .format(location=location))
+            else:
+                session.location = (domain, port)
+                # we monkey patch connector to use the new location
+                connector = client.xmlstream.transport.connector
+                connector.normal_location = connector.host, connector.port
+                connector.host = domain
+                connector.port = port
+                connector.connectionFailed_ori = connector.connectionFailed
+                connector.connectionFailed = partial(self._connectionFailed,
+                                                     connector=connector)
+
+        # resuming time
+        try:
+            max_s = int(enabled_elt['max'])
+        except (ValueError, KeyError) as e:
+            if isinstance(e, ValueError):
+                log.warning(_('Invalid "max" attribute'))
+            max_s = RESUME_MAX
+            log.info(_("Using default session max value ({max_s} s).".format(
+                max_s=max_s)))
+            log.info(_("Stream Management enabled"))
+        else:
+            log.info(_(
+                "Stream Management enabled, with a resumption time of {res_m:.2f} min"
+                .format(res_m = max_s/60)))
+        session.session_max = max_s
+
+    def on_resumed(self, enabled_elt, client):
+        session = client._xep_0198_session
+        assert not session.enabled
+        del session.resuming
+        server_acked = int(enabled_elt['h'])
+        self.update_buffer(session, server_acked)
+        resend_count = len(session.buffer)
+        # we resend all stanza which have not been received properly
+        self.replay_buffer(client, session.buffer)
+        # now we can continue the session
+        session.enabled = True
+        d_time = time.time() - session.disconnected_time
+        log.info(_("Stream session resumed (disconnected for {d_time} s, {count} "
+                   "stanza(s) resent)").format(d_time=int(d_time), count=resend_count))
+
+    def on_failed(self, failed_elt, client):
+        session = client._xep_0198_session
+        condition_elt = failed_elt.firstChildElement()
+        buffer_ = session.get_buffer_copy()
+        session.reset()
+
+        try:
+            del session.resuming
+        except AttributeError:
+            # stream management can't be started at all
+            msg = _("Can't use stream management")
+            if condition_elt is None:
+                log.error(msg + '.')
+            else:
+                log.error(_("{msg}: {reason}").format(
+                msg=msg, reason=condition_elt.name))
+        else:
+            # only stream resumption failed, we can try full session init
+            # XXX: we try to start full session init from this point, with many
+            #      variables/attributes already initialised with a potentially different
+            #      jid. This is experimental and may not be safe. It may be more
+            #      secured to abord the connection and restart everything with a fresh
+            #      client.
+            msg = _("stream resumption not possible, restarting full session")
+
+            if condition_elt is None:
+                log.warning('{msg}.'.format(msg=msg))
+            else:
+                log.warning("{msg}: {reason}".format(
+                    msg=msg, reason=condition_elt.name))
+            # stream resumption failed, but we still can do normal stream management
+            # we restore attributes as if the session was new, and init stream
+            # we keep everything initialized, and only do binding, roster request
+            # and initial presence sending.
+            if client.conn_deferred.called:
+                client.conn_deferred = defer.Deferred()
+            else:
+                log.error("conn_deferred should be called at this point")
+            plg_0045 = self.host.plugins.get('XEP-0045')
+            plg_0313 = self.host.plugins.get('XEP-0313')
+
+            # FIXME: we should call all loaded plugins with generic callbacks
+            #        (e.g. prepareResume and resume), so a hot resuming can be done
+            #        properly for all plugins.
+
+            if plg_0045 is not None:
+                # we have to remove joined rooms
+                muc_join_args = plg_0045.pop_rooms(client)
+            # we need to recreate roster
+            client.handlers.remove(client.roster)
+            client.roster = client.roster.__class__(self.host)
+            client.roster.setHandlerParent(client)
+            # bind init is not done when resuming is possible, so we have to do it now
+            bind_init = jabber_client.BindInitializer(client.xmlstream)
+            bind_init.required = True
+            d = bind_init.start()
+            # we set the jid, which may have changed
+            d.addCallback(lambda __: setattr(client.factory.authenticator, "jid", client.jid))
+            # we call the trigger who will send the <enable/> element
+            d.addCallback(lambda __: self._xml_init_trigger(client))
+            # then we have to re-request the roster, as changes may have occured
+            d.addCallback(lambda __: client.roster.request_roster())
+            # we add got_roster to be sure to have roster before sending initial presence
+            d.addCallback(lambda __: client.roster.got_roster)
+            if plg_0313 is not None:
+                # we retrieve one2one MAM archives
+                d.addCallback(lambda __: defer.ensureDeferred(plg_0313.resume(client)))
+            # initial presence must be sent manually
+            d.addCallback(lambda __: client.presence.available())
+            if plg_0045 is not None:
+                # we re-join MUC rooms
+                muc_d_list = defer.DeferredList(
+                    [defer.ensureDeferred(plg_0045.join(*args))
+                     for args in muc_join_args]
+                )
+                d.addCallback(lambda __: muc_d_list)
+            # at the end we replay the buffer, as those stanzas have probably not
+            # been received
+            d.addCallback(lambda __: self.replay_buffer(client, buffer_,
+                                                       discard_results=True))
+
+    def on_receive(self, element, client):
+        if not client.is_component:
+            session = client._xep_0198_session
+            if session.enabled and element.name.lower() in C.STANZA_NAMES:
+                session.in_counter += 1 % MAX_COUNTER
+
+    def on_send(self, obj, client):
+        if not client.is_component:
+            session = client._xep_0198_session
+            if (session.enabled
+                and domish.IElement.providedBy(obj)
+                and obj.name.lower() in C.STANZA_NAMES):
+                session.out_counter += 1 % MAX_COUNTER
+                session.buffer.appendleft(obj)
+                self.check_acks(client)
+
+    def on_ack_request(self, r_elt, client):
+        self.send_ack(client)
+
+    def on_ack_answer(self, a_elt, client):
+        session = client._xep_0198_session
+        session.ack_requested = False
+        if self._ack_timeout:
+            if session.req_timer is None:
+                log.error("req_timer should be set")
+            else:
+                session.req_timer.cancel()
+                session.req_timer = None
+        try:
+            server_acked = int(a_elt['h'])
+        except ValueError:
+            log.warning(_("Server returned invalid ack element, disabling stream "
+                          "management: {xml}").format(xml=a_elt))
+            session.enabled = False
+            return
+
+        if server_acked > session.out_counter:
+            log.error(_("Server acked more stanzas than we have sent, disabling stream "
+                        "management."))
+            session.reset()
+            return
+
+        self.update_buffer(session, server_acked)
+        self.check_acks(client)
+
+    def on_ack_time_out(self, client):
+        """Called when a requested ACK has not been received in time"""
+        log.info(_("Ack was not received in time, aborting connection"))
+        try:
+            xmlstream = client.xmlstream
+        except AttributeError:
+            log.warning("xmlstream has already been terminated")
+        else:
+            transport = xmlstream.transport
+            if transport is None:
+                log.warning("transport was already removed")
+            else:
+                transport.abortConnection()
+        client._xep_0198_session.req_timer = None
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0198_handler(xmlstream.XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            SM_ENABLED, self.plugin_parent.on_enabled, client=self.parent
+        )
+        self.xmlstream.addObserver(
+            SM_RESUMED, self.plugin_parent.on_resumed, client=self.parent
+        )
+        self.xmlstream.addObserver(
+            SM_FAILED, self.plugin_parent.on_failed, client=self.parent
+        )
+        self.xmlstream.addObserver(
+            SM_R_REQUEST, self.plugin_parent.on_ack_request, client=self.parent
+        )
+        self.xmlstream.addObserver(
+            SM_A_REQUEST, self.plugin_parent.on_ack_answer, client=self.parent
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_SM)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0199.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Delayed Delivery (XEP-0199)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core.constants import Const as C
+from wokkel import disco, iwokkel
+from twisted.words.protocols.jabber import xmlstream, jid
+from zope.interface import implementer
+import time
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XMPP PING",
+    C.PI_IMPORT_NAME: "XEP-0199",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-199"],
+    C.PI_MAIN: "XEP_0199",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: D_("""Implementation of XMPP Ping"""),
+}
+
+NS_PING = "urn:xmpp:ping"
+PING_REQUEST = C.IQ_GET + '/ping[@xmlns="' + NS_PING + '"]'
+
+
+class XEP_0199(object):
+
+    def __init__(self, host):
+        log.info(_("XMPP Ping plugin initialization"))
+        self.host = host
+        host.bridge.add_method(
+            "ping", ".plugin", in_sign='ss', out_sign='d', method=self._ping, async_=True)
+        try:
+            self.text_cmds = self.host.plugins[C.TEXT_CMDS]
+        except KeyError:
+            log.info(_("Text commands not available"))
+        else:
+            self.text_cmds.register_text_commands(self)
+
+    def get_handler(self, client):
+        return XEP_0199_handler(self)
+
+    def _ping_raise_if_failure(self, pong):
+        """If ping didn't succeed, raise the failure, else return pong delay"""
+        if pong[0] != "PONG":
+            raise pong[0]
+        return pong[1]
+
+    def _ping(self, jid_s, profile):
+        client = self.host.get_client(profile)
+        entity_jid = jid.JID(jid_s)
+        d = self.ping(client, entity_jid)
+        d.addCallback(self._ping_raise_if_failure)
+        return d
+
+    def _ping_cb(self, iq_result, send_time):
+        receive_time = time.time()
+        return ("PONG", receive_time - send_time)
+
+    def _ping_eb(self, failure_, send_time):
+        receive_time = time.time()
+        return (failure_.value, receive_time - send_time)
+
+    def ping(self, client, entity_jid):
+        """Ping an XMPP entity
+
+        @param entity_jid(jid.JID): entity to ping
+        @return (tuple[(unicode,failure), float]): pong data:
+            - either u"PONG" if it was successful, or failure
+            - delay between sending time and reception time
+        """
+        iq_elt = client.IQ("get")
+        iq_elt["to"] = entity_jid.full()
+        iq_elt.addElement((NS_PING, "ping"))
+        d = iq_elt.send()
+        send_time = time.time()
+        d.addCallback(self._ping_cb, send_time)
+        d.addErrback(self._ping_eb, send_time)
+        return d
+
+    def _cmd_ping_fb(self, pong, client, mess_data):
+        """Send feedback to client when pong data is received"""
+        txt_cmd = self.host.plugins[C.TEXT_CMDS]
+
+        if pong[0] == "PONG":
+            txt_cmd.feed_back(client, "PONG ({time} s)".format(time=pong[1]), mess_data)
+        else:
+            txt_cmd.feed_back(
+                client, _("ping error ({err_msg}). Response time: {time} s")
+                .format(err_msg=pong[0], time=pong[1]), mess_data)
+
+    def cmd_ping(self, client, mess_data):
+        """ping an entity
+
+        @command (all): [JID]
+            - JID: jid of the entity to ping
+        """
+        if mess_data["unparsed"].strip():
+            try:
+                entity_jid = jid.JID(mess_data["unparsed"].strip())
+            except RuntimeError:
+                txt_cmd = self.host.plugins[C.TEXT_CMDS]
+                txt_cmd.feed_back(client, _('Invalid jid: "{entity_jid}"').format(
+                    entity_jid=mess_data["unparsed"].strip()), mess_data)
+                return False
+        else:
+            entity_jid = mess_data["to"]
+        d = self.ping(client, entity_jid)
+        d.addCallback(self._cmd_ping_fb, client, mess_data)
+
+        return False
+
+    def on_ping_request(self, iq_elt, client):
+        log.info(_("XMPP PING received from {from_jid} [{profile}]").format(
+            from_jid=iq_elt["from"], profile=client.profile))
+        iq_elt.handled = True
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        client.send(iq_result_elt)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0199_handler(xmlstream.XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            PING_REQUEST, self.plugin_parent.on_ping_request, client=self.parent
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_PING)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0203.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Delayed Delivery (XEP-0203)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+from wokkel import disco, iwokkel, delay
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+from zope.interface import implementer
+
+
+NS_DD = "urn:xmpp:delay"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Delayed Delivery",
+    C.PI_IMPORT_NAME: "XEP-0203",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0203"],
+    C.PI_MAIN: "XEP_0203",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Delayed Delivery"""),
+}
+
+
+class XEP_0203(object):
+    def __init__(self, host):
+        log.info(_("Delayed Delivery plugin initialization"))
+        self.host = host
+
+    def get_handler(self, client):
+        return XEP_0203_handler(self, client.profile)
+
+    def delay(self, stamp, sender=None, desc="", parent=None):
+        """Build a delay element, eventually append it to the given parent element.
+
+        @param stamp (datetime): offset-aware timestamp of the original sending.
+        @param sender (JID): entity that originally sent or delayed the message.
+        @param desc (unicode): optional natural language description.
+        @param parent (domish.Element): add the delay element to this element.
+        @return: the delay element (domish.Element)
+        """
+        elt = delay.Delay(stamp, sender).toElement()
+        if desc:
+            elt.addContent(desc)
+        if parent:
+            parent.addChild(elt)
+        return elt
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0203_handler(XMPPHandler):
+
+    def __init__(self, plugin_parent, profile):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+        self.profile = profile
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_DD)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0215.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,328 @@
+#!/usr/bin/env python3
+
+# Libervia plugin
+# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Dict, Final, List, Optional, Optional
+
+from twisted.internet import defer
+from twisted.words.protocols.jabber import error, jid
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from wokkel import data_form, disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools import utils
+from libervia.backend.tools.common import data_format
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "External Service Discovery",
+    C.PI_IMPORT_NAME: "XEP-0215",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "XEP_0215",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Discover services external to the XMPP network"""),
+}
+
+NS_EXTDISCO: Final = "urn:xmpp:extdisco:2"
+IQ_PUSH: Final = f'{C.IQ_SET}/services[@xmlns="{NS_EXTDISCO}"]'
+
+
+class XEP_0215:
+    def __init__(self, host):
+        log.info(_("External Service Discovery plugin initialization"))
+        self.host = host
+        host.bridge.add_method(
+            "external_disco_get",
+            ".plugin",
+            in_sign="ss",
+            out_sign="s",
+            method=self._external_disco_get,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "external_disco_credentials_get",
+            ".plugin",
+            in_sign="ssis",
+            out_sign="s",
+            method=self._external_disco_credentials_get,
+            async_=True,
+        )
+
+    def get_handler(self, client):
+        return XEP_0215_handler(self)
+
+    async def profile_connecting(self, client: SatXMPPEntity) -> None:
+        client._xep_0215_services = {}
+
+    def parse_services(
+        self, element: domish.Element, parent_elt_name: str = "services"
+    ) -> List[dict]:
+        """Retrieve services from element
+
+        @param element: <[parent_elt_name]/> element or its parent
+        @param parent_elt_name: name of the parent element
+            can be "services" or "credentials"
+        @return: list of parsed services
+        """
+        if parent_elt_name not in ("services", "credentials"):
+            raise exceptions.InternalError(
+                f"invalid parent_elt_name: {parent_elt_name!r}"
+            )
+        if element.name == parent_elt_name and element.uri == NS_EXTDISCO:
+            services_elt = element
+        else:
+            try:
+                services_elt = next(element.elements(NS_EXTDISCO, parent_elt_name))
+            except StopIteration:
+                raise exceptions.DataError(
+                    f"XEP-0215 response is missing <{parent_elt_name}> element"
+                )
+
+        services = []
+        for service_elt in services_elt.elements(NS_EXTDISCO, "service"):
+            service = {}
+            for key in [
+                "action",
+                "expires",
+                "host",
+                "name",
+                "password",
+                "port",
+                "restricted",
+                "transport",
+                "type",
+                "username",
+            ]:
+                value = service_elt.getAttribute(key)
+                if value is not None:
+                    if key == "expires":
+                        try:
+                            service[key] = utils.parse_xmpp_date(value)
+                        except ValueError:
+                            log.warning(f"invalid expiration date: {value!r}")
+                            continue
+                    elif key == "port":
+                        try:
+                            service[key] = int(value)
+                        except ValueError:
+                            log.warning(f"invalid port: {value!r}")
+                            continue
+                    elif key == "restricted":
+                        service[key] = C.bool(value)
+                    else:
+                        service[key] = value
+            if not {"host", "type"}.issubset(service):
+                log.warning(
+                    'mandatory "host" or "type" are missing in service, ignoring it: '
+                    "{service_elt.toXml()}"
+                )
+                continue
+            for x_elt in service_elt.elements(data_form.NS_X_DATA, "x"):
+                form = data_form.Form.fromElement(x_elt)
+                extended = service.setdefault("extended", [])
+                extended.append(xml_tools.data_form_2_data_dict(form))
+            services.append(service)
+
+        return services
+
+    def _external_disco_get(self, entity: str, profile_key: str) -> defer.Deferred:
+        client = self.host.get_client(profile_key)
+        d = defer.ensureDeferred(
+            self.get_external_services(client, jid.JID(entity) if entity else None)
+        )
+        d.addCallback(data_format.serialise)
+        return d
+
+    async def get_external_services(
+        self, client: SatXMPPEntity, entity: Optional[jid.JID] = None
+    ) -> List[Dict]:
+        """Get non XMPP service proposed by the entity
+
+        Response is cached after first query
+
+        @param entity: XMPP entity to query. Default to our own server
+        @return: found services
+        """
+        if entity is None:
+            entity = client.server_jid
+
+        if entity.resource:
+            raise exceptions.DataError("A bare jid was expected for target entity")
+
+        try:
+            cached_services = client._xep_0215_services[entity]
+        except KeyError:
+            if not self.host.hasFeature(client, NS_EXTDISCO, entity):
+                cached_services = client._xep_0215_services[entity] = None
+            else:
+                iq_elt = client.IQ("get")
+                iq_elt["to"] = entity.full()
+                iq_elt.addElement((NS_EXTDISCO, "services"))
+                try:
+                    iq_result_elt = await iq_elt.send()
+                except error.StanzaError as e:
+                    log.warning(f"Can't get external services: {e}")
+                    cached_services = client._xep_0215_services[entity] = None
+                else:
+                    cached_services = self.parse_services(iq_result_elt)
+                    client._xep_0215_services[entity] = cached_services
+
+        return cached_services or []
+
+    def _external_disco_credentials_get(
+        self,
+        entity: str,
+        host: str,
+        type_: str,
+        port: int = 0,
+        profile_key=C.PROF_KEY_NONE,
+    ) -> defer.Deferred:
+        client = self.host.get_client(profile_key)
+        d = defer.ensureDeferred(
+            self.request_credentials(
+                client, host, type_, port or None, jid.JID(entity) if entity else None
+            )
+        )
+        d.addCallback(data_format.serialise)
+        return d
+
+    async def request_credentials(
+        self,
+        client: SatXMPPEntity,
+        host: str,
+        type_: str,
+        port: Optional[int] = None,
+        entity: Optional[jid.JID] = None,
+    ) -> List[dict]:
+        """Request credentials for specified service(s)
+
+        While usually a single service is expected, several may be returned if the same
+        service is launched on several ports (cf. XEP-0215 §3.3)
+        @param entity: XMPP entity to query. Defaut to our own server
+        @param host: service host
+        @param type_: service type
+        @param port: service port (to be used when several services have same host and
+            type but on different ports)
+        @return: matching services with filled credentials
+        """
+        if entity is None:
+            entity = client.server_jid
+
+        iq_elt = client.IQ("get")
+        iq_elt["to"] = entity.full()
+        iq_elt.addElement((NS_EXTDISCO, "credentials"))
+        iq_result_elt = await iq_elt.send()
+        return self.parse_services(iq_result_elt, parent_elt_name="credentials")
+
+    def get_matching_service(
+        self, services: List[dict], host: str, type_: str, port: Optional[int]
+    ) -> Optional[dict]:
+        """Retrieve service data from its characteristics"""
+        try:
+            return next(
+                s
+                for s in services
+                if (
+                    s["host"] == host
+                    and s["type"] == type_
+                    and (port is None or s.get("port") == port)
+                )
+            )
+        except StopIteration:
+            return None
+
+    def on_services_push(self, iq_elt: domish.Element, client: SatXMPPEntity) -> None:
+        iq_elt.handled = True
+        entity = jid.JID(iq_elt["from"]).userhostJID()
+        cached_services = client._xep_0215_services.get(entity)
+        if cached_services is None:
+            log.info(f"ignoring services push for uncached entity {entity}")
+            return
+        try:
+            services = self.parse_services(iq_elt)
+        except Exception:
+            log.exception(f"Can't parse services push: {iq_elt.toXml()}")
+            return
+        for service in services:
+            host = service["host"]
+            type_ = service["type"]
+            port = service.get("port")
+
+            action = service.pop("action", None)
+            if action is None:
+                # action is not specified, we first check if the service exists
+                found_service = self.get_matching_service(
+                    cached_services, host, type_, port
+                )
+                if found_service is not None:
+                    # existing service, we replace by the new one
+                    found_service.clear()
+                    found_service.update(service)
+                else:
+                    # new service
+                    cached_services.append(service)
+            elif action == "add":
+                cached_services.append(service)
+            elif action in ("modify", "delete"):
+                found_service = self.get_matching_service(
+                    cached_services, host, type_, port
+                )
+                if found_service is None:
+                    log.warning(
+                        f"{entity} want to {action} an unknow service, we ask for the "
+                        "full list again"
+                    )
+                    # we delete cache and request a fresh list to make a new one
+                    del client._xep_0215_services[entity]
+                    defer.ensureDeferred(self.get_external_services(client, entity))
+                elif action == "modify":
+                    found_service.clear()
+                    found_service.update(service)
+                else:
+                    cached_services.remove(found_service)
+            else:
+                log.warning(f"unknown action for services push, ignoring: {action!r}")
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0215_handler(XMPPHandler):
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+
+    def connectionInitialized(self):
+        self.xmlstream.addObserver(
+            IQ_PUSH, self.plugin_parent.on_services_push, client=self.parent
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_EXTDISCO)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0231.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,250 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Bit of Binary handling (XEP-0231)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import base64
+import time
+from pathlib import Path
+from functools import partial
+from zope.interface import implementer
+from twisted.python import failure
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import error as jabber_error
+from twisted.internet import defer
+from wokkel import disco, iwokkel
+from libervia.backend.tools import xml_tools
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Bits of Binary",
+    C.PI_IMPORT_NAME: "XEP-0231",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0231"],
+    C.PI_MAIN: "XEP_0231",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _(
+        """Implementation of bits of binary (used for small images/files)"""
+    ),
+}
+
+NS_BOB = "urn:xmpp:bob"
+IQ_BOB_REQUEST = C.IQ_GET + '/data[@xmlns="' + NS_BOB + '"]'
+
+
+class XEP_0231(object):
+    def __init__(self, host):
+        log.info(_("plugin Bits of Binary initialization"))
+        self.host = host
+        host.register_namespace("bob", NS_BOB)
+        host.trigger.add("xhtml_post_treat", self.xhtml_trigger)
+        host.bridge.add_method(
+            "bob_get_file",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._get_file,
+            async_=True,
+        )
+
+    def dump_data(self, cache, data_elt, cid):
+        """save file encoded in data_elt to cache
+
+        @param cache(memory.cache.Cache): cache to use to store the data
+        @param data_elt(domish.Element): <data> as in XEP-0231
+        @param cid(unicode): content-id
+        @return(unicode): full path to dumped file
+        """
+        #  FIXME: is it needed to use a separate thread?
+        #        probably not with the little data expected with BoB
+        try:
+            max_age = int(data_elt["max-age"])
+            if max_age < 0:
+                raise ValueError
+        except (KeyError, ValueError):
+            log.warning("invalid max-age found")
+            max_age = None
+
+        with cache.cache_data(
+            PLUGIN_INFO[C.PI_IMPORT_NAME], cid, data_elt.getAttribute("type"), max_age
+        ) as f:
+
+            file_path = Path(f.name)
+            f.write(base64.b64decode(str(data_elt)))
+
+        return file_path
+
+    def get_handler(self, client):
+        return XEP_0231_handler(self)
+
+    def _request_cb(self, iq_elt, cache, cid):
+        for data_elt in iq_elt.elements(NS_BOB, "data"):
+            if data_elt.getAttribute("cid") == cid:
+                file_path = self.dump_data(cache, data_elt, cid)
+                return file_path
+
+        log.warning(
+            "invalid data stanza received, requested cid was not found:\n{iq_elt}\nrequested cid: {cid}".format(
+                iq_elt=iq_elt, cid=cid
+            )
+        )
+        raise failure.Failure(exceptions.DataError("missing data"))
+
+    def _request_eb(self, failure_):
+        """Log the error and continue errback chain"""
+        log.warning("Can't get requested data:\n{reason}".format(reason=failure_))
+        return failure_
+
+    def request_data(self, client, to_jid, cid, cache=None):
+        """Request data if we don't have it in cache
+
+        @param to_jid(jid.JID): jid to request the data to
+        @param cid(unicode): content id
+        @param cache(memory.cache.Cache, None): cache to use
+            client.cache will be used if None
+        @return D(unicode): path to file with data
+        """
+        if cache is None:
+            cache = client.cache
+        iq_elt = client.IQ("get")
+        iq_elt["to"] = to_jid.full()
+        data_elt = iq_elt.addElement((NS_BOB, "data"))
+        data_elt["cid"] = cid
+        d = iq_elt.send()
+        d.addCallback(self._request_cb, cache, cid)
+        d.addErrback(self._request_eb)
+        return d
+
+    def _set_img_elt_src(self, path, img_elt):
+        img_elt["src"] = "file://{}".format(path)
+
+    def xhtml_trigger(self, client, message_elt, body_elt, lang, treat_d):
+        for img_elt in xml_tools.find_all(body_elt, C.NS_XHTML, "img"):
+            source = img_elt.getAttribute("src", "")
+            if source.startswith("cid:"):
+                cid = source[4:]
+                file_path = client.cache.get_file_path(cid)
+                if file_path is not None:
+                    #  image is in cache, we change the url
+                    img_elt["src"] = "file://{}".format(file_path)
+                    continue
+                else:
+                    # image is not in cache, is it given locally?
+                    for data_elt in message_elt.elements(NS_BOB, "data"):
+                        if data_elt.getAttribute("cid") == cid:
+                            file_path = self.dump_data(client.cache, data_elt, cid)
+                            img_elt["src"] = "file://{}".format(file_path)
+                            break
+                    else:
+                        # cid not found locally, we need to request it
+                        # so we use the deferred
+                        d = self.request_data(client, jid.JID(message_elt["from"]), cid)
+                        d.addCallback(partial(self._set_img_elt_src, img_elt=img_elt))
+                        treat_d.addCallback(lambda __: d)
+
+    def on_component_request(self, iq_elt, client):
+        """cache data is retrieve from common cache for components"""
+        # FIXME: this is a security/privacy issue as no access check is done
+        #        but this is mitigated by the fact that the cid must be known.
+        #        An access check should be implemented though.
+
+        iq_elt.handled = True
+        data_elt = next(iq_elt.elements(NS_BOB, "data"))
+        try:
+            cid = data_elt["cid"]
+        except KeyError:
+            error_elt = jabber_error.StanzaError("not-acceptable").toResponse(iq_elt)
+            client.send(error_elt)
+            return
+
+        metadata = self.host.common_cache.get_metadata(cid)
+        if metadata is None:
+            error_elt = jabber_error.StanzaError("item-not-found").toResponse(iq_elt)
+            client.send(error_elt)
+            return
+
+        with open(metadata["path"], 'rb') as f:
+            data = f.read()
+
+        result_elt = xmlstream.toResponse(iq_elt, "result")
+        data_elt = result_elt.addElement(
+            (NS_BOB, "data"), content=base64.b64encode(data).decode())
+        data_elt["cid"] = cid
+        data_elt["type"] = metadata["mime_type"]
+        data_elt["max-age"] = str(int(max(0, metadata["eol"] - time.time())))
+        client.send(result_elt)
+
+    def _get_file(self, peer_jid_s, cid, profile):
+        peer_jid = jid.JID(peer_jid_s)
+        assert cid
+        client = self.host.get_client(profile)
+        d = self.get_file(client, peer_jid, cid)
+        d.addCallback(lambda path: str(path))
+        return d
+
+    def get_file(self, client, peer_jid, cid, parent_elt=None):
+        """Retrieve a file from it's content-id
+
+        @param peer_jid(jid.JID): jid of the entity offering the data
+        @param cid(unicode): content-id of file data
+        @param parent_elt(domish.Element, None): if file is not in cache,
+            data will be looked after in children of this elements.
+            None to ignore
+        @return D(Path): path to cached data
+        """
+        file_path = client.cache.get_file_path(cid)
+        if file_path is not None:
+            #  file is in cache
+            return defer.succeed(file_path)
+        else:
+            # file not in cache, is it given locally?
+            if parent_elt is not None:
+                for data_elt in parent_elt.elements(NS_BOB, "data"):
+                    if data_elt.getAttribute("cid") == cid:
+                        return defer.succeed(self.dump_data(client.cache, data_elt, cid))
+
+            # cid not found locally, we need to request it
+            # so we use the deferred
+            return self.request_data(client, peer_jid, cid)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0231_handler(xmlstream.XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+
+    def connectionInitialized(self):
+        if self.parent.is_component:
+            self.xmlstream.addObserver(
+                IQ_BOB_REQUEST, self.plugin_parent.on_component_request, client=self.parent
+            )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_BOB)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0234.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,826 @@
+#!/usr/bin/env python3
+
+# SàT plugin for Jingle File Transfer (XEP-0234)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from collections import namedtuple
+import mimetypes
+import os.path
+
+from twisted.internet import defer
+from twisted.internet import reactor
+from twisted.internet import error as internet_error
+from twisted.python import failure
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import utils
+from libervia.backend.tools import stream
+from libervia.backend.tools.common import date_utils
+from libervia.backend.tools.common import regex
+
+
+log = getLogger(__name__)
+
+NS_JINGLE_FT = "urn:xmpp:jingle:apps:file-transfer:5"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Jingle File Transfer",
+    C.PI_IMPORT_NAME: "XEP-0234",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0234"],
+    C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0300", "FILE"],
+    C.PI_MAIN: "XEP_0234",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Jingle File Transfer"""),
+}
+
+EXTRA_ALLOWED = {"path", "namespace", "file_desc", "file_hash", "hash_algo"}
+Range = namedtuple("Range", ("offset", "length"))
+
+
+class XEP_0234:
+    # TODO: assure everything is closed when file is sent or session terminate is received
+    # TODO: call self._f.unregister when unloading order will be managing (i.e. when
+    #   dependencies will be unloaded at the end)
+    Range = Range  # we copy the class here, so it can be used by other plugins
+    name = PLUGIN_INFO[C.PI_NAME]
+    human_name = D_("file transfer")
+
+    def __init__(self, host):
+        log.info(_("plugin Jingle File Transfer initialization"))
+        self.host = host
+        host.register_namespace("jingle-ft", NS_JINGLE_FT)
+        self._j = host.plugins["XEP-0166"]  # shortcut to access jingle
+        self._j.register_application(NS_JINGLE_FT, self)
+        self._f = host.plugins["FILE"]
+        self._f.register(self, priority=10000)
+        self._hash = self.host.plugins["XEP-0300"]
+        host.bridge.add_method(
+            "file_jingle_send",
+            ".plugin",
+            in_sign="ssssa{ss}s",
+            out_sign="",
+            method=self._file_send,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "file_jingle_request",
+            ".plugin",
+            in_sign="sssssa{ss}s",
+            out_sign="s",
+            method=self._file_jingle_request,
+            async_=True,
+        )
+
+    def get_handler(self, client):
+        return XEP_0234_handler()
+
+    def get_progress_id(self, session, content_name):
+        """Return a unique progress ID
+
+        @param session(dict): jingle session
+        @param content_name(unicode): name of the content
+        @return (unicode): unique progress id
+        """
+        return "{}_{}".format(session["id"], content_name)
+
+    async def can_handle_file_send(self, client, peer_jid, filepath):
+        if peer_jid.resource:
+            return await self.host.hasFeature(client, NS_JINGLE_FT, peer_jid)
+        else:
+            # if we have a bare jid, Jingle Message Initiation will be tried
+            return True
+
+    # generic methods
+
+    def build_file_element(
+        self, client, name=None, file_hash=None, hash_algo=None, size=None,
+        mime_type=None, desc=None, modified=None, transfer_range=None, path=None,
+        namespace=None, file_elt=None, **kwargs):
+        """Generate a <file> element with available metadata
+
+        @param file_hash(unicode, None): hash of the file
+            empty string to set <hash-used/> element
+        @param hash_algo(unicode, None): hash algorithm used
+            if file_hash is None and hash_algo is set, a <hash-used/> element will be
+            generated
+        @param transfer_range(Range, None): where transfer must start/stop
+        @param modified(int, unicode, None): date of last modification
+            0 to use current date
+            int to use an unix timestamp
+            else must be an unicode string which will be used as it (it must be an XMPP
+            time)
+        @param file_elt(domish.Element, None): element to use
+            None to create a new one
+        @param **kwargs: data for plugin extension (ignored by default)
+        @return (domish.Element): generated element
+        @trigger XEP-0234_buildFileElement(file_elt, extra_args): can be used to extend
+            elements to add
+        """
+        if file_elt is None:
+            file_elt = domish.Element((NS_JINGLE_FT, "file"))
+        for name, value in (
+            ("name", name),
+            ("size", size),
+            ("media-type", mime_type),
+            ("desc", desc),
+            ("path", path),
+            ("namespace", namespace),
+        ):
+            if value is not None:
+                file_elt.addElement(name, content=str(value))
+
+        if modified is not None:
+            if isinstance(modified, int):
+                file_elt.addElement("date", utils.xmpp_date(modified or None))
+            else:
+                file_elt.addElement("date", modified)
+        elif "created" in kwargs:
+            file_elt.addElement("date", utils.xmpp_date(kwargs.pop("created")))
+
+        range_elt = file_elt.addElement("range")
+        if transfer_range is not None:
+            if transfer_range.offset is not None:
+                range_elt["offset"] = transfer_range.offset
+            if transfer_range.length is not None:
+                range_elt["length"] = transfer_range.length
+        if file_hash is not None:
+            if not file_hash:
+                file_elt.addChild(self._hash.build_hash_used_elt())
+            else:
+                file_elt.addChild(self._hash.build_hash_elt(file_hash, hash_algo))
+        elif hash_algo is not None:
+            file_elt.addChild(self._hash.build_hash_used_elt(hash_algo))
+        self.host.trigger.point(
+            "XEP-0234_buildFileElement", client, file_elt, extra_args=kwargs)
+        if kwargs:
+            for kw in kwargs:
+                log.debug("ignored keyword: {}".format(kw))
+        return file_elt
+
+    def build_file_element_from_dict(self, client, file_data, **kwargs):
+        """like build_file_element but get values from a file_data dict
+
+        @param file_data(dict): metadata to use
+        @param **kwargs: data to override
+        """
+        if kwargs:
+            file_data = file_data.copy()
+            file_data.update(kwargs)
+        try:
+            file_data["mime_type"] = (
+                f'{file_data.pop("media_type")}/{file_data.pop("media_subtype")}'
+            )
+        except KeyError:
+            pass
+        return self.build_file_element(client, **file_data)
+
+    async def parse_file_element(
+            self, client, file_elt, file_data=None, given=False, parent_elt=None,
+            keep_empty_range=False):
+        """Parse a <file> element and file dictionary accordingly
+
+        @param file_data(dict, None): dict where the data will be set
+            following keys will be set (and overwritten if they already exist):
+                name, file_hash, hash_algo, size, mime_type, desc, path, namespace, range
+            if None, a new dict is created
+        @param given(bool): if True, prefix hash key with "given_"
+        @param parent_elt(domish.Element, None): parent of the file element
+            if set, file_elt must not be set
+        @param keep_empty_range(bool): if True, keep empty range (i.e. range when offset
+            and length are None).
+            Empty range is useful to know if a peer_jid can handle range
+        @return (dict): file_data
+        @trigger XEP-0234_parseFileElement(file_elt, file_data): can be used to parse new
+            elements
+        @raise exceptions.NotFound: there is not <file> element in parent_elt
+        @raise exceptions.DataError: if file_elt uri is not NS_JINGLE_FT
+        """
+        if parent_elt is not None:
+            if file_elt is not None:
+                raise exceptions.InternalError(
+                    "file_elt must be None if parent_elt is set"
+                )
+            try:
+                file_elt = next(parent_elt.elements(NS_JINGLE_FT, "file"))
+            except StopIteration:
+                raise exceptions.NotFound()
+        else:
+            if not file_elt or file_elt.uri != NS_JINGLE_FT:
+                raise exceptions.DataError(
+                    "invalid <file> element: {stanza}".format(stanza=file_elt.toXml())
+                )
+
+        if file_data is None:
+            file_data = {}
+
+        for name in ("name", "desc", "path", "namespace"):
+            try:
+                file_data[name] = str(next(file_elt.elements(NS_JINGLE_FT, name)))
+            except StopIteration:
+                pass
+
+        name = file_data.get("name")
+        if name == "..":
+            # we don't want to go to parent dir when joining to a path
+            name = "--"
+            file_data["name"] = name
+        elif name is not None and ("/" in name or "\\" in name):
+            file_data["name"] = regex.path_escape(name)
+
+        try:
+            file_data["mime_type"] = str(
+                next(file_elt.elements(NS_JINGLE_FT, "media-type"))
+            )
+        except StopIteration:
+            pass
+
+        try:
+            file_data["size"] = int(
+                str(next(file_elt.elements(NS_JINGLE_FT, "size")))
+            )
+        except StopIteration:
+            pass
+
+        try:
+            file_data["modified"] = date_utils.date_parse(
+                next(file_elt.elements(NS_JINGLE_FT, "date"))
+            )
+        except StopIteration:
+            pass
+
+        try:
+            range_elt = next(file_elt.elements(NS_JINGLE_FT, "range"))
+        except StopIteration:
+            pass
+        else:
+            offset = range_elt.getAttribute("offset")
+            length = range_elt.getAttribute("length")
+            if offset or length or keep_empty_range:
+                file_data["transfer_range"] = Range(offset=offset, length=length)
+
+        prefix = "given_" if given else ""
+        hash_algo_key, hash_key = "hash_algo", prefix + "file_hash"
+        try:
+            file_data[hash_algo_key], file_data[hash_key] = self._hash.parse_hash_elt(
+                file_elt
+            )
+        except exceptions.NotFound:
+            pass
+
+        self.host.trigger.point("XEP-0234_parseFileElement", client, file_elt, file_data)
+
+        return file_data
+
+    # bridge methods
+
+    def _file_send(
+        self,
+        peer_jid,
+        filepath,
+        name="",
+        file_desc="",
+        extra=None,
+        profile=C.PROF_KEY_NONE,
+    ):
+        client = self.host.get_client(profile)
+        return defer.ensureDeferred(self.file_send(
+            client,
+            jid.JID(peer_jid),
+            filepath,
+            name or None,
+            file_desc or None,
+            extra or None,
+        ))
+
+    async def file_send(
+        self, client, peer_jid, filepath, name, file_desc=None, extra=None
+    ):
+        """Send a file using jingle file transfer
+
+        @param peer_jid(jid.JID): destinee jid
+        @param filepath(str): absolute path of the file
+        @param name(unicode, None): name of the file
+        @param file_desc(unicode, None): description of the file
+        @return (D(unicode)): progress id
+        """
+        progress_id_d = defer.Deferred()
+        if extra is None:
+            extra = {}
+        if file_desc is not None:
+            extra["file_desc"] = file_desc
+        encrypted = extra.pop("encrypted", False)
+        await self._j.initiate(
+            client,
+            peer_jid,
+            [
+                {
+                    "app_ns": NS_JINGLE_FT,
+                    "senders": self._j.ROLE_INITIATOR,
+                    "app_kwargs": {
+                        "filepath": filepath,
+                        "name": name,
+                        "extra": extra,
+                        "progress_id_d": progress_id_d,
+                    },
+                }
+            ],
+            encrypted = encrypted
+        )
+        return await progress_id_d
+
+    def _file_jingle_request(
+            self, peer_jid, filepath, name="", file_hash="", hash_algo="", extra=None,
+            profile=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile)
+        return defer.ensureDeferred(self.file_jingle_request(
+            client,
+            jid.JID(peer_jid),
+            filepath,
+            name or None,
+            file_hash or None,
+            hash_algo or None,
+            extra or None,
+        ))
+
+    async def file_jingle_request(
+            self, client, peer_jid, filepath, name=None, file_hash=None, hash_algo=None,
+            extra=None):
+        """Request a file using jingle file transfer
+
+        @param peer_jid(jid.JID): destinee jid
+        @param filepath(str): absolute path where the file will be downloaded
+        @param name(unicode, None): name of the file
+        @param file_hash(unicode, None): hash of the file
+        @return (D(unicode)): progress id
+        """
+        progress_id_d = defer.Deferred()
+        if extra is None:
+            extra = {}
+        if file_hash is not None:
+            if hash_algo is None:
+                raise ValueError(_("hash_algo must be set if file_hash is set"))
+            extra["file_hash"] = file_hash
+            extra["hash_algo"] = hash_algo
+        else:
+            if hash_algo is not None:
+                raise ValueError(_("file_hash must be set if hash_algo is set"))
+        await self._j.initiate(
+            client,
+            peer_jid,
+            [
+                {
+                    "app_ns": NS_JINGLE_FT,
+                    "senders": self._j.ROLE_RESPONDER,
+                    "app_kwargs": {
+                        "filepath": filepath,
+                        "name": name,
+                        "extra": extra,
+                        "progress_id_d": progress_id_d,
+                    },
+                }
+            ],
+        )
+        return await progress_id_d
+
+    # jingle callbacks
+
+    def jingle_description_elt(
+        self, client, session, content_name, filepath, name, extra, progress_id_d
+    ):
+        return domish.Element((NS_JINGLE_FT, "description"))
+
+    def jingle_session_init(
+        self, client, session, content_name, filepath, name, extra, progress_id_d
+    ):
+        if extra is None:
+            extra = {}
+        else:
+            if not EXTRA_ALLOWED.issuperset(extra):
+                raise ValueError(
+                    _("only the following keys are allowed in extra: {keys}").format(
+                        keys=", ".join(EXTRA_ALLOWED)
+                    )
+                )
+        progress_id_d.callback(self.get_progress_id(session, content_name))
+        content_data = session["contents"][content_name]
+        application_data = content_data["application_data"]
+        assert "file_path" not in application_data
+        application_data["file_path"] = filepath
+        file_data = application_data["file_data"] = {}
+        desc_elt = self.jingle_description_elt(
+            client, session, content_name, filepath, name, extra, progress_id_d)
+        file_elt = desc_elt.addElement("file")
+
+        if content_data["senders"] == self._j.ROLE_INITIATOR:
+            # we send a file
+            if name is None:
+                name = os.path.basename(filepath)
+            file_data["date"] = utils.xmpp_date()
+            file_data["desc"] = extra.pop("file_desc", "")
+            file_data["name"] = name
+            mime_type = mimetypes.guess_type(name, strict=False)[0]
+            if mime_type is not None:
+                file_data["mime_type"] = mime_type
+            file_data["size"] = os.path.getsize(filepath)
+            if "namespace" in extra:
+                file_data["namespace"] = extra["namespace"]
+            if "path" in extra:
+                file_data["path"] = extra["path"]
+            self.build_file_element_from_dict(
+                client, file_data, file_elt=file_elt, file_hash="")
+        else:
+            # we request a file
+            file_hash = extra.pop("file_hash", "")
+            if not name and not file_hash:
+                raise ValueError(_("you need to provide at least name or file hash"))
+            if name:
+                file_data["name"] = name
+            if file_hash:
+                file_data["file_hash"] = file_hash
+                file_data["hash_algo"] = extra["hash_algo"]
+            else:
+                file_data["hash_algo"] = self._hash.get_default_algo()
+            if "namespace" in extra:
+                file_data["namespace"] = extra["namespace"]
+            if "path" in extra:
+                file_data["path"] = extra["path"]
+            self.build_file_element_from_dict(client, file_data, file_elt=file_elt)
+
+        return desc_elt
+
+    async def jingle_request_confirmation(
+        self, client, action, session, content_name, desc_elt
+    ):
+        """This method request confirmation for a jingle session"""
+        content_data = session["contents"][content_name]
+        senders = content_data["senders"]
+        if senders not in (self._j.ROLE_INITIATOR, self._j.ROLE_RESPONDER):
+            log.warning("Bad sender, assuming initiator")
+            senders = content_data["senders"] = self._j.ROLE_INITIATOR
+        # first we grab file informations
+        try:
+            file_elt = next(desc_elt.elements(NS_JINGLE_FT, "file"))
+        except StopIteration:
+            raise failure.Failure(exceptions.DataError)
+        file_data = {"progress_id": self.get_progress_id(session, content_name)}
+
+        if senders == self._j.ROLE_RESPONDER:
+            # we send the file
+            return await self._file_sending_request_conf(
+                client, session, content_data, content_name, file_data, file_elt
+            )
+        else:
+            # we receive the file
+            return await self._file_receiving_request_conf(
+                client, session, content_data, content_name, file_data, file_elt
+            )
+
+    async def _file_sending_request_conf(
+        self, client, session, content_data, content_name, file_data, file_elt
+    ):
+        """parse file_elt, and handle file retrieving/permission checking"""
+        await self.parse_file_element(client, file_elt, file_data)
+        content_data["application_data"]["file_data"] = file_data
+        finished_d = content_data["finished_d"] = defer.Deferred()
+
+        # confirmed_d is a deferred returning confimed value (only used if cont is False)
+        cont, confirmed_d = self.host.trigger.return_point(
+            "XEP-0234_fileSendingRequest",
+            client,
+            session,
+            content_data,
+            content_name,
+            file_data,
+            file_elt,
+        )
+        if not cont:
+            confirmed = await confirmed_d
+            if confirmed:
+                args = [client, session, content_name, content_data]
+                finished_d.addCallbacks(
+                    self._finished_cb, self._finished_eb, args, None, args
+                )
+            return confirmed
+
+        log.warning(_("File continue is not implemented yet"))
+        return False
+
+    async def _file_receiving_request_conf(
+        self, client, session, content_data, content_name, file_data, file_elt
+    ):
+        """parse file_elt, and handle user permission/file opening"""
+        await self.parse_file_element(client, file_elt, file_data, given=True)
+        try:
+            hash_algo, file_data["given_file_hash"] = self._hash.parse_hash_elt(file_elt)
+        except exceptions.NotFound:
+            try:
+                hash_algo = self._hash.parse_hash_used_elt(file_elt)
+            except exceptions.NotFound:
+                raise failure.Failure(exceptions.DataError)
+
+        if hash_algo is not None:
+            file_data["hash_algo"] = hash_algo
+            file_data["hash_hasher"] = hasher = self._hash.get_hasher(hash_algo)
+            file_data["data_cb"] = lambda data: hasher.update(data)
+
+        try:
+            file_data["size"] = int(file_data["size"])
+        except ValueError:
+            raise failure.Failure(exceptions.DataError)
+
+        name = file_data["name"]
+        if "/" in name or "\\" in name:
+            log.warning(
+                "File name contain path characters, we replace them: {}".format(name)
+            )
+            file_data["name"] = name.replace("/", "_").replace("\\", "_")
+
+        content_data["application_data"]["file_data"] = file_data
+
+        # now we actualy request permission to user
+
+        # deferred to track end of transfer
+        finished_d = content_data["finished_d"] = defer.Deferred()
+        confirmed = await self._f.get_dest_dir(
+            client, session["peer_jid"], content_data, file_data, stream_object=True
+        )
+        if confirmed:
+            await self.host.trigger.async_point(
+                "XEP-0234_file_receiving_request_conf",
+                client, session, content_data, file_elt
+            )
+            args = [client, session, content_name, content_data]
+            finished_d.addCallbacks(
+                self._finished_cb, self._finished_eb, args, None, args
+            )
+        return confirmed
+
+    async def jingle_handler(self, client, action, session, content_name, desc_elt):
+        content_data = session["contents"][content_name]
+        application_data = content_data["application_data"]
+        if action in (self._j.A_ACCEPTED_ACK,):
+            pass
+        elif action == self._j.A_SESSION_INITIATE:
+            file_elt = next(desc_elt.elements(NS_JINGLE_FT, "file"))
+            try:
+                next(file_elt.elements(NS_JINGLE_FT, "range"))
+            except StopIteration:
+                # initiator doesn't manage <range>, but we do so we advertise it
+                # FIXME: to be checked
+                log.debug("adding <range> element")
+                file_elt.addElement("range")
+        elif action == self._j.A_SESSION_ACCEPT:
+            assert not "stream_object" in content_data
+            file_data = application_data["file_data"]
+            file_path = application_data["file_path"]
+            senders = content_data["senders"]
+            if senders != session["role"]:
+                # we are receiving the file
+                try:
+                    # did the responder specified the size of the file?
+                    file_elt = next(desc_elt.elements(NS_JINGLE_FT, "file"))
+                    size_elt = next(file_elt.elements(NS_JINGLE_FT, "size"))
+                    size = int(str(size_elt))
+                except (StopIteration, ValueError):
+                    size = None
+                # XXX: hash security is not critical here, so we just take the higher
+                #      mandatory one
+                hasher = file_data["hash_hasher"] = self._hash.get_hasher()
+                progress_id = self.get_progress_id(session, content_name)
+                try:
+                    content_data["stream_object"] = stream.FileStreamObject(
+                        self.host,
+                        client,
+                        file_path,
+                        mode="wb",
+                        uid=progress_id,
+                        size=size,
+                        data_cb=lambda data: hasher.update(data),
+                    )
+                except Exception as e:
+                    self.host.bridge.progress_error(
+                        progress_id, C.PROGRESS_ERROR_FAILED, client.profile
+                    )
+                    await self._j.terminate(
+                        client, self._j.REASON_FAILED_APPLICATION, session)
+                    raise e
+            else:
+                # we are sending the file
+                size = file_data["size"]
+                # XXX: hash security is not critical here, so we just take the higher
+                #      mandatory one
+                hasher = file_data["hash_hasher"] = self._hash.get_hasher()
+                content_data["stream_object"] = stream.FileStreamObject(
+                    self.host,
+                    client,
+                    file_path,
+                    uid=self.get_progress_id(session, content_name),
+                    size=size,
+                    data_cb=lambda data: hasher.update(data),
+                )
+            finished_d = content_data["finished_d"] = defer.Deferred()
+            args = [client, session, content_name, content_data]
+            finished_d.addCallbacks(self._finished_cb, self._finished_eb, args, None, args)
+            await self.host.trigger.async_point(
+                "XEP-0234_jingle_handler",
+                client, session, content_data, desc_elt
+            )
+        else:
+            log.warning("FIXME: unmanaged action {}".format(action))
+        return desc_elt
+
+    def jingle_session_info(self, client, action, session, content_name, jingle_elt):
+        """Called on session-info action
+
+        manage checksum, and ignore <received/> element
+        """
+        # TODO: manage <received/> element
+        content_data = session["contents"][content_name]
+        elts = [elt for elt in jingle_elt.elements() if elt.uri == NS_JINGLE_FT]
+        if not elts:
+            return
+        for elt in elts:
+            if elt.name == "received":
+                pass
+            elif elt.name == "checksum":
+                # we have received the file hash, we need to parse it
+                if content_data["senders"] == session["role"]:
+                    log.warning(
+                        "unexpected checksum received while we are the file sender"
+                    )
+                    raise exceptions.DataError
+                info_content_name = elt["name"]
+                if info_content_name != content_name:
+                    # it was for an other content...
+                    return
+                file_data = content_data["application_data"]["file_data"]
+                try:
+                    file_elt = next(elt.elements(NS_JINGLE_FT, "file"))
+                except StopIteration:
+                    raise exceptions.DataError
+                algo, file_data["given_file_hash"] = self._hash.parse_hash_elt(file_elt)
+                if algo != file_data.get("hash_algo"):
+                    log.warning(
+                        "Hash algorithm used in given hash ({peer_algo}) doesn't correspond to the one we have used ({our_algo}) [{profile}]".format(
+                            peer_algo=algo,
+                            our_algo=file_data.get("hash_algo"),
+                            profile=client.profile,
+                        )
+                    )
+                else:
+                    self._receiver_try_terminate(
+                        client, session, content_name, content_data
+                    )
+            else:
+                raise NotImplementedError
+
+    def jingle_terminate(self, client, action, session, content_name, reason_elt):
+        if reason_elt.decline:
+            # progress is the only way to tell to frontends that session has been declined
+            progress_id = self.get_progress_id(session, content_name)
+            self.host.bridge.progress_error(
+                progress_id, C.PROGRESS_ERROR_DECLINED, client.profile
+            )
+        elif not reason_elt.success:
+            progress_id = self.get_progress_id(session, content_name)
+            first_child = reason_elt.firstChildElement()
+            if first_child is not None:
+                reason = first_child.name
+                if reason_elt.text is not None:
+                    reason = f"{reason} - {reason_elt.text}"
+            else:
+                reason = C.PROGRESS_ERROR_FAILED
+            self.host.bridge.progress_error(
+                progress_id, reason, client.profile
+            )
+
+    def _send_check_sum(self, client, session, content_name, content_data):
+        """Send the session-info with the hash checksum"""
+        file_data = content_data["application_data"]["file_data"]
+        hasher = file_data["hash_hasher"]
+        hash_ = hasher.hexdigest()
+        log.debug("Calculated hash: {}".format(hash_))
+        iq_elt, jingle_elt = self._j.build_session_info(client, session)
+        checksum_elt = jingle_elt.addElement((NS_JINGLE_FT, "checksum"))
+        checksum_elt["creator"] = content_data["creator"]
+        checksum_elt["name"] = content_name
+        file_elt = checksum_elt.addElement("file")
+        file_elt.addChild(self._hash.build_hash_elt(hash_))
+        iq_elt.send()
+
+    def _receiver_try_terminate(
+        self, client, session, content_name, content_data, last_try=False
+    ):
+        """Try to terminate the session
+
+        This method must only be used by the receiver.
+        It check if transfer is finished, and hash available,
+        if everything is OK, it check hash and terminate the session
+        @param last_try(bool): if True this mean than session must be terminated even given hash is not available
+        @return (bool): True if session was terminated
+        """
+        if not content_data.get("transfer_finished", False):
+            return False
+        file_data = content_data["application_data"]["file_data"]
+        given_hash = file_data.get("given_file_hash")
+        if given_hash is None:
+            if last_try:
+                log.warning(
+                    "sender didn't sent hash checksum, we can't check the file [{profile}]".format(
+                        profile=client.profile
+                    )
+                )
+                self._j.delayed_content_terminate(client, session, content_name)
+                content_data["stream_object"].close()
+                return True
+            return False
+        hasher = file_data["hash_hasher"]
+        hash_ = hasher.hexdigest()
+
+        if hash_ == given_hash:
+            log.info(f"Hash checked, file was successfully transfered: {hash_}")
+            progress_metadata = {
+                "hash": hash_,
+                "hash_algo": file_data["hash_algo"],
+                "hash_verified": C.BOOL_TRUE,
+            }
+            error = None
+        else:
+            log.warning("Hash mismatch, the file was not transfered correctly")
+            progress_metadata = None
+            error = "Hash mismatch: given={algo}:{given}, calculated={algo}:{our}".format(
+                algo=file_data["hash_algo"], given=given_hash, our=hash_
+            )
+
+        self._j.delayed_content_terminate(client, session, content_name)
+        content_data["stream_object"].close(progress_metadata, error)
+        # we may have the last_try timer still active, so we try to cancel it
+        try:
+            content_data["last_try_timer"].cancel()
+        except (KeyError, internet_error.AlreadyCalled):
+            pass
+        return True
+
+    def _finished_cb(self, __, client, session, content_name, content_data):
+        log.info("File transfer terminated")
+        if content_data["senders"] != session["role"]:
+            # we terminate the session only if we are the receiver,
+            # as recommanded in XEP-0234 §2 (after example 6)
+            content_data["transfer_finished"] = True
+            if not self._receiver_try_terminate(
+                client, session, content_name, content_data
+            ):
+                # we have not received the hash yet, we wait 5 more seconds
+                content_data["last_try_timer"] = reactor.callLater(
+                    5,
+                    self._receiver_try_terminate,
+                    client,
+                    session,
+                    content_name,
+                    content_data,
+                    last_try=True,
+                )
+        else:
+            # we are the sender, we send the checksum
+            self._send_check_sum(client, session, content_name, content_data)
+            content_data["stream_object"].close()
+
+    def _finished_eb(self, failure, client, session, content_name, content_data):
+        log.warning("Error while streaming file: {}".format(failure))
+        content_data["stream_object"].close()
+        self._j.content_terminate(
+            client, session, content_name, reason=self._j.REASON_FAILED_TRANSPORT
+        )
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0234_handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_JINGLE_FT)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0249.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,237 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing xep-0249
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xml_tools
+
+log = getLogger(__name__)
+
+
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+MESSAGE = "/message"
+NS_X_CONFERENCE = "jabber:x:conference"
+AUTOJOIN_KEY = "Misc"
+AUTOJOIN_NAME = "Auto-join MUC on invitation"
+AUTOJOIN_VALUES = ["ask", "always", "never"]
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP 0249 Plugin",
+    C.PI_IMPORT_NAME: "XEP-0249",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0249"],
+    C.PI_DEPENDENCIES: ["XEP-0045"],
+    C.PI_RECOMMENDATIONS: [C.TEXT_CMDS],
+    C.PI_MAIN: "XEP_0249",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Direct MUC Invitations"""),
+}
+
+
+class XEP_0249(object):
+
+    params = """
+    <params>
+    <individual>
+    <category name="%(category_name)s" label="%(category_label)s">
+        <param name="%(param_name)s" label="%(param_label)s" type="list" security="0">
+            %(param_options)s
+        </param>
+     </category>
+    </individual>
+    </params>
+    """ % {
+        "category_name": AUTOJOIN_KEY,
+        "category_label": _("Misc"),
+        "param_name": AUTOJOIN_NAME,
+        "param_label": _("Auto-join MUC on invitation"),
+        "param_options": "\n".join(
+            [
+                '<option value="%s" %s/>'
+                % (value, 'selected="true"' if value == AUTOJOIN_VALUES[0] else "")
+                for value in AUTOJOIN_VALUES
+            ]
+        ),
+    }
+
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0249 initialization"))
+        self.host = host
+        host.memory.update_params(self.params)
+        host.bridge.add_method(
+            "muc_invite", ".plugin", in_sign="ssa{ss}s", out_sign="", method=self._invite
+        )
+        try:
+            self.host.plugins[C.TEXT_CMDS].register_text_commands(self)
+        except KeyError:
+            log.info(_("Text commands not available"))
+        host.register_namespace('x-conference', NS_X_CONFERENCE)
+        host.trigger.add("message_received", self._message_received_trigger)
+
+    def get_handler(self, client):
+        return XEP_0249_handler()
+
+    def _invite(self, guest_jid_s, room_jid_s, options, profile_key):
+        """Invite an user to a room
+
+        @param guest_jid_s: jid of the user to invite
+        @param service: jid of the MUC service
+        @param roomId: name of the room
+        @param profile_key: %(doc_profile_key)s
+        """
+        # TODO: check parameters validity
+        client = self.host.get_client(profile_key)
+        self.invite(client, jid.JID(guest_jid_s), jid.JID(room_jid_s, options))
+
+    def invite(self, client, guest, room, options={}):
+        """Invite a user to a room
+
+        @param guest(jid.JID): jid of the user to invite
+        @param room(jid.JID): jid of the room where the user is invited
+        @param options(dict): attribute with extra info (reason, password) as in #XEP-0249
+        """
+        message = domish.Element((None, "message"))
+        message["to"] = guest.full()
+        x_elt = message.addElement((NS_X_CONFERENCE, "x"))
+        x_elt["jid"] = room.userhost()
+        for key, value in options.items():
+            if key not in ("password", "reason", "thread"):
+                log.warning("Ignoring invalid invite option: {}".format(key))
+                continue
+            x_elt[key] = value
+        #  there is not body in this message, so we can use directly send()
+        client.send(message)
+
+    def _accept(self, room_jid, profile_key=C.PROF_KEY_NONE):
+        """Accept the invitation to join a MUC.
+
+        @param room (jid.JID): JID of the room
+        """
+        client = self.host.get_client(profile_key)
+        log.info(
+            _("Invitation accepted for room %(room)s [%(profile)s]")
+            % {"room": room_jid.userhost(), "profile": client.profile}
+        )
+        d = defer.ensureDeferred(
+            self.host.plugins["XEP-0045"].join(client, room_jid, client.jid.user, {})
+        )
+        return d
+
+    def _message_received_trigger(self, client, message_elt, post_treat):
+        """Check if a direct invitation is in the message, and handle it"""
+        x_elt = next(message_elt.elements(NS_X_CONFERENCE, 'x'), None)
+        if x_elt is None:
+            return True
+
+        try:
+            room_jid_s = x_elt["jid"]
+        except KeyError:
+            log.warning(_("invalid invitation received: {xml}").format(
+                xml=message_elt.toXml()))
+            return False
+        log.info(
+            _("Invitation received for room %(room)s [%(profile)s]")
+            % {"room": room_jid_s, "profile": client.profile}
+        )
+        from_jid_s = message_elt["from"]
+        room_jid = jid.JID(room_jid_s)
+        try:
+            self.host.plugins["XEP-0045"].check_room_joined(client, room_jid)
+        except exceptions.NotFound:
+            pass
+        else:
+            log.info(
+                _("Invitation silently discarded because user is already in the room.")
+            )
+            return
+
+        autojoin = self.host.memory.param_get_a(
+            AUTOJOIN_NAME, AUTOJOIN_KEY, profile_key=client.profile
+        )
+
+        if autojoin == "always":
+            self._accept(room_jid, client.profile)
+        elif autojoin == "never":
+            msg = D_(
+                "An invitation from %(user)s to join the room %(room)s has been "
+                "declined according to your personal settings."
+            ) % {"user": from_jid_s, "room": room_jid_s}
+            title = D_("MUC invitation")
+            xml_tools.quick_note(self.host, client, msg, title, C.XMLUI_DATA_LVL_INFO)
+        else:  # leave the default value here
+            confirm_msg = D_(
+                "You have been invited by %(user)s to join the room %(room)s. "
+                "Do you accept?"
+            ) % {"user": from_jid_s, "room": room_jid_s}
+            confirm_title = D_("MUC invitation")
+            d = xml_tools.defer_confirm(
+                self.host, confirm_msg, confirm_title, profile=client.profile
+            )
+
+            def accept_cb(accepted):
+                if accepted:
+                    self._accept(room_jid, client.profile)
+
+            d.addCallback(accept_cb)
+        return False
+
+    def cmd_invite(self, client, mess_data):
+        """invite someone in the room
+
+        @command (group): JID
+            - JID: the JID of the person to invite
+        """
+        contact_jid_s = mess_data["unparsed"].strip()
+        my_host = client.jid.host
+        try:
+            contact_jid = jid.JID(contact_jid_s)
+        except (RuntimeError, jid.InvalidFormat, AttributeError):
+            feedback = _(
+                "You must provide a valid JID to invite, like in '/invite "
+                "contact@{host}'"
+            ).format(host=my_host)
+            self.host.plugins[C.TEXT_CMDS].feed_back(client, feedback, mess_data)
+            return False
+        if not contact_jid.user:
+            contact_jid.user, contact_jid.host = contact_jid.host, my_host
+        self.invite(client, contact_jid, mess_data["to"])
+        return False
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0249_handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_X_CONFERENCE)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0260.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,551 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Jingle (XEP-0260)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber import jid
+from twisted.internet import defer
+import uuid
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+
+NS_JINGLE_S5B = "urn:xmpp:jingle:transports:s5b:1"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Jingle SOCKS5 Bytestreams",
+    C.PI_IMPORT_NAME: "XEP-0260",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0260"],
+    C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0065"],
+    C.PI_RECOMMENDATIONS: ["XEP-0261"],  # needed for fallback
+    C.PI_MAIN: "XEP_0260",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Jingle SOCKS5 Bytestreams"""),
+}
+
+
+class ProxyError(Exception):
+    def __str__(self):
+        return "an error happened while trying to use the proxy"
+
+
+class XEP_0260(object):
+    # TODO: udp handling
+
+    def __init__(self, host):
+        log.info(_("plugin Jingle SOCKS5 Bytestreams"))
+        self.host = host
+        self._j = host.plugins["XEP-0166"]  # shortcut to access jingle
+        self._s5b = host.plugins["XEP-0065"]  # and socks5 bytestream
+        try:
+            self._jingle_ibb = host.plugins["XEP-0261"]
+        except KeyError:
+            self._jingle_ibb = None
+        self._j.register_transport(NS_JINGLE_S5B, self._j.TRANSPORT_STREAMING, self, 100)
+
+    def get_handler(self, client):
+        return XEP_0260_handler()
+
+    def _parse_candidates(self, transport_elt):
+        """Parse <candidate> elements
+
+        @param transport_elt(domish.Element): parent <transport> element
+        @return (list[plugin_xep_0065.Candidate): list of parsed candidates
+        """
+        candidates = []
+        for candidate_elt in transport_elt.elements(NS_JINGLE_S5B, "candidate"):
+            try:
+                cid = candidate_elt["cid"]
+                host = candidate_elt["host"]
+                jid_ = jid.JID(candidate_elt["jid"])
+                port = int(candidate_elt.getAttribute("port", 1080))
+                priority = int(candidate_elt["priority"])
+                type_ = candidate_elt.getAttribute("type", self._s5b.TYPE_DIRECT)
+            except (KeyError, ValueError):
+                raise exceptions.DataError()
+            candidate = self._s5b.Candidate(host, port, type_, priority, jid_, cid)
+            candidates.append(candidate)
+            # self._s5b.registerCandidate(candidate)
+        return candidates
+
+    def _build_candidates(self, session, candidates, sid, session_hash, client, mode=None):
+        """Build <transport> element with candidates
+
+        @param session(dict): jingle session data
+        @param candidates(iterator[plugin_xep_0065.Candidate]): iterator of candidates to add
+        @param sid(unicode): transport stream id
+        @param client: %(doc_client)s
+        @param mode(str, None): 'tcp' or 'udp', or None to have no attribute
+        @return (domish.Element): parent <transport> element where <candidate> elements must be added
+        """
+        proxy = next(
+            (
+                candidate
+                for candidate in candidates
+                if candidate.type == self._s5b.TYPE_PROXY
+            ),
+            None,
+        )
+        transport_elt = domish.Element((NS_JINGLE_S5B, "transport"))
+        transport_elt["sid"] = sid
+        if proxy is not None:
+            transport_elt["dstaddr"] = session_hash
+        if mode is not None:
+            transport_elt["mode"] = "tcp"  # XXX: we only manage tcp for now
+
+        for candidate in candidates:
+            log.debug("Adding candidate: {}".format(candidate))
+            candidate_elt = transport_elt.addElement("candidate", NS_JINGLE_S5B)
+            if candidate.id is None:
+                candidate.id = str(uuid.uuid4())
+            candidate_elt["cid"] = candidate.id
+            candidate_elt["host"] = candidate.host
+            candidate_elt["jid"] = candidate.jid.full()
+            candidate_elt["port"] = str(candidate.port)
+            candidate_elt["priority"] = str(candidate.priority)
+            candidate_elt["type"] = candidate.type
+        return transport_elt
+
+    @defer.inlineCallbacks
+    def jingle_session_init(self, client, session, content_name):
+        content_data = session["contents"][content_name]
+        transport_data = content_data["transport_data"]
+        sid = transport_data["sid"] = str(uuid.uuid4())
+        session_hash = transport_data["session_hash"] = self._s5b.get_session_hash(
+            session["local_jid"], session["peer_jid"], sid
+        )
+        transport_data["peer_session_hash"] = self._s5b.get_session_hash(
+            session["peer_jid"], session["local_jid"], sid
+        )  # requester and target are inversed for peer candidates
+        transport_data["stream_d"] = self._s5b.register_hash(client, session_hash, None)
+        candidates = transport_data["candidates"] = yield self._s5b.get_candidates(
+            client, session["local_jid"])
+        mode = "tcp"  # XXX: we only manage tcp for now
+        transport_elt = self._build_candidates(
+            session, candidates, sid, session_hash, client, mode
+        )
+
+        defer.returnValue(transport_elt)
+
+    def _proxy_activated_cb(self, iq_result_elt, client, candidate, session, content_name):
+        """Called when activation confirmation has been received from proxy
+
+        cf XEP-0260 § 2.4
+        """
+        # now that the proxy is activated, we have to inform other peer
+        content_data = session["contents"][content_name]
+        iq_elt, transport_elt = self._j.build_action(
+            client, self._j.A_TRANSPORT_INFO, session, content_name
+        )
+        transport_elt["sid"] = content_data["transport_data"]["sid"]
+        activated_elt = transport_elt.addElement("activated")
+        activated_elt["cid"] = candidate.id
+        iq_elt.send()
+
+    def _proxy_activated_eb(self, stanza_error, client, candidate, session, content_name):
+        """Called when activation error has been received from proxy
+
+        cf XEP-0260 § 2.4
+        """
+        # TODO: fallback to IBB
+        # now that the proxy is activated, we have to inform other peer
+        content_data = session["contents"][content_name]
+        iq_elt, transport_elt = self._j.build_action(
+            client, self._j.A_TRANSPORT_INFO, session, content_name
+        )
+        transport_elt["sid"] = content_data["transport_data"]["sid"]
+        transport_elt.addElement("proxy-error")
+        iq_elt.send()
+        log.warning(
+            "Can't activate proxy, we need to fallback to IBB: {reason}".format(
+                reason=stanza_error.value.condition
+            )
+        )
+        self.do_fallback(session, content_name, client)
+
+    def _found_peer_candidate(
+        self, candidate, session, transport_data, content_name, client
+    ):
+        """Called when the best candidate from other peer is found
+
+        @param candidate(XEP_0065.Candidate, None): selected candidate,
+            or None if no candidate is accessible
+        @param session(dict):  session data
+        @param transport_data(dict): transport data
+        @param content_name(unicode): name of the current content
+        @param client(unicode): %(doc_client)s
+        """
+
+        content_data = session["contents"][content_name]
+        transport_data["best_candidate"] = candidate
+        # we need to disconnect all non selected candidates before removing them
+        for c in transport_data["peer_candidates"]:
+            if c is None or c is candidate:
+                continue
+            c.discard()
+        del transport_data["peer_candidates"]
+        iq_elt, transport_elt = self._j.build_action(
+            client, self._j.A_TRANSPORT_INFO, session, content_name
+        )
+        transport_elt["sid"] = content_data["transport_data"]["sid"]
+        if candidate is None:
+            log.warning("Can't connect to any peer candidate")
+            candidate_elt = transport_elt.addElement("candidate-error")
+        else:
+            log.info("Found best peer candidate: {}".format(str(candidate)))
+            candidate_elt = transport_elt.addElement("candidate-used")
+            candidate_elt["cid"] = candidate.id
+        iq_elt.send()  # TODO: check result stanza
+        self._check_candidates(session, content_name, transport_data, client)
+
+    def _check_candidates(self, session, content_name, transport_data, client):
+        """Called when a candidate has been choosed
+
+        if we have both candidates, we select one, or fallback to an other transport
+        @param session(dict):  session data
+        @param content_name(unicode): name of the current content
+        @param transport_data(dict): transport data
+        @param client(unicode): %(doc_client)s
+        """
+        content_data = session["contents"][content_name]
+        try:
+            best_candidate = transport_data["best_candidate"]
+        except KeyError:
+            # we have not our best candidate yet
+            return
+        try:
+            peer_best_candidate = transport_data["peer_best_candidate"]
+        except KeyError:
+            # we have not peer best candidate yet
+            return
+
+        # at this point we have both candidates, it's time to choose one
+        if best_candidate is None or peer_best_candidate is None:
+            choosed_candidate = best_candidate or peer_best_candidate
+        else:
+            if best_candidate.priority == peer_best_candidate.priority:
+                # same priority, we choose initiator one according to XEP-0260 §2.4 #4
+                log.debug(
+                    "Candidates have same priority, we select the one choosed by initiator"
+                )
+                if session["initiator"] == session["local_jid"]:
+                    choosed_candidate = best_candidate
+                else:
+                    choosed_candidate = peer_best_candidate
+            else:
+                choosed_candidate = max(
+                    best_candidate, peer_best_candidate, key=lambda c: c.priority
+                )
+
+        if choosed_candidate is None:
+            log.warning("Socks5 negociation failed, we need to fallback to IBB")
+            self.do_fallback(session, content_name, client)
+        else:
+            if choosed_candidate == peer_best_candidate:
+                # peer_best_candidate was choosed from the candidates we have sent
+                # so our_candidate is true if choosed_candidate is peer_best_candidate
+                our_candidate = True
+                # than also mean that best_candidate must be discarded !
+                try:
+                    best_candidate.discard()
+                except AttributeError:  # but it can be None
+                    pass
+            else:
+                our_candidate = False
+
+            log.info(
+                "Socks5 negociation successful, {who} candidate will be used: {candidate}".format(
+                    who="our" if our_candidate else "other peer",
+                    candidate=choosed_candidate,
+                )
+            )
+            del transport_data["best_candidate"]
+            del transport_data["peer_best_candidate"]
+
+            if choosed_candidate.type == self._s5b.TYPE_PROXY:
+                # the stream transfer need to wait for proxy activation
+                # (see XEP-0260 § 2.4)
+                if our_candidate:
+                    d = self._s5b.connect_candidate(
+                        client, choosed_candidate, transport_data["session_hash"]
+                    )
+                    d.addCallback(
+                        lambda __: choosed_candidate.activate(
+                            transport_data["sid"], session["peer_jid"], client
+                        )
+                    )
+                    args = [client, choosed_candidate, session, content_name]
+                    d.addCallbacks(
+                        self._proxy_activated_cb, self._proxy_activated_eb, args, None, args
+                    )
+                else:
+                    # this Deferred will be called when we'll receive activation confirmation from other peer
+                    d = transport_data["activation_d"] = defer.Deferred()
+            else:
+                d = defer.succeed(None)
+
+            if content_data["senders"] == session["role"]:
+                # we can now start the stream transfer (or start it after proxy activation)
+                d.addCallback(
+                    lambda __: choosed_candidate.start_transfer(
+                        transport_data["session_hash"]
+                    )
+                )
+                d.addErrback(self._start_eb, session, content_name, client)
+
+    def _start_eb(self, fail, session, content_name, client):
+        """Called when it's not possible to start the transfer
+
+        Will try to fallback to IBB
+        """
+        try:
+            reason = str(fail.value)
+        except AttributeError:
+            reason = str(fail)
+        log.warning("Cant start transfert, we'll try fallback method: {}".format(reason))
+        self.do_fallback(session, content_name, client)
+
+    def _candidate_info(
+        self, candidate_elt, session, content_name, transport_data, client
+    ):
+        """Called when best candidate has been received from peer (or if none is working)
+
+        @param candidate_elt(domish.Element): candidate-used or candidate-error element
+            (see XEP-0260 §2.3)
+        @param session(dict):  session data
+        @param content_name(unicode): name of the current content
+        @param transport_data(dict): transport data
+        @param client(unicode): %(doc_client)s
+        """
+        if candidate_elt.name == "candidate-error":
+            # candidate-error, no candidate worked
+            transport_data["peer_best_candidate"] = None
+        else:
+            # candidate-used, one candidate was choosed
+            try:
+                cid = candidate_elt.attributes["cid"]
+            except KeyError:
+                log.warning("No cid found in <candidate-used>")
+                raise exceptions.DataError
+            try:
+                candidate = next((
+                    c for c in transport_data["candidates"] if c.id == cid
+                ))
+            except StopIteration:
+                log.warning("Given cid doesn't correspond to any known candidate !")
+                raise exceptions.DataError  # TODO: send an error to other peer, and use better exception
+            except KeyError:
+                # a transport-info can also be intentionaly sent too early by other peer
+                # but there is little probability
+                log.error(
+                    '"candidates" key doesn\'t exists in transport_data, it should at this point'
+                )
+                raise exceptions.InternalError
+            # at this point we have the candidate choosed by other peer
+            transport_data["peer_best_candidate"] = candidate
+            log.info("Other peer best candidate: {}".format(candidate))
+
+        del transport_data["candidates"]
+        self._check_candidates(session, content_name, transport_data, client)
+
+    def _proxy_activation_info(
+        self, proxy_elt, session, content_name, transport_data, client
+    ):
+        """Called when proxy has been activated (or has sent an error)
+
+        @param proxy_elt(domish.Element): <activated/> or <proxy-error/> element
+            (see XEP-0260 §2.4)
+        @param session(dict):  session data
+        @param content_name(unicode): name of the current content
+        @param transport_data(dict): transport data
+        @param client(unicode): %(doc_client)s
+        """
+        try:
+            activation_d = transport_data.pop("activation_d")
+        except KeyError:
+            log.warning("Received unexpected transport-info for proxy activation")
+
+        if proxy_elt.name == "activated":
+            activation_d.callback(None)
+        else:
+            activation_d.errback(ProxyError())
+
+    @defer.inlineCallbacks
+    def jingle_handler(self, client, action, session, content_name, transport_elt):
+        content_data = session["contents"][content_name]
+        transport_data = content_data["transport_data"]
+
+        if action in (self._j.A_ACCEPTED_ACK, self._j.A_PREPARE_RESPONDER):
+            pass
+
+        elif action == self._j.A_SESSION_ACCEPT:
+            # initiator side, we select a candidate in the ones sent by responder
+            assert "peer_candidates" not in transport_data
+            transport_data["peer_candidates"] = self._parse_candidates(transport_elt)
+
+        elif action == self._j.A_START:
+            session_hash = transport_data["session_hash"]
+            peer_candidates = transport_data["peer_candidates"]
+            stream_object = content_data["stream_object"]
+            self._s5b.associate_stream_object(client, session_hash, stream_object)
+            stream_d = transport_data.pop("stream_d")
+            stream_d.chainDeferred(content_data["finished_d"])
+            peer_session_hash = transport_data["peer_session_hash"]
+            d = self._s5b.get_best_candidate(
+                client, peer_candidates, session_hash, peer_session_hash
+            )
+            d.addCallback(
+                self._found_peer_candidate, session, transport_data, content_name, client
+            )
+
+        elif action == self._j.A_SESSION_INITIATE:
+            # responder side, we select a candidate in the ones sent by initiator
+            # and we give our candidates
+            assert "peer_candidates" not in transport_data
+            sid = transport_data["sid"] = transport_elt["sid"]
+            session_hash = transport_data["session_hash"] = self._s5b.get_session_hash(
+                session["local_jid"], session["peer_jid"], sid
+            )
+            peer_session_hash = transport_data[
+                "peer_session_hash"
+            ] = self._s5b.get_session_hash(
+                session["peer_jid"], session["local_jid"], sid
+            )  # requester and target are inversed for peer candidates
+            peer_candidates = transport_data["peer_candidates"] = self._parse_candidates(
+                transport_elt
+            )
+            stream_object = content_data["stream_object"]
+            stream_d = self._s5b.register_hash(client, session_hash, stream_object)
+            stream_d.chainDeferred(content_data["finished_d"])
+            d = self._s5b.get_best_candidate(
+                client, peer_candidates, session_hash, peer_session_hash
+            )
+            d.addCallback(
+                self._found_peer_candidate, session, transport_data, content_name, client
+            )
+            candidates = yield self._s5b.get_candidates(client, session["local_jid"])
+            # we remove duplicate candidates
+            candidates = [
+                candidate for candidate in candidates if candidate not in peer_candidates
+            ]
+
+            transport_data["candidates"] = candidates
+            # we can now build a new <transport> element with our candidates
+            transport_elt = self._build_candidates(
+                session, candidates, sid, session_hash, client
+            )
+
+        elif action == self._j.A_TRANSPORT_INFO:
+            # transport-info can be about candidate or proxy activation
+            candidate_elt = None
+
+            for method, names in (
+                (self._candidate_info, ("candidate-used", "candidate-error")),
+                (self._proxy_activation_info, ("activated", "proxy-error")),
+            ):
+                for name in names:
+                    try:
+                        candidate_elt = next(transport_elt.elements(NS_JINGLE_S5B, name))
+                    except StopIteration:
+                        continue
+                    else:
+                        method(
+                            candidate_elt, session, content_name, transport_data, client
+                        )
+                        break
+
+            if candidate_elt is None:
+                log.warning(
+                    "Unexpected transport element: {}".format(transport_elt.toXml())
+                )
+        elif action == self._j.A_DESTROY:
+            # the transport is replaced (fallback ?), We need mainly to kill XEP-0065 session.
+            # note that sid argument is not necessary for sessions created by this plugin
+            self._s5b.kill_session(None, transport_data["session_hash"], None, client)
+        else:
+            log.warning("FIXME: unmanaged action {}".format(action))
+
+        defer.returnValue(transport_elt)
+
+    def jingle_terminate(self, client, action, session, content_name, reason_elt):
+        if reason_elt.decline:
+            log.debug("Session declined, deleting S5B session")
+            # we just need to clean the S5B session if it is declined
+            content_data = session["contents"][content_name]
+            transport_data = content_data["transport_data"]
+            self._s5b.kill_session(None, transport_data["session_hash"], None, client)
+
+    def _do_fallback(self, feature_checked, session, content_name, client):
+        """Do the fallback, method called once feature is checked
+
+         @param feature_checked(bool): True if other peer can do IBB
+         """
+        if not feature_checked:
+            log.warning(
+                "Other peer can't manage jingle IBB, be have to terminate the session"
+            )
+            self._j.terminate(client, self._j.REASON_CONNECTIVITY_ERROR, session)
+        else:
+            self._j.transport_replace(
+                client, self._jingle_ibb.NAMESPACE, session, content_name
+            )
+
+    def do_fallback(self, session, content_name, client):
+        """Fallback to IBB transport, used in last resort
+
+        @param session(dict):  session data
+        @param content_name(unicode): name of the current content
+        @param client(unicode): %(doc_client)s
+        """
+        if session["role"] != self._j.ROLE_INITIATOR:
+            # only initiator must do the fallback, see XEP-0260 §3
+            return
+        if self._jingle_ibb is None:
+            log.warning(
+                "Jingle IBB (XEP-0261) plugin is not available, we have to close the session"
+            )
+            self._j.terminate(client, self._j.REASON_CONNECTIVITY_ERROR, session)
+        else:
+            d = self.host.hasFeature(
+                client, self._jingle_ibb.NAMESPACE, session["peer_jid"]
+            )
+            d.addCallback(self._do_fallback, session, content_name, client)
+        return d
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0260_handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_JINGLE_S5B)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0261.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Jingle (XEP-0261)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+from twisted.words.xish import domish
+import uuid
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+
+NS_JINGLE_IBB = "urn:xmpp:jingle:transports:ibb:1"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Jingle In-Band Bytestreams",
+    C.PI_IMPORT_NAME: "XEP-0261",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0261"],
+    C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0047"],
+    C.PI_MAIN: "XEP_0261",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Jingle In-Band Bytestreams"""),
+}
+
+
+class XEP_0261(object):
+    NAMESPACE = NS_JINGLE_IBB  # used by XEP-0260 plugin for transport-replace
+
+    def __init__(self, host):
+        log.info(_("plugin Jingle In-Band Bytestreams"))
+        self.host = host
+        self._j = host.plugins["XEP-0166"]  # shortcut to access jingle
+        self._ibb = host.plugins["XEP-0047"]  # and in-band bytestream
+        self._j.register_transport(
+            NS_JINGLE_IBB, self._j.TRANSPORT_STREAMING, self, -10000
+        )  # must be the lowest priority
+
+    def get_handler(self, client):
+        return XEP_0261_handler()
+
+    def jingle_session_init(self, client, session, content_name):
+        transport_elt = domish.Element((NS_JINGLE_IBB, "transport"))
+        content_data = session["contents"][content_name]
+        transport_data = content_data["transport_data"]
+        transport_data["block_size"] = self._ibb.BLOCK_SIZE
+        transport_elt["block-size"] = str(transport_data["block_size"])
+        transport_elt["sid"] = transport_data["sid"] = str(uuid.uuid4())
+        return transport_elt
+
+    def jingle_handler(self, client, action, session, content_name, transport_elt):
+        content_data = session["contents"][content_name]
+        transport_data = content_data["transport_data"]
+        if action in (
+            self._j.A_SESSION_ACCEPT,
+            self._j.A_ACCEPTED_ACK,
+            self._j.A_TRANSPORT_ACCEPT,
+        ):
+            pass
+        elif action in (self._j.A_SESSION_INITIATE, self._j.A_TRANSPORT_REPLACE):
+            transport_data["sid"] = transport_elt["sid"]
+        elif action in (self._j.A_START, self._j.A_PREPARE_RESPONDER):
+            local_jid = session["local_jid"]
+            peer_jid = session["peer_jid"]
+            sid = transport_data["sid"]
+            stream_object = content_data["stream_object"]
+            if action == self._j.A_START:
+                block_size = transport_data["block_size"]
+                d = self._ibb.start_stream(
+                    client, stream_object, local_jid, peer_jid, sid, block_size
+                )
+                d.chainDeferred(content_data["finished_d"])
+            else:
+                d = self._ibb.create_session(
+                    client, stream_object, local_jid, peer_jid, sid)
+                d.chainDeferred(content_data["finished_d"])
+        else:
+            log.warning("FIXME: unmanaged action {}".format(action))
+        return transport_elt
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0261_handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_JINGLE_IBB)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0264.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,207 @@
+#!/usr/bin/env python3
+
+# SàT plugin for managing xep-0264
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from twisted.internet import threads
+from twisted.python.failure import Failure
+
+from zope.interface import implementer
+
+from wokkel import disco, iwokkel
+
+from libervia.backend.core import exceptions
+import hashlib
+
+try:
+    from PIL import Image, ImageOps
+except:
+    raise exceptions.MissingModule(
+        "Missing module pillow, please download/install it from https://python-pillow.github.io"
+    )
+
+#  cf. https://stackoverflow.com/a/23575424
+from PIL import ImageFile
+
+ImageFile.LOAD_TRUNCATED_IMAGES = True
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+
+MIME_TYPE = "image/jpeg"
+SAVE_FORMAT = "JPEG"  # (cf. Pillow documentation)
+
+NS_THUMBS = "urn:xmpp:thumbs:1"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP-0264",
+    C.PI_IMPORT_NAME: "XEP-0264",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0264"],
+    C.PI_DEPENDENCIES: ["XEP-0234"],
+    C.PI_MAIN: "XEP_0264",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Thumbnails handling"""),
+}
+
+
+class XEP_0264(object):
+    SIZE_SMALL = (320, 320)
+    SIZE_MEDIUM = (640, 640)
+    SIZE_BIG = (1280, 1280)
+    SIZE_FULL_SCREEN = (2560, 2560)
+    # FIXME: SIZE_FULL_SCREEN is currently discarded as the resulting files are too big
+    # for BoB
+    # TODO: use an other mechanism than BoB for bigger files
+    SIZES = (SIZE_SMALL, SIZE_MEDIUM, SIZE_BIG)
+
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0264 initialization"))
+        self.host = host
+        host.trigger.add("XEP-0234_buildFileElement", self._add_file_thumbnails)
+        host.trigger.add("XEP-0234_parseFileElement", self._get_file_thumbnails)
+
+    def get_handler(self, client):
+        return XEP_0264_handler()
+
+    ## triggers ##
+
+    def _add_file_thumbnails(self, client, file_elt, extra_args):
+        try:
+            thumbnails = extra_args["extra"][C.KEY_THUMBNAILS]
+        except KeyError:
+            return
+        for thumbnail in thumbnails:
+            thumbnail_elt = file_elt.addElement((NS_THUMBS, "thumbnail"))
+            thumbnail_elt["uri"] = "cid:" + thumbnail["id"]
+            thumbnail_elt["media-type"] = MIME_TYPE
+            width, height = thumbnail["size"]
+            thumbnail_elt["width"] = str(width)
+            thumbnail_elt["height"] = str(height)
+        return True
+
+    def _get_file_thumbnails(self, client, file_elt, file_data):
+        thumbnails = []
+        for thumbnail_elt in file_elt.elements(NS_THUMBS, "thumbnail"):
+            uri = thumbnail_elt["uri"]
+            if uri.startswith("cid:"):
+                thumbnail = {"id": uri[4:]}
+            width = thumbnail_elt.getAttribute("width")
+            height = thumbnail_elt.getAttribute("height")
+            if width and height:
+                try:
+                    thumbnail["size"] = (int(width), int(height))
+                except ValueError:
+                    pass
+            try:
+                thumbnail["mime_type"] = thumbnail_elt["media-type"]
+            except KeyError:
+                pass
+            thumbnails.append(thumbnail)
+
+        if thumbnails:
+            # we want thumbnails ordered from smallest to biggest
+            thumbnails.sort(key=lambda t: t.get('size', (0, 0)))
+            file_data.setdefault("extra", {})[C.KEY_THUMBNAILS] = thumbnails
+        return True
+
+    ## thumbnails generation ##
+
+    def get_thumb_id(self, image_uid, size):
+        """return an ID unique for image/size combination
+
+        @param image_uid(unicode): unique id of the image
+            can be a hash
+        @param size(tuple(int)): requested size of thumbnail
+        @return (unicode): unique id for this image/size
+        """
+        return hashlib.sha256(repr((image_uid, size)).encode()).hexdigest()
+
+    def _blocking_gen_thumb(
+            self, source_path, size=None, max_age=None, image_uid=None,
+            fix_orientation=True):
+        """Generate a thumbnail for image
+
+        This is a blocking method and must be executed in a thread
+        params are the same as for [generate_thumbnail]
+        """
+        if size is None:
+            size = self.SIZE_SMALL
+        try:
+            img = Image.open(source_path)
+        except IOError:
+            return Failure(exceptions.DataError("Can't open image"))
+
+        img.thumbnail(size)
+        if fix_orientation:
+            img = ImageOps.exif_transpose(img)
+
+        uid = self.get_thumb_id(image_uid or source_path, size)
+
+        with self.host.common_cache.cache_data(
+            PLUGIN_INFO[C.PI_IMPORT_NAME], uid, MIME_TYPE, max_age
+        ) as f:
+            img.save(f, SAVE_FORMAT)
+            if fix_orientation:
+                log.debug(f"fixed orientation for {f.name}")
+
+        return img.size, uid
+
+    def generate_thumbnail(
+        self, source_path, size=None, max_age=None, image_uid=None, fix_orientation=True):
+        """Generate a thumbnail of image
+
+        @param source_path(unicode): absolute path to source image
+        @param size(int, None): max size of the thumbnail
+            can be one of self.SIZE_*
+            None to use default value (i.e. self.SIZE_SMALL)
+        @param max_age(int, None): same as for [memory.cache.Cache.cache_data])
+        @param image_uid(unicode, None): unique ID to identify the image
+            use hash whenever possible
+            if None, source_path will be used
+        @param fix_orientation(bool): if True, fix orientation using EXIF data
+        @return D(tuple[tuple[int,int], unicode]): tuple with:
+            - size of the thumbnail
+            - unique Id of the thumbnail
+        """
+        d = threads.deferToThread(
+            self._blocking_gen_thumb, source_path, size, max_age, image_uid=image_uid,
+            fix_orientation=fix_orientation
+        )
+        d.addErrback(
+            lambda failure_: log.error("thumbnail generation error: {}".format(failure_))
+        )
+        return d
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0264_handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_THUMBS)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0277.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1724 @@
+#!/usr/bin/env python3
+
+# SAT plugin for microblogging over XMPP (xep-0277)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import time
+import dateutil
+import calendar
+from mimetypes import guess_type
+from secrets import token_urlsafe
+from typing import List, Optional, Dict, Tuple, Any, Dict
+from functools import partial
+
+import shortuuid
+
+from twisted.words.protocols.jabber import jid, error
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from twisted.internet import defer
+from twisted.python import failure
+
+# XXX: sat_tmp.wokkel.pubsub is actually used instead of wokkel version
+from wokkel import pubsub
+from wokkel import disco, iwokkel, rsm
+from zope.interface import implementer
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools import sat_defer
+from libervia.backend.tools import utils
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import uri as xmpp_uri
+from libervia.backend.tools.common import regex
+
+
+log = getLogger(__name__)
+
+
+NS_MICROBLOG = "urn:xmpp:microblog:0"
+NS_ATOM = "http://www.w3.org/2005/Atom"
+NS_PUBSUB_EVENT = f"{pubsub.NS_PUBSUB}#event"
+NS_COMMENT_PREFIX = f"{NS_MICROBLOG}:comments/"
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Microblogging over XMPP Plugin",
+    C.PI_IMPORT_NAME: "XEP-0277",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0277"],
+    C.PI_DEPENDENCIES: ["XEP-0163", "XEP-0060", "TEXT_SYNTAXES"],
+    C.PI_RECOMMENDATIONS: ["XEP-0059", "EXTRA-PEP", "PUBSUB_CACHE"],
+    C.PI_MAIN: "XEP_0277",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of microblogging Protocol"""),
+}
+
+
+class NodeAccessChangeException(Exception):
+    pass
+
+
+class XEP_0277(object):
+    namespace = NS_MICROBLOG
+    NS_ATOM = NS_ATOM
+
+    def __init__(self, host):
+        log.info(_("Microblogging plugin initialization"))
+        self.host = host
+        host.register_namespace("microblog", NS_MICROBLOG)
+        self._p = self.host.plugins[
+            "XEP-0060"
+        ]  # this facilitate the access to pubsub plugin
+        ps_cache = self.host.plugins.get("PUBSUB_CACHE")
+        if ps_cache is not None:
+            ps_cache.register_analyser(
+                {
+                    "name": "XEP-0277",
+                    "node": NS_MICROBLOG,
+                    "namespace": NS_ATOM,
+                    "type": "blog",
+                    "to_sync": True,
+                    "parser": self.item_2_mb_data,
+                    "match_cb": self._cache_node_match_cb,
+                }
+            )
+        self.rt_sessions = sat_defer.RTDeferredSessions()
+        self.host.plugins["XEP-0060"].add_managed_node(
+            NS_MICROBLOG, items_cb=self._items_received
+        )
+
+        host.bridge.add_method(
+            "mb_send",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="s",
+            method=self._mb_send,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "mb_repeat",
+            ".plugin",
+            in_sign="sssss",
+            out_sign="s",
+            method=self._mb_repeat,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "mb_preview",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="s",
+            method=self._mb_preview,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "mb_retract",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="",
+            method=self._mb_retract,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "mb_get",
+            ".plugin",
+            in_sign="ssiasss",
+            out_sign="s",
+            method=self._mb_get,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "mb_rename",
+            ".plugin",
+            in_sign="sssss",
+            out_sign="",
+            method=self._mb_rename,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "mb_access_set",
+            ".plugin",
+            in_sign="ss",
+            out_sign="",
+            method=self.mb_access_set,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "mb_subscribe_to_many",
+            ".plugin",
+            in_sign="sass",
+            out_sign="s",
+            method=self._mb_subscribe_to_many,
+        )
+        host.bridge.add_method(
+            "mb_get_from_many_rt_result",
+            ".plugin",
+            in_sign="ss",
+            out_sign="(ua(sssasa{ss}))",
+            method=self._mb_get_from_many_rt_result,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "mb_get_from_many",
+            ".plugin",
+            in_sign="sasia{ss}s",
+            out_sign="s",
+            method=self._mb_get_from_many,
+        )
+        host.bridge.add_method(
+            "mb_get_from_many_with_comments_rt_result",
+            ".plugin",
+            in_sign="ss",
+            out_sign="(ua(sssa(sa(sssasa{ss}))a{ss}))",
+            method=self._mb_get_from_many_with_comments_rt_result,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "mb_get_from_many_with_comments",
+            ".plugin",
+            in_sign="sasiia{ss}a{ss}s",
+            out_sign="s",
+            method=self._mb_get_from_many_with_comments,
+        )
+        host.bridge.add_method(
+            "mb_is_comment_node",
+            ".plugin",
+            in_sign="s",
+            out_sign="b",
+            method=self.is_comment_node,
+        )
+
+    def get_handler(self, client):
+        return XEP_0277_handler()
+
+    def _cache_node_match_cb(
+        self,
+        client: SatXMPPEntity,
+        analyse: dict,
+    ) -> None:
+        """Check is analysed node is a comment and fill analyse accordingly"""
+        if analyse["node"].startswith(NS_COMMENT_PREFIX):
+            analyse["subtype"] = "comment"
+
+    def _check_features_cb(self, available):
+        return {"available": C.BOOL_TRUE}
+
+    def _check_features_eb(self, fail):
+        return {"available": C.BOOL_FALSE}
+
+    def features_get(self, profile):
+        client = self.host.get_client(profile)
+        d = self.host.check_features(client, [], identity=("pubsub", "pep"))
+        d.addCallbacks(self._check_features_cb, self._check_features_eb)
+        return d
+
+    ## plugin management methods ##
+
+    def _items_received(self, client, itemsEvent):
+        """Callback which manage items notifications (publish + retract)"""
+
+        def manage_item(data, event):
+            self.host.bridge.ps_event(
+                C.PS_MICROBLOG,
+                itemsEvent.sender.full(),
+                itemsEvent.nodeIdentifier,
+                event,
+                data_format.serialise(data),
+                client.profile,
+            )
+
+        for item in itemsEvent.items:
+            if item.name == C.PS_ITEM:
+                # FIXME: service and node should be used here
+                self.item_2_mb_data(client, item, None, None).addCallbacks(
+                    manage_item, lambda failure: None, (C.PS_PUBLISH,)
+                )
+            elif item.name == C.PS_RETRACT:
+                manage_item({"id": item["id"]}, C.PS_RETRACT)
+            else:
+                raise exceptions.InternalError("Invalid event value")
+
+    ## data/item transformation ##
+
+    @defer.inlineCallbacks
+    def item_2_mb_data(
+        self,
+        client: SatXMPPEntity,
+        item_elt: domish.Element,
+        service: Optional[jid.JID],
+        # FIXME: node is Optional until all calls to item_2_mb_data set properly service
+        #   and node. Once done, the Optional must be removed here
+        node: Optional[str]
+    ) -> dict:
+        """Convert an XML Item to microblog data
+
+        @param item_elt: microblog item element
+        @param service: PubSub service where the item has been retrieved
+            profile's PEP is used when service is None
+        @param node: PubSub node where the item has been retrieved
+            if None, "uri" won't be set
+        @return: microblog data
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+
+        extra: Dict[str, Any] = {}
+        microblog_data: Dict[str, Any] = {
+            "service": service.full(),
+            "extra": extra
+        }
+
+        def check_conflict(key, increment=False):
+            """Check if key is already in microblog data
+
+            @param key(unicode): key to check
+            @param increment(bool): if suffix the key with an increment
+                instead of raising an exception
+            @raise exceptions.DataError: the key already exists
+                (not raised if increment is True)
+            """
+            if key in microblog_data:
+                if not increment:
+                    raise failure.Failure(
+                        exceptions.DataError(
+                            "key {} is already present for item {}"
+                        ).format(key, item_elt["id"])
+                    )
+                else:
+                    idx = 1  # the idx 0 is the key without suffix
+                    fmt = "{}#{}"
+                    new_key = fmt.format(key, idx)
+                    while new_key in microblog_data:
+                        idx += 1
+                        new_key = fmt.format(key, idx)
+                    key = new_key
+            return key
+
+        @defer.inlineCallbacks
+        def parseElement(elem):
+            """Parse title/content elements and fill microblog_data accordingly"""
+            type_ = elem.getAttribute("type")
+            if type_ == "xhtml":
+                data_elt = elem.firstChildElement()
+                if data_elt is None:
+                    raise failure.Failure(
+                        exceptions.DataError(
+                            "XHML content not wrapped in a <div/> element, this is not "
+                            "standard !"
+                        )
+                    )
+                if data_elt.uri != C.NS_XHTML:
+                    raise failure.Failure(
+                        exceptions.DataError(
+                            _("Content of type XHTML must declare its namespace!")
+                        )
+                    )
+                key = check_conflict("{}_xhtml".format(elem.name))
+                data = data_elt.toXml()
+                microblog_data[key] = yield self.host.plugins["TEXT_SYNTAXES"].clean_xhtml(
+                    data
+                )
+            else:
+                key = check_conflict(elem.name)
+                microblog_data[key] = str(elem)
+
+        id_ = item_elt.getAttribute("id", "")  # there can be no id for transient nodes
+        microblog_data["id"] = id_
+        if item_elt.uri not in (pubsub.NS_PUBSUB, NS_PUBSUB_EVENT):
+            msg = "Unsupported namespace {ns} in pubsub item {id_}".format(
+                ns=item_elt.uri, id_=id_
+            )
+            log.warning(msg)
+            raise failure.Failure(exceptions.DataError(msg))
+
+        try:
+            entry_elt = next(item_elt.elements(NS_ATOM, "entry"))
+        except StopIteration:
+            msg = "No atom entry found in the pubsub item {}".format(id_)
+            raise failure.Failure(exceptions.DataError(msg))
+
+        # uri
+        # FIXME: node should alway be set in the future, check FIXME in method signature
+        if node is not None:
+            microblog_data["node"] = node
+            microblog_data['uri'] = xmpp_uri.build_xmpp_uri(
+                "pubsub",
+                path=service.full(),
+                node=node,
+                item=id_,
+            )
+
+        # language
+        try:
+            microblog_data["language"] = entry_elt[(C.NS_XML, "lang")].strip()
+        except KeyError:
+            pass
+
+        # atom:id
+        try:
+            id_elt = next(entry_elt.elements(NS_ATOM, "id"))
+        except StopIteration:
+            msg = ("No atom id found in the pubsub item {}, this is not standard !"
+                   .format(id_))
+            log.warning(msg)
+            microblog_data["atom_id"] = ""
+        else:
+            microblog_data["atom_id"] = str(id_elt)
+
+        # title/content(s)
+
+        # FIXME: ATOM and XEP-0277 only allow 1 <title/> element
+        #        but in the wild we have some blogs with several ones
+        #        so we don't respect the standard for now (it doesn't break
+        #        anything anyway), and we'll find a better option later
+        # try:
+        #     title_elt = entry_elt.elements(NS_ATOM, 'title').next()
+        # except StopIteration:
+        #     msg = u'No atom title found in the pubsub item {}'.format(id_)
+        #     raise failure.Failure(exceptions.DataError(msg))
+        title_elts = list(entry_elt.elements(NS_ATOM, "title"))
+        if not title_elts:
+            msg = "No atom title found in the pubsub item {}".format(id_)
+            raise failure.Failure(exceptions.DataError(msg))
+        for title_elt in title_elts:
+            yield parseElement(title_elt)
+
+        # FIXME: as for <title/>, Atom only authorise at most 1 content
+        #        but XEP-0277 allows several ones. So for no we handle as
+        #        if more than one can be present
+        for content_elt in entry_elt.elements(NS_ATOM, "content"):
+            yield parseElement(content_elt)
+
+        # we check that text content is present
+        for key in ("title", "content"):
+            if key not in microblog_data and ("{}_xhtml".format(key)) in microblog_data:
+                log.warning(
+                    "item {id_} provide a {key}_xhtml data but not a text one".format(
+                        id_=id_, key=key
+                    )
+                )
+                # ... and do the conversion if it's not
+                microblog_data[key] = yield self.host.plugins["TEXT_SYNTAXES"].convert(
+                    microblog_data["{}_xhtml".format(key)],
+                    self.host.plugins["TEXT_SYNTAXES"].SYNTAX_XHTML,
+                    self.host.plugins["TEXT_SYNTAXES"].SYNTAX_TEXT,
+                    False,
+                )
+
+        if "content" not in microblog_data:
+            # use the atom title data as the microblog body content
+            microblog_data["content"] = microblog_data["title"]
+            del microblog_data["title"]
+            if "title_xhtml" in microblog_data:
+                microblog_data["content_xhtml"] = microblog_data["title_xhtml"]
+                del microblog_data["title_xhtml"]
+
+        # published/updated dates
+        try:
+            updated_elt = next(entry_elt.elements(NS_ATOM, "updated"))
+        except StopIteration:
+            msg = "No atom updated element found in the pubsub item {}".format(id_)
+            raise failure.Failure(exceptions.DataError(msg))
+        microblog_data["updated"] = calendar.timegm(
+            dateutil.parser.parse(str(updated_elt)).utctimetuple()
+        )
+        try:
+            published_elt = next(entry_elt.elements(NS_ATOM, "published"))
+        except StopIteration:
+            microblog_data["published"] = microblog_data["updated"]
+        else:
+            microblog_data["published"] = calendar.timegm(
+                dateutil.parser.parse(str(published_elt)).utctimetuple()
+            )
+
+        # links
+        comments = microblog_data['comments'] = []
+        for link_elt in entry_elt.elements(NS_ATOM, "link"):
+            href = link_elt.getAttribute("href")
+            if not href:
+                log.warning(
+                    f'missing href in <link> element: {link_elt.toXml()}'
+                )
+                continue
+            rel = link_elt.getAttribute("rel")
+            if (rel == "replies" and link_elt.getAttribute("title") == "comments"):
+                uri = href
+                comments_data = {
+                    "uri": uri,
+                }
+                try:
+                    comment_service, comment_node = self.parse_comment_url(uri)
+                except Exception as e:
+                    log.warning(f"Can't parse comments url: {e}")
+                    continue
+                else:
+                    comments_data["service"] = comment_service.full()
+                    comments_data["node"] = comment_node
+                comments.append(comments_data)
+            elif rel == "via":
+                try:
+                    repeater_jid = jid.JID(item_elt["publisher"])
+                except (KeyError, RuntimeError):
+                    try:
+                        # we look for stanza element which is at the root, meaning that it
+                        # has not parent
+                        top_elt = item_elt.parent
+                        while top_elt.parent is not None:
+                            top_elt = top_elt.parent
+                        repeater_jid = jid.JID(top_elt["from"])
+                    except (AttributeError, RuntimeError):
+                        # we should always have either the "publisher" attribute or the
+                        # stanza available
+                        log.error(
+                            f"Can't find repeater of the post: {item_elt.toXml()}"
+                        )
+                        continue
+
+                extra["repeated"] = {
+                    "by": repeater_jid.full(),
+                    "uri": href
+                }
+            elif rel in ("related", "enclosure"):
+                attachment: Dict[str, Any] = {
+                    "sources": [{"url": href}]
+                }
+                if rel == "related":
+                    attachment["external"] = True
+                for attr, key in (
+                    ("type", "media_type"),
+                    ("title", "desc"),
+                ):
+                    value = link_elt.getAttribute(attr)
+                    if value:
+                        attachment[key] = value
+                try:
+                    attachment["size"] = int(link_elt.attributes["lenght"])
+                except (KeyError, ValueError):
+                    pass
+                if "media_type" not in attachment:
+                    media_type = guess_type(href, False)[0]
+                    if media_type is not None:
+                        attachment["media_type"] = media_type
+
+                attachments = extra.setdefault("attachments", [])
+                attachments.append(attachment)
+            else:
+                log.warning(
+                    f"Unmanaged link element: {link_elt.toXml()}"
+                )
+
+        # author
+        publisher = item_elt.getAttribute("publisher")
+        try:
+            author_elt = next(entry_elt.elements(NS_ATOM, "author"))
+        except StopIteration:
+            log.debug("Can't find author element in item {}".format(id_))
+        else:
+            # name
+            try:
+                name_elt = next(author_elt.elements(NS_ATOM, "name"))
+            except StopIteration:
+                log.warning(
+                    "No name element found in author element of item {}".format(id_)
+                )
+                author = None
+            else:
+                author = microblog_data["author"] = str(name_elt).strip()
+            # uri
+            try:
+                uri_elt = next(author_elt.elements(NS_ATOM, "uri"))
+            except StopIteration:
+                log.debug(
+                    "No uri element found in author element of item {}".format(id_)
+                )
+                if publisher:
+                    microblog_data["author_jid"] = publisher
+            else:
+                uri = str(uri_elt)
+                if uri.startswith("xmpp:"):
+                    uri = uri[5:]
+                    microblog_data["author_jid"] = uri
+                else:
+                    microblog_data["author_jid"] = (
+                        item_elt.getAttribute("publisher") or ""
+                    )
+                if not author and microblog_data["author_jid"]:
+                    # FIXME: temporary workaround for missing author name, would be
+                    #   better to use directly JID's identity (to be done from frontends?)
+                    try:
+                        microblog_data["author"] = jid.JID(microblog_data["author_jid"]).user
+                    except Exception as e:
+                        log.warning(f"No author name found, and can't parse author jid: {e}")
+
+                if not publisher:
+                    log.debug("No publisher attribute, we can't verify author jid")
+                    microblog_data["author_jid_verified"] = False
+                elif jid.JID(publisher).userhostJID() == jid.JID(uri).userhostJID():
+                    microblog_data["author_jid_verified"] = True
+                else:
+                    if "repeated" not in extra:
+                        log.warning(
+                            "item atom:uri differ from publisher attribute, spoofing "
+                            "attempt ? atom:uri = {} publisher = {}".format(
+                                uri, item_elt.getAttribute("publisher")
+                            )
+                        )
+                    microblog_data["author_jid_verified"] = False
+            # email
+            try:
+                email_elt = next(author_elt.elements(NS_ATOM, "email"))
+            except StopIteration:
+                pass
+            else:
+                microblog_data["author_email"] = str(email_elt)
+
+        if not microblog_data.get("author_jid"):
+            if publisher:
+                microblog_data["author_jid"] = publisher
+                microblog_data["author_jid_verified"] = True
+            else:
+                iq_elt = xml_tools.find_ancestor(item_elt, "iq", C.NS_STREAM)
+                microblog_data["author_jid"] = iq_elt["from"]
+                microblog_data["author_jid_verified"] = False
+
+        # categories
+        categories = [
+            category_elt.getAttribute("term", "")
+            for category_elt in entry_elt.elements(NS_ATOM, "category")
+        ]
+        microblog_data["tags"] = categories
+
+        ## the trigger ##
+        # if other plugins have things to add or change
+        yield self.host.trigger.point(
+            "XEP-0277_item2data", item_elt, entry_elt, microblog_data
+        )
+
+        defer.returnValue(microblog_data)
+
+    async def mb_data_2_entry_elt(self, client, mb_data, item_id, service, node):
+        """Convert a data dict to en entry usable to create an item
+
+        @param mb_data: data dict as given by bridge method.
+        @param item_id(unicode): id of the item to use
+        @param service(jid.JID, None): pubsub service where the item is sent
+            Needed to construct Atom id
+        @param node(unicode): pubsub node where the item is sent
+            Needed to construct Atom id
+        @return: deferred which fire domish.Element
+        """
+        entry_elt = domish.Element((NS_ATOM, "entry"))
+        extra = mb_data.get("extra", {})
+
+        ## language ##
+        if "language" in mb_data:
+            entry_elt[(C.NS_XML, "lang")] = mb_data["language"].strip()
+
+        ## content and title ##
+        synt = self.host.plugins["TEXT_SYNTAXES"]
+
+        for elem_name in ("title", "content"):
+            for type_ in ["", "_rich", "_xhtml"]:
+                attr = f"{elem_name}{type_}"
+                if attr in mb_data:
+                    elem = entry_elt.addElement(elem_name)
+                    if type_:
+                        if type_ == "_rich":  # convert input from current syntax to XHTML
+                            xml_content = await synt.convert(
+                                mb_data[attr], synt.get_current_syntax(client.profile), "XHTML"
+                            )
+                            if f"{elem_name}_xhtml" in mb_data:
+                                raise failure.Failure(
+                                    exceptions.DataError(
+                                        _(
+                                            "Can't have xhtml and rich content at the same time"
+                                        )
+                                    )
+                                )
+                        else:
+                            xml_content = mb_data[attr]
+
+                        div_elt = xml_tools.ElementParser()(
+                            xml_content, namespace=C.NS_XHTML
+                        )
+                        if (
+                            div_elt.name != "div"
+                            or div_elt.uri != C.NS_XHTML
+                            or div_elt.attributes
+                        ):
+                            # we need a wrapping <div/> at the top with XHTML namespace
+                            wrap_div_elt = domish.Element((C.NS_XHTML, "div"))
+                            wrap_div_elt.addChild(div_elt)
+                            div_elt = wrap_div_elt
+                        elem.addChild(div_elt)
+                        elem["type"] = "xhtml"
+                        if elem_name not in mb_data:
+                            # there is raw text content, which is mandatory
+                            # so we create one from xhtml content
+                            elem_txt = entry_elt.addElement(elem_name)
+                            text_content = await self.host.plugins[
+                                "TEXT_SYNTAXES"
+                            ].convert(
+                                xml_content,
+                                self.host.plugins["TEXT_SYNTAXES"].SYNTAX_XHTML,
+                                self.host.plugins["TEXT_SYNTAXES"].SYNTAX_TEXT,
+                                False,
+                            )
+                            elem_txt.addContent(text_content)
+                            elem_txt["type"] = "text"
+
+                    else:  # raw text only needs to be escaped to get HTML-safe sequence
+                        elem.addContent(mb_data[attr])
+                        elem["type"] = "text"
+
+        try:
+            next(entry_elt.elements(NS_ATOM, "title"))
+        except StopIteration:
+            # we have no title element which is mandatory
+            # so we transform content element to title
+            elems = list(entry_elt.elements(NS_ATOM, "content"))
+            if not elems:
+                raise exceptions.DataError(
+                    "There must be at least one content or title element"
+                )
+            for elem in elems:
+                elem.name = "title"
+
+        ## attachments ##
+        attachments = extra.get(C.KEY_ATTACHMENTS)
+        if attachments:
+            for attachment in attachments:
+                try:
+                    url = attachment["url"]
+                except KeyError:
+                    try:
+                        url = next(
+                            s['url'] for s in attachment["sources"] if 'url' in s
+                        )
+                    except (StopIteration, KeyError):
+                        log.warning(
+                            f'"url" missing in attachment, ignoring: {attachment}'
+                        )
+                        continue
+
+                if not url.startswith("http"):
+                    log.warning(f"non HTTP URL in attachment, ignoring: {attachment}")
+                    continue
+                link_elt = entry_elt.addElement("link")
+                # XXX: "uri" is set in self._manage_comments if not already existing
+                link_elt["href"] = url
+                if attachment.get("external", False):
+                    # this is a link to an external data such as a website
+                    link_elt["rel"] = "related"
+                else:
+                    # this is an attached file
+                    link_elt["rel"] = "enclosure"
+                for key, attr in (
+                    ("media_type", "type"),
+                    ("desc", "title"),
+                    ("size", "lenght")
+                ):
+                    value = attachment.get(key)
+                    if value:
+                        link_elt[attr]  = str(value)
+
+        ## author ##
+        author_elt = entry_elt.addElement("author")
+        try:
+            author_name = mb_data["author"]
+        except KeyError:
+            # FIXME: must use better name
+            author_name = client.jid.user
+        author_elt.addElement("name", content=author_name)
+
+        try:
+            author_jid_s = mb_data["author_jid"]
+        except KeyError:
+            author_jid_s = client.jid.userhost()
+        author_elt.addElement("uri", content="xmpp:{}".format(author_jid_s))
+
+        try:
+            author_jid_s = mb_data["author_email"]
+        except KeyError:
+            pass
+
+        ## published/updated time ##
+        current_time = time.time()
+        entry_elt.addElement(
+            "updated", content=utils.xmpp_date(float(mb_data.get("updated", current_time)))
+        )
+        entry_elt.addElement(
+            "published",
+            content=utils.xmpp_date(float(mb_data.get("published", current_time))),
+        )
+
+        ## categories ##
+        for tag in mb_data.get('tags', []):
+            category_elt = entry_elt.addElement("category")
+            category_elt["term"] = tag
+
+        ## id ##
+        entry_id = mb_data.get(
+            "id",
+            xmpp_uri.build_xmpp_uri(
+                "pubsub",
+                path=service.full() if service is not None else client.jid.userhost(),
+                node=node,
+                item=item_id,
+            ),
+        )
+        entry_elt.addElement("id", content=entry_id)  #
+
+        ## comments ##
+        for comments_data in mb_data.get('comments', []):
+            link_elt = entry_elt.addElement("link")
+            # XXX: "uri" is set in self._manage_comments if not already existing
+            link_elt["href"] = comments_data["uri"]
+            link_elt["rel"] = "replies"
+            link_elt["title"] = "comments"
+
+        if "repeated" in extra:
+            try:
+                repeated = extra["repeated"]
+                link_elt = entry_elt.addElement("link")
+                link_elt["rel"] = "via"
+                link_elt["href"] = repeated["uri"]
+            except KeyError as e:
+                log.warning(
+                    f"invalid repeated element({e}): {extra['repeated']}"
+                )
+
+        ## final item building ##
+        item_elt = pubsub.Item(id=item_id, payload=entry_elt)
+
+        ## the trigger ##
+        # if other plugins have things to add or change
+        self.host.trigger.point(
+            "XEP-0277_data2entry", client, mb_data, entry_elt, item_elt
+        )
+
+        return item_elt
+
+    ## publish/preview ##
+
+    def is_comment_node(self, node: str) -> bool:
+        """Indicate if the node is prefixed with comments namespace"""
+        return node.startswith(NS_COMMENT_PREFIX)
+
+    def get_parent_item(self, item_id: str) -> str:
+        """Return parent of a comment node
+
+        @param item_id: a comment node
+        """
+        if not self.is_comment_node(item_id):
+            raise ValueError("This node is not a comment node")
+        return item_id[len(NS_COMMENT_PREFIX):]
+
+    def get_comments_node(self, item_id):
+        """Generate comment node
+
+        @param item_id(unicode): id of the parent item
+        @return (unicode): comment node to use
+        """
+        return f"{NS_COMMENT_PREFIX}{item_id}"
+
+    def get_comments_service(self, client, parent_service=None):
+        """Get prefered PubSub service to create comment node
+
+        @param pubsub_service(jid.JID, None): PubSub service of the parent item
+        @param return((D)jid.JID, None): PubSub service to use
+        """
+        if parent_service is not None:
+            if parent_service.user:
+                # we are on a PEP
+                if parent_service.host == client.jid.host:
+                    #  it's our server, we use already found client.pubsub_service below
+                    pass
+                else:
+                    # other server, let's try to find a non PEP service there
+                    d = self.host.find_service_entity(
+                        client, "pubsub", "service", parent_service
+                    )
+                    d.addCallback(lambda entity: entity or parent_service)
+            else:
+                # parent is already on a normal Pubsub service, we re-use it
+                return defer.succeed(parent_service)
+
+        return defer.succeed(
+            client.pubsub_service if client.pubsub_service is not None else parent_service
+        )
+
+    async def _manage_comments(self, client, mb_data, service, node, item_id, access=None):
+        """Check comments keys in mb_data and create comments node if necessary
+
+        if a comments node metadata is set in the mb_data['comments'] list, it is used
+        otherwise it is generated (if allow_comments is True).
+        @param mb_data(dict): microblog mb_data
+        @param service(jid.JID, None): PubSub service of the parent item
+        @param node(unicode): node of the parent item
+        @param item_id(unicode): id of the parent item
+        @param access(unicode, None): access model
+            None to use same access model as parent item
+        """
+        allow_comments = mb_data.pop("allow_comments", None)
+        if allow_comments is None:
+            if "comments" in mb_data:
+                mb_data["allow_comments"] = True
+            else:
+                # no comments set or requested, nothing to do
+                return
+        elif allow_comments == False:
+            if "comments" in mb_data:
+                log.warning(
+                    "comments are not allowed but there is already a comments node, "
+                    "it may be lost: {uri}".format(
+                        uri=mb_data["comments"]
+                    )
+                )
+                del mb_data["comments"]
+            return
+
+        # we have usually a single comment node, but the spec allow several, so we need to
+        # handle this in a list
+        if len(mb_data.setdefault('comments', [])) == 0:
+            # we need at least one comment node
+            comments_data = {}
+            mb_data['comments'].append({})
+
+        if access is None:
+            # TODO: cache access models per service/node
+            parent_node_config = await self._p.getConfiguration(client, service, node)
+            access = parent_node_config.get(self._p.OPT_ACCESS_MODEL, self._p.ACCESS_OPEN)
+
+        options = {
+            self._p.OPT_ACCESS_MODEL: access,
+            self._p.OPT_MAX_ITEMS: "max",
+            self._p.OPT_PERSIST_ITEMS: 1,
+            self._p.OPT_DELIVER_PAYLOADS: 1,
+            self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
+            # FIXME: would it make sense to restrict publish model to subscribers?
+            self._p.OPT_PUBLISH_MODEL: self._p.ACCESS_OPEN,
+        }
+
+        # if other plugins need to change the options
+        self.host.trigger.point("XEP-0277_comments", client, mb_data, options)
+
+        for comments_data in mb_data['comments']:
+            uri = comments_data.get('uri')
+            comments_node = comments_data.get('node')
+            try:
+                comments_service = jid.JID(comments_data["service"])
+            except KeyError:
+                comments_service = None
+
+            if uri:
+                uri_service, uri_node = self.parse_comment_url(uri)
+                if ((comments_node is not None and comments_node!=uri_node)
+                     or (comments_service is not None and comments_service!=uri_service)):
+                    raise ValueError(
+                        f"Incoherence between comments URI ({uri}) and comments_service "
+                        f"({comments_service}) or comments_node ({comments_node})")
+                comments_data['service'] = comments_service = uri_service
+                comments_data['node'] = comments_node = uri_node
+            else:
+                if not comments_node:
+                    comments_node = self.get_comments_node(item_id)
+                comments_data['node'] = comments_node
+                if comments_service is None:
+                    comments_service = await self.get_comments_service(client, service)
+                    if comments_service is None:
+                        comments_service = client.jid.userhostJID()
+                comments_data['service'] = comments_service
+
+                comments_data['uri'] = xmpp_uri.build_xmpp_uri(
+                    "pubsub",
+                    path=comments_service.full(),
+                    node=comments_node,
+                )
+
+            try:
+                await self._p.createNode(client, comments_service, comments_node, options)
+            except error.StanzaError as e:
+                if e.condition == "conflict":
+                    log.info(
+                        "node {} already exists on service {}".format(
+                            comments_node, comments_service
+                        )
+                    )
+                else:
+                    raise e
+            else:
+                if access == self._p.ACCESS_WHITELIST:
+                    # for whitelist access we need to copy affiliations from parent item
+                    comments_affiliations = await self._p.get_node_affiliations(
+                        client, service, node
+                    )
+                    # …except for "member", that we transform to publisher
+                    # because we wants members to be able to write to comments
+                    for jid_, affiliation in list(comments_affiliations.items()):
+                        if affiliation == "member":
+                            comments_affiliations[jid_] == "publisher"
+
+                    await self._p.set_node_affiliations(
+                        client, comments_service, comments_node, comments_affiliations
+                    )
+
+    def friendly_id(self, data):
+        """Generate a user friendly id from title or content"""
+        # TODO: rich content should be converted to plain text
+        id_base = regex.url_friendly_text(
+            data.get('title')
+            or data.get('title_rich')
+            or data.get('content')
+            or data.get('content_rich')
+            or ''
+        )
+        return f"{id_base}-{token_urlsafe(3)}"
+
+    def _mb_send(self, service, node, data, profile_key):
+        service = jid.JID(service) if service else None
+        node = node if node else NS_MICROBLOG
+        client = self.host.get_client(profile_key)
+        data = data_format.deserialise(data)
+        return defer.ensureDeferred(self.send(client, data, service, node))
+
+    async def send(
+        self,
+        client: SatXMPPEntity,
+        data: dict,
+        service: Optional[jid.JID] = None,
+        node: Optional[str] = NS_MICROBLOG
+    ) -> Optional[str]:
+        """Send XEP-0277's microblog data
+
+        @param data: microblog data (must include at least a "content" or a "title" key).
+            see http://wiki.goffi.org/wiki/Bridge_API_-_Microblogging/en for details
+        @param service: PubSub service where the microblog must be published
+            None to publish on profile's PEP
+        @param node: PubSub node to use (defaut to microblog NS)
+            None is equivalend as using default value
+        @return: ID of the published item
+        """
+        # TODO: check that all data keys are used, this would avoid sending publicly a private message
+        #       by accident (e.g. if group plugin is not loaded, and "group*" key are not used)
+        if service is None:
+            service = client.jid.userhostJID()
+        if node is None:
+            node = NS_MICROBLOG
+
+        item_id = data.get("id")
+        if item_id is None:
+            if data.get("user_friendly_id", True):
+                item_id = self.friendly_id(data)
+            else:
+                item_id = str(shortuuid.uuid())
+
+        try:
+            await self._manage_comments(client, data, service, node, item_id, access=None)
+        except error.StanzaError:
+            log.warning("Can't create comments node for item {}".format(item_id))
+        item = await self.mb_data_2_entry_elt(client, data, item_id, service, node)
+
+        if not await self.host.trigger.async_point(
+            "XEP-0277_send", client, service, node, item, data
+        ):
+            return None
+
+        extra = {}
+        for key in ("encrypted", "encrypted_for", "signed"):
+            value = data.get(key)
+            if value is not None:
+                extra[key] = value
+
+        await self._p.publish(client, service, node, [item], extra=extra)
+        return item_id
+
+    def _mb_repeat(
+            self,
+            service_s: str,
+            node: str,
+            item: str,
+            extra_s: str,
+            profile_key: str
+    ) -> defer.Deferred:
+        service = jid.JID(service_s) if service_s else None
+        node = node if node else NS_MICROBLOG
+        client = self.host.get_client(profile_key)
+        extra = data_format.deserialise(extra_s)
+        d = defer.ensureDeferred(
+            self.repeat(client, item, service, node, extra)
+        )
+        # [repeat] can return None, and we always need a str
+        d.addCallback(lambda ret: ret or "")
+        return d
+
+    async def repeat(
+        self,
+        client: SatXMPPEntity,
+        item: str,
+        service: Optional[jid.JID] = None,
+        node: str = NS_MICROBLOG,
+        extra: Optional[dict] = None,
+    ) -> Optional[str]:
+        """Re-publish a post from somewhere else
+
+        This is a feature often name "share" or "boost", it is generally used to make a
+        publication more visible by sharing it with our own audience
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+
+        # we first get the post to repeat
+        items, __ = await self._p.get_items(
+            client,
+            service,
+            node,
+            item_ids = [item]
+        )
+        if not items:
+            raise exceptions.NotFound(
+                f"no item found at node {node!r} on {service} with ID {item!r}"
+            )
+        item_elt = items[0]
+        try:
+            entry_elt = next(item_elt.elements(NS_ATOM, "entry"))
+        except StopIteration:
+            raise exceptions.DataError(
+                "post to repeat is not a XEP-0277 blog item"
+            )
+
+        # we want to be sure that we have an author element
+        try:
+            author_elt = next(entry_elt.elements(NS_ATOM, "author"))
+        except StopIteration:
+            author_elt = entry_elt.addElement("author")
+
+        try:
+            next(author_elt.elements(NS_ATOM, "name"))
+        except StopIteration:
+            author_elt.addElement("name", content=service.user)
+
+        try:
+            next(author_elt.elements(NS_ATOM, "uri"))
+        except StopIteration:
+            entry_elt.addElement(
+                "uri", content=xmpp_uri.build_xmpp_uri(None, path=service.full())
+            )
+
+        # we add the link indicating that it's a repeated post
+        link_elt = entry_elt.addElement("link")
+        link_elt["rel"] = "via"
+        link_elt["href"] = xmpp_uri.build_xmpp_uri(
+            "pubsub", path=service.full(), node=node, item=item
+        )
+
+        return await self._p.send_item(
+            client,
+            client.jid.userhostJID(),
+            NS_MICROBLOG,
+            entry_elt
+        )
+
+    def _mb_preview(self, service, node, data, profile_key):
+        service = jid.JID(service) if service else None
+        node = node if node else NS_MICROBLOG
+        client = self.host.get_client(profile_key)
+        data = data_format.deserialise(data)
+        d = defer.ensureDeferred(self.preview(client, data, service, node))
+        d.addCallback(data_format.serialise)
+        return d
+
+    async def preview(
+        self,
+        client: SatXMPPEntity,
+        data: dict,
+        service: Optional[jid.JID] = None,
+        node: Optional[str] = NS_MICROBLOG
+    ) -> dict:
+        """Preview microblog data without publishing them
+
+        params are the same as for [send]
+        @return: microblog data as would be retrieved from published item
+        """
+        if node is None:
+            node = NS_MICROBLOG
+
+        item_id = data.get("id", "")
+
+        # we have to serialise then deserialise to be sure that all triggers are called
+        item_elt = await self.mb_data_2_entry_elt(client, data, item_id, service, node)
+        item_elt.uri = pubsub.NS_PUBSUB
+        return await self.item_2_mb_data(client, item_elt, service, node)
+
+
+    ## retract ##
+
+    def _mb_retract(self, service_jid_s, nodeIdentifier, itemIdentifier, profile_key):
+        """Call self._p._retract_item, but use default node if node is empty"""
+        return self._p._retract_item(
+            service_jid_s,
+            nodeIdentifier or NS_MICROBLOG,
+            itemIdentifier,
+            True,
+            profile_key,
+        )
+
+    ## get ##
+
+    def _mb_get_serialise(self, data):
+        items, metadata = data
+        metadata['items'] = items
+        return data_format.serialise(metadata)
+
+    def _mb_get(self, service="", node="", max_items=10, item_ids=None, extra="",
+               profile_key=C.PROF_KEY_NONE):
+        """
+        @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
+        @param item_ids (list[unicode]): list of item IDs
+        """
+        client = self.host.get_client(profile_key)
+        service = jid.JID(service) if service else None
+        max_items = None if max_items == C.NO_LIMIT else max_items
+        extra = self._p.parse_extra(data_format.deserialise(extra))
+        d = defer.ensureDeferred(
+            self.mb_get(client, service, node or None, max_items, item_ids,
+                       extra.rsm_request, extra.extra)
+        )
+        d.addCallback(self._mb_get_serialise)
+        return d
+
+    async def mb_get(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID] = None,
+        node: Optional[str] = None,
+        max_items: Optional[int] = 10,
+        item_ids: Optional[List[str]] = None,
+        rsm_request: Optional[rsm.RSMRequest] = None,
+        extra: Optional[Dict[str, Any]] = None
+    ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
+        """Get some microblogs
+
+        @param service(jid.JID, None): jid of the publisher
+            None to get profile's PEP
+        @param node(unicode, None): node to get (or microblog node if None)
+        @param max_items(int): maximum number of item to get, None for no limit
+            ignored if rsm_request is set
+        @param item_ids (list[unicode]): list of item IDs
+        @param rsm_request (rsm.RSMRequest): RSM request data
+        @param extra (dict): extra data
+
+        @return: a deferred couple with the list of items and metadatas.
+        """
+        if node is None:
+            node = NS_MICROBLOG
+        if rsm_request:
+            max_items = None
+        items_data = await self._p.get_items(
+            client,
+            service,
+            node,
+            max_items=max_items,
+            item_ids=item_ids,
+            rsm_request=rsm_request,
+            extra=extra,
+        )
+        mb_data_list, metadata = await self._p.trans_items_data_d(
+            items_data, partial(self.item_2_mb_data, client, service=service, node=node))
+        encrypted = metadata.pop("encrypted", None)
+        if encrypted is not None:
+            for mb_data in mb_data_list:
+                try:
+                    mb_data["encrypted"] = encrypted[mb_data["id"]]
+                except KeyError:
+                    pass
+        return (mb_data_list, metadata)
+
+    def _mb_rename(self, service, node, item_id, new_id, profile_key):
+        return defer.ensureDeferred(self.mb_rename(
+            self.host.get_client(profile_key),
+            jid.JID(service) if service else None,
+            node or None,
+            item_id,
+            new_id
+        ))
+
+    async def mb_rename(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: Optional[str],
+        item_id: str,
+        new_id: str
+    ) -> None:
+        if not node:
+            node = NS_MICROBLOG
+        await self._p.rename_item(client, service, node, item_id, new_id)
+
+    def parse_comment_url(self, node_url):
+        """Parse a XMPP URI
+
+        Determine the fields comments_service and comments_node of a microblog data
+        from the href attribute of an entry's link element. For example this input:
+        xmpp:sat-pubsub.example.net?;node=urn%3Axmpp%3Acomments%3A_af43b363-3259-4b2a-ba4c-1bc33aa87634__urn%3Axmpp%3Agroupblog%3Asomebody%40example.net
+        will return(JID(u'sat-pubsub.example.net'), 'urn:xmpp:comments:_af43b363-3259-4b2a-ba4c-1bc33aa87634__urn:xmpp:groupblog:somebody@example.net')
+        @return (tuple[jid.JID, unicode]): service and node
+        """
+        try:
+            parsed_url = xmpp_uri.parse_xmpp_uri(node_url)
+            service = jid.JID(parsed_url["path"])
+            node = parsed_url["node"]
+        except Exception as e:
+            raise exceptions.DataError(f"Invalid comments link: {e}")
+
+        return (service, node)
+
+    ## configure ##
+
+    def mb_access_set(self, access="presence", profile_key=C.PROF_KEY_NONE):
+        """Create a microblog node on PEP with given access
+
+        If the node already exists, it change options
+        @param access: Node access model, according to xep-0060 #4.5
+        @param profile_key: profile key
+        """
+        #  FIXME: check if this mehtod is need, deprecate it if not
+        client = self.host.get_client(profile_key)
+
+        _options = {
+            self._p.OPT_ACCESS_MODEL: access,
+            self._p.OPT_MAX_ITEMS: "max",
+            self._p.OPT_PERSIST_ITEMS: 1,
+            self._p.OPT_DELIVER_PAYLOADS: 1,
+            self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
+        }
+
+        def cb(result):
+            # Node is created with right permission
+            log.debug(_("Microblog node has now access %s") % access)
+
+        def fatal_err(s_error):
+            # Something went wrong
+            log.error(_("Can't set microblog access"))
+            raise NodeAccessChangeException()
+
+        def err_cb(s_error):
+            # If the node already exists, the condition is "conflict",
+            # else we have an unmanaged error
+            if s_error.value.condition == "conflict":
+                # d = self.host.plugins["XEP-0060"].deleteNode(client, client.jid.userhostJID(), NS_MICROBLOG)
+                # d.addCallback(lambda x: create_node().addCallback(cb).addErrback(fatal_err))
+                change_node_options().addCallback(cb).addErrback(fatal_err)
+            else:
+                fatal_err(s_error)
+
+        def create_node():
+            return self._p.createNode(
+                client, client.jid.userhostJID(), NS_MICROBLOG, _options
+            )
+
+        def change_node_options():
+            return self._p.setOptions(
+                client.jid.userhostJID(),
+                NS_MICROBLOG,
+                client.jid.userhostJID(),
+                _options,
+                profile_key=profile_key,
+            )
+
+        create_node().addCallback(cb).addErrback(err_cb)
+
+    ## methods to manage several stanzas/jids at once ##
+
+    # common
+
+    def _get_client_and_node_data(self, publishers_type, publishers, profile_key):
+        """Helper method to construct node_data from publishers_type/publishers
+
+        @param publishers_type: type of the list of publishers, one of:
+            C.ALL: get all jids from roster, publishers is not used
+            C.GROUP: get jids from groups
+            C.JID: use publishers directly as list of jids
+        @param publishers: list of publishers, according to "publishers_type" (None,
+            list of groups or list of jids)
+        @param profile_key: %(doc_profile_key)s
+        """
+        client = self.host.get_client(profile_key)
+        if publishers_type == C.JID:
+            jids_set = set(publishers)
+        else:
+            jids_set = client.roster.get_jids_set(publishers_type, publishers)
+            if publishers_type == C.ALL:
+                try:
+                    # display messages from salut-a-toi@libervia.org or other PEP services
+                    services = self.host.plugins["EXTRA-PEP"].get_followed_entities(
+                        profile_key
+                    )
+                except KeyError:
+                    pass  # plugin is not loaded
+                else:
+                    if services:
+                        log.debug(
+                            "Extra PEP followed entities: %s"
+                            % ", ".join([str(service) for service in services])
+                        )
+                        jids_set.update(services)
+
+        node_data = []
+        for jid_ in jids_set:
+            node_data.append((jid_, NS_MICROBLOG))
+        return client, node_data
+
+    def _check_publishers(self, publishers_type, publishers):
+        """Helper method to deserialise publishers coming from bridge
+
+        publishers_type(unicode): type of the list of publishers, one of:
+        publishers: list of publishers according to type
+        @return: deserialised (publishers_type, publishers) tuple
+        """
+        if publishers_type == C.ALL:
+            if publishers:
+                raise failure.Failure(
+                    ValueError(
+                        "Can't use publishers with {} type".format(publishers_type)
+                    )
+                )
+            else:
+                publishers = None
+        elif publishers_type == C.JID:
+            publishers[:] = [jid.JID(publisher) for publisher in publishers]
+        return publishers_type, publishers
+
+    # subscribe #
+
+    def _mb_subscribe_to_many(self, publishers_type, publishers, profile_key):
+        """
+
+        @return (str): session id: Use pubsub.getSubscribeRTResult to get the results
+        """
+        publishers_type, publishers = self._check_publishers(publishers_type, publishers)
+        return self.mb_subscribe_to_many(publishers_type, publishers, profile_key)
+
+    def mb_subscribe_to_many(self, publishers_type, publishers, profile_key):
+        """Subscribe microblogs for a list of groups or jids
+
+        @param publishers_type: type of the list of publishers, one of:
+            C.ALL: get all jids from roster, publishers is not used
+            C.GROUP: get jids from groups
+            C.JID: use publishers directly as list of jids
+        @param publishers: list of publishers, according to "publishers_type" (None, list
+            of groups or list of jids)
+        @param profile: %(doc_profile)s
+        @return (str): session id
+        """
+        client, node_data = self._get_client_and_node_data(
+            publishers_type, publishers, profile_key
+        )
+        return self._p.subscribe_to_many(
+            node_data, client.jid.userhostJID(), profile_key=profile_key
+        )
+
+    # get #
+
+    def _mb_get_from_many_rt_result(self, session_id, profile_key=C.PROF_KEY_DEFAULT):
+        """Get real-time results for mb_get_from_many session
+
+        @param session_id: id of the real-time deferred session
+        @param return (tuple): (remaining, results) where:
+            - remaining is the number of still expected results
+            - results is a list of tuple with
+                - service (unicode): pubsub service
+                - node (unicode): pubsub node
+                - failure (unicode): empty string in case of success, error message else
+                - items_data(list): data as returned by [mb_get]
+                - items_metadata(dict): metadata as returned by [mb_get]
+        @param profile_key: %(doc_profile_key)s
+        """
+
+        client = self.host.get_client(profile_key)
+
+        def onSuccess(items_data):
+            """convert items elements to list of microblog data in items_data"""
+            d = self._p.trans_items_data_d(
+                items_data,
+                # FIXME: service and node should be used here
+                partial(self.item_2_mb_data, client),
+                serialise=True
+            )
+            d.addCallback(lambda serialised: ("", serialised))
+            return d
+
+        d = self._p.get_rt_results(
+            session_id,
+            on_success=onSuccess,
+            on_error=lambda failure: (str(failure.value), ([], {})),
+            profile=client.profile,
+        )
+        d.addCallback(
+            lambda ret: (
+                ret[0],
+                [
+                    (service.full(), node, failure, items, metadata)
+                    for (service, node), (success, (failure, (items, metadata))) in ret[
+                        1
+                    ].items()
+                ],
+            )
+        )
+        return d
+
+    def _mb_get_from_many(self, publishers_type, publishers, max_items=10, extra_dict=None,
+                       profile_key=C.PROF_KEY_NONE):
+        """
+        @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
+        """
+        max_items = None if max_items == C.NO_LIMIT else max_items
+        publishers_type, publishers = self._check_publishers(publishers_type, publishers)
+        extra = self._p.parse_extra(extra_dict)
+        return self.mb_get_from_many(
+            publishers_type,
+            publishers,
+            max_items,
+            extra.rsm_request,
+            extra.extra,
+            profile_key,
+        )
+
+    def mb_get_from_many(self, publishers_type, publishers, max_items=None, rsm_request=None,
+                      extra=None, profile_key=C.PROF_KEY_NONE):
+        """Get the published microblogs for a list of groups or jids
+
+        @param publishers_type (str): type of the list of publishers (one of "GROUP" or
+            "JID" or "ALL")
+        @param publishers (list): list of publishers, according to publishers_type (list
+            of groups or list of jids)
+        @param max_items (int): optional limit on the number of retrieved items.
+        @param rsm_request (rsm.RSMRequest): RSM request data, common to all publishers
+        @param extra (dict): Extra data
+        @param profile_key: profile key
+        @return (str): RT Deferred session id
+        """
+        # XXX: extra is unused here so far
+        client, node_data = self._get_client_and_node_data(
+            publishers_type, publishers, profile_key
+        )
+        return self._p.get_from_many(
+            node_data, max_items, rsm_request, profile_key=profile_key
+        )
+
+    # comments #
+
+    def _mb_get_from_many_with_comments_rt_result_serialise(self, data):
+        """Serialisation of result
+
+        This is probably the longest method name of whole SàT ecosystem ^^
+        @param data(dict): data as received by rt_sessions
+        @return (tuple): see [_mb_get_from_many_with_comments_rt_result]
+        """
+        ret = []
+        data_iter = iter(data[1].items())
+        for (service, node), (success, (failure_, (items_data, metadata))) in data_iter:
+            items = []
+            for item, item_metadata in items_data:
+                item = data_format.serialise(item)
+                items.append((item, item_metadata))
+            ret.append((
+                service.full(),
+                node,
+                failure_,
+                items,
+                metadata))
+
+        return data[0], ret
+
+    def _mb_get_from_many_with_comments_rt_result(self, session_id,
+                                           profile_key=C.PROF_KEY_DEFAULT):
+        """Get real-time results for [mb_get_from_many_with_comments] session
+
+        @param session_id: id of the real-time deferred session
+        @param return (tuple): (remaining, results) where:
+            - remaining is the number of still expected results
+            - results is a list of 5-tuple with
+                - service (unicode): pubsub service
+                - node (unicode): pubsub node
+                - failure (unicode): empty string in case of success, error message else
+                - items(list[tuple(dict, list)]): list of 2-tuple with
+                    - item(dict): item microblog data
+                    - comments_list(list[tuple]): list of 5-tuple with
+                        - service (unicode): pubsub service where the comments node is
+                        - node (unicode): comments node
+                        - failure (unicode): empty in case of success, else error message
+                        - comments(list[dict]): list of microblog data
+                        - comments_metadata(dict): metadata of the comment node
+                - metadata(dict): original node metadata
+        @param profile_key: %(doc_profile_key)s
+        """
+        profile = self.host.get_client(profile_key).profile
+        d = self.rt_sessions.get_results(session_id, profile=profile)
+        d.addCallback(self._mb_get_from_many_with_comments_rt_result_serialise)
+        return d
+
+    def _mb_get_from_many_with_comments(self, publishers_type, publishers, max_items=10,
+                                   max_comments=C.NO_LIMIT, extra_dict=None,
+                                   extra_comments_dict=None, profile_key=C.PROF_KEY_NONE):
+        """
+        @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
+        @param max_comments(int): maximum number of comments to get, C.NO_LIMIT for no
+            limit
+        """
+        max_items = None if max_items == C.NO_LIMIT else max_items
+        max_comments = None if max_comments == C.NO_LIMIT else max_comments
+        publishers_type, publishers = self._check_publishers(publishers_type, publishers)
+        extra = self._p.parse_extra(extra_dict)
+        extra_comments = self._p.parse_extra(extra_comments_dict)
+        return self.mb_get_from_many_with_comments(
+            publishers_type,
+            publishers,
+            max_items,
+            max_comments or None,
+            extra.rsm_request,
+            extra.extra,
+            extra_comments.rsm_request,
+            extra_comments.extra,
+            profile_key,
+        )
+
+    def mb_get_from_many_with_comments(self, publishers_type, publishers, max_items=None,
+                                  max_comments=None, rsm_request=None, extra=None,
+                                  rsm_comments=None, extra_comments=None,
+                                  profile_key=C.PROF_KEY_NONE):
+        """Helper method to get the microblogs and their comments in one shot
+
+        @param publishers_type (str): type of the list of publishers (one of "GROUP" or
+            "JID" or "ALL")
+        @param publishers (list): list of publishers, according to publishers_type (list
+            of groups or list of jids)
+        @param max_items (int): optional limit on the number of retrieved items.
+        @param max_comments (int): maximum number of comments to retrieve
+        @param rsm_request (rsm.RSMRequest): RSM request for initial items only
+        @param extra (dict): extra configuration for initial items only
+        @param rsm_comments (rsm.RSMRequest): RSM request for comments only
+        @param extra_comments (dict): extra configuration for comments only
+        @param profile_key: profile key
+        @return (str): RT Deferred session id
+        """
+        # XXX: this method seems complicated because it do a couple of treatments
+        #      to serialise and associate the data, but it make life in frontends side
+        #      a lot easier
+
+        client, node_data = self._get_client_and_node_data(
+            publishers_type, publishers, profile_key
+        )
+
+        def get_comments(items_data):
+            """Retrieve comments and add them to the items_data
+
+            @param items_data: serialised items data
+            @return (defer.Deferred): list of items where each item is associated
+                with a list of comments data (service, node, list of items, metadata)
+            """
+            items, metadata = items_data
+            items_dlist = []  # deferred list for items
+            for item in items:
+                dlist = []  # deferred list for comments
+                for key, value in item.items():
+                    # we look for comments
+                    if key.startswith("comments") and key.endswith("_service"):
+                        prefix = key[: key.find("_")]
+                        service_s = value
+                        service = jid.JID(service_s)
+                        node = item["{}{}".format(prefix, "_node")]
+                        # time to get the comments
+                        d = defer.ensureDeferred(
+                            self._p.get_items(
+                                client,
+                                service,
+                                node,
+                                max_comments,
+                                rsm_request=rsm_comments,
+                                extra=extra_comments,
+                            )
+                        )
+                        # then serialise
+                        d.addCallback(
+                            lambda items_data: self._p.trans_items_data_d(
+                                items_data,
+                                partial(
+                                    self.item_2_mb_data, client, service=service, node=node
+                                ),
+                                serialise=True
+                            )
+                        )
+                        # with failure handling
+                        d.addCallback(
+                            lambda serialised_items_data: ("",) + serialised_items_data
+                        )
+                        d.addErrback(lambda failure: (str(failure.value), [], {}))
+                        # and associate with service/node (needed if there are several
+                        # comments nodes)
+                        d.addCallback(
+                            lambda serialised, service_s=service_s, node=node: (
+                                service_s,
+                                node,
+                            )
+                            + serialised
+                        )
+                        dlist.append(d)
+                # we get the comments
+                comments_d = defer.gatherResults(dlist)
+                # and add them to the item data
+                comments_d.addCallback(
+                    lambda comments_data, item=item: (item, comments_data)
+                )
+                items_dlist.append(comments_d)
+            # we gather the items + comments in a list
+            items_d = defer.gatherResults(items_dlist)
+            # and add the metadata
+            items_d.addCallback(lambda items_completed: (items_completed, metadata))
+            return items_d
+
+        deferreds = {}
+        for service, node in node_data:
+            d = deferreds[(service, node)] = defer.ensureDeferred(self._p.get_items(
+                client, service, node, max_items, rsm_request=rsm_request, extra=extra
+            ))
+            d.addCallback(
+                lambda items_data: self._p.trans_items_data_d(
+                    items_data,
+                    partial(self.item_2_mb_data, client, service=service, node=node),
+                )
+            )
+            d.addCallback(get_comments)
+            d.addCallback(lambda items_comments_data: ("", items_comments_data))
+            d.addErrback(lambda failure: (str(failure.value), ([], {})))
+
+        return self.rt_sessions.new_session(deferreds, client.profile)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0277_handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_MICROBLOG)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0280.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,174 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for managing xep-0280
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from twisted.words.protocols.jabber.error import StanzaError
+from twisted.internet import defer
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+
+
+PARAM_CATEGORY = "Misc"
+PARAM_NAME = "carbon"
+PARAM_LABEL = D_("Message carbons")
+NS_CARBONS = "urn:xmpp:carbons:2"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP-0280 Plugin",
+    C.PI_IMPORT_NAME: "XEP-0280",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0280"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "XEP_0280",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: D_("""Implementation of Message Carbons"""),
+}
+
+
+class XEP_0280(object):
+    #  TODO: param is only checked at profile connection
+    #       activate carbons on param change even after profile connection
+    # TODO: chat state notifications are not handled yet (and potentially other XEPs?)
+
+    params = """
+    <params>
+    <individual>
+    <category name="{category_name}" label="{category_label}">
+        <param name="{param_name}" label="{param_label}" value="true" type="bool" security="0" />
+     </category>
+    </individual>
+    </params>
+    """.format(
+        category_name=PARAM_CATEGORY,
+        category_label=D_(PARAM_CATEGORY),
+        param_name=PARAM_NAME,
+        param_label=PARAM_LABEL,
+    )
+
+    def __init__(self, host):
+        log.info(_("Plugin XEP_0280 initialization"))
+        self.host = host
+        host.memory.update_params(self.params)
+        host.trigger.add("message_received", self.message_received_trigger, priority=200000)
+
+    def get_handler(self, client):
+        return XEP_0280_handler()
+
+    def set_private(self, message_elt):
+        """Add a <private/> element to a message
+
+        this method is intented to be called on final domish.Element by other plugins
+        (in particular end 2 end encryption plugins)
+        @param message_elt(domish.Element): <message> stanza
+        """
+        if message_elt.name != "message":
+            log.error("addPrivateElt must be used with <message> stanzas")
+            return
+        message_elt.addElement((NS_CARBONS, "private"))
+
+    @defer.inlineCallbacks
+    def profile_connected(self, client):
+        """activate message carbons on connection if possible and activated in config"""
+        activate = self.host.memory.param_get_a(
+            PARAM_NAME, PARAM_CATEGORY, profile_key=client.profile
+        )
+        if not activate:
+            log.info(_("Not activating message carbons as requested in params"))
+            return
+        try:
+            yield self.host.check_features(client, (NS_CARBONS,))
+        except exceptions.FeatureNotFound:
+            log.warning(_("server doesn't handle message carbons"))
+        else:
+            log.info(_("message carbons available, enabling it"))
+            iq_elt = client.IQ()
+            iq_elt.addElement((NS_CARBONS, "enable"))
+            try:
+                yield iq_elt.send()
+            except StanzaError as e:
+                log.warning("Can't activate message carbons: {}".format(e))
+            else:
+                log.info(_("message carbons activated"))
+
+    def message_received_trigger(self, client, message_elt, post_treat):
+        """get message and handle it if carbons namespace is present"""
+        carbons_elt = None
+        for e in message_elt.elements():
+            if e.uri == NS_CARBONS:
+                carbons_elt = e
+                break
+
+        if carbons_elt is None:
+            # this is not a message carbons,
+            # we continue normal behaviour
+            return True
+
+        if message_elt["from"] != client.jid.userhost():
+            log.warning(
+                "The message carbon received is not from our server, hack attempt?\n{xml}".format(
+                    xml=message_elt.toXml()
+                )
+            )
+            return
+        forwarded_elt = next(carbons_elt.elements(C.NS_FORWARD, "forwarded"))
+        cc_message_elt = next(forwarded_elt.elements(C.NS_CLIENT, "message"))
+
+        # we replace the wrapping message with the CCed one
+        # and continue the normal behaviour
+        if carbons_elt.name == "received":
+            message_elt["from"] = cc_message_elt["from"]
+        elif carbons_elt.name == "sent":
+            try:
+                message_elt["to"] = cc_message_elt["to"]
+            except KeyError:
+                # we may not have "to" in case of message from ourself (from an other
+                # device)
+                pass
+        else:
+            log.warning(
+                "invalid message carbons received:\n{xml}".format(
+                    xml=message_elt.toXml()
+                )
+            )
+            return False
+
+        del message_elt.children[:]
+        for c in cc_message_elt.children:
+            message_elt.addChild(c)
+
+        return True
+
+@implementer(iwokkel.IDisco)
+class XEP_0280_handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_CARBONS)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0292.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for XEP-0292
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import List, Dict, Union, Any, Optional
+from functools import partial
+
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
+from zope.interface import implementer
+from wokkel import disco, iwokkel
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core import exceptions
+from libervia.backend.tools.common.async_utils import async_lru
+
+
+log = getLogger(__name__)
+
+IMPORT_NAME = "XEP-0292"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "vCard4 Over XMPP",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0292"],
+    C.PI_DEPENDENCIES: ["IDENTITY", "XEP-0060", "XEP-0163"],
+    C.PI_MAIN: "XEP_0292",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""XEP-0292 (vCard4 Over XMPP) implementation"""),
+}
+
+NS_VCARD4 = "urn:ietf:params:xml:ns:vcard-4.0"
+VCARD4_NODE = "urn:xmpp:vcard4"
+text_fields = {
+    "fn": "name",
+    "nickname": "nicknames",
+    "note": "description"
+}
+text_fields_inv = {v: k for k,v in text_fields.items()}
+
+
+class XEP_0292:
+    namespace = NS_VCARD4
+    node = VCARD4_NODE
+
+    def __init__(self, host):
+        # XXX: as of XEP-0292 v0.11, there is a dedicated <IQ/> protocol in this XEP which
+        # should be used according to the XEP. Hovewer it feels like an outdated behaviour
+        # and other clients don't seem to use it. After discussing it on xsf@ MUC, it
+        # seems that implemeting the dedicated <IQ/> protocol is a waste of time, and thus
+        # it is not done here. It is expected that this dedicated protocol will be removed
+        # from a future version of the XEP.
+        log.info(_("vCard4 Over XMPP initialization"))
+        host.register_namespace("vcard4", NS_VCARD4)
+        self.host = host
+        self._p = host.plugins["XEP-0060"]
+        self._i = host.plugins['IDENTITY']
+        self._i.register(
+            IMPORT_NAME,
+            'nicknames',
+            partial(self.getValue, field="nicknames"),
+            partial(self.set_value, field="nicknames"),
+            priority=1000
+        )
+        self._i.register(
+            IMPORT_NAME,
+            'description',
+            partial(self.getValue, field="description"),
+            partial(self.set_value, field="description"),
+            priority=1000
+        )
+
+    def get_handler(self, client):
+        return XEP_0292_Handler()
+
+    def vcard_2_dict(self, vcard_elt: domish.Element) -> Dict[str, Any]:
+        """Convert vcard element to equivalent identity metadata"""
+        vcard: Dict[str, Any] = {}
+
+        for metadata_elt in vcard_elt.elements():
+            # Text values
+            for source_field, dest_field in text_fields.items():
+                if metadata_elt.name == source_field:
+                    if metadata_elt.text is not None:
+                        dest_type = self._i.get_field_type(dest_field)
+                        value = str(metadata_elt.text)
+                        if dest_type is str:
+                            if dest_field in vcard:
+                                vcard[dest_field] +=  value
+                            else:
+                                vcard[dest_field] = value
+                        elif dest_type is list:
+                            vcard.setdefault(dest_field, []).append(value)
+                        else:
+                            raise exceptions.InternalError(
+                                f"unexpected dest_type: {dest_type!r}"
+                            )
+                    break
+            else:
+                log.debug(
+                    f"Following element is currently unmanaged: {metadata_elt.toXml()}"
+                )
+        return vcard
+
+    def dict_2_v_card(self, vcard: dict[str, Any]) -> domish.Element:
+        """Convert vcard metadata to vCard4 element"""
+        vcard_elt = domish.Element((NS_VCARD4, "vcard"))
+        for field, elt_name in text_fields_inv.items():
+            value = vcard.get(field)
+            if value:
+                if isinstance(value, str):
+                    value = [value]
+                if isinstance(value, list):
+                    for v in value:
+                        field_elt = vcard_elt.addElement(elt_name)
+                        field_elt.addElement("text", content=v)
+                else:
+                    log.warning(
+                        f"ignoring unexpected value: {value!r}"
+                    )
+
+        return vcard_elt
+
+    @async_lru(5)
+    async def get_card(self, client: SatXMPPEntity, entity: jid.JID) -> dict:
+        try:
+            items, metadata = await self._p.get_items(
+                client, entity, VCARD4_NODE, item_ids=["current"]
+            )
+        except exceptions.NotFound:
+            log.info(f"No vCard node found for {entity}")
+            return {}
+        item_elt = items[0]
+        try:
+            vcard_elt = next(item_elt.elements(NS_VCARD4, "vcard"))
+        except StopIteration:
+            log.info(f"vCard element is not present for {entity}")
+            return {}
+
+        return self.vcard_2_dict(vcard_elt)
+
+    async def update_vcard_elt(
+        self,
+        client: SatXMPPEntity,
+        vcard_elt: domish.Element,
+        entity: Optional[jid.JID] = None
+    ) -> None:
+        """Update VCard 4 of given entity, create node if doesn't already exist
+
+        @param vcard_elt: whole vCard element to update
+        @param entity: entity for which the vCard must be updated
+            None to update profile's own vCard
+        """
+        service = entity or client.jid.userhostJID()
+        node_options = {
+            self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
+            self._p.OPT_PUBLISH_MODEL: self._p.PUBLISH_MODEL_PUBLISHERS
+        }
+        await self._p.create_if_new_node(client, service, VCARD4_NODE, node_options)
+        await self._p.send_item(
+            client, service, VCARD4_NODE, vcard_elt, item_id=self._p.ID_SINGLETON
+        )
+
+    async def update_v_card(
+        self,
+        client: SatXMPPEntity,
+        vcard: Dict[str, Any],
+        entity: Optional[jid.JID] = None,
+        update: bool = True,
+    ) -> None:
+        """Update VCard 4 of given entity, create node if doesn't already exist
+
+        @param vcard: identity metadata
+        @param entity: entity for which the vCard must be updated
+            None to update profile's own vCard
+        @param update: if True, current vCard will be retrieved and updated with given
+        vcard (thus if False, `vcard` data will fully replace previous one).
+        """
+        service = entity or client.jid.userhostJID()
+        if update:
+            current_vcard = await self.get_card(client, service)
+            current_vcard.update(vcard)
+            vcard = current_vcard
+        vcard_elt = self.dict_2_v_card(vcard)
+        await self.update_vcard_elt(client, vcard_elt, service)
+
+    async def getValue(
+        self,
+        client: SatXMPPEntity,
+        entity: jid.JID,
+        field: str,
+    ) -> Optional[Union[str, List[str]]]:
+        """Return generic value
+
+        @param entity: entity from who the vCard comes
+        @param field: name of the field to get
+            This has to be a string field
+        @return request value
+        """
+        vcard_data = await self.get_card(client, entity)
+        return vcard_data.get(field)
+
+    async def set_value(
+        self,
+        client: SatXMPPEntity,
+        value: Union[str, List[str]],
+        entity: jid.JID,
+        field: str
+    ) -> None:
+        """Set generic value
+
+        @param entity: entity from who the vCard comes
+        @param field: name of the field to get
+            This has to be a string field
+        """
+        await self.update_v_card(client, {field: value}, entity)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0292_Handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_VCARD4)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0293.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,312 @@
+#!/usr/bin/env python3
+
+# Libervia plugin
+# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import List
+
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+NS_JINGLE_RTP_RTCP_FB = "urn:xmpp:jingle:apps:rtp:rtcp-fb:0"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Jingle RTP Feedback Negotiation",
+    C.PI_IMPORT_NAME: "XEP-0293",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0293"],
+    C.PI_DEPENDENCIES: ["XEP-0092", "XEP-0166", "XEP-0167"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "XEP_0293",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Jingle RTP Feedback Negotiation"""),
+}
+
+RTCP_FB_KEY = "rtcp-fb"
+
+
+class XEP_0293:
+    def __init__(self, host):
+        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
+        host.trigger.add("XEP-0167_parse_sdp_a", self._parse_sdp_a_trigger)
+        host.trigger.add(
+            "XEP-0167_generate_sdp_content", self._generate_sdp_content_trigger
+        )
+        host.trigger.add("XEP-0167_parse_description", self._parse_description_trigger)
+        host.trigger.add(
+            "XEP-0167_parse_description_payload_type",
+            self._parse_description_payload_type_trigger,
+        )
+        host.trigger.add("XEP-0167_build_description", self._build_description_trigger)
+        host.trigger.add(
+            "XEP-0167_build_description_payload_type",
+            self._build_description_payload_type_trigger,
+        )
+
+    def get_handler(self, client):
+        return XEP_0293_handler()
+
+    ## SDP
+
+    def _parse_sdp_a_trigger(
+        self,
+        attribute: str,
+        parts: List[str],
+        call_data: dict,
+        metadata: dict,
+        media_type: str,
+        application_data: dict,
+        transport_data: dict,
+    ) -> None:
+        """Parse "rtcp-fb" and "rtcp-fb-trr-int" attributes
+
+        @param attribute: The attribute being parsed.
+        @param parts: The list of parts in the attribute.
+        @param call_data: The call data dict.
+        @param metadata: The metadata dict.
+        @param media_type: The media type (e.g., audio, video).
+        @param application_data: The application data dict.
+        @param transport_data: The transport data dict.
+        @param payload_map: The payload map dict.
+        """
+        if attribute == "rtcp-fb":
+            pt_id = parts[0]
+            feedback_type = parts[1]
+
+            feedback_subtype = None
+            parameters = {}
+
+            # Check if there are extra parameters
+            if len(parts) > 2:
+                feedback_subtype = parts[2]
+
+            if len(parts) > 3:
+                for parameter in parts[3:]:
+                    name, _, value = parameter.partition("=")
+                    parameters[name] = value or None
+
+            # Check if this feedback is linked to a payload type
+            if pt_id == "*":
+                # Not linked to a payload type, add to application data
+                application_data.setdefault(RTCP_FB_KEY, []).append(
+                    (feedback_type, feedback_subtype, parameters)
+                )
+            else:
+                payload_types = application_data.get("payload_types", {})
+                try:
+                    payload_type = payload_types[int(pt_id)]
+                except KeyError:
+                    log.warning(
+                        f"Got reference to unknown payload type {pt_id}: "
+                        f"{' '.join(parts)}"
+                    )
+                else:
+                    # Linked to a payload type, add to payload data
+                    payload_type.setdefault(RTCP_FB_KEY, []).append(
+                        (feedback_type, feedback_subtype, parameters)
+                    )
+
+        elif attribute == "rtcp-fb-trr-int":
+            pt_id = parts[0]  # Payload type ID
+            interval = int(parts[1])
+
+            # Check if this interval is linked to a payload type
+            if pt_id == "*":
+                # Not linked to a payload type, add to application data
+                application_data["rtcp-fb-trr-int"] = interval
+            else:
+                payload_types = application_data.get("payload_types", {})
+                try:
+                    payload_type = payload_types[int(pt_id)]
+                except KeyError:
+                    log.warning(
+                        f"Got reference to unknown payload type {pt_id}: "
+                        f"{' '.join(parts)}"
+                    )
+                else:
+                    # Linked to a payload type, add to payload data
+                    payload_type["rtcp-fb-trr-int"] = interval
+
+    def _generate_rtcp_fb_lines(
+        self, data: dict, pt_id: str, sdp_lines: List[str]
+    ) -> None:
+        for type_, subtype, parameters in data.get(RTCP_FB_KEY, []):
+            parameters_strs = [
+                f"{k}={v}" if v is not None else k for k, v in parameters.items()
+            ]
+            parameters_str = " ".join(parameters_strs)
+
+            sdp_line = f"a=rtcp-fb:{pt_id} {type_}"
+            if subtype:
+                sdp_line += f" {subtype}"
+            if parameters_str:
+                sdp_line += f" {parameters_str}"
+            sdp_lines.append(sdp_line)
+
+    def _generate_rtcp_fb_trr_int_lines(
+        self, data: dict, pt_id: str, sdp_lines: List[str]
+    ) -> None:
+        if "rtcp-fb-trr-int" not in data:
+            return
+        sdp_lines.append(f"a=rtcp-fb:{pt_id} trr-int {data['rtcp-fb-trr-int']}")
+
+    def _generate_sdp_content_trigger(
+        self,
+        session: dict,
+        local: bool,
+        content_name: str,
+        content_data: dict,
+        sdp_lines: List[str],
+        application_data: dict,
+        app_data_key: str,
+        media_data: dict,
+        media: str,
+    ) -> None:
+        """Generate SDP attributes "rtcp-fb" and "rtcp-fb-trr-int" from application data.
+
+        @param session: The session data.
+        @param local: Whether this is local or remote content.
+        @param content_name: The name of the content.
+        @param content_data: The data of the content.
+        @param sdp_lines: The list of SDP lines to append to.
+        @param application_data: The application data dict.
+        @param app_data_key: The key for the application data.
+        @param media_data: The media data dict.
+        @param media: The media type (e.g., audio, video).
+        """
+        # Generate lines for application data
+        self._generate_rtcp_fb_lines(application_data, "*", sdp_lines)
+        self._generate_rtcp_fb_trr_int_lines(application_data, "*", sdp_lines)
+
+        # Generate lines for each payload type
+        for pt_id, payload_data in media_data.get("payload_types", {}).items():
+            self._generate_rtcp_fb_lines(payload_data, pt_id, sdp_lines)
+            self._generate_rtcp_fb_trr_int_lines(payload_data, pt_id, sdp_lines)
+
+    ## XML
+
+    def _parse_rtcp_fb_elements(self, parent_elt: domish.Element, data: dict) -> None:
+        """Parse the <rtcp-fb> and <rtcp-fb-trr-int> elements.
+
+        @param parent_elt: The parent domish.Element.
+        @param data: The data dict to populate.
+        """
+        for rtcp_fb_elt in parent_elt.elements(NS_JINGLE_RTP_RTCP_FB, "rtcp-fb"):
+            try:
+                type_ = rtcp_fb_elt["type"]
+                subtype = rtcp_fb_elt.getAttribute("subtype")
+
+                parameters = {}
+                for parameter_elt in rtcp_fb_elt.elements(
+                    NS_JINGLE_RTP_RTCP_FB, "parameter"
+                ):
+                    parameters[parameter_elt["name"]] = parameter_elt.getAttribute(
+                        "value"
+                    )
+
+                data.setdefault(RTCP_FB_KEY, []).append((type_, subtype, parameters))
+            except (KeyError, ValueError) as e:
+                log.warning(f"Error while parsing <rtcp-fb>: {e}\n{rtcp_fb_elt.toXml()}")
+
+        for rtcp_fb_trr_int_elt in parent_elt.elements(
+            NS_JINGLE_RTP_RTCP_FB, "rtcp-fb-trr-int"
+        ):
+            try:
+                interval_value = int(rtcp_fb_trr_int_elt["value"])
+                data.setdefault("rtcp_fb_trr_int", []).append(interval_value)
+            except (KeyError, ValueError) as e:
+                log.warning(
+                    f"Error while parsing <rtcp-fb-trr-int>: {e}\n"
+                    f"{rtcp_fb_trr_int_elt.toXml()}"
+                )
+
+    def _parse_description_trigger(
+        self, desc_elt: domish.Element, media_data: dict
+    ) -> None:
+        """Parse the <rtcp-fb> and <rtcp-fb-trr-int> elements from a description.
+
+        @param desc_elt: The <description> domish.Element.
+        @param media_data: The media data dict to populate.
+        """
+        self._parse_rtcp_fb_elements(desc_elt, media_data)
+
+    def _parse_description_payload_type_trigger(
+        self,
+        desc_elt: domish.Element,
+        media_data: dict,
+        payload_type_elt: domish.Element,
+        payload_type_data: dict,
+    ) -> None:
+        """Parse the <rtcp-fb> and <rtcp-fb-trr-int> elements from a payload type.
+
+        @param desc_elt: The <description> domish.Element.
+        @param media_data: The media data dict.
+        @param payload_type_elt: The <payload-type> domish.Element.
+        @param payload_type_data: The payload type data dict to populate.
+        """
+        self._parse_rtcp_fb_elements(payload_type_elt, payload_type_data)
+
+    def build_rtcp_fb_elements(self, parent_elt: domish.Element, data: dict) -> None:
+        """Helper method to build the <rtcp-fb> and <rtcp-fb-trr-int> elements"""
+        for type_, subtype, parameters in data.get(RTCP_FB_KEY, []):
+            rtcp_fb_elt = parent_elt.addElement((NS_JINGLE_RTP_RTCP_FB, "rtcp-fb"))
+            rtcp_fb_elt["type"] = type_
+            if subtype:
+                rtcp_fb_elt["subtype"] = subtype
+            for name, value in parameters.items():
+                param_elt = rtcp_fb_elt.addElement(name)
+                if value is not None:
+                    param_elt.addContent(str(value))
+
+        if "rtcp-fb-trr-int" in data:
+            rtcp_fb_trr_int_elt = parent_elt.addElement(
+                (NS_JINGLE_RTP_RTCP_FB, "rtcp-fb-trr-int")
+            )
+            rtcp_fb_trr_int_elt["value"] = str(data["rtcp-fb-trr-int"])
+
+    def _build_description_payload_type_trigger(
+        self,
+        desc_elt: domish.Element,
+        media_data: dict,
+        payload_type: dict,
+        payload_type_elt: domish.Element,
+    ) -> None:
+        """Build the <rtcp-fb> and <rtcp-fb-trr-int> elements for a payload type"""
+        self.build_rtcp_fb_elements(payload_type_elt, payload_type)
+
+    def _build_description_trigger(
+        self, desc_elt: domish.Element, media_data: dict, session: dict
+    ) -> None:
+        """Build the <rtcp-fb> and <rtcp-fb-trr-int> elements for a media description"""
+        self.build_rtcp_fb_elements(desc_elt, media_data)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0293_handler(XMPPHandler):
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_JINGLE_RTP_RTCP_FB)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0294.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,253 @@
+#!/usr/bin/env python3
+
+# Libervia plugin
+# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Dict, List, Optional, Union
+
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+NS_JINGLE_RTP_HDREXT = "urn:xmpp:jingle:apps:rtp:rtp-hdrext:0"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Jingle RTP Header Extensions Negotiation",
+    C.PI_IMPORT_NAME: "XEP-0294",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0294"],
+    C.PI_DEPENDENCIES: ["XEP-0167"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "XEP_0294",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Jingle RTP Header Extensions Negotiation"""),
+}
+
+
+class XEP_0294:
+    def __init__(self, host):
+        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
+        host.trigger.add("XEP-0167_parse_sdp_a", self._parse_sdp_a_trigger)
+        host.trigger.add(
+            "XEP-0167_generate_sdp_content", self._generate_sdp_content_trigger
+        )
+        host.trigger.add("XEP-0167_parse_description", self._parse_description_trigger)
+        host.trigger.add("XEP-0167_build_description", self._build_description_trigger)
+
+    def get_handler(self, client):
+        return XEP_0294_handler()
+
+    def _parse_extmap(self, parts: List[str], application_data: dict) -> None:
+        """Parse an individual extmap line and fill application_data accordingly"""
+        if "/" in parts[0]:
+            id_, direction = parts[0].split("/", 1)
+        else:
+            id_ = parts[0]
+            direction = None
+        uri = parts[1]
+        attributes = parts[2:]
+
+        if direction in (None, "sendrecv"):
+            senders = "both"
+        elif direction == "sendonly":
+            senders = "initiator"
+        elif direction == "recvonly":
+            senders = "responder"
+        elif direction == "inactive":
+            senders = "none"
+        else:
+            log.warning(f"invalid direction for extmap: {direction}")
+            senders = "sendrecv"
+
+        rtp_hdr_ext_data: Dict[str, Union[str, dict]] = {
+            "id": id_,
+            "uri": uri,
+            "senders": senders,
+        }
+
+        if attributes:
+            parameters = {}
+            for attribute in attributes:
+                name, *value = attribute.split("=", 1)
+                parameters[name] = value[0] if value else None
+            rtp_hdr_ext_data["parameters"] = parameters
+
+        application_data.setdefault("rtp-hdrext", {})[id_] = rtp_hdr_ext_data
+
+    def _parse_sdp_a_trigger(
+        self,
+        attribute: str,
+        parts: List[str],
+        call_data: dict,
+        metadata: dict,
+        media_type: str,
+        application_data: Optional[dict],
+        transport_data: dict,
+    ) -> None:
+        """Parse "extmap" and "extmap-allow-mixed" attributes"""
+        if attribute == "extmap":
+            if application_data is None:
+                call_data.setdefault("_extmaps", []).append(parts)
+            else:
+                self._parse_extmap(parts, application_data)
+        elif attribute == "extmap-allow-mixed":
+            if application_data is None:
+                call_data["_extmap-allow-mixed"] = True
+            else:
+                application_data["extmap-allow-mixed"] = True
+        elif (
+            application_data is not None
+            and "_extmaps" in call_data
+            and "rtp-hdrext" not in application_data
+        ):
+            extmaps = call_data.pop("_extmaps")
+            for parts in extmaps:
+                self._parse_extmap(parts, application_data)
+        elif (
+            application_data is not None
+            and "_extmap-allow-mixed" in call_data
+            and "extmap-allow-mixed" not in application_data
+        ):
+            value = call_data.pop("_extmap-allow-mixed")
+            application_data["extmap-allow-mixed"] = value
+
+    def _generate_sdp_content_trigger(
+        self,
+        session: dict,
+        local: bool,
+        idx: int,
+        content_data: dict,
+        sdp_lines: List[str],
+        application_data: dict,
+        app_data_key: str,
+        media_data: dict,
+        media: str,
+    ) -> None:
+        """Generate "extmap" and "extmap-allow-mixed" attributes"""
+        rtp_hdrext_dict = media_data.get("rtp-hdrext", {})
+
+        for id_, ext_data in rtp_hdrext_dict.items():
+            senders = ext_data.get("senders")
+            if senders in (None, "both"):
+                direction = "sendrecv"
+            elif senders == "initiator":
+                direction = "sendonly"
+            elif senders == "responder":
+                direction = "recvonly"
+            elif senders == "none":
+                direction = "inactive"
+            else:
+                raise exceptions.InternalError(
+                    f"Invalid senders value for extmap: {ext_data.get('senders')}"
+                )
+
+            parameters_str = ""
+            if "parameters" in ext_data:
+                parameters_str = " " + " ".join(
+                    f"{k}={v}" if v is not None else f"{k}"
+                    for k, v in ext_data["parameters"].items()
+                )
+
+            sdp_lines.append(
+                f"a=extmap:{id_}/{direction} {ext_data['uri']}{parameters_str}"
+            )
+
+        if media_data.get("extmap-allow-mixed", False):
+            sdp_lines.append("a=extmap-allow-mixed")
+
+    def _parse_description_trigger(
+        self, desc_elt: domish.Element, media_data: dict
+    ) -> None:
+        """Parse the <rtp-hdrext> and <extmap-allow-mixed> elements"""
+        for rtp_hdrext_elt in desc_elt.elements(NS_JINGLE_RTP_HDREXT, "rtp-hdrext"):
+            id_ = rtp_hdrext_elt["id"]
+            uri = rtp_hdrext_elt["uri"]
+            senders = rtp_hdrext_elt.getAttribute("senders", "both")
+            # FIXME: workaround for Movim bug https://github.com/movim/movim/issues/1212
+            if senders in ("sendonly", "recvonly", "sendrecv", "inactive"):
+                log.warning("Movim bug workaround for wrong extmap value")
+                if senders == "sendonly":
+                    senders = "initiator"
+                elif senders == "recvonly":
+                    senders = "responder"
+                elif senders == "sendrecv":
+                    senders = "both"
+                else:
+                    senders = "none"
+
+            media_data.setdefault("rtp-hdrext", {})[id_] = {
+                "id": id_,
+                "uri": uri,
+                "senders": senders,
+            }
+
+            parameters = {}
+            for param_elt in rtp_hdrext_elt.elements(NS_JINGLE_RTP_HDREXT, "parameter"):
+                try:
+                    parameters[param_elt["name"]] = param_elt.getAttribute("value")
+                except KeyError:
+                    log.warning(f"invalid parameters (missing name): {param_elt.toXml()}")
+
+            if parameters:
+                media_data["rtp-hdrext"][id_]["parameters"] = parameters
+
+        try:
+            next(desc_elt.elements(NS_JINGLE_RTP_HDREXT, "extmap-allow-mixed"))
+        except StopIteration:
+            pass
+        else:
+            media_data["extmap-allow-mixed"] = True
+
+    def _build_description_trigger(
+        self, desc_elt: domish.Element, media_data: dict, session: dict
+    ) -> None:
+        """Build the <rtp-hdrext> and <extmap-allow-mixed> elements if possible"""
+        for id_, hdrext_data in media_data.get("rtp-hdrext", {}).items():
+            rtp_hdrext_elt = desc_elt.addElement((NS_JINGLE_RTP_HDREXT, "rtp-hdrext"))
+            rtp_hdrext_elt["id"] = id_
+            rtp_hdrext_elt["uri"] = hdrext_data["uri"]
+            senders = hdrext_data.get("senders", "both")
+            if senders != "both":
+                # we must not set "both" senders otherwise calls will fail with Movim due
+                # to https://github.com/movim/movim/issues/1213
+                rtp_hdrext_elt["senders"] = senders
+
+            for name, value in hdrext_data.get("parameters", {}).items():
+                param_elt = rtp_hdrext_elt.addElement((NS_JINGLE_RTP_HDREXT, "parameter"))
+                param_elt["name"] = name
+                if value is not None:
+                    param_elt["value"] = value
+
+        if media_data.get("extmap-allow-mixed", False):
+            desc_elt.addElement((NS_JINGLE_RTP_HDREXT, "extmap-allow-mixed"))
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0294_handler(XMPPHandler):
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_JINGLE_RTP_HDREXT)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0297.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Stanza Forwarding (XEP-0297)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger
+
+from twisted.internet import defer
+
+log = getLogger(__name__)
+
+from wokkel import disco, iwokkel
+
+try:
+    from twisted.words.protocols.xmlstream import XMPPHandler
+except ImportError:
+    from wokkel.subprotocols import XMPPHandler
+from zope.interface import implementer
+
+from twisted.words.xish import domish
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Stanza Forwarding",
+    C.PI_IMPORT_NAME: "XEP-0297",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0297"],
+    C.PI_MAIN: "XEP_0297",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: D_("""Implementation of Stanza Forwarding"""),
+}
+
+
+class XEP_0297(object):
+    # FIXME: check this implementation which doesn't seems to be used
+
+    def __init__(self, host):
+        log.info(_("Stanza Forwarding plugin initialization"))
+        self.host = host
+
+    def get_handler(self, client):
+        return XEP_0297_handler(self, client.profile)
+
+    @classmethod
+    def update_uri(cls, element, uri):
+        """Update recursively the element URI.
+
+        @param element (domish.Element): element to update
+        @param uri (unicode): new URI
+        """
+        # XXX: we need this because changing the URI of an existing element
+        # containing children doesn't update the children's blank URI.
+        element.uri = uri
+        element.defaultUri = uri
+        for child in element.children:
+            if isinstance(child, domish.Element) and not child.uri:
+                XEP_0297.update_uri(child, uri)
+
+    def forward(self, stanza, to_jid, stamp, body="", profile_key=C.PROF_KEY_NONE):
+        """Forward a message to the given JID.
+
+        @param stanza (domish.Element): original stanza to be forwarded.
+        @param to_jid (JID): recipient JID.
+        @param stamp (datetime): offset-aware timestamp of the original reception.
+        @param body (unicode): optional description.
+        @param profile_key (unicode): %(doc_profile_key)s
+        @return: a Deferred when the message has been sent
+        """
+        # FIXME: this method is not used and doesn't use mess_data which should be used for client.send_message_data
+        #        should it be deprecated? A method constructing the element without sending it seems more natural
+        log.warning(
+            "THIS METHOD IS DEPRECATED"
+        )  #  FIXME: we use this warning until we check the method
+        msg = domish.Element((None, "message"))
+        msg["to"] = to_jid.full()
+        msg["type"] = stanza["type"]
+
+        body_elt = domish.Element((None, "body"))
+        if body:
+            body_elt.addContent(body)
+
+        forwarded_elt = domish.Element((C.NS_FORWARD, "forwarded"))
+        delay_elt = self.host.plugins["XEP-0203"].delay(stamp)
+        forwarded_elt.addChild(delay_elt)
+        if not stanza.uri:  # None or ''
+            XEP_0297.update_uri(stanza, "jabber:client")
+        forwarded_elt.addChild(stanza)
+
+        msg.addChild(body_elt)
+        msg.addChild(forwarded_elt)
+
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(client.send_message_data({"xml": msg}))
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0297_handler(XMPPHandler):
+
+    def __init__(self, plugin_parent, profile):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+        self.profile = profile
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(C.NS_FORWARD)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0300.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Hash functions (XEP-0300)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Tuple
+import base64
+from collections import OrderedDict
+import hashlib
+
+from twisted.internet import threads
+from twisted.internet import defer
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Cryptographic Hash Functions",
+    C.PI_IMPORT_NAME: "XEP-0300",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0300"],
+    C.PI_MAIN: "XEP_0300",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Management of cryptographic hashes"""),
+}
+
+NS_HASHES = "urn:xmpp:hashes:2"
+NS_HASHES_FUNCTIONS = "urn:xmpp:hash-function-text-names:{}"
+BUFFER_SIZE = 2 ** 12
+ALGO_DEFAULT = "sha-256"
+
+
+class XEP_0300(object):
+    # TODO: add blake after moving to Python 3
+    ALGOS = OrderedDict(
+        (
+            ("md5", hashlib.md5),
+            ("sha-1", hashlib.sha1),
+            ("sha-256", hashlib.sha256),
+            ("sha-512", hashlib.sha512),
+        )
+    )
+    ALGO_DEFAULT = ALGO_DEFAULT
+
+    def __init__(self, host):
+        log.info(_("plugin Hashes initialization"))
+        host.register_namespace("hashes", NS_HASHES)
+
+    def get_handler(self, client):
+        return XEP_0300_handler()
+
+    def get_hasher(self, algo=ALGO_DEFAULT):
+        """Return hasher instance
+
+        @param algo(unicode): one of the XEP_300.ALGOS keys
+        @return (hash object): same object s in hashlib.
+           update method need to be called for each chunh
+           diget or hexdigest can be used at the end
+        """
+        return self.ALGOS[algo]()
+
+    def get_default_algo(self):
+        return ALGO_DEFAULT
+
+    @defer.inlineCallbacks
+    def get_best_peer_algo(self, to_jid, profile):
+        """Return the best available hashing algorith of other peer
+
+         @param to_jid(jid.JID): peer jid
+         @parm profile: %(doc_profile)s
+         @return (D(unicode, None)): best available algorithm,
+            or None if hashing is not possible
+        """
+        client = self.host.get_client(profile)
+        for algo in reversed(XEP_0300.ALGOS):
+            has_feature = yield self.host.hasFeature(
+                client, NS_HASHES_FUNCTIONS.format(algo), to_jid
+            )
+            if has_feature:
+                log.debug(
+                    "Best hashing algorithm found for {jid}: {algo}".format(
+                        jid=to_jid.full(), algo=algo
+                    )
+                )
+                defer.returnValue(algo)
+
+    def _calculate_hash_blocking(self, file_obj, hasher):
+        """Calculate hash in a blocking way
+
+        /!\\ blocking method, please use calculate_hash instead
+        @param file_obj(file): a file-like object
+        @param hasher(hash object): the method to call to initialise hash object
+        @return (str): the hex digest of the hash
+        """
+        while True:
+            buf = file_obj.read(BUFFER_SIZE)
+            if not buf:
+                break
+            hasher.update(buf)
+        return hasher.hexdigest()
+
+    def calculate_hash(self, file_obj, hasher):
+        return threads.deferToThread(self._calculate_hash_blocking, file_obj, hasher)
+
+    def calculate_hash_elt(self, file_obj=None, algo=ALGO_DEFAULT):
+        """Compute hash and build hash element
+
+        @param file_obj(file, None): file-like object to use to calculate the hash
+        @param algo(unicode): algorithme to use, must be a key of XEP_0300.ALGOS
+        @return (D(domish.Element)): hash element
+        """
+
+        def hash_calculated(hash_):
+            return self.build_hash_elt(hash_, algo)
+
+        hasher = self.get_hasher(algo)
+        hash_d = self.calculate_hash(file_obj, hasher)
+        hash_d.addCallback(hash_calculated)
+        return hash_d
+
+    def build_hash_used_elt(self, algo=ALGO_DEFAULT):
+        hash_used_elt = domish.Element((NS_HASHES, "hash-used"))
+        hash_used_elt["algo"] = algo
+        return hash_used_elt
+
+    def parse_hash_used_elt(self, parent):
+        """Find and parse a hash-used element
+
+        @param (domish.Element): parent of <hash/> element
+        @return (unicode): hash algorithm used
+        @raise exceptions.NotFound: the element is not present
+        @raise exceptions.DataError: the element is invalid
+        """
+        try:
+            hash_used_elt = next(parent.elements(NS_HASHES, "hash-used"))
+        except StopIteration:
+            raise exceptions.NotFound
+        algo = hash_used_elt["algo"]
+        if not algo:
+            raise exceptions.DataError
+        return algo
+
+    def build_hash_elt(self, hash_, algo=ALGO_DEFAULT):
+        """Compute hash and build hash element
+
+        @param hash_(str): hash to use
+        @param algo(unicode): algorithme to use, must be a key of XEP_0300.ALGOS
+        @return (domish.Element): computed hash
+        """
+        assert hash_
+        assert algo
+        hash_elt = domish.Element((NS_HASHES, "hash"))
+        if hash_ is not None:
+            b64_hash = base64.b64encode(hash_.encode('utf-8')).decode('utf-8')
+            hash_elt.addContent(b64_hash)
+        hash_elt["algo"] = algo
+        return hash_elt
+
+    def parse_hash_elt(self, parent: domish.Element) -> Tuple[str, bytes]:
+        """Find and parse a hash element
+
+        if multiple elements are found, the strongest managed one is returned
+        @param parent: parent of <hash/> element
+        @return: (algo, hash) tuple
+            both values can be None if <hash/> is empty
+        @raise exceptions.NotFound: the element is not present
+        @raise exceptions.DataError: the element is invalid
+        """
+        algos = list(XEP_0300.ALGOS.keys())
+        hash_elt = None
+        best_algo = None
+        best_value = None
+        for hash_elt in parent.elements(NS_HASHES, "hash"):
+            algo = hash_elt.getAttribute("algo")
+            try:
+                idx = algos.index(algo)
+            except ValueError:
+                log.warning(f"Proposed {algo} algorithm is not managed")
+                algo = None
+                continue
+
+            if best_algo is None or algos.index(best_algo) < idx:
+                best_algo = algo
+                best_value = base64.b64decode(str(hash_elt)).decode('utf-8')
+
+        if not hash_elt:
+            raise exceptions.NotFound
+        if not best_algo or not best_value:
+            raise exceptions.DataError
+        return best_algo, best_value
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0300_handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        hash_functions_names = [
+            disco.DiscoFeature(NS_HASHES_FUNCTIONS.format(algo))
+            for algo in XEP_0300.ALGOS
+        ]
+        return [disco.DiscoFeature(NS_HASHES)] + hash_functions_names
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0313.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,459 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Message Archive Management (XEP-0313)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.tools.common import data_format
+from twisted.words.protocols.jabber import jid
+from twisted.internet import defer
+from zope.interface import implementer
+from datetime import datetime
+from dateutil import tz
+from wokkel import disco
+from wokkel import data_form
+import uuid
+
+# XXX: mam and rsm come from sat_tmp.wokkel
+from wokkel import rsm
+from wokkel import mam
+
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Message Archive Management",
+    C.PI_IMPORT_NAME: "XEP-0313",
+    C.PI_TYPE: "XEP",
+    # XEP-0431 only defines a namespace, so we register it here
+    C.PI_PROTOCOLS: ["XEP-0313", "XEP-0431"],
+    C.PI_DEPENDENCIES: ["XEP-0059", "XEP-0359"],
+    C.PI_MAIN: "XEP_0313",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Message Archive Management"""),
+}
+
+MAM_PREFIX = "mam_"
+FILTER_PREFIX = MAM_PREFIX + "filter_"
+KEY_LAST_STANZA_ID = "last_stanza_id"
+MESSAGE_RESULT = "/message/result[@xmlns='{mam_ns}' and @queryid='{query_id}']"
+MESSAGE_STANZA_ID = '/message/stanza-id[@xmlns="{ns_stanza_id}"]'
+NS_FTS = "urn:xmpp:fulltext:0"
+
+
+class XEP_0313(object):
+    def __init__(self, host):
+        log.info(_("Message Archive Management plugin initialization"))
+        self.host = host
+        self.host.register_namespace("mam", mam.NS_MAM)
+        host.register_namespace("fulltextmam", NS_FTS)
+        self._rsm = host.plugins["XEP-0059"]
+        self._sid = host.plugins["XEP-0359"]
+        # Deferred used to store last stanza id in order of reception
+        self._last_stanza_id_d = defer.Deferred()
+        self._last_stanza_id_d.callback(None)
+        host.bridge.add_method(
+            "mam_get", ".plugin", in_sign='sss',
+            out_sign='(a(sdssa{ss}a{ss}ss)ss)', method=self._get_archives,
+            async_=True)
+
+    async def resume(self, client):
+        """Retrieve one2one messages received since the last we have in local storage"""
+        stanza_id_data = await self.host.memory.storage.get_privates(
+            mam.NS_MAM, [KEY_LAST_STANZA_ID], profile=client.profile)
+        stanza_id = stanza_id_data.get(KEY_LAST_STANZA_ID)
+        rsm_req = None
+        if stanza_id is None:
+            log.info("can't retrieve last stanza ID, checking history")
+            last_mess = await self.host.memory.history_get(
+                None, None, limit=1, filters={'not_types': C.MESS_TYPE_GROUPCHAT,
+                                              'last_stanza_id': True},
+                profile=client.profile)
+            if not last_mess:
+                log.info(_("It seems that we have no MAM history yet"))
+                stanza_id = None
+                rsm_req = rsm.RSMRequest(max_=50, before="")
+            else:
+                stanza_id = last_mess[0][-1]['stanza_id']
+        if rsm_req is None:
+            rsm_req = rsm.RSMRequest(max_=100, after=stanza_id)
+        mam_req = mam.MAMRequest(rsm_=rsm_req)
+        complete = False
+        count = 0
+        while not complete:
+            mam_data = await self.get_archives(client, mam_req,
+                                              service=client.jid.userhostJID())
+            elt_list, rsm_response, mam_response = mam_data
+            complete = mam_response["complete"]
+            # we update MAM request for next iteration
+            mam_req.rsm.after = rsm_response.last
+            # before may be set if we had no previous history
+            mam_req.rsm.before = None
+            if not elt_list:
+                break
+            else:
+                count += len(elt_list)
+
+            for mess_elt in elt_list:
+                try:
+                    fwd_message_elt = self.get_message_from_result(
+                        client, mess_elt, mam_req)
+                except exceptions.DataError:
+                    continue
+
+                try:
+                    destinee = jid.JID(fwd_message_elt['to'])
+                except KeyError:
+                    log.warning(_('missing "to" attribute in forwarded message'))
+                    destinee = client.jid
+                if destinee.userhostJID() == client.jid.userhostJID():
+                    # message to use, we insert the forwarded message in the normal
+                    # workflow
+                    client.xmlstream.dispatch(fwd_message_elt)
+                else:
+                    # this message should be from us, we just add it to history
+                    try:
+                        from_jid = jid.JID(fwd_message_elt['from'])
+                    except KeyError:
+                        log.warning(_('missing "from" attribute in forwarded message'))
+                        from_jid = client.jid
+                    if from_jid.userhostJID() != client.jid.userhostJID():
+                        log.warning(_(
+                            'was expecting a message sent by our jid, but this one if '
+                            'from {from_jid}, ignoring\n{xml}').format(
+                                from_jid=from_jid.full(), xml=mess_elt.toXml()))
+                        continue
+                    # adding message to history
+                    mess_data = client.messageProt.parse_message(fwd_message_elt)
+                    try:
+                        await client.messageProt.add_to_history(mess_data)
+                    except exceptions.CancelError as e:
+                        log.warning(
+                            "message has not been added to history: {e}".format(e=e))
+                    except Exception as e:
+                        log.error(
+                            "can't add message to history: {e}\n{xml}"
+                            .format(e=e, xml=mess_elt.toXml()))
+
+        if not count:
+            log.info(_("We have received no message while offline"))
+        else:
+            log.info(_("We have received {num_mess} message(s) while offline.")
+                .format(num_mess=count))
+
+    def profile_connected(self, client):
+        defer.ensureDeferred(self.resume(client))
+
+    def get_handler(self, client):
+        mam_client = client._mam = SatMAMClient(self)
+        return mam_client
+
+    def parse_extra(self, extra, with_rsm=True):
+        """Parse extra dictionnary to retrieve MAM arguments
+
+        @param extra(dict): data for parse
+        @param with_rsm(bool): if True, RSM data will be parsed too
+        @return (data_form, None): request with parsed arguments
+            or None if no MAM arguments have been found
+        """
+        mam_args = {}
+        form_args = {}
+        for arg in ("start", "end"):
+            try:
+                value = extra.pop(MAM_PREFIX + arg)
+                form_args[arg] = datetime.fromtimestamp(float(value), tz.tzutc())
+            except (TypeError, ValueError):
+                log.warning("Bad value for {arg} filter ({value}), ignoring".format(
+                    arg=arg, value=value))
+            except KeyError:
+                continue
+
+        try:
+            form_args["with_jid"] = jid.JID(extra.pop(
+                MAM_PREFIX + "with"))
+        except (jid.InvalidFormat):
+            log.warning("Bad value for jid filter")
+        except KeyError:
+            pass
+
+        for name, value in extra.items():
+            if name.startswith(FILTER_PREFIX):
+                var = name[len(FILTER_PREFIX):]
+                extra_fields = form_args.setdefault("extra_fields", [])
+                extra_fields.append(data_form.Field(var=var, value=value))
+
+        for arg in ("node", "query_id"):
+            try:
+                value = extra.pop(MAM_PREFIX + arg)
+                mam_args[arg] = value
+            except KeyError:
+                continue
+
+        if with_rsm:
+            rsm_request = self._rsm.parse_extra(extra)
+            if rsm_request is not None:
+                mam_args["rsm_"] = rsm_request
+
+        if form_args:
+            mam_args["form"] = mam.buildForm(**form_args)
+
+        # we only set orderBy if we have other MAM args
+        # else we would make a MAM query while it's not expected
+        if "order_by" in extra and mam_args:
+            order_by = extra.pop("order_by")
+            assert isinstance(order_by, list)
+            mam_args["orderBy"] = order_by
+
+        return mam.MAMRequest(**mam_args) if mam_args else None
+
+    def get_message_from_result(self, client, mess_elt, mam_req, service=None):
+        """Extract usable <message/> from MAM query result
+
+        The message will be validated, and stanza-id/delay will be added if necessary.
+        @param mess_elt(domish.Element): result <message/> element wrapping the message
+            to retrieve
+        @param mam_req(mam.MAMRequest): request used (needed to get query_id)
+        @param service(jid.JID, None): MAM service where the request has been sent
+            None if it's user server
+        @return domish.Element): <message/> that can be used directly with onMessage
+        """
+        if mess_elt.name != "message":
+            log.warning("unexpected stanza in archive: {xml}".format(
+                xml=mess_elt.toXml()))
+            raise exceptions.DataError("Invalid element")
+        service_jid = client.jid.userhostJID() if service is None else service
+        mess_from = mess_elt["from"]
+        # we check that the message has been sent by the right service
+        # if service is None (i.e. message expected from our own server)
+        # from can be server jid or user's bare jid
+        if (mess_from != service_jid.full()
+            and not (service is None and mess_from == client.jid.host)):
+            log.error("Message is not from our server, something went wrong: "
+                      "{xml}".format(xml=mess_elt.toXml()))
+            raise exceptions.DataError("Invalid element")
+        try:
+            result_elt = next(mess_elt.elements(mam.NS_MAM, "result"))
+            forwarded_elt = next(result_elt.elements(C.NS_FORWARD, "forwarded"))
+            try:
+                delay_elt = next(forwarded_elt.elements(C.NS_DELAY, "delay"))
+            except StopIteration:
+                # delay_elt is not mandatory
+                delay_elt = None
+            fwd_message_elt = next(forwarded_elt.elements(C.NS_CLIENT, "message"))
+        except StopIteration:
+            log.warning("Invalid message received from MAM: {xml}".format(
+                xml=mess_elt.toXml()))
+            raise exceptions.DataError("Invalid element")
+        else:
+            if not result_elt["queryid"] == mam_req.query_id:
+                log.error("Unexpected query id (was expecting {query_id}): {xml}"
+                    .format(query_id=mam.query_id, xml=mess_elt.toXml()))
+                raise exceptions.DataError("Invalid element")
+            stanza_id = self._sid.get_stanza_id(fwd_message_elt,
+                                              service_jid)
+            if stanza_id is None:
+                # not stanza-id element is present, we add one so message
+                # will be archived with it, and we won't request several times
+                # the same MAM achive
+                try:
+                    stanza_id = result_elt["id"]
+                except AttributeError:
+                    log.warning('Invalid MAM result: missing "id" attribute: {xml}'
+                                .format(xml=result_elt.toXml()))
+                    raise exceptions.DataError("Invalid element")
+                self._sid.add_stanza_id(client, fwd_message_elt, stanza_id, by=service_jid)
+
+            if delay_elt is not None:
+                fwd_message_elt.addChild(delay_elt)
+
+            return fwd_message_elt
+
+    def queryFields(self, client, service=None):
+        """Ask the server about supported fields.
+
+        @param service: entity offering the MAM service (None for user archives)
+        @return (D(data_form.Form)): form with the implemented fields (cf XEP-0313 §4.1.5)
+        """
+        return client._mam.queryFields(service)
+
+    def queryArchive(self, client, mam_req, service=None):
+        """Query a user, MUC or pubsub archive.
+
+        @param mam_req(mam.MAMRequest): MAM query instance
+        @param service(jid.JID, None): entity offering the MAM service
+            None for user server
+        @return (D(domish.Element)): <IQ/> result
+        """
+        return client._mam.queryArchive(mam_req, service)
+
+    def _append_message(self, elt_list, message_cb, message_elt):
+        if message_cb is not None:
+            elt_list.append(message_cb(message_elt))
+        else:
+            elt_list.append(message_elt)
+
+    def _query_finished(self, iq_result, client, elt_list, event):
+        client.xmlstream.removeObserver(event, self._append_message)
+        try:
+            fin_elt = next(iq_result.elements(mam.NS_MAM, "fin"))
+        except StopIteration:
+            raise exceptions.DataError("Invalid MAM result")
+
+        mam_response = {"complete": C.bool(fin_elt.getAttribute("complete", C.BOOL_FALSE)),
+                        "stable": C.bool(fin_elt.getAttribute("stable", C.BOOL_TRUE))}
+
+        try:
+            rsm_response = rsm.RSMResponse.fromElement(fin_elt)
+        except rsm.RSMNotFoundError:
+            rsm_response = None
+
+        return (elt_list, rsm_response, mam_response)
+
+    def serialize_archive_result(self, data, client, mam_req, service):
+        elt_list, rsm_response, mam_response = data
+        mess_list = []
+        for elt in elt_list:
+            fwd_message_elt = self.get_message_from_result(client, elt, mam_req,
+                                                        service=service)
+            mess_data = client.messageProt.parse_message(fwd_message_elt)
+            mess_list.append(client.message_get_bridge_args(mess_data))
+        metadata = {
+            'rsm': self._rsm.response2dict(rsm_response),
+            'mam': mam_response
+        }
+        return mess_list, data_format.serialise(metadata), client.profile
+
+    def _get_archives(self, service, extra_ser, profile_key):
+        """
+        @return: tuple with:
+            - list of message with same data as in bridge.message_new
+            - response metadata with:
+                - rsm data (first, last, count, index)
+                - mam data (complete, stable)
+            - profile
+        """
+        client = self.host.get_client(profile_key)
+        service = jid.JID(service) if service else None
+        extra = data_format.deserialise(extra_ser, {})
+        mam_req = self.parse_extra(extra)
+
+        d = self.get_archives(client, mam_req, service=service)
+        d.addCallback(self.serialize_archive_result, client, mam_req, service)
+        return d
+
+    def get_archives(self, client, query, service=None, message_cb=None):
+        """Query archive and gather page result
+
+        @param query(mam.MAMRequest): MAM request
+        @param service(jid.JID, None): MAM service to use
+            None to use our own server
+        @param message_cb(callable, None): callback to use on each message
+            this method can be used to unwrap messages
+        @return (tuple[list[domish.Element], rsm.RSMResponse, dict): result data with:
+            - list of found elements
+            - RSM response
+            - MAM response, which is a dict with following value:
+                - complete: a boolean which is True if all items have been received
+                - stable: a boolean which is False if items order may be changed
+        """
+        if query.query_id is None:
+            query.query_id = str(uuid.uuid4())
+        elt_list = []
+        event = MESSAGE_RESULT.format(mam_ns=mam.NS_MAM, query_id=query.query_id)
+        client.xmlstream.addObserver(event, self._append_message, 0, elt_list, message_cb)
+        d = self.queryArchive(client, query, service)
+        d.addCallback(self._query_finished, client, elt_list, event)
+        return d
+
+    def get_prefs(self, client, service=None):
+        """Retrieve the current user preferences.
+
+        @param service: entity offering the MAM service (None for user archives)
+        @return: the server response as a Deferred domish.Element
+        """
+        # http://xmpp.org/extensions/xep-0313.html#prefs
+        return client._mam.queryPrefs(service)
+
+    def _set_prefs(self, service_s=None, default="roster", always=None, never=None,
+                  profile_key=C.PROF_KEY_NONE):
+        service = jid.JID(service_s) if service_s else None
+        always_jid = [jid.JID(entity) for entity in always]
+        never_jid = [jid.JID(entity) for entity in never]
+        # TODO: why not build here a MAMPrefs object instead of passing the args separately?
+        return self.setPrefs(service, default, always_jid, never_jid, profile_key)
+
+    def setPrefs(self, client, service=None, default="roster", always=None, never=None):
+        """Set news user preferences.
+
+        @param service: entity offering the MAM service (None for user archives)
+        @param default (unicode): a value in ('always', 'never', 'roster')
+        @param always (list): a list of JID instances
+        @param never (list): a list of JID instances
+        @param profile_key (unicode): %(doc_profile_key)s
+        @return: the server response as a Deferred domish.Element
+        """
+        # http://xmpp.org/extensions/xep-0313.html#prefs
+        return client._mam.setPrefs(service, default, always, never)
+
+    def on_message_stanza_id(self, message_elt, client):
+        """Called when a message with a stanza-id is received
+
+        the messages' stanza ids are stored when received, so the last one can be used
+        to retrieve missing history on next connection
+        @param message_elt(domish.Element): <message> with a stanza-id
+        """
+        service_jid = client.jid.userhostJID()
+        stanza_id = self._sid.get_stanza_id(message_elt, service_jid)
+        if stanza_id is None:
+            log.debug("Ignoring <message>, stanza id is not from our server")
+        else:
+            # we use self._last_stanza_id_d do be sure that last_stanza_id is stored in
+            # the order of reception
+            self._last_stanza_id_d.addCallback(
+                lambda __: self.host.memory.storage.set_private_value(
+                    namespace=mam.NS_MAM,
+                    key=KEY_LAST_STANZA_ID,
+                    value=stanza_id,
+                    profile=client.profile))
+
+
+@implementer(disco.IDisco)
+class SatMAMClient(mam.MAMClient):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+
+    @property
+    def host(self):
+        return self.parent.host_app
+
+    def connectionInitialized(self):
+        observer_xpath = MESSAGE_STANZA_ID.format(
+            ns_stanza_id=self.host.ns_map['stanza_id'])
+        self.xmlstream.addObserver(
+            observer_xpath, self.plugin_parent.on_message_stanza_id, client=self.parent
+        )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(mam.NS_MAM)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0320.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,105 @@
+#!/usr/bin/env python3
+
+# Libervia plugin
+# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+
+log = getLogger(__name__)
+
+NS_JINGLE_DTLS = "urn:xmpp:jingle:apps:dtls:0"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Use of DTLS-SRTP in Jingle Sessions",
+    C.PI_IMPORT_NAME: "XEP-0320",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0320"],
+    C.PI_DEPENDENCIES: ["XEP-0176"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "XEP_0320",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Use of DTLS-SRTP with RTP (for e2ee of A/V calls)"""),
+}
+
+
+class XEP_0320:
+    def __init__(self, host):
+        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
+        host.trigger.add("XEP-0176_parse_transport", self._parse_transport_trigger)
+        host.trigger.add("XEP-0176_build_transport", self._build_transport_trigger)
+
+    def get_handler(self, client):
+        return XEP_0320_handler()
+
+    def _parse_transport_trigger(
+        self, transport_elt: domish.Element, ice_data: dict
+    ) -> bool:
+        """Parse the <fingerprint> element"""
+        fingerprint_elt = next(
+            transport_elt.elements(NS_JINGLE_DTLS, "fingerprint"), None
+        )
+        if fingerprint_elt is not None:
+            try:
+                ice_data["fingerprint"] = {
+                    "hash": fingerprint_elt["hash"],
+                    "setup": fingerprint_elt["setup"],
+                    "fingerprint": str(fingerprint_elt),
+                }
+            except KeyError as e:
+                log.warning(
+                    f"invalid <fingerprint> (attribue {e} is missing): "
+                    f"{fingerprint_elt.toXml()})"
+                )
+
+        return True
+
+    def _build_transport_trigger(
+        self, tranport_elt: domish.Element, ice_data: dict
+    ) -> bool:
+        """Build the <fingerprint> element if possible"""
+        try:
+            fingerprint_data = ice_data["fingerprint"]
+            hash_ = fingerprint_data["hash"]
+            setup = fingerprint_data["setup"]
+            fingerprint = fingerprint_data["fingerprint"]
+        except KeyError:
+            pass
+        else:
+            fingerprint_elt = tranport_elt.addElement(
+                (NS_JINGLE_DTLS, "fingerprint"), content=fingerprint
+            )
+            fingerprint_elt["hash"] = hash_
+            fingerprint_elt["setup"] = setup
+
+        return True
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0320_handler(XMPPHandler):
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_JINGLE_DTLS)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0329.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1275 @@
+#!/usr/bin/env python3
+
+# SAT plugin for File Information Sharing (XEP-0329)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import mimetypes
+import json
+import os
+import traceback
+from pathlib import Path
+from typing import Optional, Dict
+from zope.interface import implementer
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import error as jabber_error
+from twisted.internet import defer
+from wokkel import disco, iwokkel, data_form
+from libervia.backend.core.i18n import _
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import stream
+from libervia.backend.tools import utils
+from libervia.backend.tools.common import regex
+
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "File Information Sharing",
+    C.PI_IMPORT_NAME: "XEP-0329",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0329"],
+    C.PI_DEPENDENCIES: ["XEP-0231", "XEP-0234", "XEP-0300", "XEP-0106"],
+    C.PI_MAIN: "XEP_0329",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of File Information Sharing"""),
+}
+
+NS_FIS = "urn:xmpp:fis:0"
+NS_FIS_AFFILIATION = "org.salut-a-toi.fis-affiliation"
+NS_FIS_CONFIGURATION = "org.salut-a-toi.fis-configuration"
+NS_FIS_CREATE = "org.salut-a-toi.fis-create"
+
+IQ_FIS_REQUEST = f'{C.IQ_GET}/query[@xmlns="{NS_FIS}"]'
+# not in the standard, but needed, and it's handy to keep it here
+IQ_FIS_AFFILIATION_GET = f'{C.IQ_GET}/affiliations[@xmlns="{NS_FIS_AFFILIATION}"]'
+IQ_FIS_AFFILIATION_SET = f'{C.IQ_SET}/affiliations[@xmlns="{NS_FIS_AFFILIATION}"]'
+IQ_FIS_CONFIGURATION_GET = f'{C.IQ_GET}/configuration[@xmlns="{NS_FIS_CONFIGURATION}"]'
+IQ_FIS_CONFIGURATION_SET = f'{C.IQ_SET}/configuration[@xmlns="{NS_FIS_CONFIGURATION}"]'
+IQ_FIS_CREATE_DIR = f'{C.IQ_SET}/dir[@xmlns="{NS_FIS_CREATE}"]'
+SINGLE_FILES_DIR = "files"
+TYPE_VIRTUAL = "virtual"
+TYPE_PATH = "path"
+SHARE_TYPES = (TYPE_PATH, TYPE_VIRTUAL)
+KEY_TYPE = "type"
+
+
+class RootPathException(Exception):
+    """Root path is requested"""
+
+
+class ShareNode(object):
+    """Node containing directory or files to share, virtual or real"""
+
+    host = None
+
+    def __init__(self, name, parent, type_, access, path=None):
+        assert type_ in SHARE_TYPES
+        if name is not None:
+            if name == ".." or "/" in name or "\\" in name:
+                log.warning(
+                    _("path change chars found in name [{name}], hack attempt?").format(
+                        name=name
+                    )
+                )
+                if name == "..":
+                    name = "--"
+                else:
+                    name = regex.path_escape(name)
+        self.name = name
+        self.children = {}
+        self.type = type_
+        self.access = {} if access is None else access
+        assert isinstance(self.access, dict)
+        self.parent = None
+        if parent is not None:
+            assert name
+            parent.addChild(self)
+        else:
+            assert name is None
+        if path is not None:
+            if type_ != TYPE_PATH:
+                raise exceptions.InternalError(_("path can only be set on path nodes"))
+            self._path = path
+
+    @property
+    def path(self):
+        return self._path
+
+    def __getitem__(self, key):
+        return self.children[key]
+
+    def __contains__(self, item):
+        return self.children.__contains__(item)
+
+    def __iter__(self):
+        return self.children.__iter__()
+
+    def items(self):
+        return self.children.items()
+
+    def values(self):
+        return self.children.values()
+
+    def get_or_create(self, name, type_=TYPE_VIRTUAL, access=None):
+        """Get a node or create a virtual node and return it"""
+        if access is None:
+            access = {C.ACCESS_PERM_READ: {KEY_TYPE: C.ACCESS_TYPE_PUBLIC}}
+        try:
+            return self.children[name]
+        except KeyError:
+            node = ShareNode(name, self, type_=type_, access=access)
+            return node
+
+    def addChild(self, node):
+        if node.parent is not None:
+            raise exceptions.ConflictError(_("a node can't have several parents"))
+        node.parent = self
+        self.children[node.name] = node
+
+    def remove_from_parent(self):
+        try:
+            del self.parent.children[self.name]
+        except TypeError:
+            raise exceptions.InternalError(
+                "trying to remove a node from inexisting parent"
+            )
+        except KeyError:
+            raise exceptions.InternalError("node not found in parent's children")
+        self.parent = None
+
+    def _check_node_permission(self, client, node, perms, peer_jid):
+        """Check access to this node for peer_jid
+
+        @param node(SharedNode): node to check access
+        @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_*
+        @param peer_jid(jid.JID): entity which try to access the node
+        @return (bool): True if entity can access
+        """
+        file_data = {"access": self.access, "owner": client.jid.userhostJID()}
+        try:
+            self.host.memory.check_file_permission(file_data, peer_jid, perms)
+        except exceptions.PermissionError:
+            return False
+        else:
+            return True
+
+    def check_permissions(
+        self, client, peer_jid, perms=(C.ACCESS_PERM_READ,), check_parents=True
+    ):
+        """Check that peer_jid can access this node and all its parents
+
+        @param peer_jid(jid.JID): entrity trying to access the node
+        @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_*
+        @param check_parents(bool): if True, access of all parents of this node will be
+            checked too
+        @return (bool): True if entity can access this node
+        """
+        peer_jid = peer_jid.userhostJID()
+        if peer_jid == client.jid.userhostJID():
+            return True
+
+        parent = self
+        while parent != None:
+            if not self._check_node_permission(client, parent, perms, peer_jid):
+                return False
+            parent = parent.parent
+
+        return True
+
+    @staticmethod
+    def find(client, path, peer_jid, perms=(C.ACCESS_PERM_READ,)):
+        """find node corresponding to a path
+
+        @param path(unicode): path to the requested file or directory
+        @param peer_jid(jid.JID): entity trying to find the node
+            used to check permission
+        @return (dict, unicode): shared data, remaining path
+        @raise exceptions.PermissionError: user can't access this file
+        @raise exceptions.DataError: path is invalid
+        @raise NotFound: path lead to a non existing file/directory
+        """
+        path_elts = [_f for _f in path.split("/") if _f]
+
+        if ".." in path_elts:
+            log.warning(_(
+                'parent dir ("..") found in path, hack attempt? path is {path} '
+                '[{profile}]').format(path=path, profile=client.profile))
+            raise exceptions.PermissionError("illegal path elements")
+
+        node = client._XEP_0329_root_node
+
+        while path_elts:
+            if node.type == TYPE_VIRTUAL:
+                try:
+                    node = node[path_elts.pop(0)]
+                except KeyError:
+                    raise exceptions.NotFound
+            elif node.type == TYPE_PATH:
+                break
+
+        if not node.check_permissions(client, peer_jid, perms=perms):
+            raise exceptions.PermissionError("permission denied")
+
+        return node, "/".join(path_elts)
+
+    def find_by_local_path(self, path):
+        """retrieve nodes linking to local path
+
+        @return (list[ShareNode]): found nodes associated to path
+        @raise exceptions.NotFound: no node has been found with this path
+        """
+        shared_paths = self.get_shared_paths()
+        try:
+            return shared_paths[path]
+        except KeyError:
+            raise exceptions.NotFound
+
+    def _get_shared_paths(self, node, paths):
+        if node.type == TYPE_VIRTUAL:
+            for node in node.values():
+                self._get_shared_paths(node, paths)
+        elif node.type == TYPE_PATH:
+            paths.setdefault(node.path, []).append(node)
+        else:
+            raise exceptions.InternalError(
+                "unknown node type: {type}".format(type=node.type)
+            )
+
+    def get_shared_paths(self):
+        """retrieve nodes by shared path
+
+        this method will retrieve recursively shared path in children of this node
+        @return (dict): map from shared path to list of nodes
+        """
+        if self.type == TYPE_PATH:
+            raise exceptions.InternalError(
+                "get_shared_paths must be used on a virtual node"
+            )
+        paths = {}
+        self._get_shared_paths(self, paths)
+        return paths
+
+
+class XEP_0329(object):
+    def __init__(self, host):
+        log.info(_("File Information Sharing initialization"))
+        self.host = host
+        ShareNode.host = host
+        self._b = host.plugins["XEP-0231"]
+        self._h = host.plugins["XEP-0300"]
+        self._jf = host.plugins["XEP-0234"]
+        host.bridge.add_method(
+            "fis_list",
+            ".plugin",
+            in_sign="ssa{ss}s",
+            out_sign="aa{ss}",
+            method=self._list_files,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "fis_local_shares_get",
+            ".plugin",
+            in_sign="s",
+            out_sign="as",
+            method=self._local_shares_get,
+        )
+        host.bridge.add_method(
+            "fis_share_path",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="s",
+            method=self._share_path,
+        )
+        host.bridge.add_method(
+            "fis_unshare_path",
+            ".plugin",
+            in_sign="ss",
+            out_sign="",
+            method=self._unshare_path,
+        )
+        host.bridge.add_method(
+            "fis_affiliations_get",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="a{ss}",
+            method=self._affiliations_get,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "fis_affiliations_set",
+            ".plugin",
+            in_sign="sssa{ss}s",
+            out_sign="",
+            method=self._affiliations_set,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "fis_configuration_get",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="a{ss}",
+            method=self._configuration_get,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "fis_configuration_set",
+            ".plugin",
+            in_sign="sssa{ss}s",
+            out_sign="",
+            method=self._configuration_set,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "fis_create_dir",
+            ".plugin",
+            in_sign="sssa{ss}s",
+            out_sign="",
+            method=self._create_dir,
+            async_=True,
+        )
+        host.bridge.add_signal("fis_shared_path_new", ".plugin", signature="sss")
+        host.bridge.add_signal("fis_shared_path_removed", ".plugin", signature="ss")
+        host.trigger.add("XEP-0234_fileSendingRequest", self._file_sending_request_trigger)
+        host.register_namespace("fis", NS_FIS)
+
+    def get_handler(self, client):
+        return XEP_0329_handler(self)
+
+    def profile_connected(self, client):
+        if client.is_component:
+            client._file_sharing_allowed_hosts = self.host.memory.config_get(
+                'component file_sharing',
+                'http_upload_allowed_hosts_list') or [client.host]
+        else:
+            client._XEP_0329_root_node = ShareNode(
+                None,
+                None,
+                TYPE_VIRTUAL,
+                {C.ACCESS_PERM_READ: {KEY_TYPE: C.ACCESS_TYPE_PUBLIC}},
+            )
+            client._XEP_0329_names_data = {}  #  name to share map
+
+    def _file_sending_request_trigger(
+        self, client, session, content_data, content_name, file_data, file_elt
+    ):
+        """This trigger check that a requested file is available, and fill suitable data
+
+        Path and name are used to retrieve the file. If path is missing, we try our luck
+        with known names
+        """
+        if client.is_component:
+            return True, None
+
+        try:
+            name = file_data["name"]
+        except KeyError:
+            return True, None
+        assert "/" not in name
+
+        path = file_data.get("path")
+        if path is not None:
+            # we have a path, we can follow it to find node
+            try:
+                node, rem_path = ShareNode.find(client, path, session["peer_jid"])
+            except (exceptions.PermissionError, exceptions.NotFound):
+                #  no file, or file not allowed, we continue normal workflow
+                return True, None
+            except exceptions.DataError:
+                log.warning(_("invalid path: {path}").format(path=path))
+                return True, None
+
+            if node.type == TYPE_VIRTUAL:
+                # we have a virtual node, so name must link to a path node
+                try:
+                    path = node[name].path
+                except KeyError:
+                    return True, None
+            elif node.type == TYPE_PATH:
+                # we have a path node, so we can retrieve the full path now
+                path = os.path.join(node.path, rem_path, name)
+            else:
+                raise exceptions.InternalError(
+                    "unknown type: {type}".format(type=node.type)
+                )
+            if not os.path.exists(path):
+                return True, None
+            size = os.path.getsize(path)
+        else:
+            # we don't have the path, we try to find the file by its name
+            try:
+                name_data = client._XEP_0329_names_data[name]
+            except KeyError:
+                return True, None
+
+            for path, shared_file in name_data.items():
+                if True:  #  FIXME: filters are here
+                    break
+            else:
+                return True, None
+            parent_node = shared_file["parent"]
+            if not parent_node.check_permissions(client, session["peer_jid"]):
+                log.warning(
+                    _(
+                        "{peer_jid} requested a file (s)he can't access [{profile}]"
+                    ).format(peer_jid=session["peer_jid"], profile=client.profile)
+                )
+                return True, None
+            size = shared_file["size"]
+
+        file_data["size"] = size
+        file_elt.addElement("size", content=str(size))
+        hash_algo = file_data["hash_algo"] = self._h.get_default_algo()
+        hasher = file_data["hash_hasher"] = self._h.get_hasher(hash_algo)
+        file_elt.addChild(self._h.build_hash_used_elt(hash_algo))
+        content_data["stream_object"] = stream.FileStreamObject(
+            self.host,
+            client,
+            path,
+            uid=self._jf.get_progress_id(session, content_name),
+            size=size,
+            data_cb=lambda data: hasher.update(data),
+        )
+        return False, defer.succeed(True)
+
+    # common methods
+
+    def _request_handler(self, client, iq_elt, root_nodes_cb, files_from_node_cb):
+        iq_elt.handled = True
+        node = iq_elt.query.getAttribute("node")
+        if not node:
+            d = utils.as_deferred(root_nodes_cb, client, iq_elt)
+        else:
+            d = utils.as_deferred(files_from_node_cb, client, iq_elt, node)
+        d.addErrback(
+            lambda failure_: log.error(
+                _("error while retrieving files: {msg}").format(msg=failure_)
+            )
+        )
+
+    def _iq_error(self, client, iq_elt, condition="item-not-found"):
+        error_elt = jabber_error.StanzaError(condition).toResponse(iq_elt)
+        client.send(error_elt)
+
+    #  client
+
+    def _add_path_data(self, client, query_elt, path, parent_node):
+        """Fill query_elt with files/directories found in path"""
+        name = os.path.basename(path)
+        if os.path.isfile(path):
+            size = os.path.getsize(path)
+            mime_type = mimetypes.guess_type(path, strict=False)[0]
+            file_elt = self._jf.build_file_element(
+                client=client, name=name, size=size, mime_type=mime_type,
+                modified=os.path.getmtime(path)
+            )
+
+            query_elt.addChild(file_elt)
+            # we don't specify hash as it would be too resource intensive to calculate
+            # it for all files.
+            # we add file to name_data, so users can request it later
+            name_data = client._XEP_0329_names_data.setdefault(name, {})
+            if path not in name_data:
+                name_data[path] = {
+                    "size": size,
+                    "mime_type": mime_type,
+                    "parent": parent_node,
+                }
+        else:
+            # we have a directory
+            directory_elt = query_elt.addElement("directory")
+            directory_elt["name"] = name
+
+    def _path_node_handler(self, client, iq_elt, query_elt, node, path):
+        """Fill query_elt for path nodes, i.e. physical directories"""
+        path = os.path.join(node.path, path)
+
+        if not os.path.exists(path):
+            # path may have been moved since it has been shared
+            return self._iq_error(client, iq_elt)
+        elif os.path.isfile(path):
+            self._add_path_data(client, query_elt, path, node)
+        else:
+            for name in sorted(os.listdir(path.encode("utf-8")), key=lambda n: n.lower()):
+                try:
+                    name = name.decode("utf-8", "strict")
+                except UnicodeDecodeError as e:
+                    log.warning(
+                        _("ignoring invalid unicode name ({name}): {msg}").format(
+                            name=name.decode("utf-8", "replace"), msg=e
+                        )
+                    )
+                    continue
+                full_path = os.path.join(path, name)
+                self._add_path_data(client, query_elt, full_path, node)
+
+    def _virtual_node_handler(self, client, peer_jid, iq_elt, query_elt, node):
+        """Fill query_elt for virtual nodes"""
+        for name, child_node in node.items():
+            if not child_node.check_permissions(client, peer_jid, check_parents=False):
+                continue
+            node_type = child_node.type
+            if node_type == TYPE_VIRTUAL:
+                directory_elt = query_elt.addElement("directory")
+                directory_elt["name"] = name
+            elif node_type == TYPE_PATH:
+                self._add_path_data(client, query_elt, child_node.path, child_node)
+            else:
+                raise exceptions.InternalError(
+                    _("unexpected type: {type}").format(type=node_type)
+                )
+
+    def _get_root_nodes_cb(self, client, iq_elt):
+        peer_jid = jid.JID(iq_elt["from"])
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        query_elt = iq_result_elt.addElement((NS_FIS, "query"))
+        for name, node in client._XEP_0329_root_node.items():
+            if not node.check_permissions(client, peer_jid, check_parents=False):
+                continue
+            directory_elt = query_elt.addElement("directory")
+            directory_elt["name"] = name
+        client.send(iq_result_elt)
+
+    def _get_files_from_node_cb(self, client, iq_elt, node_path):
+        """Main method to retrieve files/directories from a node_path"""
+        peer_jid = jid.JID(iq_elt["from"])
+        try:
+            node, path = ShareNode.find(client, node_path, peer_jid)
+        except (exceptions.PermissionError, exceptions.NotFound):
+            return self._iq_error(client, iq_elt)
+        except exceptions.DataError:
+            return self._iq_error(client, iq_elt, condition="not-acceptable")
+
+        node_type = node.type
+        peer_jid = jid.JID(iq_elt["from"])
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        query_elt = iq_result_elt.addElement((NS_FIS, "query"))
+        query_elt["node"] = node_path
+
+        # we now fill query_elt according to node_type
+        if node_type == TYPE_PATH:
+            #  it's a physical path
+            self._path_node_handler(client, iq_elt, query_elt, node, path)
+        elif node_type == TYPE_VIRTUAL:
+            assert not path
+            self._virtual_node_handler(client, peer_jid, iq_elt, query_elt, node)
+        else:
+            raise exceptions.InternalError(
+                _("unknown node type: {type}").format(type=node_type)
+            )
+
+        client.send(iq_result_elt)
+
+    def on_request(self, iq_elt, client):
+        return self._request_handler(
+            client, iq_elt, self._get_root_nodes_cb, self._get_files_from_node_cb
+        )
+
+    # Component
+
+    def _comp_parse_jids(self, client, iq_elt):
+        """Retrieve peer_jid and owner to use from IQ stanza
+
+        @param iq_elt(domish.Element): IQ stanza of the FIS request
+        @return (tuple[jid.JID, jid.JID]): peer_jid and owner
+        """
+
+    async def _comp_get_root_nodes_cb(self, client, iq_elt):
+        peer_jid, owner = client.get_owner_and_peer(iq_elt)
+        files_data = await self.host.memory.get_files(
+            client,
+            peer_jid=peer_jid,
+            parent="",
+            type_=C.FILE_TYPE_DIRECTORY,
+            owner=owner,
+        )
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        query_elt = iq_result_elt.addElement((NS_FIS, "query"))
+        for file_data in files_data:
+            name = file_data["name"]
+            directory_elt = query_elt.addElement("directory")
+            directory_elt["name"] = name
+        client.send(iq_result_elt)
+
+    async def _comp_get_files_from_node_cb(self, client, iq_elt, node_path):
+        """Retrieve files from local files repository according to permissions
+
+        result stanza is then built and sent to requestor
+        @trigger XEP-0329_compGetFilesFromNode(client, iq_elt, owner, node_path,
+                                               files_data):
+            can be used to add data/elements
+        """
+        peer_jid, owner = client.get_owner_and_peer(iq_elt)
+        try:
+            files_data = await self.host.memory.get_files(
+                client, peer_jid=peer_jid, path=node_path, owner=owner
+            )
+        except exceptions.NotFound:
+            self._iq_error(client, iq_elt)
+            return
+        except exceptions.PermissionError:
+            self._iq_error(client, iq_elt, condition='not-allowed')
+            return
+        except Exception as e:
+            tb = traceback.format_tb(e.__traceback__)
+            log.error(f"internal server error: {e}\n{''.join(tb)}")
+            self._iq_error(client, iq_elt, condition='internal-server-error')
+            return
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        query_elt = iq_result_elt.addElement((NS_FIS, "query"))
+        query_elt["node"] = node_path
+        if not self.host.trigger.point(
+            "XEP-0329_compGetFilesFromNode",
+            client,
+            iq_elt,
+            iq_result_elt,
+            owner,
+            node_path,
+            files_data
+        ):
+            return
+        for file_data in files_data:
+            if file_data['type'] == C.FILE_TYPE_DIRECTORY:
+                directory_elt = query_elt.addElement("directory")
+                directory_elt['name'] = file_data['name']
+                self.host.trigger.point(
+                    "XEP-0329_compGetFilesFromNode_build_directory",
+                    client,
+                    file_data,
+                    directory_elt,
+                    owner,
+                    node_path,
+                )
+            else:
+                file_elt = self._jf.build_file_element_from_dict(
+                    client,
+                    file_data,
+                    modified=file_data.get("modified", file_data["created"])
+                )
+                query_elt.addChild(file_elt)
+        client.send(iq_result_elt)
+
+    def on_component_request(self, iq_elt, client):
+        return self._request_handler(
+            client, iq_elt, self._comp_get_root_nodes_cb, self._comp_get_files_from_node_cb
+        )
+
+    async def _parse_result(self, client, peer_jid, iq_elt):
+        query_elt = next(iq_elt.elements(NS_FIS, "query"))
+        files = []
+
+        for elt in query_elt.elements():
+            if elt.name == "file":
+                # we have a file
+                try:
+                    file_data = await self._jf.parse_file_element(client, elt)
+                except exceptions.DataError:
+                    continue
+                file_data["type"] = C.FILE_TYPE_FILE
+                try:
+                    thumbs = file_data['extra'][C.KEY_THUMBNAILS]
+                except KeyError:
+                    log.debug(f"No thumbnail found for {file_data}")
+                else:
+                    for thumb in thumbs:
+                        if 'url' not in thumb and "id" in thumb:
+                            try:
+                                file_path = await self._b.get_file(client, peer_jid, thumb['id'])
+                            except Exception as e:
+                                log.warning(f"Can't get thumbnail {thumb['id']!r} for {file_data}: {e}")
+                            else:
+                                thumb['filename'] = file_path.name
+
+            elif elt.name == "directory" and elt.uri == NS_FIS:
+                # we have a directory
+
+                file_data = {"name": elt["name"], "type": C.FILE_TYPE_DIRECTORY}
+                self.host.trigger.point(
+                    "XEP-0329_parseResult_directory",
+                    client,
+                    elt,
+                    file_data,
+                )
+            else:
+                log.warning(
+                    _("unexpected element, ignoring: {elt}")
+                    .format(elt=elt.toXml())
+                )
+                continue
+            files.append(file_data)
+        return files
+
+    # affiliations #
+
+    async def _parse_element(self, client, iq_elt, element, namespace):
+        peer_jid, owner = client.get_owner_and_peer(iq_elt)
+        elt = next(iq_elt.elements(namespace, element))
+        path = Path("/", elt['path'])
+        if len(path.parts) < 2:
+            raise RootPathException
+        namespace = elt.getAttribute('namespace')
+        files_data = await self.host.memory.get_files(
+            client,
+            peer_jid=peer_jid,
+            path=str(path.parent),
+            name=path.name,
+            namespace=namespace,
+            owner=owner,
+        )
+        if len(files_data) != 1:
+            client.sendError(iq_elt, 'item-not-found')
+            raise exceptions.CancelError
+        file_data = files_data[0]
+        return peer_jid, elt, path, namespace, file_data
+
+    def _affiliations_get(self, service_jid_s, namespace, path, profile):
+        client = self.host.get_client(profile)
+        service = jid.JID(service_jid_s)
+        d = defer.ensureDeferred(self.affiliationsGet(
+            client, service, namespace or None, path))
+        d.addCallback(
+            lambda affiliations: {
+                str(entity): affiliation for entity, affiliation in affiliations.items()
+            }
+        )
+        return d
+
+    async def affiliationsGet(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        namespace: Optional[str],
+        path: str
+    ) -> Dict[jid.JID, str]:
+        if not path:
+            raise ValueError(f"invalid path: {path!r}")
+        iq_elt = client.IQ("get")
+        iq_elt['to'] = service.full()
+        affiliations_elt = iq_elt.addElement((NS_FIS_AFFILIATION, "affiliations"))
+        if namespace:
+            affiliations_elt["namespace"] = namespace
+        affiliations_elt["path"] = path
+        iq_result_elt = await iq_elt.send()
+        try:
+            affiliations_elt = next(iq_result_elt.elements(NS_FIS_AFFILIATION, "affiliations"))
+        except StopIteration:
+            raise exceptions.DataError(f"Invalid result to affiliations request: {iq_result_elt.toXml()}")
+
+        affiliations = {}
+        for affiliation_elt in affiliations_elt.elements(NS_FIS_AFFILIATION, 'affiliation'):
+            try:
+                affiliations[jid.JID(affiliation_elt['jid'])] = affiliation_elt['affiliation']
+            except (KeyError, RuntimeError):
+                raise exceptions.DataError(
+                    f"invalid affiliation element: {affiliation_elt.toXml()}")
+
+        return affiliations
+
+    def _affiliations_set(self, service_jid_s, namespace, path, affiliations, profile):
+        client = self.host.get_client(profile)
+        service = jid.JID(service_jid_s)
+        affiliations = {jid.JID(e): a for e, a in affiliations.items()}
+        return defer.ensureDeferred(self.affiliationsSet(
+            client, service, namespace or None, path, affiliations))
+
+    async def affiliationsSet(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        namespace: Optional[str],
+        path: str,
+        affiliations: Dict[jid.JID, str],
+    ):
+        if not path:
+            raise ValueError(f"invalid path: {path!r}")
+        iq_elt = client.IQ("set")
+        iq_elt['to'] = service.full()
+        affiliations_elt = iq_elt.addElement((NS_FIS_AFFILIATION, "affiliations"))
+        if namespace:
+            affiliations_elt["namespace"] = namespace
+        affiliations_elt["path"] = path
+        for entity_jid, affiliation in affiliations.items():
+            affiliation_elt = affiliations_elt.addElement('affiliation')
+            affiliation_elt['jid'] = entity_jid.full()
+            affiliation_elt['affiliation'] = affiliation
+        await iq_elt.send()
+
+    def _on_component_affiliations_get(self, iq_elt, client):
+        iq_elt.handled = True
+        defer.ensureDeferred(self.on_component_affiliations_get(client, iq_elt))
+
+    async def on_component_affiliations_get(self, client, iq_elt):
+        try:
+            (
+                from_jid, affiliations_elt, path, namespace, file_data
+            ) = await self._parse_element(client, iq_elt, "affiliations", NS_FIS_AFFILIATION)
+        except exceptions.CancelError:
+            return
+        except RootPathException:
+            # if root path is requested, we only get owner affiliation
+            peer_jid, owner = client.get_owner_and_peer(iq_elt)
+            is_owner = peer_jid.userhostJID() == owner
+            affiliations = {owner: 'owner'}
+        except exceptions.NotFound:
+            client.sendError(iq_elt, "item-not-found")
+            return
+        except Exception as e:
+            client.sendError(iq_elt, "internal-server-error", str(e))
+            return
+        else:
+            from_jid_bare = from_jid.userhostJID()
+            is_owner = from_jid_bare == file_data.get('owner')
+            affiliations = self.host.memory.get_file_affiliations(file_data)
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        affiliations_elt = iq_result_elt.addElement((NS_FIS_AFFILIATION, 'affiliations'))
+        for entity_jid, affiliation in affiliations.items():
+            if not is_owner and entity_jid.userhostJID() != from_jid_bare:
+                # only onwer can get all affiliations
+                continue
+            affiliation_elt = affiliations_elt.addElement('affiliation')
+            affiliation_elt['jid'] = entity_jid.userhost()
+            affiliation_elt['affiliation'] = affiliation
+        client.send(iq_result_elt)
+
+    def _on_component_affiliations_set(self, iq_elt, client):
+        iq_elt.handled = True
+        defer.ensureDeferred(self.on_component_affiliations_set(client, iq_elt))
+
+    async def on_component_affiliations_set(self, client, iq_elt):
+        try:
+            (
+                from_jid, affiliations_elt, path, namespace, file_data
+            ) = await self._parse_element(client, iq_elt, "affiliations", NS_FIS_AFFILIATION)
+        except exceptions.CancelError:
+            return
+        except RootPathException:
+            client.sendError(iq_elt, 'bad-request', "Root path can't be used")
+            return
+
+        if from_jid.userhostJID() != file_data['owner']:
+            log.warning(
+                f"{from_jid} tried to modify {path} affiliations while the owner is "
+                f"{file_data['owner']}"
+            )
+            client.sendError(iq_elt, 'forbidden')
+            return
+
+        try:
+            affiliations = {
+                jid.JID(e['jid']): e['affiliation']
+                for e in affiliations_elt.elements(NS_FIS_AFFILIATION, 'affiliation')
+            }
+        except (KeyError, RuntimeError):
+                log.warning(
+                    f"invalid affiliation element: {affiliations_elt.toXml()}"
+                )
+                client.sendError(iq_elt, 'bad-request', "invalid affiliation element")
+                return
+        except Exception as e:
+                log.error(
+                    f"unexepected exception while setting affiliation element: {e}\n"
+                    f"{affiliations_elt.toXml()}"
+                )
+                client.sendError(iq_elt, 'internal-server-error', f"{e}")
+                return
+
+        await self.host.memory.set_file_affiliations(client, file_data, affiliations)
+
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        client.send(iq_result_elt)
+
+    # configuration
+
+    def _configuration_get(self, service_jid_s, namespace, path, profile):
+        client = self.host.get_client(profile)
+        service = jid.JID(service_jid_s)
+        d = defer.ensureDeferred(self.configuration_get(
+            client, service, namespace or None, path))
+        d.addCallback(
+            lambda configuration: {
+                str(entity): affiliation for entity, affiliation in configuration.items()
+            }
+        )
+        return d
+
+    async def configuration_get(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        namespace: Optional[str],
+        path: str
+    ) -> Dict[str, str]:
+        if not path:
+            raise ValueError(f"invalid path: {path!r}")
+        iq_elt = client.IQ("get")
+        iq_elt['to'] = service.full()
+        configuration_elt = iq_elt.addElement((NS_FIS_CONFIGURATION, "configuration"))
+        if namespace:
+            configuration_elt["namespace"] = namespace
+        configuration_elt["path"] = path
+        iq_result_elt = await iq_elt.send()
+        try:
+            configuration_elt = next(iq_result_elt.elements(NS_FIS_CONFIGURATION, "configuration"))
+        except StopIteration:
+            raise exceptions.DataError(f"Invalid result to configuration request: {iq_result_elt.toXml()}")
+
+        form = data_form.findForm(configuration_elt, NS_FIS_CONFIGURATION)
+        configuration = {f.var: f.value for f in form.fields.values()}
+
+        return configuration
+
+    def _configuration_set(self, service_jid_s, namespace, path, configuration, profile):
+        client = self.host.get_client(profile)
+        service = jid.JID(service_jid_s)
+        return defer.ensureDeferred(self.configuration_set(
+            client, service, namespace or None, path, configuration))
+
+    async def configuration_set(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        namespace: Optional[str],
+        path: str,
+        configuration: Dict[str, str],
+    ):
+        if not path:
+            raise ValueError(f"invalid path: {path!r}")
+        iq_elt = client.IQ("set")
+        iq_elt['to'] = service.full()
+        configuration_elt = iq_elt.addElement((NS_FIS_CONFIGURATION, "configuration"))
+        if namespace:
+            configuration_elt["namespace"] = namespace
+        configuration_elt["path"] = path
+        form = data_form.Form(formType="submit", formNamespace=NS_FIS_CONFIGURATION)
+        form.makeFields(configuration)
+        configuration_elt.addChild(form.toElement())
+        await iq_elt.send()
+
+    def _on_component_configuration_get(self, iq_elt, client):
+        iq_elt.handled = True
+        defer.ensureDeferred(self.on_component_configuration_get(client, iq_elt))
+
+    async def on_component_configuration_get(self, client, iq_elt):
+        try:
+            (
+                from_jid, configuration_elt, path, namespace, file_data
+            ) = await self._parse_element(client, iq_elt, "configuration", NS_FIS_CONFIGURATION)
+        except exceptions.CancelError:
+            return
+        except RootPathException:
+            client.sendError(iq_elt, 'bad-request', "Root path can't be used")
+            return
+        try:
+            access_type = file_data['access'][C.ACCESS_PERM_READ]['type']
+        except KeyError:
+            access_model = 'whitelist'
+        else:
+            access_model = 'open' if access_type == C.ACCESS_TYPE_PUBLIC else 'whitelist'
+
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        configuration_elt = iq_result_elt.addElement((NS_FIS_CONFIGURATION, 'configuration'))
+        form = data_form.Form(formType="form", formNamespace=NS_FIS_CONFIGURATION)
+        form.makeFields({'access_model': access_model})
+        configuration_elt.addChild(form.toElement())
+        client.send(iq_result_elt)
+
+    async def _set_configuration(self, client, configuration_elt, file_data):
+        form = data_form.findForm(configuration_elt, NS_FIS_CONFIGURATION)
+        for name, value in form.items():
+            if name == 'access_model':
+                await self.host.memory.set_file_access_model(client, file_data, value)
+            else:
+                # TODO: send a IQ error?
+                log.warning(
+                    f"Trying to set a not implemented configuration option: {name}")
+
+    def _on_component_configuration_set(self, iq_elt, client):
+        iq_elt.handled = True
+        defer.ensureDeferred(self.on_component_configuration_set(client, iq_elt))
+
+    async def on_component_configuration_set(self, client, iq_elt):
+        try:
+            (
+                from_jid, configuration_elt, path, namespace, file_data
+            ) = await self._parse_element(client, iq_elt, "configuration", NS_FIS_CONFIGURATION)
+        except exceptions.CancelError:
+            return
+        except RootPathException:
+            client.sendError(iq_elt, 'bad-request', "Root path can't be used")
+            return
+
+        from_jid_bare = from_jid.userhostJID()
+        is_owner = from_jid_bare == file_data.get('owner')
+        if not is_owner:
+            log.warning(
+                f"{from_jid} tried to modify {path} configuration while the owner is "
+                f"{file_data['owner']}"
+            )
+            client.sendError(iq_elt, 'forbidden')
+            return
+
+        await self._set_configuration(client, configuration_elt, file_data)
+
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        client.send(iq_result_elt)
+
+    # directory creation
+
+    def _create_dir(self, service_jid_s, namespace, path, configuration, profile):
+        client = self.host.get_client(profile)
+        service = jid.JID(service_jid_s)
+        return defer.ensureDeferred(self.create_dir(
+            client, service, namespace or None, path, configuration or None))
+
+    async def create_dir(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        namespace: Optional[str],
+        path: str,
+        configuration: Optional[Dict[str, str]],
+    ):
+        if not path:
+            raise ValueError(f"invalid path: {path!r}")
+        iq_elt = client.IQ("set")
+        iq_elt['to'] = service.full()
+        create_dir_elt = iq_elt.addElement((NS_FIS_CREATE, "dir"))
+        if namespace:
+            create_dir_elt["namespace"] = namespace
+        create_dir_elt["path"] = path
+        if configuration:
+            configuration_elt = create_dir_elt.addElement((NS_FIS_CONFIGURATION, "configuration"))
+            form = data_form.Form(formType="submit", formNamespace=NS_FIS_CONFIGURATION)
+            form.makeFields(configuration)
+            configuration_elt.addChild(form.toElement())
+        await iq_elt.send()
+
+    def _on_component_create_dir(self, iq_elt, client):
+        iq_elt.handled = True
+        defer.ensureDeferred(self.on_component_create_dir(client, iq_elt))
+
+    async def on_component_create_dir(self, client, iq_elt):
+        peer_jid, owner = client.get_owner_and_peer(iq_elt)
+        if peer_jid.host not in client._file_sharing_allowed_hosts:
+            client.sendError(iq_elt, 'forbidden')
+            return
+        create_dir_elt = next(iq_elt.elements(NS_FIS_CREATE, "dir"))
+        namespace = create_dir_elt.getAttribute('namespace')
+        path = Path("/", create_dir_elt['path'])
+        if len(path.parts) < 2:
+            client.sendError(iq_elt, 'bad-request', "Root path can't be used")
+            return
+        # for root directories, we check permission here
+        if len(path.parts) == 2 and owner != peer_jid.userhostJID():
+            log.warning(
+                f"{peer_jid} is trying to create a dir at {owner}'s repository:\n"
+                f"path: {path}\nnamespace: {namespace!r}"
+            )
+            client.sendError(iq_elt, 'forbidden', "You can't create a directory there")
+            return
+        # when going further into the path, the permissions will be checked by get_files
+        files_data = await self.host.memory.get_files(
+            client,
+            peer_jid=peer_jid,
+            path=path.parent,
+            namespace=namespace,
+            owner=owner,
+        )
+        if path.name in [d['name'] for d in files_data]:
+            log.warning(
+                f"Conflict when trying to create a directory (from: {peer_jid} "
+                f"namespace: {namespace!r} path: {path!r})"
+            )
+            client.sendError(
+                iq_elt, 'conflict', "there is already a file or dir at this path")
+            return
+
+        try:
+            configuration_elt = next(
+                create_dir_elt.elements(NS_FIS_CONFIGURATION, 'configuration'))
+        except StopIteration:
+            configuration_elt = None
+
+        await self.host.memory.set_file(
+            client,
+            path.name,
+            path=path.parent,
+            type_=C.FILE_TYPE_DIRECTORY,
+            namespace=namespace,
+            owner=owner,
+            peer_jid=peer_jid
+        )
+
+        if configuration_elt is not None:
+            file_data = (await self.host.memory.get_files(
+                client,
+                peer_jid=peer_jid,
+                path=path.parent,
+                name=path.name,
+                namespace=namespace,
+                owner=owner,
+            ))[0]
+
+            await self._set_configuration(client, configuration_elt, file_data)
+
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        client.send(iq_result_elt)
+
+    # file methods #
+
+    def _serialize_data(self, files_data):
+        for file_data in files_data:
+            for key, value in file_data.items():
+                file_data[key] = (
+                    json.dumps(value) if key in ("extra",) else str(value)
+                )
+        return files_data
+
+    def _list_files(self, target_jid, path, extra, profile):
+        client = self.host.get_client(profile)
+        target_jid = client.jid if not target_jid else jid.JID(target_jid)
+        d = defer.ensureDeferred(self.list_files(client, target_jid, path or None))
+        d.addCallback(self._serialize_data)
+        return d
+
+    async def list_files(self, client, peer_jid, path=None, extra=None):
+        """List file shared by an entity
+
+        @param peer_jid(jid.JID): jid of the sharing entity
+        @param path(unicode, None): path to the directory containing shared files
+            None to get root directories
+        @param extra(dict, None): extra data
+        @return list(dict): shared files
+        """
+        iq_elt = client.IQ("get")
+        iq_elt["to"] = peer_jid.full()
+        query_elt = iq_elt.addElement((NS_FIS, "query"))
+        if path:
+            query_elt["node"] = path
+        iq_result_elt = await iq_elt.send()
+        return await self._parse_result(client, peer_jid, iq_result_elt)
+
+    def _local_shares_get(self, profile):
+        client = self.host.get_client(profile)
+        return self.local_shares_get(client)
+
+    def local_shares_get(self, client):
+        return list(client._XEP_0329_root_node.get_shared_paths().keys())
+
+    def _share_path(self, name, path, access, profile):
+        client = self.host.get_client(profile)
+        access = json.loads(access)
+        return self.share_path(client, name or None, path, access)
+
+    def share_path(self, client, name, path, access):
+        if client.is_component:
+            raise exceptions.ClientTypeError
+        if not os.path.exists(path):
+            raise ValueError(_("This path doesn't exist!"))
+        if not path or not path.strip(" /"):
+            raise ValueError(_("A path need to be specified"))
+        if not isinstance(access, dict):
+            raise ValueError(_("access must be a dict"))
+
+        node = client._XEP_0329_root_node
+        node_type = TYPE_PATH
+        if os.path.isfile(path):
+            # we have a single file, the workflow is diferrent as we store all single
+            # files in the same dir
+            node = node.get_or_create(SINGLE_FILES_DIR)
+
+        if not name:
+            name = os.path.basename(path.rstrip(" /"))
+            if not name:
+                raise exceptions.InternalError(_("Can't find a proper name"))
+
+        if name in node or name == SINGLE_FILES_DIR:
+            idx = 1
+            new_name = name + "_" + str(idx)
+            while new_name in node:
+                idx += 1
+                new_name = name + "_" + str(idx)
+            name = new_name
+            log.info(_(
+                "A directory with this name is already shared, renamed to {new_name} "
+                "[{profile}]".format( new_name=new_name, profile=client.profile)))
+
+        ShareNode(name=name, parent=node, type_=node_type, access=access, path=path)
+        self.host.bridge.fis_shared_path_new(path, name, client.profile)
+        return name
+
+    def _unshare_path(self, path, profile):
+        client = self.host.get_client(profile)
+        return self.unshare_path(client, path)
+
+    def unshare_path(self, client, path):
+        nodes = client._XEP_0329_root_node.find_by_local_path(path)
+        for node in nodes:
+            node.remove_from_parent()
+        self.host.bridge.fis_shared_path_removed(path, client.profile)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0329_handler(xmlstream.XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+
+    def connectionInitialized(self):
+        if self.parent.is_component:
+            self.xmlstream.addObserver(
+                IQ_FIS_REQUEST, self.plugin_parent.on_component_request, client=self.parent
+            )
+            self.xmlstream.addObserver(
+                IQ_FIS_AFFILIATION_GET,
+                self.plugin_parent._on_component_affiliations_get,
+                client=self.parent
+            )
+            self.xmlstream.addObserver(
+                IQ_FIS_AFFILIATION_SET,
+                self.plugin_parent._on_component_affiliations_set,
+                client=self.parent
+            )
+            self.xmlstream.addObserver(
+                IQ_FIS_CONFIGURATION_GET,
+                self.plugin_parent._on_component_configuration_get,
+                client=self.parent
+            )
+            self.xmlstream.addObserver(
+                IQ_FIS_CONFIGURATION_SET,
+                self.plugin_parent._on_component_configuration_set,
+                client=self.parent
+            )
+            self.xmlstream.addObserver(
+                IQ_FIS_CREATE_DIR,
+                self.plugin_parent._on_component_create_dir,
+                client=self.parent
+            )
+        else:
+            self.xmlstream.addObserver(
+                IQ_FIS_REQUEST, self.plugin_parent.on_request, client=self.parent
+            )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_FIS)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0334.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,140 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Delayed Delivery (XEP-0334)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Iterable
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core.constants import Const as C
+
+from libervia.backend.tools.common import data_format
+
+from wokkel import disco, iwokkel
+
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.xish import domish
+from zope.interface import implementer
+from textwrap import dedent
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Message Processing Hints",
+    C.PI_IMPORT_NAME: "XEP-0334",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0334"],
+    C.PI_MAIN: "XEP_0334",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: D_("""Implementation of Message Processing Hints"""),
+    C.PI_USAGE: dedent(
+        D_(
+            """\
+             Frontends can use HINT_* constants in mess_data['extra'] in a serialized 'hints' dict.
+             Internal plugins can use directly add_hint([HINT_* constant]).
+             Will set mess_data['extra']['history'] to 'skipped' when no store is requested and message is not saved in history."""
+        )
+    ),
+}
+
+NS_HINTS = "urn:xmpp:hints"
+
+
+class XEP_0334(object):
+    HINT_NO_PERMANENT_STORE = "no-permanent-store"
+    HINT_NO_STORE = "no-store"
+    HINT_NO_COPY = "no-copy"
+    HINT_STORE = "store"
+    HINTS = (HINT_NO_PERMANENT_STORE, HINT_NO_STORE, HINT_NO_COPY, HINT_STORE)
+
+    def __init__(self, host):
+        log.info(_("Message Processing Hints plugin initialization"))
+        self.host = host
+        host.trigger.add("sendMessage", self.send_message_trigger)
+        host.trigger.add("message_received", self.message_received_trigger, priority=-1000)
+
+    def get_handler(self, client):
+        return XEP_0334_handler()
+
+    def add_hint(self, mess_data, hint):
+        if hint == self.HINT_NO_COPY and not mess_data["to"].resource:
+            log.error(
+                "{hint} can only be used with full jids! Ignoring it.".format(hint=hint)
+            )
+            return
+        hints = mess_data.setdefault("hints", set())
+        if hint in self.HINTS:
+            hints.add(hint)
+        else:
+            log.error("Unknown hint: {}".format(hint))
+
+    def add_hint_elements(self, message_elt: domish.Element, hints: Iterable[str]) -> None:
+        """Add hints elements to message stanza
+
+        @param message_elt: stanza where hints must be added
+        @param hints: hints to add
+        """
+        for hint in hints:
+            if not list(message_elt.elements(NS_HINTS, hint)):
+                message_elt.addElement((NS_HINTS, hint))
+            else:
+                log.debug('Not adding {hint!r} hint: it is already present in <message>')
+
+    def _send_post_xml_treatment(self, mess_data):
+        if "hints" in mess_data:
+            self.add_hint_elements(mess_data["xml"], mess_data["hints"])
+        return mess_data
+
+    def send_message_trigger(
+        self, client, mess_data, pre_xml_treatments, post_xml_treatments
+    ):
+        """Add the hints element to the message to be sent"""
+        if "hints" in mess_data["extra"]:
+            for hint in data_format.dict2iter("hints", mess_data["extra"], pop=True):
+                self.add_hint(hint)
+
+        post_xml_treatments.addCallback(self._send_post_xml_treatment)
+        return True
+
+    def _received_skip_history(self, mess_data):
+        mess_data["history"] = C.HISTORY_SKIP
+        return mess_data
+
+    def message_received_trigger(self, client, message_elt, post_treat):
+        """Check for hints in the received message"""
+        for elt in message_elt.elements():
+            if elt.uri == NS_HINTS and elt.name in (
+                self.HINT_NO_PERMANENT_STORE,
+                self.HINT_NO_STORE,
+            ):
+                log.debug("history will be skipped for this message, as requested")
+                post_treat.addCallback(self._received_skip_history)
+                break
+        return True
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0334_handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_HINTS)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0338.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+
+# Libervia plugin
+# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import List
+
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.core_types import SatXMPPEntity
+
+log = getLogger(__name__)
+
+NS_JINGLE_GROUPING = "urn:xmpp:jingle:apps:grouping:0"
+NS_RFC_5888 = "urn:ietf:rfc:5888"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Jingle Grouping Framework",
+    C.PI_IMPORT_NAME: "XEP-0338",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0338"],
+    C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0167"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "XEP_0338",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Jingle mapping of RFC 5888 SDP Grouping Framework"""),
+}
+
+
+class XEP_0338:
+    def __init__(self, host):
+        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
+        self._j = host.plugins["XEP-0166"]
+        host.trigger.add("XEP-0167_parse_sdp_a", self._parse_sdp_a_trigger)
+        host.trigger.add(
+            "XEP-0167_generate_sdp_session", self._generate_sdp_session_trigger
+        )
+        host.trigger.add("XEP-0167_jingle_session_init", self._jingle_session_init_trigger)
+        host.trigger.add("XEP-0167_jingle_handler", self._jingle_handler_trigger)
+
+    def get_handler(self, client):
+        return XEP_0338_handler()
+
+    def _parse_sdp_a_trigger(
+        self,
+        attribute: str,
+        parts: List[str],
+        call_data: dict,
+        metadata: dict,
+        media_type: str,
+        application_data: dict,
+        transport_data: dict,
+    ) -> None:
+        """Parse "group" attributes"""
+        if attribute == "group":
+            semantics = parts[0]
+            content_names = parts[1:]
+            metadata.setdefault("group", {})[semantics] = content_names
+
+    def _generate_sdp_session_trigger(
+        self,
+        session: dict,
+        local: bool,
+        sdp_lines: List[str],
+    ) -> None:
+        """Generate "group" attributes"""
+        key = "metadata" if local else "peer_metadata"
+        group_data = session[key].get("group", {})
+
+        for semantics, content_names in group_data.items():
+            sdp_lines.append(f"a=group:{semantics} {' '.join(content_names)}")
+
+    def parse_group_element(
+        self, jingle_elt: domish.Element, session: dict
+    ) -> None:
+        """Parse the <group> and <content> elements"""
+        for group_elt in jingle_elt.elements(NS_JINGLE_GROUPING, "group"):
+            try:
+                metadata = session["peer_metadata"]
+                semantics = group_elt["semantics"]
+                group_content = metadata.setdefault("group", {})[semantics] = []
+                for content_elt in group_elt.elements(NS_JINGLE_GROUPING, "content"):
+                    group_content.append(content_elt["name"])
+            except KeyError as e:
+                log.warning(f"Error while parsing <group>: {e}\n{group_elt.toXml()}")
+
+    def add_group_element(
+        self, jingle_elt: domish.Element, session: dict
+    ) -> None:
+        """Build the <group> and <content> elements if possible"""
+        for semantics, content_names in session["metadata"].get("group", {}).items():
+            group_elt = jingle_elt.addElement((NS_JINGLE_GROUPING, "group"))
+            group_elt["semantics"] = semantics
+            for content_name in content_names:
+                content_elt = group_elt.addElement((NS_JINGLE_GROUPING, "content"))
+                content_elt["name"] = content_name
+
+    def _jingle_session_init_trigger(
+        self,
+        client: SatXMPPEntity,
+        session: dict,
+        content_name: str,
+        media: str,
+        media_data: dict,
+        desc_elt: domish.Element,
+    ) -> None:
+        jingle_elt = session["jingle_elt"]
+        self.add_group_element(jingle_elt, session)
+
+    def _jingle_handler_trigger(
+        self,
+        client: SatXMPPEntity,
+        action: str,
+        session: dict,
+        content_name: str,
+        desc_elt: domish.Element,
+    ) -> None:
+        # this is a session metadata, so we only generate it on the first content
+        if content_name == next(iter(session["contents"])) and action in (
+            self._j.A_PREPARE_CONFIRMATION,
+            self._j.A_SESSION_INITIATE,
+            self._j.A_PREPARE_INITIATOR,
+        ):
+            jingle_elt = session["jingle_elt"]
+            self.parse_group_element(jingle_elt, session)
+            if action == self._j.A_SESSION_INITIATE:
+                self.add_group_element(jingle_elt, session)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0338_handler(XMPPHandler):
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_RFC_5888)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0339.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,199 @@
+#!/usr/bin/env python3
+
+# Libervia plugin
+# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import List
+
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xml_tools
+
+log = getLogger(__name__)
+
+NS_JINGLE_RTP_SSMA = "urn:xmpp:jingle:apps:rtp:ssma:0"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Source-Specific Media Attributes in Jingle",
+    C.PI_IMPORT_NAME: "XEP-0339",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0339"],
+    C.PI_DEPENDENCIES: ["XEP-0092", "XEP-0167"],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "XEP_0339",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Source-Specific Media Attributes in Jingle"""),
+}
+
+
+class XEP_0339:
+    def __init__(self, host):
+        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
+        self.host = host
+        host.trigger.add("XEP-0167_parse_sdp_a", self._parse_sdp_a_trigger)
+        host.trigger.add(
+            "XEP-0167_generate_sdp_content", self._generate_sdp_content_trigger
+        )
+        host.trigger.add("XEP-0167_parse_description", self._parse_description_trigger)
+        host.trigger.add("XEP-0167_build_description", self._build_description_trigger)
+
+    def get_handler(self, client):
+        return XEP_0339_handler()
+
+    def _parse_sdp_a_trigger(
+        self,
+        attribute: str,
+        parts: List[str],
+        call_data: dict,
+        metadata: dict,
+        media_type: str,
+        application_data: dict,
+        transport_data: dict,
+    ) -> None:
+        """Parse "ssrc" attributes"""
+        if attribute == "ssrc":
+            assert application_data is not None
+            ssrc_id = int(parts[0])
+
+            if len(parts) > 1:
+                name, *values = " ".join(parts[1:]).split(":", 1)
+                if values:
+                    value = values[0] or None
+                else:
+                    value = None
+                application_data.setdefault("ssrc", {}).setdefault(ssrc_id, {})[
+                    name
+                ] = value
+            else:
+                log.warning(f"no attribute in ssrc: {' '.join(parts)}")
+                application_data.setdefault("ssrc", {}).setdefault(ssrc_id, {})
+        elif attribute == "ssrc-group":
+            assert application_data is not None
+            semantics, *ssrc_ids = parts
+            ssrc_ids = [int(ssrc_id) for ssrc_id in ssrc_ids]
+            application_data.setdefault("ssrc-group", {})[semantics] = ssrc_ids
+        elif attribute == "msid":
+            assert application_data is not None
+            application_data["msid"] = " ".join(parts)
+
+
+    def _generate_sdp_content_trigger(
+        self,
+        session: dict,
+        local: bool,
+        idx: int,
+        content_data: dict,
+        sdp_lines: List[str],
+        application_data: dict,
+        app_data_key: str,
+        media_data: dict,
+        media: str
+    ) -> None:
+        """Generate "msid" and "ssrc" attributes"""
+        if "msid" in media_data:
+            sdp_lines.append(f"a=msid:{media_data['msid']}")
+
+        ssrc_data = media_data.get("ssrc", {})
+        ssrc_group_data = media_data.get("ssrc-group", {})
+
+        for ssrc_id, attributes in ssrc_data.items():
+            if not attributes:
+                # there are no attributes for this SSRC ID, we add a simple line with only
+                # the SSRC ID
+                sdp_lines.append(f"a=ssrc:{ssrc_id}")
+            else:
+                for attr_name, attr_value in attributes.items():
+                    if attr_value is not None:
+                        sdp_lines.append(f"a=ssrc:{ssrc_id} {attr_name}:{attr_value}")
+                    else:
+                        sdp_lines.append(f"a=ssrc:{ssrc_id} {attr_name}")
+        for semantics, ssrc_ids in ssrc_group_data.items():
+            ssrc_lines = " ".join(str(ssrc_id) for ssrc_id in ssrc_ids)
+            sdp_lines.append(f"a=ssrc-group:{semantics} {ssrc_lines}")
+
+    def _parse_description_trigger(
+        self, desc_elt: domish.Element, media_data: dict
+    ) -> bool:
+        """Parse the <source> and <ssrc-group> elements"""
+        for source_elt in desc_elt.elements(NS_JINGLE_RTP_SSMA, "source"):
+            try:
+                ssrc_id = int(source_elt["ssrc"])
+                media_data.setdefault("ssrc", {})[ssrc_id] = {}
+                for param_elt in source_elt.elements(NS_JINGLE_RTP_SSMA, "parameter"):
+                    name = param_elt["name"]
+                    value = param_elt.getAttribute("value")
+                    media_data["ssrc"][ssrc_id][name] = value
+                    if name == "msid" and "msid" not in media_data:
+                        media_data["msid"] = value
+            except (KeyError, ValueError) as e:
+                log.warning(f"Error while parsing <source>: {e}\n{source_elt.toXml()}")
+
+        for ssrc_group_elt in desc_elt.elements(NS_JINGLE_RTP_SSMA, "ssrc-group"):
+            try:
+                semantics = ssrc_group_elt["semantics"]
+                semantic_ids = media_data.setdefault("ssrc-group", {})[semantics] = []
+                for source_elt in ssrc_group_elt.elements(NS_JINGLE_RTP_SSMA, "source"):
+                    semantic_ids.append(
+                        int(source_elt["ssrc"])
+                    )
+            except (KeyError, ValueError) as e:
+                log.warning(
+                    f"Error while parsing <ssrc-group>: {e}\n{ssrc_group_elt.toXml()}"
+                )
+
+        return True
+
+    def _build_description_trigger(
+        self, desc_elt: domish.Element, media_data: dict, session: dict
+    ) -> bool:
+        """Build the <source> and <ssrc-group> elements if possible"""
+        for ssrc_id, parameters in media_data.get("ssrc", {}).items():
+            if "msid" not in parameters and "msid" in media_data:
+                parameters["msid"] = media_data["msid"]
+            source_elt = desc_elt.addElement((NS_JINGLE_RTP_SSMA, "source"))
+            source_elt["ssrc"] = str(ssrc_id)
+            for name, value in parameters.items():
+                param_elt = source_elt.addElement((NS_JINGLE_RTP_SSMA, "parameter"))
+                param_elt["name"] = name
+                if value is not None:
+                    param_elt["value"] = value
+
+        for semantics, ssrc_ids in media_data.get("ssrc-group", {}).items():
+            ssrc_group_elt = desc_elt.addElement((NS_JINGLE_RTP_SSMA, "ssrc-group"))
+            ssrc_group_elt["semantics"] = semantics
+            for ssrc_id in ssrc_ids:
+                source_elt = ssrc_group_elt.addElement((NS_JINGLE_RTP_SSMA, "source"))
+                source_elt["ssrc"] = str(ssrc_id)
+
+        return True
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0339_handler(XMPPHandler):
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_JINGLE_RTP_SSMA)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0346.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,750 @@
+#!/usr/bin/env python3
+
+# SàT plugin for XEP-0346
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from collections.abc import Iterable
+import itertools
+from typing import Optional
+from zope.interface import implementer
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from twisted.internet import defer
+from wokkel import disco, iwokkel
+from wokkel import data_form
+from wokkel import generic
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools import utils
+from libervia.backend.tools.common import date_utils
+from libervia.backend.tools.common import data_format
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+NS_FDP = "urn:xmpp:fdp:0"
+TEMPLATE_PREFIX = "fdp/template/"
+SUBMITTED_PREFIX = "fdp/submitted/"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Form Discovery and Publishing",
+    C.PI_IMPORT_NAME: "XEP-0346",
+    C.PI_TYPE: "EXP",
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060", "IDENTITY"],
+    C.PI_MAIN: "PubsubSchema",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Handle Pubsub data schemas"""),
+}
+
+
+class PubsubSchema(object):
+    def __init__(self, host):
+        log.info(_("PubSub Schema initialization"))
+        self.host = host
+        self._p = self.host.plugins["XEP-0060"]
+        self._i = self.host.plugins["IDENTITY"]
+        host.bridge.add_method(
+            "ps_schema_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._get_schema,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_schema_set",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="",
+            method=self._set_schema,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_schema_ui_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=lambda service, nodeIdentifier, profile_key: self._get_ui_schema(
+                service, nodeIdentifier, default_node=None, profile_key=profile_key),
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_schema_dict_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._get_schema_dict,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_schema_application_ns_get",
+            ".plugin",
+            in_sign="s",
+            out_sign="s",
+            method=self.get_application_ns,
+        )
+        host.bridge.add_method(
+            "ps_schema_template_node_get",
+            ".plugin",
+            in_sign="s",
+            out_sign="s",
+            method=self.get_template_ns,
+        )
+        host.bridge.add_method(
+            "ps_schema_submitted_node_get",
+            ".plugin",
+            in_sign="s",
+            out_sign="s",
+            method=self.get_submitted_ns,
+        )
+        host.bridge.add_method(
+            "ps_items_form_get",
+            ".plugin",
+            in_sign="ssssiassss",
+            out_sign="(asa{ss})",
+            method=self._get_data_form_items,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_item_form_send",
+            ".plugin",
+            in_sign="ssa{sas}ssa{ss}s",
+            out_sign="s",
+            method=self._send_data_form_item,
+            async_=True,
+        )
+
+    def get_handler(self, client):
+        return SchemaHandler()
+
+    def get_application_ns(self, namespace):
+        """Retrieve application namespace, i.e. namespace without FDP prefix"""
+        if namespace.startswith(SUBMITTED_PREFIX):
+            namespace = namespace[len(SUBMITTED_PREFIX):]
+        elif namespace.startswith(TEMPLATE_PREFIX):
+            namespace = namespace[len(TEMPLATE_PREFIX):]
+        return namespace
+
+    def get_template_ns(self, namespace: str) -> str:
+        """Returns node used for data template (i.e. schema)"""
+        app_ns = self.get_application_ns(namespace)
+        return f"{TEMPLATE_PREFIX}{app_ns}"
+
+    def get_submitted_ns(self, namespace: str) -> str:
+        """Returns node to use to submit forms"""
+        return f"{SUBMITTED_PREFIX}{self.get_application_ns(namespace)}"
+
+    def _get_schema_bridge_cb(self, schema_elt):
+        if schema_elt is None:
+            return ""
+        return schema_elt.toXml()
+
+    def _get_schema(self, service, nodeIdentifier, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        service = None if not service else jid.JID(service)
+        d = defer.ensureDeferred(self.get_schema(client, service, nodeIdentifier))
+        d.addCallback(self._get_schema_bridge_cb)
+        return d
+
+    async def get_schema(self, client, service, nodeIdentifier):
+        """retrieve PubSub node schema
+
+        @param service(jid.JID, None): jid of PubSub service
+            None to use our PEP
+        @param nodeIdentifier(unicode): node to get schema from
+        @return (domish.Element, None): schema (<x> element)
+            None if no schema has been set on this node
+        """
+        app_ns = self.get_application_ns(nodeIdentifier)
+        node_id = f"{TEMPLATE_PREFIX}{app_ns}"
+        items_data = await self._p.get_items(client, service, node_id, max_items=1)
+        try:
+            schema = next(items_data[0][0].elements(data_form.NS_X_DATA, 'x'))
+        except IndexError:
+            schema = None
+        except StopIteration:
+            log.warning(
+                f"No schema found in item of {service!r} at node {nodeIdentifier!r}: "
+                f"\n{items_data[0][0].toXml()}"
+            )
+            schema = None
+        return schema
+
+    async def get_schema_form(self, client, service, nodeIdentifier, schema=None,
+                      form_type="form", copy_form=True):
+        """Get data form from node's schema
+
+        @param service(None, jid.JID): PubSub service
+        @param nodeIdentifier(unicode): node
+        @param schema(domish.Element, data_form.Form, None): node schema
+            if domish.Element, will be converted to data form
+            if data_form.Form it will be returned without modification
+            if None, it will be retrieved from node (imply one additional XMPP request)
+        @param form_type(unicode): type of the form
+        @param copy_form(bool): if True and if schema is already a data_form.Form, will deep copy it before returning
+            needed when the form is reused and it will be modified (e.g. in send_data_form_item)
+        @return(data_form.Form): data form
+            the form should not be modified if copy_form is not set
+        """
+        if schema is None:
+            log.debug(_("unspecified schema, we need to request it"))
+            schema = await self.get_schema(client, service, nodeIdentifier)
+            if schema is None:
+                raise exceptions.DataError(
+                    _(
+                        "no schema specified, and this node has no schema either, we can't construct the data form"
+                    )
+                )
+        elif isinstance(schema, data_form.Form):
+            if copy_form:
+                # XXX: we don't use deepcopy as it will do an infinite loop if a
+                #      domish.Element is present in the form fields (happens for
+                #      XEP-0315 data forms XML Element)
+                schema = data_form.Form(
+                    formType = schema.formType,
+                    title = schema.title,
+                    instructions = schema.instructions[:],
+                    formNamespace = schema.formNamespace,
+                    fields = schema.fieldList,
+                )
+            return schema
+
+        try:
+            form = data_form.Form.fromElement(schema)
+        except data_form.Error as e:
+            raise exceptions.DataError(_("Invalid Schema: {msg}").format(msg=e))
+        form.formType = form_type
+        return form
+
+    def schema_2_xmlui(self, schema_elt):
+        form = data_form.Form.fromElement(schema_elt)
+        xmlui = xml_tools.data_form_2_xmlui(form, "")
+        return xmlui
+
+    def _get_ui_schema(self, service, nodeIdentifier, default_node=None,
+                     profile_key=C.PROF_KEY_NONE):
+        if not nodeIdentifier:
+            if not default_node:
+                raise ValueError(_("nodeIndentifier needs to be set"))
+            nodeIdentifier = default_node
+        client = self.host.get_client(profile_key)
+        service = None if not service else jid.JID(service)
+        d = self.get_ui_schema(client, service, nodeIdentifier)
+        d.addCallback(lambda xmlui: xmlui.toXml())
+        return d
+
+    def get_ui_schema(self, client, service, nodeIdentifier):
+        d = defer.ensureDeferred(self.get_schema(client, service, nodeIdentifier))
+        d.addCallback(self.schema_2_xmlui)
+        return d
+
+    def _set_schema(self, service, nodeIdentifier, schema, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        service = None if not service else jid.JID(service)
+        schema = generic.parseXml(schema.encode())
+        return defer.ensureDeferred(
+            self.set_schema(client, service, nodeIdentifier, schema)
+        )
+
+    async def set_schema(self, client, service, nodeIdentifier, schema):
+        """Set or replace PubSub node schema
+
+        @param schema(domish.Element, None): schema to set
+            None if schema need to be removed
+        """
+        node_id = self.get_template_ns(nodeIdentifier)
+        node_options = {
+            self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
+            self._p.OPT_PERSIST_ITEMS: 1,
+            self._p.OPT_MAX_ITEMS: 1,
+            self._p.OPT_DELIVER_PAYLOADS: 1,
+            self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
+            self._p.OPT_PUBLISH_MODEL: self._p.PUBLISH_MODEL_PUBLISHERS,
+        }
+        await self._p.create_if_new_node(client, service, node_id, node_options)
+        await self._p.send_item(client, service, node_id, schema, self._p.ID_SINGLETON)
+
+    def _get_schema_dict(self, service, nodeIdentifier, profile):
+        service = None if not service else jid.JID(service)
+        client = self.host.get_client(profile)
+        d = defer.ensureDeferred(self.get_schema_dict(client, service, nodeIdentifier))
+        d.addCallback(data_format.serialise)
+        return d
+
+    async def get_schema_dict(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        nodeIdentifier: str) -> dict:
+        """Retrieve a node schema and format it a simple dictionary
+
+        The dictionary is made so it can be easily serialisable
+        """
+        schema_form = await self.get_schema_form(client, service, nodeIdentifier)
+        return xml_tools.data_form_2_data_dict(schema_form)
+
+    def _get_data_form_items(self, form_ns="", service="", node="", schema="", max_items=10,
+                          item_ids=None, sub_id=None, extra="",
+                          profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        service = jid.JID(service) if service else None
+        if not node:
+            raise exceptions.DataError(_("empty node is not allowed"))
+        if schema:
+            schema = generic.parseXml(schema.encode("utf-8"))
+        else:
+            schema = None
+        max_items = None if max_items == C.NO_LIMIT else max_items
+        extra = self._p.parse_extra(data_format.deserialise(extra))
+        d = defer.ensureDeferred(
+            self.get_data_form_items(
+                client,
+                service,
+                node,
+                schema,
+                max_items or None,
+                item_ids,
+                sub_id or None,
+                extra.rsm_request,
+                extra.extra,
+                form_ns=form_ns or None,
+            )
+        )
+        d.addCallback(self._p.trans_items_data)
+        return d
+
+    async def get_data_form_items(self, client, service, nodeIdentifier, schema=None,
+                         max_items=None, item_ids=None, sub_id=None, rsm_request=None,
+                         extra=None, default_node=None, form_ns=None, filters=None):
+        """Get items known as being data forms, and convert them to XMLUI
+
+        @param schema(domish.Element, data_form.Form, None): schema of the node if known
+            if None, it will be retrieved from node
+        @param default_node(unicode): node to use if nodeIdentifier is None or empty
+        @param form_ns (unicode, None): namespace of the form
+            None to accept everything, even if form has no namespace
+        @param filters(dict, None): same as for xml_tools.data_form_result_2_xmlui
+        other parameters as the same as for [get_items]
+        @return (list[unicode]): XMLUI of the forms
+            if an item is invalid (not corresponding to form_ns or not a data_form)
+            it will be skipped
+        @raise ValueError: one argument is invalid
+        """
+        if not nodeIdentifier:
+            if not default_node:
+                raise ValueError(
+                    _("default_node must be set if nodeIdentifier is not set")
+                )
+            nodeIdentifier = default_node
+        submitted_ns = self.get_submitted_ns(nodeIdentifier)
+        # we need the initial form to get options of fields when suitable
+        schema_form = await self.get_schema_form(
+            client, service, nodeIdentifier, schema, form_type="result", copy_form=False
+        )
+        items_data = await self._p.get_items(
+            client,
+            service,
+            submitted_ns,
+            max_items,
+            item_ids,
+            sub_id,
+            rsm_request,
+            extra,
+        )
+        items, metadata = items_data
+        items_xmlui = []
+        for item_elt in items:
+            for x_elt in item_elt.elements(data_form.NS_X_DATA, "x"):
+                form = data_form.Form.fromElement(x_elt)
+                if form_ns and form.formNamespace != form_ns:
+                    log.debug(
+                        f"form's namespace ({form.formNamespace!r}) differs from expected"
+                        f"{form_ns!r}"
+                    )
+                    continue
+                prepend = [
+                    ("label", "id"),
+                    ("text", item_elt["id"], "id"),
+                    ("label", "publisher"),
+                ]
+                try:
+                    publisher = jid.JID(item_elt['publisher'])
+                except (KeyError, jid.InvalidFormat):
+                    pass
+                else:
+                    prepend.append(("jid", publisher, "publisher"))
+                xmlui = xml_tools.data_form_result_2_xmlui(
+                    form,
+                    schema_form,
+                    # FIXME: conflicts with schema (i.e. if "id" or "publisher" already exists)
+                    #        are not checked
+                    prepend=prepend,
+                    filters=filters,
+                    read_only=False,
+                )
+                items_xmlui.append(xmlui)
+                break
+        return (items_xmlui, metadata)
+
+    def _send_data_form_item(self, service, nodeIdentifier, values, schema=None,
+                          item_id=None, extra=None, profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        service = None if not service else jid.JID(service)
+        if schema:
+            schema = generic.parseXml(schema.encode("utf-8"))
+        else:
+            schema = None
+        d = defer.ensureDeferred(
+            self.send_data_form_item(
+                client,
+                service,
+                nodeIdentifier,
+                values,
+                schema,
+                item_id or None,
+                extra,
+                deserialise=True,
+            )
+        )
+        d.addCallback(lambda ret: ret or "")
+        return d
+
+    async def send_data_form_item(
+        self, client, service, nodeIdentifier, values, schema=None, item_id=None,
+        extra=None, deserialise=False):
+        """Publish an item as a dataform when we know that there is a schema
+
+        @param values(dict[key(unicode), [iterable[object], object]]): values set for the
+            form. If not iterable, will be put in a list.
+        @param schema(domish.Element, data_form.Form, None): data schema
+            None to retrieve data schema from node (need to do a additional XMPP call)
+            Schema is needed to construct data form to publish
+        @param deserialise(bool): if True, data are list of unicode and must be
+            deserialized according to expected type.
+            This is done in this method and not directly in _send_data_form_item because we
+            need to know the data type which is in the form, not availablable in
+            _send_data_form_item
+        other parameters as the same as for [self._p.send_item]
+        @return (unicode): id of the created item
+        """
+        form = await self.get_schema_form(
+            client, service, nodeIdentifier, schema, form_type="submit"
+        )
+
+        for name, values_list in values.items():
+            try:
+                field = form.fields[name]
+            except KeyError:
+                log.warning(
+                    _("field {name} doesn't exist, ignoring it").format(name=name)
+                )
+                continue
+            if isinstance(values_list, str) or not isinstance(
+                values_list, Iterable
+            ):
+                values_list = [values_list]
+            if deserialise:
+                if field.fieldType == "boolean":
+                    values_list = [C.bool(v) for v in values_list]
+                elif field.fieldType == "text-multi":
+                    # for text-multi, lines must be put on separate values
+                    values_list = list(
+                        itertools.chain(*[v.splitlines() for v in values_list])
+                    )
+                elif xml_tools.is_xhtml_field(field):
+                   values_list = [generic.parseXml(v.encode("utf-8"))
+                                  for v in values_list]
+                elif "jid" in (field.fieldType or ""):
+                    values_list = [jid.JID(v) for v in values_list]
+            if "list" in (field.fieldType or ""):
+                # for lists, we check that given values are allowed in form
+                allowed_values = [o.value for o in field.options]
+                values_list = [v for v in values_list if v in allowed_values]
+                if not values_list:
+                    # if values don't map to allowed values, we use default ones
+                    values_list = field.values
+            elif field.ext_type == 'xml':
+                # FIXME: XML elements are not handled correctly, we need to know if we
+                #        have actual XML/XHTML, or text to escape
+                for idx, value in enumerate(values_list[:]):
+                    if isinstance(value, domish.Element):
+                        if (field.value and (value.name != field.value.name
+                                             or value.uri != field.value.uri)):
+                            # the element is not the one expected in form, so we create the right element
+                            # to wrap the current value
+                            wrapper_elt = domish.Element((field.value.uri, field.value.name))
+                            wrapper_elt.addChild(value)
+                            values_list[idx] = wrapper_elt
+                    else:
+                        # we have to convert the value to a domish.Element
+                        if field.value and field.value.uri == C.NS_XHTML:
+                            div_elt = domish.Element((C.NS_XHTML, 'div'))
+                            div_elt.addContent(str(value))
+                            values_list[idx] = div_elt
+                        else:
+                            # only XHTML fields are handled for now
+                            raise NotImplementedError
+
+            field.values = values_list
+
+        return await self._p.send_item(
+            client, service, nodeIdentifier, form.toElement(), item_id, extra
+        )
+
+    ## filters ##
+    # filters useful for data form to XMLUI conversion #
+
+    def value_or_publisher_filter(self, form_xmlui, widget_type, args, kwargs):
+        """Replace missing value by publisher's user part"""
+        if not args[0]:
+            # value is not filled: we use user part of publisher (if we have it)
+            try:
+                publisher = jid.JID(form_xmlui.named_widgets["publisher"].value)
+            except (KeyError, RuntimeError):
+                pass
+            else:
+                args[0] = publisher.user.capitalize()
+        return widget_type, args, kwargs
+
+    def textbox_2_list_filter(self, form_xmlui, widget_type, args, kwargs):
+        """Split lines of a textbox in a list
+
+        main use case is using a textbox for labels
+        """
+        if widget_type != "textbox":
+            return widget_type, args, kwargs
+        widget_type = "list"
+        options = [o for o in args.pop(0).split("\n") if o]
+        kwargs = {
+            "options": options,
+            "name": kwargs.get("name"),
+            "styles": ("noselect", "extensible", "reducible"),
+        }
+        return widget_type, args, kwargs
+
+    def date_filter(self, form_xmlui, widget_type, args, kwargs):
+        """Convert a string with a date to a unix timestamp"""
+        if widget_type != "string" or not args[0]:
+            return widget_type, args, kwargs
+        # we convert XMPP date to timestamp
+        try:
+            args[0] = str(date_utils.date_parse(args[0]))
+        except Exception as e:
+            log.warning(_("Can't parse date field: {msg}").format(msg=e))
+        return widget_type, args, kwargs
+
+    ## Helper methods ##
+
+    def prepare_bridge_get(self, service, node, max_items, sub_id, extra, profile_key):
+        """Parse arguments received from bridge *Get methods and return higher level data
+
+        @return (tuple): (client, service, node, max_items, extra, sub_id) usable for
+            internal methods
+        """
+        client = self.host.get_client(profile_key)
+        service = jid.JID(service) if service else None
+        if not node:
+            node = None
+        max_items = None if max_items == C.NO_LIMIT else max_items
+        if not sub_id:
+            sub_id = None
+        extra = self._p.parse_extra(extra)
+
+        return client, service, node, max_items, extra, sub_id
+
+    def _get(self, service="", node="", max_items=10, item_ids=None, sub_id=None,
+             extra="", default_node=None, form_ns=None, filters=None,
+             profile_key=C.PROF_KEY_NONE):
+        """bridge method to retrieve data from node with schema
+
+        this method is a helper so dependant plugins can use it directly
+        when adding *Get methods
+        extra can have the key "labels_as_list" which is a hack to convert
+            labels from textbox to list in XMLUI, which usually render better
+            in final UI.
+        """
+        if filters is None:
+            filters = {}
+        extra = data_format.deserialise(extra)
+        # XXX: Q&D way to get list for labels when displaying them, but text when we
+        #      have to modify them
+        if C.bool(extra.get("labels_as_list", C.BOOL_FALSE)):
+            filters = filters.copy()
+            filters["labels"] = self.textbox_2_list_filter
+        client, service, node, max_items, extra, sub_id = self.prepare_bridge_get(
+            service, node, max_items, sub_id, extra, profile_key
+        )
+        d = defer.ensureDeferred(
+            self.get_data_form_items(
+                client,
+                service,
+                node or None,
+                max_items=max_items,
+                item_ids=item_ids,
+                sub_id=sub_id,
+                rsm_request=extra.rsm_request,
+                extra=extra.extra,
+                default_node=default_node,
+                form_ns=form_ns,
+                filters=filters,
+            )
+        )
+        d.addCallback(self._p.trans_items_data)
+        d.addCallback(lambda data: data_format.serialise(data))
+        return d
+
+    def prepare_bridge_set(self, service, node, schema, item_id, extra, profile_key):
+        """Parse arguments received from bridge *Set methods and return higher level data
+
+        @return (tuple): (client, service, node, schema, item_id, extra) usable for
+            internal methods
+        """
+        client = self.host.get_client(profile_key)
+        service = None if not service else jid.JID(service)
+        if schema:
+            schema = generic.parseXml(schema.encode("utf-8"))
+        else:
+            schema = None
+        extra = data_format.deserialise(extra)
+        return client, service, node or None, schema, item_id or None, extra
+
+    async def copy_missing_values(self, client, service, node, item_id, form_ns, values):
+        """Retrieve values existing in original item and missing in update
+
+        Existing item will be retrieve, and values not already specified in values will
+        be filled
+        @param service: same as for [XEP_0060.get_items]
+        @param node: same as for [XEP_0060.get_items]
+        @param item_id(unicode): id of the item to retrieve
+        @param form_ns (unicode, None): namespace of the form
+        @param values(dict): values to fill
+            This dict will be modified *in place* to fill value present in existing
+            item and missing in the dict.
+        """
+        try:
+            # we get previous item
+            items_data = await self._p.get_items(
+                client, service, node, item_ids=[item_id]
+            )
+            item_elt = items_data[0][0]
+        except Exception as e:
+            log.warning(
+                _("Can't get previous item, update ignored: {reason}").format(
+                    reason=e
+                )
+            )
+        else:
+            # and parse it
+            form = data_form.findForm(item_elt, form_ns)
+            if form is None:
+                log.warning(
+                    _("Can't parse previous item, update ignored: data form not found")
+                )
+            else:
+                for name, field in form.fields.items():
+                    if name not in values:
+                        values[name] = "\n".join(str(v) for v in field.values)
+
+    def _set(self, service, node, values, schema=None, item_id=None, extra=None,
+             default_node=None, form_ns=None, fill_author=True,
+             profile_key=C.PROF_KEY_NONE):
+        """bridge method to set item in node with schema
+
+        this method is a helper so dependant plugins can use it directly
+        when adding *Set methods
+        """
+        client, service, node, schema, item_id, extra = self.prepare_bridge_set(
+            service, node, schema, item_id, extra
+        )
+        d = defer.ensureDeferred(self.set(
+            client,
+            service,
+            node,
+            values,
+            schema,
+            item_id,
+            extra,
+            deserialise=True,
+            form_ns=form_ns,
+            default_node=default_node,
+            fill_author=fill_author,
+        ))
+        d.addCallback(lambda ret: ret or "")
+        return d
+
+    async def set(
+            self, client, service, node, values, schema, item_id, extra, deserialise,
+            form_ns, default_node=None, fill_author=True):
+        """Set an item in a node with a schema
+
+        This method can be used directly by *Set methods added by dependant plugin
+        @param values(dict[key(unicode), [iterable[object]|object]]): values of the items
+            if value is not iterable, it will be put in a list
+            'created' and 'updated' will be forced to current time:
+                - 'created' is set if item_id is None, i.e. if it's a new ticket
+                - 'updated' is set everytime
+        @param extra(dict, None): same as for [XEP-0060.send_item] with additional keys:
+            - update(bool): if True, get previous item data to merge with current one
+                if True, item_id must be set
+        @param form_ns (unicode, None): namespace of the form
+            needed when an update is done
+        @param default_node(unicode, None): value to use if node is not set
+        other arguments are same as for [self._s.send_data_form_item]
+        @return (unicode): id of the created item
+        """
+        if extra is None:
+            extra = {}
+        if not node:
+            if default_node is None:
+                raise ValueError(_("default_node must be set if node is not set"))
+            node = default_node
+        node = self.get_submitted_ns(node)
+        now = utils.xmpp_date()
+        if not item_id:
+            values["created"] = now
+        elif extra.get("update", False):
+            if item_id is None:
+                raise exceptions.DataError(
+                    _('if extra["update"] is set, item_id must be set too')
+                )
+            await self.copy_missing_values(client, service, node, item_id, form_ns, values)
+
+        values["updated"] = now
+        if fill_author:
+            if not values.get("author"):
+                id_data = await self._i.get_identity(client, None, ["nicknames"])
+                values["author"] = id_data['nicknames'][0]
+            if not values.get("author_jid"):
+                values["author_jid"] = client.jid.full()
+        item_id = await self.send_data_form_item(
+            client, service, node, values, schema, item_id, extra, deserialise
+        )
+        return item_id
+
+
+@implementer(iwokkel.IDisco)
+class SchemaHandler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_FDP)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0352.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Explicit Message Encryption
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from twisted.words.xish import domish
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Client State Indication",
+    C.PI_IMPORT_NAME: "XEP-0352",
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_PROTOCOLS: ["XEP-0352"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "XEP_0352",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: D_("Notify server when frontend is not actively used, to limit "
+                         "traffic and save bandwidth and battery life"),
+}
+
+NS_CSI = "urn:xmpp:csi:0"
+
+
+class XEP_0352(object):
+
+    def __init__(self, host):
+        log.info(_("Client State Indication plugin initialization"))
+        self.host = host
+        host.register_namespace("csi", NS_CSI)
+
+    def is_active(self, client):
+        try:
+            if not client._xep_0352_enabled:
+                return True
+            return client._xep_0352_active
+        except AttributeError:
+            # _xep_0352_active can not be set if is_active is called before
+            # profile_connected has been called
+            log.debug("is_active called when XEP-0352 plugin has not yet set the "
+                      "attributes")
+            return True
+
+    def profile_connected(self, client):
+        if (NS_CSI, 'csi') in client.xmlstream.features:
+            log.info(_("Client State Indication is available on this server"))
+            client._xep_0352_enabled = True
+            client._xep_0352_active = True
+        else:
+            log.warning(_("Client State Indication is not available on this server, some"
+                          " bandwidth optimisations can't be used."))
+            client._xep_0352_enabled = False
+
+    def set_inactive(self, client):
+        if self.is_active(client):
+            inactive_elt = domish.Element((NS_CSI, 'inactive'))
+            client.send(inactive_elt)
+            client._xep_0352_active = False
+            log.info("inactive state set")
+
+    def set_active(self, client):
+        if not self.is_active(client):
+            active_elt = domish.Element((NS_CSI, 'active'))
+            client.send(active_elt)
+            client._xep_0352_active = True
+            log.info("active state set")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0353.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,263 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Jingle Message Initiation (XEP-0353)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from twisted.internet import defer
+from twisted.internet import reactor
+from twisted.words.protocols.jabber import error, jid
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xml_tools
+
+log = getLogger(__name__)
+
+
+NS_JINGLE_MESSAGE = "urn:xmpp:jingle-message:0"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Jingle Message Initiation",
+    C.PI_IMPORT_NAME: "XEP-0353",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: [C.PLUG_MODE_CLIENT],
+    C.PI_PROTOCOLS: ["XEP-0353"],
+    C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0334"],
+    C.PI_MAIN: "XEP_0353",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Jingle Message Initiation"""),
+}
+
+
+class XEP_0353:
+    def __init__(self, host):
+        log.info(_("plugin {name} initialization").format(name=PLUGIN_INFO[C.PI_NAME]))
+        self.host = host
+        host.register_namespace("jingle-message", NS_JINGLE_MESSAGE)
+        self._j = host.plugins["XEP-0166"]
+        self._h = host.plugins["XEP-0334"]
+        host.trigger.add(
+            "XEP-0166_initiate_elt_built",
+            self._on_initiate_trigger,
+            # this plugin set the resource, we want it to happen first to other trigger
+            # can get the full peer JID
+            priority=host.trigger.MAX_PRIORITY,
+        )
+        host.trigger.add("message_received", self._on_message_received)
+
+    def get_handler(self, client):
+        return Handler()
+
+    def profile_connecting(self, client):
+        # mapping from session id to deferred used to wait for destinee answer
+        client._xep_0353_pending_sessions = {}
+
+    def build_message_data(self, client, peer_jid, verb, session_id):
+        mess_data = {
+            "from": client.jid,
+            "to": peer_jid,
+            "uid": "",
+            "message": {},
+            "type": C.MESS_TYPE_CHAT,
+            "subject": {},
+            "extra": {},
+        }
+        client.generate_message_xml(mess_data)
+        message_elt = mess_data["xml"]
+        verb_elt = message_elt.addElement((NS_JINGLE_MESSAGE, verb))
+        verb_elt["id"] = session_id
+        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
+        return mess_data
+
+    async def _on_initiate_trigger(
+        self,
+        client: SatXMPPEntity,
+        session: dict,
+        iq_elt: domish.Element,
+        jingle_elt: domish.Element,
+    ) -> bool:
+        peer_jid = session["peer_jid"]
+        if peer_jid.resource:
+            return True
+
+        try:
+            infos = await self.host.memory.disco.get_infos(client, peer_jid)
+        except error.StanzaError as e:
+            if e.condition == "service-unavailable":
+                categories = {}
+            else:
+                raise e
+        else:
+            categories = {c for c, __ in infos.identities}
+        if "component" in categories:
+            # we don't use message initiation with components
+            return True
+
+        if peer_jid.userhostJID() not in client.roster:
+            # if the contact is not in our roster, we need to send a directed presence
+            # according to XEP-0353 §3.1
+            await client.presence.available(peer_jid)
+
+        mess_data = self.build_message_data(client, peer_jid, "propose", session["id"])
+        message_elt = mess_data["xml"]
+        for content_data in session["contents"].values():
+            # we get the full element build by the application plugin
+            jingle_description_elt = content_data["application_data"]["desc_elt"]
+            # and copy it to only keep the root <description> element, no children
+            description_elt = domish.Element(
+                (jingle_description_elt.uri, jingle_description_elt.name),
+                defaultUri=jingle_description_elt.defaultUri,
+                attribs=jingle_description_elt.attributes,
+                localPrefixes=jingle_description_elt.localPrefixes,
+            )
+            message_elt.propose.addChild(description_elt)
+        response_d = defer.Deferred()
+        # we wait for 2 min before cancelling the session init
+        # FIXME: let's application decide timeout?
+        response_d.addTimeout(2 * 60, reactor)
+        client._xep_0353_pending_sessions[session["id"]] = response_d
+        await client.send_message_data(mess_data)
+        try:
+            accepting_jid = await response_d
+        except defer.TimeoutError:
+            log.warning(
+                _("Message initiation with {peer_jid} timed out").format(
+                    peer_jid=peer_jid
+                )
+            )
+        else:
+            if iq_elt["to"] != accepting_jid.userhost():
+                raise exceptions.InternalError(
+                    f"<jingle> 'to' attribute ({iq_elt['to']!r}) must not differ "
+                    f"from bare JID of the accepting entity ({accepting_jid!r}), this "
+                    "may be a sign of an internal bug, a hack attempt, or a MITM attack!"
+                )
+            iq_elt["to"] = accepting_jid.full()
+            session["peer_jid"] = accepting_jid
+        del client._xep_0353_pending_sessions[session["id"]]
+        return True
+
+    async def _on_message_received(self, client, message_elt, post_treat):
+        for elt in message_elt.elements():
+            if elt.uri == NS_JINGLE_MESSAGE:
+                if elt.name == "propose":
+                    return await self._handle_propose(client, message_elt, elt)
+                elif elt.name == "retract":
+                    return self._handle_retract(client, message_elt, elt)
+                elif elt.name == "proceed":
+                    return self._handle_proceed(client, message_elt, elt)
+                elif elt.name == "accept":
+                    return self._handle_accept(client, message_elt, elt)
+                elif elt.name == "reject":
+                    return self._handle_accept(client, message_elt, elt)
+                else:
+                    log.warning(f"invalid element: {elt.toXml}")
+                    return True
+        return True
+
+    async def _handle_propose(self, client, message_elt, elt):
+        peer_jid = jid.JID(message_elt["from"])
+        session_id = elt["id"]
+        if peer_jid.userhostJID() not in client.roster:
+            app_ns = elt.description.uri
+            try:
+                application = self._j.get_application(app_ns)
+                human_name = getattr(application.handler, "human_name", application.name)
+            except (exceptions.NotFound, AttributeError):
+                if app_ns.startswith("urn:xmpp:jingle:apps:"):
+                    human_name = app_ns[21:].split(":", 1)[0].replace("-", " ").title()
+                else:
+                    splitted_ns = app_ns.split(":")
+                    if len(splitted_ns) > 1:
+                        human_name = splitted_ns[-2].replace("- ", " ").title()
+                    else:
+                        human_name = app_ns
+
+            confirm_msg = D_(
+                "Somebody not in your contact list ({peer_jid}) wants to do a "
+                '"{human_name}" session with you, this would leak your presence and '
+                "possibly you IP (internet localisation), do you accept?"
+            ).format(peer_jid=peer_jid, human_name=human_name)
+            confirm_title = D_("Invitation from an unknown contact")
+            accept = await xml_tools.defer_confirm(
+                self.host,
+                confirm_msg,
+                confirm_title,
+                profile=client.profile,
+                action_extra={
+                    "type": C.META_TYPE_NOT_IN_ROSTER_LEAK,
+                    "session_id": session_id,
+                    "from_jid": peer_jid.full(),
+                },
+            )
+            if not accept:
+                mess_data = self.build_message_data(
+                    client, client.jid.userhostJID(), "reject", session_id
+                )
+                await client.send_message_data(mess_data)
+                # we don't sent anything to sender, to avoid leaking presence
+                return False
+            else:
+                await client.presence.available(peer_jid)
+        session_id = elt["id"]
+        mess_data = self.build_message_data(client, peer_jid, "proceed", session_id)
+        await client.send_message_data(mess_data)
+        return False
+
+    def _handle_retract(self, client, message_elt, proceed_elt):
+        log.warning("retract is not implemented yet")
+        return False
+
+    def _handle_proceed(self, client, message_elt, proceed_elt):
+        try:
+            session_id = proceed_elt["id"]
+        except KeyError:
+            log.warning(f"invalid proceed element in message_elt: {message_elt}")
+            return True
+        try:
+            response_d = client._xep_0353_pending_sessions[session_id]
+        except KeyError:
+            log.warning(
+                _(
+                    "no pending session found with id {session_id}, did it timed out?"
+                ).format(session_id=session_id)
+            )
+            return True
+
+        response_d.callback(jid.JID(message_elt["from"]))
+        return False
+
+    def _handle_accept(self, client, message_elt, accept_elt):
+        pass
+
+    def _handle_reject(self, client, message_elt, accept_elt):
+        pass
+
+
+@implementer(iwokkel.IDisco)
+class Handler(xmlstream.XMPPHandler):
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_JINGLE_MESSAGE)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0359.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Message Archive Management (XEP-0359)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional
+import uuid
+from zope.interface import implementer
+from twisted.words.protocols.jabber import xmlstream
+from wokkel import disco
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from twisted.words.xish import domish
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Unique and Stable Stanza IDs",
+    C.PI_IMPORT_NAME: "XEP-0359",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0359"],
+    C.PI_MAIN: "XEP_0359",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of Unique and Stable Stanza IDs"""),
+}
+
+NS_SID = "urn:xmpp:sid:0"
+
+
+class XEP_0359(object):
+
+    def __init__(self, host):
+        log.info(_("Unique and Stable Stanza IDs plugin initialization"))
+        self.host = host
+        host.register_namespace("stanza_id", NS_SID)
+        host.trigger.add("message_parse", self._message_parse_trigger)
+        host.trigger.add("send_message_data", self._send_message_data_trigger)
+
+    def _message_parse_trigger(self, client, message_elt, mess_data):
+        """Check if message has a stanza-id"""
+        stanza_id = self.get_stanza_id(message_elt, client.jid.userhostJID())
+        if stanza_id is not None:
+            mess_data['extra']['stanza_id'] = stanza_id
+        origin_id = self.get_origin_id(message_elt)
+        if origin_id is not None:
+            mess_data['extra']['origin_id'] = origin_id
+        return True
+
+    def _send_message_data_trigger(self, client, mess_data):
+        origin_id = mess_data["extra"].get("origin_id")
+        if not origin_id:
+            origin_id = str(uuid.uuid4())
+            mess_data["extra"]["origin_id"] = origin_id
+        message_elt = mess_data["xml"]
+        self.add_origin_id(message_elt, origin_id)
+
+    def get_stanza_id(self, element, by):
+        """Return stanza-id if found in element
+
+        @param element(domish.Element): element to parse
+        @param by(jid.JID): entity which should have set a stanza-id
+        @return (unicode, None): stanza-id if found
+        """
+        stanza_id = None
+        for stanza_elt in element.elements(NS_SID, "stanza-id"):
+            if stanza_elt.getAttribute("by") == by.full():
+                if stanza_id is not None:
+                    # we must not have more than one element (§3 #4)
+                    raise exceptions.DataError(
+                        "More than one corresponding stanza-id found!")
+                stanza_id = stanza_elt.getAttribute("id")
+                # we don't break to be sure that there is no more than one element
+                # with this "by" attribute
+
+        return stanza_id
+
+    def add_stanza_id(self, client, element, stanza_id, by=None):
+        """Add a <stanza-id/> to a stanza
+
+        @param element(domish.Element): stanza where the <stanza-id/> must be added
+        @param stanza_id(unicode): id to use
+        @param by(jid.JID, None): jid to use or None to use client.jid
+        """
+        sid_elt = element.addElement((NS_SID, "stanza-id"))
+        sid_elt["by"] = client.jid.userhost() if by is None else by.userhost()
+        sid_elt["id"] = stanza_id
+
+    def get_origin_id(self, element: domish.Element) -> Optional[str]:
+        """Return origin-id if found in element
+
+        @param element: element to parse
+        @return: origin-id if found
+        """
+        try:
+            origin_elt = next(element.elements(NS_SID, "origin-id"))
+        except StopIteration:
+            return None
+        else:
+            return origin_elt.getAttribute("id")
+
+    def add_origin_id(self, element, origin_id=None):
+        """Add a <origin-id/> to a stanza
+
+        @param element(domish.Element): stanza where the <origin-id/> must be added
+        @param origin_id(str): id to use, None to automatically generate
+        @return (str): origin_id
+        """
+        if origin_id is None:
+            origin_id = str(uuid.uuid4())
+        sid_elt = element.addElement((NS_SID, "origin-id"))
+        sid_elt["id"] = origin_id
+        return origin_id
+
+    def get_handler(self, client):
+        return XEP_0359_handler()
+
+
+@implementer(disco.IDisco)
+class XEP_0359_handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_SID)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0363.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,451 @@
+#!/usr/bin/env python3
+
+# SàT plugin for HTTP File Upload (XEP-0363)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from dataclasses import dataclass
+import mimetypes
+import os.path
+from pathlib import Path
+from typing import Callable, NamedTuple, Optional, Tuple
+from urllib import parse
+
+from twisted.internet import reactor
+from twisted.internet import defer
+from twisted.web import client as http_client
+from twisted.web import http_headers
+from twisted.words.protocols.jabber import error, jid, xmlstream
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.xmpp import SatXMPPComponent
+from libervia.backend.tools import utils, web as sat_web
+
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "HTTP File Upload",
+    C.PI_IMPORT_NAME: "XEP-0363",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0363"],
+    C.PI_DEPENDENCIES: ["FILE", "UPLOAD"],
+    C.PI_MAIN: "XEP_0363",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of HTTP File Upload"""),
+}
+
+NS_HTTP_UPLOAD = "urn:xmpp:http:upload:0"
+IQ_HTTP_UPLOAD_REQUEST = C.IQ_GET + '/request[@xmlns="' + NS_HTTP_UPLOAD + '"]'
+ALLOWED_HEADERS = ('authorization', 'cookie', 'expires')
+
+
+@dataclass
+class Slot:
+    """Upload slot"""
+    put: str
+    get: str
+    headers: list
+
+
+class UploadRequest(NamedTuple):
+    from_: jid.JID
+    filename: str
+    size: int
+    content_type: Optional[str]
+
+
+class RequestHandler(NamedTuple):
+    callback: Callable[[SatXMPPComponent, UploadRequest], Optional[Slot]]
+    priority: int
+
+
+class XEP_0363:
+    Slot=Slot
+
+    def __init__(self, host):
+        log.info(_("plugin HTTP File Upload initialization"))
+        self.host = host
+        host.bridge.add_method(
+            "file_http_upload",
+            ".plugin",
+            in_sign="sssbs",
+            out_sign="",
+            method=self._file_http_upload,
+        )
+        host.bridge.add_method(
+            "file_http_upload_get_slot",
+            ".plugin",
+            in_sign="sisss",
+            out_sign="(ssaa{ss})",
+            method=self._get_slot,
+            async_=True,
+        )
+        host.plugins["UPLOAD"].register(
+            "HTTP Upload", self.get_http_upload_entity, self.file_http_upload
+        )
+        # list of callbacks used when a request is done to a component
+        self.handlers = []
+        # XXX: there is not yet official short name, so we use "http_upload"
+        host.register_namespace("http_upload", NS_HTTP_UPLOAD)
+
+    def get_handler(self, client):
+        return XEP_0363_handler(self)
+
+    def register_handler(self, callback, priority=0):
+        """Register a request handler
+
+        @param callack: method to call when a request is done
+            the callback must return a Slot if the request is handled,
+            otherwise, other callbacks will be tried.
+            If the callback raises a StanzaError, its condition will be used if no other
+            callback can handle the request.
+        @param priority: handlers with higher priorities will be called first
+        """
+        assert callback not in self.handlers
+        req_handler = RequestHandler(callback, priority)
+        self.handlers.append(req_handler)
+        self.handlers.sort(key=lambda handler: handler.priority, reverse=True)
+
+    def get_file_too_large_elt(self, max_size: int) -> domish.Element:
+        """Generate <file-too-large> app condition for errors"""
+        file_too_large_elt = domish.Element((NS_HTTP_UPLOAD, "file-too-large"))
+        file_too_large_elt.addElement("max-file-size", str(max_size))
+        return file_too_large_elt
+
+    async def get_http_upload_entity(self, client, upload_jid=None):
+        """Get HTTP upload capable entity
+
+         upload_jid is checked, then its components
+         @param upload_jid(None, jid.JID): entity to check
+         @return(D(jid.JID)): first HTTP upload capable entity
+         @raise exceptions.NotFound: no entity found
+         """
+        try:
+            entity = client.http_upload_service
+        except AttributeError:
+            found_entities = await self.host.find_features_set(client, (NS_HTTP_UPLOAD,))
+            try:
+                entity = client.http_upload_service = next(iter(found_entities))
+            except StopIteration:
+                entity = client.http_upload_service = None
+
+        if entity is None:
+            raise exceptions.NotFound("No HTTP upload entity found")
+
+        return entity
+
+    def _file_http_upload(self, filepath, filename="", upload_jid="",
+                        ignore_tls_errors=False, profile=C.PROF_KEY_NONE):
+        assert os.path.isabs(filepath) and os.path.isfile(filepath)
+        client = self.host.get_client(profile)
+        return defer.ensureDeferred(self.file_http_upload(
+            client,
+            filepath,
+            filename or None,
+            jid.JID(upload_jid) if upload_jid else None,
+            {"ignore_tls_errors": ignore_tls_errors},
+        ))
+
+    async def file_http_upload(
+        self,
+        client: SatXMPPEntity,
+        filepath: Path,
+        filename: Optional[str] = None,
+        upload_jid: Optional[jid.JID] = None,
+        extra: Optional[dict] = None
+    ) -> Tuple[str, defer.Deferred]:
+        """Upload a file through HTTP
+
+        @param filepath: absolute path of the file
+        @param filename: name to use for the upload
+            None to use basename of the path
+        @param upload_jid: upload capable entity jid,
+            or None to use autodetected, if possible
+        @param extra: options where key can be:
+            - ignore_tls_errors(bool): if True, SSL certificate will not be checked
+            - attachment(dict): file attachment data
+        @param profile: %(doc_profile)s
+        @return: progress id and Deferred which fire download URL
+        """
+        if extra is None:
+            extra = {}
+        ignore_tls_errors = extra.get("ignore_tls_errors", False)
+        file_metadata = {
+            "filename": filename or os.path.basename(filepath),
+            "filepath": filepath,
+            "size": os.path.getsize(filepath),
+        }
+
+        #: this trigger can be used to modify the filename or size requested when geting
+        #: the slot, it is notably useful with encryption.
+        self.host.trigger.point(
+            "XEP-0363_upload_pre_slot", client, extra, file_metadata,
+            triggers_no_cancel=True
+        )
+        try:
+            slot = await self.get_slot(
+                client, file_metadata["filename"], file_metadata["size"],
+                upload_jid=upload_jid
+            )
+        except Exception as e:
+            log.warning(_("Can't get upload slot: {reason}").format(reason=e))
+            raise e
+        else:
+            log.debug(f"Got upload slot: {slot}")
+            sat_file = self.host.plugins["FILE"].File(
+                self.host, client, filepath, uid=extra.get("progress_id"),
+                size=file_metadata["size"],
+                auto_end_signals=False
+            )
+            progress_id = sat_file.uid
+
+            file_producer = http_client.FileBodyProducer(sat_file)
+
+            if ignore_tls_errors:
+                agent = http_client.Agent(reactor, sat_web.NoCheckContextFactory())
+            else:
+                agent = http_client.Agent(reactor)
+
+            headers = {"User-Agent": [C.APP_NAME.encode("utf-8")]}
+
+            for name, value in slot.headers:
+                name = name.encode('utf-8')
+                value = value.encode('utf-8')
+                headers[name] = value
+
+
+            await self.host.trigger.async_point(
+                "XEP-0363_upload", client, extra, sat_file, file_producer, slot,
+                triggers_no_cancel=True)
+
+            download_d = agent.request(
+                b"PUT",
+                slot.put.encode("utf-8"),
+                http_headers.Headers(headers),
+                file_producer,
+            )
+            download_d.addCallbacks(
+                self._upload_cb,
+                self._upload_eb,
+                (sat_file, slot),
+                None,
+                (sat_file,),
+            )
+
+            return progress_id, download_d
+
+    def _upload_cb(self, __, sat_file, slot):
+        """Called once file is successfully uploaded
+
+        @param sat_file(SatFile): file used for the upload
+            should be closed, but it is needed to send the progress_finished signal
+        @param slot(Slot): put/get urls
+        """
+        log.info(f"HTTP upload finished ({slot.get})")
+        sat_file.progress_finished({"url": slot.get})
+        return slot.get
+
+    def _upload_eb(self, failure_, sat_file):
+        """Called on unsuccessful upload
+
+        @param sat_file(SatFile): file used for the upload
+            should be closed, be is needed to send the progress_error signal
+        """
+        try:
+            wrapped_fail = failure_.value.reasons[0]
+        except (AttributeError, IndexError) as e:
+            log.warning(_("upload failed: {reason}").format(reason=e))
+            sat_file.progress_error(str(failure_))
+        else:
+            if wrapped_fail.check(sat_web.SSLError):
+                msg = "TLS validation error, can't connect to HTTPS server"
+            else:
+                msg = "can't upload file"
+            log.warning(msg + ": " + str(wrapped_fail.value))
+            sat_file.progress_error(msg)
+        raise failure_
+
+    def _get_slot(self, filename, size, content_type, upload_jid,
+                 profile_key=C.PROF_KEY_NONE):
+        """Get an upload slot
+
+        This method can be used when uploading is done by the frontend
+        @param filename(unicode): name of the file to upload
+        @param size(int): size of the file (must be non null)
+        @param upload_jid(str, ''): HTTP upload capable entity
+        @param content_type(unicode, None): MIME type of the content
+            empty string or None to guess automatically
+        """
+        client = self.host.get_client(profile_key)
+        filename = filename.replace("/", "_")
+        d = defer.ensureDeferred(self.get_slot(
+            client, filename, size, content_type or None, jid.JID(upload_jid) or None
+        ))
+        d.addCallback(lambda slot: (slot.get, slot.put, slot.headers))
+        return d
+
+    async def get_slot(self, client, filename, size, content_type=None, upload_jid=None):
+        """Get a slot (i.e. download/upload links)
+
+        @param filename(unicode): name to use for the upload
+        @param size(int): size of the file to upload (must be >0)
+        @param content_type(None, unicode): MIME type of the content
+            None to autodetect
+        @param upload_jid(jid.JID, None): HTTP upload capable upload_jid
+            or None to use the server component (if any)
+        @param client: %(doc_client)s
+        @return (Slot): the upload (put) and download (get) URLs
+        @raise exceptions.NotFound: no HTTP upload capable upload_jid has been found
+        """
+        assert filename and size
+        if content_type is None:
+            # TODO: manage python magic for file guessing (in a dedicated plugin ?)
+            content_type = mimetypes.guess_type(filename, strict=False)[0]
+
+        if upload_jid is None:
+            try:
+                upload_jid = client.http_upload_service
+            except AttributeError:
+                found_entity = await self.get_http_upload_entity(client)
+                return await self.get_slot(
+                    client, filename, size, content_type, found_entity)
+            else:
+                if upload_jid is None:
+                    raise exceptions.NotFound("No HTTP upload entity found")
+
+        iq_elt = client.IQ("get")
+        iq_elt["to"] = upload_jid.full()
+        request_elt = iq_elt.addElement((NS_HTTP_UPLOAD, "request"))
+        request_elt["filename"] = filename
+        request_elt["size"] = str(size)
+        if content_type is not None:
+            request_elt["content-type"] = content_type
+
+        iq_result_elt = await iq_elt.send()
+
+        try:
+            slot_elt = next(iq_result_elt.elements(NS_HTTP_UPLOAD, "slot"))
+            put_elt = next(slot_elt.elements(NS_HTTP_UPLOAD, "put"))
+            put_url = put_elt['url']
+            get_elt = next(slot_elt.elements(NS_HTTP_UPLOAD, "get"))
+            get_url = get_elt['url']
+        except (StopIteration, KeyError):
+            raise exceptions.DataError("Incorrect stanza received from server")
+
+        headers = []
+        for header_elt in put_elt.elements(NS_HTTP_UPLOAD, "header"):
+            try:
+                name = header_elt["name"]
+                value = str(header_elt)
+            except KeyError:
+                log.warning(_("Invalid header element: {xml}").format(
+                    iq_result_elt.toXml()))
+                continue
+            name = name.replace('\n', '')
+            value = value.replace('\n', '')
+            if name.lower() not in ALLOWED_HEADERS:
+                log.warning(_('Ignoring unauthorised header "{name}": {xml}')
+                    .format(name=name, xml = iq_result_elt.toXml()))
+                continue
+            headers.append((name, value))
+
+        return Slot(put=put_url, get=get_url, headers=headers)
+
+    # component
+
+    def on_component_request(self, iq_elt, client):
+        iq_elt.handled=True
+        defer.ensureDeferred(self.handle_component_request(client, iq_elt))
+
+    async def handle_component_request(self, client, iq_elt):
+        try:
+            request_elt = next(iq_elt.elements(NS_HTTP_UPLOAD, "request"))
+            request = UploadRequest(
+                from_=jid.JID(iq_elt['from']),
+                filename=parse.quote(request_elt['filename'].replace('/', '_'), safe=''),
+                size=int(request_elt['size']),
+                content_type=request_elt.getAttribute('content-type')
+            )
+        except (StopIteration, KeyError, ValueError):
+            client.sendError(iq_elt, "bad-request")
+            return
+
+        err = None
+
+        for handler in self.handlers:
+            try:
+                slot = await utils.as_deferred(handler.callback, client, request)
+            except error.StanzaError as e:
+                log.warning(
+                    "a stanza error has been raised while processing HTTP Upload of "
+                    f"request: {e}"
+                )
+                if err is None:
+                    # we keep the first error to return its condition later,
+                    # if no other callback handle the request
+                    err = e
+            else:
+                if slot:
+                    break
+        else:
+            log.warning(
+                _("no service can handle HTTP Upload request: {elt}")
+                .format(elt=iq_elt.toXml()))
+            if err is None:
+                err = error.StanzaError("feature-not-implemented")
+            client.send(err.toResponse(iq_elt))
+            return
+
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        slot_elt = iq_result_elt.addElement((NS_HTTP_UPLOAD, 'slot'))
+        put_elt = slot_elt.addElement('put')
+        put_elt['url'] = slot.put
+        get_elt = slot_elt.addElement('get')
+        get_elt['url'] = slot.get
+        client.send(iq_result_elt)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0363_handler(xmlstream.XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+
+    def connectionInitialized(self):
+        if ((self.parent.is_component
+             and PLUGIN_INFO[C.PI_IMPORT_NAME] in self.parent.enabled_features)):
+            self.xmlstream.addObserver(
+                IQ_HTTP_UPLOAD_REQUEST, self.plugin_parent.on_component_request,
+                client=self.parent
+            )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        if ((self.parent.is_component
+             and not PLUGIN_INFO[C.PI_IMPORT_NAME] in self.parent.enabled_features)):
+            return []
+        else:
+            return [disco.DiscoFeature(NS_HTTP_UPLOAD)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0372.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,230 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for XEP-0372
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional, Dict, Union
+from textwrap import dedent
+from libervia.backend.core import exceptions
+from libervia.backend.tools.common import uri as xmpp_uri
+
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from zope.interface import implementer
+from wokkel import disco, iwokkel
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.tools.common import data_format
+
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "References",
+    C.PI_IMPORT_NAME: "XEP-0372",
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0372"],
+    C.PI_DEPENDENCIES: ["XEP-0334"],
+    C.PI_MAIN: "XEP_0372",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _(dedent("""\
+        XEP-0372 (References) implementation
+
+        This plugin implement generic references and mentions.
+    """)),
+}
+
+NS_REFS = "urn:xmpp:reference:0"
+ALLOWED_TYPES = ("mention", "data")
+
+
+class XEP_0372:
+    namespace = NS_REFS
+
+    def __init__(self, host):
+        log.info(_("References plugin initialization"))
+        host.register_namespace("refs", NS_REFS)
+        self.host = host
+        self._h = host.plugins["XEP-0334"]
+        host.trigger.add("message_received", self._message_received_trigger)
+        host.bridge.add_method(
+            "reference_send",
+            ".plugin",
+            in_sign="sssss",
+            out_sign="",
+            method=self._send_reference,
+            async_=False,
+        )
+
+    def get_handler(self, client):
+        return XEP_0372_Handler()
+
+    def ref_element_to_ref_data(
+        self,
+        reference_elt: domish.Element
+    ) -> Dict[str, Union[str, int, dict]]:
+        ref_data: Dict[str, Union[str, int, dict]] = {
+            "uri": reference_elt["uri"],
+            "type": reference_elt["type"]
+        }
+
+        if ref_data["uri"].startswith("xmpp:"):
+            ref_data["parsed_uri"] = xmpp_uri.parse_xmpp_uri(ref_data["uri"])
+
+        for attr in ("begin", "end"):
+            try:
+                ref_data[attr] = int(reference_elt[attr])
+            except (KeyError, ValueError, TypeError):
+                continue
+
+        anchor = reference_elt.getAttribute("anchor")
+        if anchor is not None:
+            ref_data["anchor"] = anchor
+            if anchor.startswith("xmpp:"):
+                ref_data["parsed_anchor"] = xmpp_uri.parse_xmpp_uri(anchor)
+        return ref_data
+
+    async def _message_received_trigger(
+        self,
+        client: SatXMPPEntity,
+        message_elt: domish.Element,
+        post_treat: defer.Deferred
+    ) -> bool:
+        """Check if a direct invitation is in the message, and handle it"""
+        reference_elt = next(message_elt.elements(NS_REFS, "reference"), None)
+        if reference_elt is None:
+            return True
+        try:
+            ref_data = self.ref_element_to_ref_data(reference_elt)
+        except KeyError:
+            log.warning("invalid <reference> element: {reference_elt.toXml}")
+            return True
+
+        if not await self.host.trigger.async_point(
+            "XEP-0372_ref_received", client, message_elt, ref_data
+        ):
+            return False
+        return True
+
+    def build_ref_element(
+        self,
+        uri: str,
+        type_: str = "mention",
+        begin: Optional[int] = None,
+        end: Optional[int] = None,
+        anchor: Optional[str] = None,
+    ) -> domish.Element:
+        """Build and return the <reference> element"""
+        if type_ not in ALLOWED_TYPES:
+            raise ValueError(f"Unknown type: {type_!r}")
+        reference_elt = domish.Element(
+            (NS_REFS, "reference"),
+            attribs={"uri": uri, "type": type_}
+        )
+        if begin is not None:
+            reference_elt["begin"] = str(begin)
+        if end is not None:
+            reference_elt["end"] = str(end)
+        if anchor is not None:
+            reference_elt["anchor"] = anchor
+        return reference_elt
+
+    def _send_reference(
+        self,
+        recipient: str,
+        anchor: str,
+        type_: str,
+        extra_s: str,
+        profile_key: str
+    ) -> defer.Deferred:
+        recipient_jid = jid.JID(recipient)
+        client = self.host.get_client(profile_key)
+        extra: dict = data_format.deserialise(extra_s, default={})
+        self.send_reference(
+            client,
+            uri=extra.get("uri"),
+            type_=type_,
+            anchor=anchor,
+            to_jid=recipient_jid
+        )
+
+    def send_reference(
+        self,
+        client: "SatXMPPEntity",
+        uri: Optional[str] = None,
+        type_: str = "mention",
+        begin: Optional[int] = None,
+        end: Optional[int] = None,
+        anchor: Optional[str] = None,
+        message_elt: Optional[domish.Element] = None,
+        to_jid: Optional[jid.JID] = None
+    ) -> None:
+        """Build and send a reference_elt
+
+        @param uri: URI pointing to referenced object (XMPP entity, Pubsub Item, etc)
+            if not set, "to_jid" will be used to build an URI to the entity
+        @param type_: type of reference
+            one of [ALLOWED_TYPES]
+        @param begin: optional begin index
+        @param end: optional end index
+        @param anchor: URI of refering object (message, pubsub item), when the refence
+            is not already in the wrapping message element. In other words, it's the
+            object where the reference appears.
+        @param message_elt: wrapping <message> element, if not set a new one will be
+            generated
+        @param to_jid: destinee of the reference. If not specified, "to" attribute of
+            message_elt will be used.
+
+        """
+        if uri is None:
+            if to_jid is None:
+                raise exceptions.InternalError(
+                    '"to_jid" must be set if "uri is None"'
+                )
+            uri = xmpp_uri.build_xmpp_uri(path=to_jid.full())
+        if message_elt is None:
+            message_elt = domish.Element((None, "message"))
+
+        if to_jid is not None:
+            message_elt["to"] = to_jid.full()
+        else:
+            try:
+                to_jid = jid.JID(message_elt["to"])
+            except (KeyError, RuntimeError):
+                raise exceptions.InternalError(
+                    'invalid "to" attribute in given message element: '
+                    '{message_elt.toXml()}'
+                )
+
+        message_elt.addChild(self.build_ref_element(uri, type_, begin, end, anchor))
+        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
+        client.send(message_elt)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0372_Handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_REFS)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0373.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,2102 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for OpenPGP for XMPP
+# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from abc import ABC, abstractmethod
+import base64
+from datetime import datetime, timezone
+import enum
+import secrets
+import string
+from typing import Any, Dict, Iterable, List, Literal, Optional, Set, Tuple, cast
+from xml.sax.saxutils import quoteattr
+
+from typing_extensions import Final, NamedTuple, Never, assert_never
+from wokkel import muc, pubsub
+from wokkel.disco import DiscoFeature, DiscoInfo
+import xmlschema
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger, Logger
+from libervia.backend.core.sat_main import SAT
+from libervia.backend.core.xmpp import SatXMPPClient
+from libervia.backend.memory import persistent
+from libervia.backend.plugins.plugin_xep_0045 import XEP_0045
+from libervia.backend.plugins.plugin_xep_0060 import XEP_0060
+from libervia.backend.plugins.plugin_xep_0163 import XEP_0163
+from libervia.backend.tools.xmpp_datetime import format_datetime, parse_datetime
+from libervia.backend.tools import xml_tools
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
+
+try:
+    import gpg
+except ImportError as import_error:
+    raise exceptions.MissingModule(
+        "You are missing the 'gpg' package required by the OX plugin. The recommended"
+        " installation method is via your operating system's package manager, since the"
+        " version of the library has to match the version of your GnuPG installation. See"
+        " https://wiki.python.org/moin/GnuPrivacyGuard#Accessing_GnuPG_via_gpgme"
+    ) from import_error
+
+
+__all__ = [  # pylint: disable=unused-variable
+    "PLUGIN_INFO",
+    "NS_OX",
+    "XEP_0373",
+    "VerificationError",
+    "XMPPInteractionFailed",
+    "InvalidPacket",
+    "DecryptionFailed",
+    "VerificationFailed",
+    "UnknownKey",
+    "GPGProviderError",
+    "GPGPublicKey",
+    "GPGSecretKey",
+    "GPGProvider",
+    "PublicKeyMetadata",
+    "gpg_provider",
+    "TrustLevel"
+]
+
+
+log = cast(Logger, getLogger(__name__))  # type: ignore[no-untyped-call]
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "XEP-0373",
+    C.PI_IMPORT_NAME: "XEP-0373",
+    C.PI_TYPE: "SEC",
+    C.PI_PROTOCOLS: [ "XEP-0373" ],
+    C.PI_DEPENDENCIES: [ "XEP-0060", "XEP-0163" ],
+    C.PI_RECOMMENDATIONS: [],
+    C.PI_MAIN: "XEP_0373",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: D_("Implementation of OpenPGP for XMPP"),
+}
+
+
+NS_OX: Final = "urn:xmpp:openpgp:0"
+
+
+PARAM_CATEGORY = "Security"
+PARAM_NAME = "ox_policy"
+STR_KEY_PUBLIC_KEYS_METADATA = "/public-keys-metadata/{}"
+
+
+class VerificationError(Exception):
+    """
+    Raised by verifying methods of :class:`XEP_0373` on semantical verification errors.
+    """
+
+
+class XMPPInteractionFailed(Exception):
+    """
+    Raised by methods of :class:`XEP_0373` on XMPP interaction failure. The reason this
+    exception exists is that the exceptions raised by XMPP interactions are not properly
+    documented for the most part, thus all exceptions are caught and wrapped in instances
+    of this class.
+    """
+
+
+class InvalidPacket(ValueError):
+    """
+    Raised by methods of :class:`GPGProvider` when an invalid packet is encountered.
+    """
+
+
+class DecryptionFailed(Exception):
+    """
+    Raised by methods of :class:`GPGProvider` on decryption failures.
+    """
+
+
+class VerificationFailed(Exception):
+    """
+    Raised by methods of :class:`GPGProvider` on verification failures.
+    """
+
+
+class UnknownKey(ValueError):
+    """
+    Raised by methods of :class:`GPGProvider` when an unknown key is referenced.
+    """
+
+
+class GPGProviderError(Exception):
+    """
+    Raised by methods of :class:`GPGProvider` on internal errors.
+    """
+
+
+class GPGPublicKey(ABC):
+    """
+    Interface describing a GPG public key.
+    """
+
+    @property
+    @abstractmethod
+    def fingerprint(self) -> str:
+        """
+        @return: The OpenPGP v4 fingerprint string of this public key.
+        """
+
+
+class GPGSecretKey(ABC):
+    """
+    Interface descibing a GPG secret key.
+    """
+
+    @property
+    @abstractmethod
+    def public_key(self) -> GPGPublicKey:
+        """
+        @return: The public key corresponding to this secret key.
+        """
+
+
+class GPGProvider(ABC):
+    """
+    Interface describing a GPG provider, i.e. a library or framework providing GPG
+    encryption, signing and key management.
+
+    All methods may raise :class:`GPGProviderError` in addition to those exception types
+    listed explicitly.
+
+    # TODO: Check keys for revoked, disabled and expired everywhere and exclude those (?)
+    """
+
+    @abstractmethod
+    def export_public_key(self, public_key: GPGPublicKey) -> bytes:
+        """Export a public key in a key material packet according to RFC 4880 §5.5.
+
+        Do not use OpenPGP's ASCII Armor.
+
+        @param public_key: The public key to export.
+        @return: The packet containing the exported public key.
+        @raise UnknownKey: if the public key is not available.
+        """
+
+    @abstractmethod
+    def import_public_key(self, packet: bytes) -> GPGPublicKey:
+        """import a public key from a key material packet according to RFC 4880 §5.5.
+
+        OpenPGP's ASCII Armor is not used.
+
+        @param packet: A packet containing an exported public key.
+        @return: The public key imported from the packet.
+        @raise InvalidPacket: if the packet is either syntactically or semantically deemed
+            invalid.
+
+        @warning: Only packets of version 4 or higher may be accepted, packets below
+            version 4 MUST be rejected.
+        """
+
+    @abstractmethod
+    def backup_secret_key(self, secret_key: GPGSecretKey) -> bytes:
+        """Export a secret key for transfer according to RFC 4880 §11.1.
+
+        Do not encrypt the secret data, i.e. set the octet indicating string-to-key usage
+        conventions to zero in the corresponding secret-key packet according to RFC 4880
+        §5.5.3. Do not use OpenPGP's ASCII Armor.
+
+        @param secret_key: The secret key to export.
+        @return: The binary blob containing the exported secret key.
+        @raise UnknownKey: if the secret key is not available.
+        """
+
+    @abstractmethod
+    def restore_secret_keys(self, data: bytes) -> Set[GPGSecretKey]:
+        """Restore secret keys exported for transfer according to RFC 4880 §11.1.
+
+        The secret data is not encrypted, i.e. the octet indicating string-to-key usage
+        conventions in the corresponding secret-key packets according to RFC 4880 §5.5.3
+        are set to zero. OpenPGP's ASCII Armor is not used.
+
+        @param data: Concatenation of one or more secret keys exported for transfer.
+        @return: The secret keys imported from the data.
+        @raise InvalidPacket: if the data or one of the packets included in the data is
+            either syntactically or semantically deemed invalid.
+
+        @warning: Only packets of version 4 or higher may be accepted, packets below
+            version 4 MUST be rejected.
+        """
+
+    @abstractmethod
+    def encrypt_symmetrically(self, plaintext: bytes, password: str) -> bytes:
+        """Encrypt data symmetrically according to RFC 4880 §5.3.
+
+        The password is used to build a Symmetric-Key Encrypted Session Key packet which
+        precedes the Symmetrically Encrypted Data packet that holds the encrypted data.
+
+        @param plaintext: The data to encrypt.
+        @param password: The password to encrypt the data with.
+        @return: The encrypted data.
+        """
+
+    @abstractmethod
+    def decrypt_symmetrically(self, ciphertext: bytes, password: str) -> bytes:
+        """Decrypt data symmetrically according to RFC 4880 §5.3.
+
+        The ciphertext consists of a Symmetrically Encrypted Data packet that holds the
+        encrypted data, preceded by a Symmetric-Key Encrypted Session Key packet using the
+        password.
+
+        @param ciphertext: The ciphertext.
+        @param password: The password to decrypt the data with.
+        @return: The plaintext.
+        @raise DecryptionFailed: on decryption failure.
+        """
+
+    @abstractmethod
+    def sign(self, data: bytes, secret_keys: Set[GPGSecretKey]) -> bytes:
+        """Sign some data.
+
+        OpenPGP's ASCII Armor is not used.
+
+        @param data: The data to sign.
+        @param secret_keys: The secret keys to sign the data with.
+        @return: The OpenPGP message carrying the signed data.
+        """
+
+    @abstractmethod
+    def sign_detached(self, data: bytes, secret_keys: Set[GPGSecretKey]) -> bytes:
+        """Sign some data. Create the signature detached from the data.
+
+        OpenPGP's ASCII Armor is not used.
+
+        @param data: The data to sign.
+        @param secret_keys: The secret keys to sign the data with.
+        @return: The OpenPGP message carrying the detached signature.
+        """
+
+    @abstractmethod
+    def verify(self, signed_data: bytes, public_keys: Set[GPGPublicKey]) -> bytes:
+        """Verify signed data.
+
+        OpenPGP's ASCII Armor is not used.
+
+        @param signed_data: The signed data as an OpenPGP message.
+        @param public_keys: The public keys to verify the signature with.
+        @return: The verified and unpacked data.
+        @raise VerificationFailed: if the data could not be verified.
+
+        @warning: For implementors: it has to be confirmed that a valid signature by one
+            of the public keys is available.
+        """
+
+    @abstractmethod
+    def verify_detached(
+        self,
+        data: bytes,
+        signature: bytes,
+        public_keys: Set[GPGPublicKey]
+    ) -> None:
+        """Verify signed data, where the signature was created detached from the data.
+
+        OpenPGP's ASCII Armor is not used.
+
+        @param data: The data.
+        @param signature: The signature as an OpenPGP message.
+        @param public_keys: The public keys to verify the signature with.
+        @raise VerificationFailed: if the data could not be verified.
+
+        @warning: For implementors: it has to be confirmed that a valid signature by one
+            of the public keys is available.
+        """
+
+    @abstractmethod
+    def encrypt(
+        self,
+        plaintext: bytes,
+        public_keys: Set[GPGPublicKey],
+        signing_keys: Optional[Set[GPGSecretKey]] = None
+    ) -> bytes:
+        """Encrypt and optionally sign some data.
+
+        OpenPGP's ASCII Armor is not used.
+
+        @param plaintext: The data to encrypt and optionally sign.
+        @param public_keys: The public keys to encrypt the data for.
+        @param signing_keys: The secret keys to sign the data with.
+        @return: The OpenPGP message carrying the encrypted and optionally signed data.
+        """
+
+    @abstractmethod
+    def decrypt(
+        self,
+        ciphertext: bytes,
+        secret_keys: Set[GPGSecretKey],
+        public_keys: Optional[Set[GPGPublicKey]] = None
+    ) -> bytes:
+        """Decrypt and optionally verify some data.
+
+        OpenPGP's ASCII Armor is not used.
+
+        @param ciphertext: The encrypted and optionally signed data as an OpenPGP message.
+        @param secret_keys: The secret keys to attempt decryption with.
+        @param public_keys: The public keys to verify the optional signature with.
+        @return: The decrypted, optionally verified and unpacked data.
+        @raise DecryptionFailed: on decryption failure.
+        @raise VerificationFailed: if the data could not be verified.
+
+        @warning: For implementors: it has to be confirmed that the data was decrypted
+            using one of the secret keys and that a valid signature by one of the public
+            keys is available in case the data is signed.
+        """
+
+    @abstractmethod
+    def list_public_keys(self, user_id: str) -> Set[GPGPublicKey]:
+        """List public keys.
+
+        @param user_id: The user id.
+        @return: The set of public keys available for this user id.
+        """
+
+    @abstractmethod
+    def list_secret_keys(self, user_id: str) -> Set[GPGSecretKey]:
+        """List secret keys.
+
+        @param user_id: The user id.
+        @return: The set of secret keys available for this user id.
+        """
+
+    @abstractmethod
+    def can_sign(self, public_key: GPGPublicKey) -> bool:
+        """
+        @return: Whether the public key belongs to a key pair capable of signing.
+        """
+
+    @abstractmethod
+    def can_encrypt(self, public_key: GPGPublicKey) -> bool:
+        """
+        @return: Whether the public key belongs to a key pair capable of encryption.
+        """
+
+    @abstractmethod
+    def create_key(self, user_id: str) -> GPGSecretKey:
+        """Create a new GPG key, capable of signing and encryption.
+
+        The key is generated without password protection and without expiration. If a key
+        with the same user id already exists, a new key is created anyway.
+
+        @param user_id: The user id to assign to the new key.
+        @return: The new key.
+        """
+
+
+class GPGME_GPGPublicKey(GPGPublicKey):
+    """
+    GPG public key implementation based on GnuPG Made Easy (GPGME).
+    """
+
+    def __init__(self, key_obj: Any) -> None:
+        """
+        @param key_obj: The GPGME key object.
+        """
+
+        self.__key_obj = key_obj
+
+    @property
+    def fingerprint(self) -> str:
+        return self.__key_obj.fpr
+
+    @property
+    def key_obj(self) -> Any:
+        return self.__key_obj
+
+
+class GPGME_GPGSecretKey(GPGSecretKey):
+    """
+    GPG secret key implementation based on GnuPG Made Easy (GPGME).
+    """
+
+    def __init__(self, public_key: GPGME_GPGPublicKey) -> None:
+        """
+        @param public_key: The public key corresponding to this secret key.
+        """
+
+        self.__public_key = public_key
+
+    @property
+    def public_key(self) -> GPGME_GPGPublicKey:
+        return self.__public_key
+
+
+class GPGME_GPGProvider(GPGProvider):
+    """
+    GPG provider implementation based on GnuPG Made Easy (GPGME).
+    """
+
+    def __init__(self, home_dir: Optional[str] = None) -> None:
+        """
+        @param home_dir: Optional GPG home directory path to use for all operations.
+        """
+
+        self.__home_dir = home_dir
+
+    def export_public_key(self, public_key: GPGPublicKey) -> bytes:
+        assert isinstance(public_key, GPGME_GPGPublicKey)
+
+        pattern = public_key.fingerprint
+
+        with gpg.Context(home_dir=self.__home_dir) as c:
+            try:
+                result = c.key_export_minimal(pattern)
+            except gpg.errors.GPGMEError as e:
+                raise GPGProviderError("Internal GPGME error") from e
+
+            if result is None:
+                raise UnknownKey(f"Public key {pattern} not found.")
+
+            return result
+
+    def import_public_key(self, packet: bytes) -> GPGPublicKey:
+        # TODO
+        # - Reject packets older than version 4
+        # - Check whether it's actually a public key (through packet inspection?)
+
+        with gpg.Context(home_dir=self.__home_dir) as c:
+            try:
+                result = c.key_import(packet)
+            except gpg.errors.GPGMEError as e:
+                # From looking at the code, `key_import` never raises. The documentation
+                # says it does though, so this is included for future-proofness.
+                raise GPGProviderError("Internal GPGME error") from e
+
+            if not hasattr(result, "considered"):
+                raise InvalidPacket(
+                    f"Data not considered for public key import: {result}"
+                )
+
+            if len(result.imports) != 1:
+                raise InvalidPacket(
+                    "Public key packet does not contain exactly one public key (not"
+                    " counting subkeys)."
+                )
+
+            try:
+                key_obj = c.get_key(result.imports[0].fpr, secret=False)
+            except gpg.errors.GPGMEError as e:
+                raise GPGProviderError("Internal GPGME error") from e
+            except gpg.errors.KeyError as e:
+                raise GPGProviderError("Newly imported public key not found") from e
+
+            return GPGME_GPGPublicKey(key_obj)
+
+    def backup_secret_key(self, secret_key: GPGSecretKey) -> bytes:
+        assert isinstance(secret_key, GPGME_GPGSecretKey)
+        # TODO
+        # - Handle password protection/pinentry
+        # - Make sure the key is exported unencrypted
+
+        pattern = secret_key.public_key.fingerprint
+
+        with gpg.Context(home_dir=self.__home_dir) as c:
+            try:
+                result = c.key_export_secret(pattern)
+            except gpg.errors.GPGMEError as e:
+                raise GPGProviderError("Internal GPGME error") from e
+
+            if result is None:
+                raise UnknownKey(f"Secret key {pattern} not found.")
+
+            return result
+
+    def restore_secret_keys(self, data: bytes) -> Set[GPGSecretKey]:
+        # TODO
+        # - Reject packets older than version 4
+        # - Check whether it's actually secret keys (through packet inspection?)
+
+        with gpg.Context(home_dir=self.__home_dir) as c:
+            try:
+                result = c.key_import(data)
+            except gpg.errors.GPGMEError as e:
+                # From looking at the code, `key_import` never raises. The documentation
+                # says it does though, so this is included for future-proofness.
+                raise GPGProviderError("Internal GPGME error") from e
+
+            if not hasattr(result, "considered"):
+                raise InvalidPacket(
+                    f"Data not considered for secret key import: {result}"
+                )
+
+            if len(result.imports) == 0:
+                raise InvalidPacket("Secret key packet does not contain a secret key.")
+
+            secret_keys = set()
+            for import_status in result.imports:
+                try:
+                    key_obj = c.get_key(import_status.fpr, secret=True)
+                except gpg.errors.GPGMEError as e:
+                    raise GPGProviderError("Internal GPGME error") from e
+                except gpg.errors.KeyError as e:
+                    raise GPGProviderError("Newly imported secret key not found") from e
+
+                secret_keys.add(GPGME_GPGSecretKey(GPGME_GPGPublicKey(key_obj)))
+
+            return secret_keys
+
+    def encrypt_symmetrically(self, plaintext: bytes, password: str) -> bytes:
+        with gpg.Context(home_dir=self.__home_dir) as c:
+            try:
+                ciphertext, __, __ = c.encrypt(plaintext, passphrase=password, sign=False)
+            except gpg.errors.GPGMEError as e:
+                raise GPGProviderError("Internal GPGME error") from e
+
+            return ciphertext
+
+    def decrypt_symmetrically(self, ciphertext: bytes, password: str) -> bytes:
+        with gpg.Context(home_dir=self.__home_dir) as c:
+            try:
+                plaintext, __, __ = c.decrypt(
+                    ciphertext,
+                    passphrase=password,
+                    verify=False
+                )
+            except gpg.errors.GPGMEError as e:
+                # TODO: Find out what kind of error is raised if the password is wrong and
+                # re-raise it as DecryptionFailed instead.
+                raise GPGProviderError("Internal GPGME error") from e
+            except gpg.UnsupportedAlgorithm as e:
+                raise DecryptionFailed("Unsupported algorithm") from e
+
+            return plaintext
+
+    def sign(self, data: bytes, secret_keys: Set[GPGSecretKey]) -> bytes:
+        signers = []
+        for secret_key in secret_keys:
+            assert isinstance(secret_key, GPGME_GPGSecretKey)
+
+            signers.append(secret_key.public_key.key_obj)
+
+        with gpg.Context(home_dir=self.__home_dir, signers=signers) as c:
+            try:
+                signed_data, __ = c.sign(data)
+            except gpg.errors.GPGMEError as e:
+                raise GPGProviderError("Internal GPGME error") from e
+            except gpg.errors.InvalidSigners as e:
+                raise GPGProviderError(
+                    "At least one of the secret keys is invalid for signing"
+                ) from e
+
+            return signed_data
+
+    def sign_detached(self, data: bytes, secret_keys: Set[GPGSecretKey]) -> bytes:
+        signers = []
+        for secret_key in secret_keys:
+            assert isinstance(secret_key, GPGME_GPGSecretKey)
+
+            signers.append(secret_key.public_key.key_obj)
+
+        with gpg.Context(home_dir=self.__home_dir, signers=signers) as c:
+            try:
+                signature, __ = c.sign(data, mode=gpg.constants.sig.mode.DETACH)
+            except gpg.errors.GPGMEError as e:
+                raise GPGProviderError("Internal GPGME error") from e
+            except gpg.errors.InvalidSigners as e:
+                raise GPGProviderError(
+                    "At least one of the secret keys is invalid for signing"
+                ) from e
+
+            return signature
+
+    def verify(self, signed_data: bytes, public_keys: Set[GPGPublicKey]) -> bytes:
+        with gpg.Context(home_dir=self.__home_dir) as c:
+            try:
+                data, result = c.verify(signed_data)
+            except gpg.errors.GPGMEError as e:
+                raise GPGProviderError("Internal GPGME error") from e
+            except gpg.errors.BadSignatures as e:
+                raise VerificationFailed("Bad signatures on signed data") from e
+
+            valid_signature_found = False
+            for public_key in public_keys:
+                assert isinstance(public_key, GPGME_GPGPublicKey)
+
+                for subkey in public_key.key_obj.subkeys:
+                    for sig in result.signatures:
+                        if subkey.can_sign and subkey.fpr == sig.fpr:
+                            valid_signature_found = True
+
+            if not valid_signature_found:
+                raise VerificationFailed(
+                    "Data not signed by one of the expected public keys"
+                )
+
+            return data
+
+    def verify_detached(
+        self,
+        data: bytes,
+        signature: bytes,
+        public_keys: Set[GPGPublicKey]
+    ) -> None:
+        with gpg.Context(home_dir=self.__home_dir) as c:
+            try:
+                __, result = c.verify(data, signature=signature)
+            except gpg.errors.GPGMEError as e:
+                raise GPGProviderError("Internal GPGME error") from e
+            except gpg.errors.BadSignatures as e:
+                raise VerificationFailed("Bad signatures on signed data") from e
+
+            valid_signature_found = False
+            for public_key in public_keys:
+                assert isinstance(public_key, GPGME_GPGPublicKey)
+
+                for subkey in public_key.key_obj.subkeys:
+                    for sig in result.signatures:
+                        if subkey.can_sign and subkey.fpr == sig.fpr:
+                            valid_signature_found = True
+
+            if not valid_signature_found:
+                raise VerificationFailed(
+                    "Data not signed by one of the expected public keys"
+                )
+
+    def encrypt(
+        self,
+        plaintext: bytes,
+        public_keys: Set[GPGPublicKey],
+        signing_keys: Optional[Set[GPGSecretKey]] = None
+    ) -> bytes:
+        recipients = []
+        for public_key in public_keys:
+            assert isinstance(public_key, GPGME_GPGPublicKey)
+
+            recipients.append(public_key.key_obj)
+
+        signers = []
+        if signing_keys is not None:
+            for secret_key in signing_keys:
+                assert isinstance(secret_key, GPGME_GPGSecretKey)
+
+                signers.append(secret_key.public_key.key_obj)
+
+        sign = signing_keys is not None
+
+        with gpg.Context(home_dir=self.__home_dir, signers=signers) as c:
+            try:
+                ciphertext, __, __ = c.encrypt(
+                    plaintext,
+                    recipients=recipients,
+                    sign=sign,
+                    always_trust=True,
+                    add_encrypt_to=True
+                )
+            except gpg.errors.GPGMEError as e:
+                raise GPGProviderError("Internal GPGME error") from e
+            except gpg.errors.InvalidRecipients as e:
+                raise GPGProviderError(
+                    "At least one of the public keys is invalid for encryption"
+                ) from e
+            except gpg.errors.InvalidSigners as e:
+                raise GPGProviderError(
+                    "At least one of the signing keys is invalid for signing"
+                ) from e
+
+            return ciphertext
+
+    def decrypt(
+        self,
+        ciphertext: bytes,
+        secret_keys: Set[GPGSecretKey],
+        public_keys: Optional[Set[GPGPublicKey]] = None
+    ) -> bytes:
+        verify = public_keys is not None
+
+        with gpg.Context(home_dir=self.__home_dir) as c:
+            try:
+                plaintext, result, verify_result = c.decrypt(
+                    ciphertext,
+                    verify=verify
+                )
+            except gpg.errors.GPGMEError as e:
+                raise GPGProviderError("Internal GPGME error") from e
+            except gpg.UnsupportedAlgorithm as e:
+                raise DecryptionFailed("Unsupported algorithm") from e
+
+            # TODO: Check whether the data was decrypted using one of the expected secret
+            # keys
+
+            if public_keys is not None:
+                valid_signature_found = False
+                for public_key in public_keys:
+                    assert isinstance(public_key, GPGME_GPGPublicKey)
+
+                    for subkey in public_key.key_obj.subkeys:
+                        for sig in verify_result.signatures:
+                            if subkey.can_sign and subkey.fpr == sig.fpr:
+                                valid_signature_found = True
+
+                if not valid_signature_found:
+                    raise VerificationFailed(
+                        "Data not signed by one of the expected public keys"
+                    )
+
+            return plaintext
+
+    def list_public_keys(self, user_id: str) -> Set[GPGPublicKey]:
+        with gpg.Context(home_dir=self.__home_dir) as c:
+            try:
+                return {
+                    GPGME_GPGPublicKey(key)
+                    for key
+                    in c.keylist(pattern=user_id, secret=False)
+                }
+            except gpg.errors.GPGMEError as e:
+                raise GPGProviderError("Internal GPGME error") from e
+
+    def list_secret_keys(self, user_id: str) -> Set[GPGSecretKey]:
+        with gpg.Context(home_dir=self.__home_dir) as c:
+            try:
+                return {
+                    GPGME_GPGSecretKey(GPGME_GPGPublicKey(key))
+                    for key
+                    in c.keylist(pattern=user_id, secret=True)
+                }
+            except gpg.errors.GPGMEError as e:
+                raise GPGProviderError("Internal GPGME error") from e
+
+    def can_sign(self, public_key: GPGPublicKey) -> bool:
+        assert isinstance(public_key, GPGME_GPGPublicKey)
+
+        return any(subkey.can_sign for subkey in public_key.key_obj.subkeys)
+
+    def can_encrypt(self, public_key: GPGPublicKey) -> bool:
+        assert isinstance(public_key, GPGME_GPGPublicKey)
+
+        return any(subkey.can_encrypt for subkey in public_key.key_obj.subkeys)
+
+    def create_key(self, user_id: str) -> GPGSecretKey:
+        with gpg.Context(home_dir=self.__home_dir) as c:
+            try:
+                result = c.create_key(
+                    user_id,
+                    expires=False,
+                    sign=True,
+                    encrypt=True,
+                    certify=False,
+                    authenticate=False,
+                    force=True
+                )
+
+                key_obj = c.get_key(result.fpr, secret=True)
+            except gpg.errors.GPGMEError as e:
+                raise GPGProviderError("Internal GPGME error") from e
+            except gpg.errors.KeyError as e:
+                raise GPGProviderError("Newly created key not found") from e
+
+            return GPGME_GPGSecretKey(GPGME_GPGPublicKey(key_obj))
+
+
+class PublicKeyMetadata(NamedTuple):
+    """
+    Metadata about a published public key.
+    """
+
+    fingerprint: str
+    timestamp: datetime
+
+
+@enum.unique
+class TrustLevel(enum.Enum):
+    """
+    The trust levels required for BTBV and manual trust.
+    """
+
+    TRUSTED: str = "TRUSTED"
+    BLINDLY_TRUSTED: str = "BLINDLY_TRUSTED"
+    UNDECIDED: str = "UNDECIDED"
+    DISTRUSTED: str = "DISTRUSTED"
+
+
+OPENPGP_SCHEMA = xmlschema.XMLSchema("""<?xml version="1.0" encoding="utf8"?>
+<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
+    targetNamespace="urn:xmpp:openpgp:0"
+    xmlns="urn:xmpp:openpgp:0">
+
+    <xs:element name="openpgp" type="xs:base64Binary"/>
+</xs:schema>
+""")
+
+
+# The following schema needs verion 1.1 of XML Schema, which is not supported by lxml.
+# Luckily, xmlschema exists, which is a clean, well maintained, cross-platform
+# implementation of XML Schema, including version 1.1.
+CONTENT_SCHEMA = xmlschema.XMLSchema11("""<?xml version="1.1" encoding="utf8"?>
+<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
+    targetNamespace="urn:xmpp:openpgp:0"
+    xmlns="urn:xmpp:openpgp:0">
+
+    <xs:element name="signcrypt">
+        <xs:complexType>
+            <xs:all>
+                <xs:element ref="to" maxOccurs="unbounded"/>
+                <xs:element ref="time"/>
+                <xs:element ref="rpad" minOccurs="0"/>
+                <xs:element ref="payload"/>
+            </xs:all>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="sign">
+        <xs:complexType>
+            <xs:all>
+                <xs:element ref="to" maxOccurs="unbounded"/>
+                <xs:element ref="time"/>
+                <xs:element ref="rpad" minOccurs="0"/>
+                <xs:element ref="payload"/>
+            </xs:all>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="crypt">
+        <xs:complexType>
+            <xs:all>
+                <xs:element ref="to" minOccurs="0" maxOccurs="unbounded"/>
+                <xs:element ref="time"/>
+                <xs:element ref="rpad" minOccurs="0"/>
+                <xs:element ref="payload"/>
+            </xs:all>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="to">
+        <xs:complexType>
+            <xs:attribute name="jid" type="xs:string"/>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="time">
+        <xs:complexType>
+            <xs:attribute name="stamp" type="xs:dateTime"/>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="rpad" type="xs:string"/>
+
+    <xs:element name="payload">
+        <xs:complexType>
+            <xs:sequence>
+                <xs:any minOccurs="0" maxOccurs="unbounded" processContents="skip"/>
+            </xs:sequence>
+        </xs:complexType>
+    </xs:element>
+</xs:schema>
+""")
+
+
+PUBLIC_KEYS_LIST_NODE = "urn:xmpp:openpgp:0:public-keys"
+PUBLIC_KEYS_LIST_SCHEMA = xmlschema.XMLSchema("""<?xml version="1.0" encoding="utf8"?>
+<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
+    targetNamespace="urn:xmpp:openpgp:0"
+    xmlns="urn:xmpp:openpgp:0">
+
+    <xs:element name="public-keys-list">
+        <xs:complexType>
+            <xs:sequence>
+                <xs:element ref="pubkey-metadata" minOccurs="0" maxOccurs="unbounded"/>
+            </xs:sequence>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="pubkey-metadata">
+        <xs:complexType>
+            <xs:attribute name="v4-fingerprint" type="xs:string"/>
+            <xs:attribute name="date" type="xs:dateTime"/>
+        </xs:complexType>
+    </xs:element>
+</xs:schema>
+""")
+
+
+PUBKEY_SCHEMA = xmlschema.XMLSchema("""<?xml version="1.0" encoding="utf8"?>
+<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
+    targetNamespace="urn:xmpp:openpgp:0"
+    xmlns="urn:xmpp:openpgp:0">
+
+    <xs:element name="pubkey">
+        <xs:complexType>
+            <xs:all>
+                <xs:element ref="data"/>
+            </xs:all>
+            <xs:anyAttribute processContents="skip"/>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="data" type="xs:base64Binary"/>
+</xs:schema>
+""")
+
+
+SECRETKEY_SCHEMA = xmlschema.XMLSchema("""<?xml version="1.0" encoding="utf8"?>
+<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
+    targetNamespace="urn:xmpp:openpgp:0"
+    xmlns="urn:xmpp:openpgp:0">
+
+    <xs:element name="secretkey" type="xs:base64Binary"/>
+</xs:schema>
+""")
+
+
+DEFAULT_TRUST_MODEL_PARAM = f"""
+<params>
+<individual>
+<category name="{PARAM_CATEGORY}" label={quoteattr(D_('Security'))}>
+    <param name="{PARAM_NAME}"
+        label={quoteattr(D_('OMEMO default trust policy'))}
+        type="list" security="3">
+        <option value="manual" label={quoteattr(D_('Manual trust (more secure)'))} />
+        <option value="btbv"
+            label={quoteattr(D_('Blind Trust Before Verification (more user friendly)'))}
+            selected="true" />
+    </param>
+</category>
+</individual>
+</params>
+"""
+
+
+def get_gpg_provider(sat: SAT, client: SatXMPPClient) -> GPGProvider:
+    """Get the GPG provider for a client.
+
+    @param sat: The SAT instance.
+    @param client: The client.
+    @return: The GPG provider specifically for that client.
+    """
+
+    return GPGME_GPGProvider(str(sat.get_local_path(client, "gnupg-home")))
+
+
+def generate_passphrase() -> str:
+    """Generate a secure passphrase for symmetric encryption.
+
+    @return: The passphrase.
+    """
+
+    return "-".join("".join(
+        secrets.choice("123456789ABCDEFGHIJKLMNPQRSTUVWXYZ") for __ in range(4)
+    ) for __ in range(6))
+
+
+# TODO: Handle the user id mess
+class XEP_0373:
+    """
+    Implementation of XEP-0373: OpenPGP for XMPP under namespace ``urn:xmpp:openpgp:0``.
+    """
+
+    def __init__(self, host: SAT) -> None:
+        """
+        @param sat: The SAT instance.
+        """
+
+        self.host = host
+
+        # Add configuration option to choose between manual trust and BTBV as the trust
+        # model
+        host.memory.update_params(DEFAULT_TRUST_MODEL_PARAM)
+
+        self.__xep_0045 = cast(Optional[XEP_0045], host.plugins.get("XEP-0045"))
+        self.__xep_0060 = cast(XEP_0060, host.plugins["XEP-0060"])
+
+        self.__storage: Dict[str, persistent.LazyPersistentBinaryDict] = {}
+
+        xep_0163 = cast(XEP_0163, host.plugins["XEP-0163"])
+        xep_0163.add_pep_event(
+            "OX_PUBLIC_KEYS_LIST",
+            PUBLIC_KEYS_LIST_NODE,
+            lambda items_event, profile: defer.ensureDeferred(
+                self.__on_public_keys_list_update(items_event, profile)
+            )
+        )
+
+    async def profile_connecting(self, client):
+        client.gpg_provider = get_gpg_provider(self.host, client)
+
+    async def profile_connected(  # pylint: disable=invalid-name
+        self,
+        client: SatXMPPClient
+    ) -> None:
+        """
+        @param client: The client.
+        """
+
+        profile = cast(str, client.profile)
+
+        if not profile in self.__storage:
+            self.__storage[profile] = \
+                persistent.LazyPersistentBinaryDict("XEP-0373", client.profile)
+
+        if len(self.list_secret_keys(client)) == 0:
+            log.debug(f"Generating first GPG key for {client.jid.userhost()}.")
+            await self.create_key(client)
+
+    async def __on_public_keys_list_update(
+        self,
+        items_event: pubsub.ItemsEvent,
+        profile: str
+    ) -> None:
+        """Handle public keys list updates fired by PEP.
+
+        @param items_event: The event.
+        @param profile: The profile this event belongs to.
+        """
+
+        client = self.host.get_client(profile)
+
+        sender = cast(jid.JID, items_event.sender)
+        items = cast(List[domish.Element], items_event.items)
+
+        if len(items) > 1:
+            log.warning("Ignoring public keys list update with more than one element.")
+            return
+
+        item_elt = next(iter(items), None)
+        if item_elt is None:
+            log.debug("Ignoring empty public keys list update.")
+            return
+
+        public_keys_list_elt = cast(
+            Optional[domish.Element],
+            next(item_elt.elements(NS_OX, "public-keys-list"), None)
+        )
+
+        pubkey_metadata_elts: Optional[List[domish.Element]] = None
+
+        if public_keys_list_elt is not None:
+            try:
+                PUBLIC_KEYS_LIST_SCHEMA.validate(public_keys_list_elt.toXml())
+            except xmlschema.XMLSchemaValidationError:
+                pass
+            else:
+                pubkey_metadata_elts = \
+                    list(public_keys_list_elt.elements(NS_OX, "pubkey-metadata"))
+
+        if pubkey_metadata_elts is None:
+            log.warning(f"Malformed public keys list update item: {item_elt.toXml()}")
+            return
+
+        new_public_keys_metadata = { PublicKeyMetadata(
+            fingerprint=cast(str, pubkey_metadata_elt["v4-fingerprint"]),
+            timestamp=parse_datetime(cast(str, pubkey_metadata_elt["date"]))
+        ) for pubkey_metadata_elt in pubkey_metadata_elts }
+
+        storage_key = STR_KEY_PUBLIC_KEYS_METADATA.format(sender.userhost())
+
+        local_public_keys_metadata = cast(
+            Set[PublicKeyMetadata],
+            await self.__storage[profile].get(storage_key, set())
+        )
+
+        unchanged_keys = new_public_keys_metadata & local_public_keys_metadata
+        changed_or_new_keys = new_public_keys_metadata - unchanged_keys
+        available_keys = self.list_public_keys(client, sender)
+
+        for key_metadata in changed_or_new_keys:
+            # Check whether the changed or new key has been imported before
+            if any(key.fingerprint == key_metadata.fingerprint for key in available_keys):
+                try:
+                    # If it has been imported before, try to update it
+                    await self.import_public_key(client, sender, key_metadata.fingerprint)
+                except Exception as e:
+                    log.warning(f"Public key import failed: {e}")
+
+                    # If the update fails, remove the key from the local metadata list
+                    # such that the update is attempted again next time
+                    new_public_keys_metadata.remove(key_metadata)
+
+        # Check whether this update was for our account and make sure all of our keys are
+        # included in the update
+        if sender.userhost() == client.jid.userhost():
+            secret_keys = self.list_secret_keys(client)
+            missing_keys = set(filter(lambda secret_key: all(
+                key_metadata.fingerprint != secret_key.public_key.fingerprint
+                for key_metadata
+                in new_public_keys_metadata
+            ), secret_keys))
+
+            if len(missing_keys) > 0:
+                log.warning(
+                    "Public keys list update did not contain at least one of our keys."
+                    f" {new_public_keys_metadata}"
+                )
+
+                for missing_key in missing_keys:
+                    log.warning(missing_key.public_key.fingerprint)
+                    new_public_keys_metadata.add(PublicKeyMetadata(
+                        fingerprint=missing_key.public_key.fingerprint,
+                        timestamp=datetime.now(timezone.utc)
+                    ))
+
+                await self.publish_public_keys_list(client, new_public_keys_metadata)
+
+        await self.__storage[profile].force(storage_key, new_public_keys_metadata)
+
+    def list_public_keys(self, client: SatXMPPClient, jid: jid.JID) -> Set[GPGPublicKey]:
+        """List GPG public keys available for a JID.
+
+        @param client: The client to perform this operation with.
+        @param jid: The JID. Can be a bare JID.
+        @return: The set of public keys available for this JID.
+        """
+
+        gpg_provider = get_gpg_provider(self.host, client)
+
+        return gpg_provider.list_public_keys(f"xmpp:{jid.userhost()}")
+
+    def list_secret_keys(self, client: SatXMPPClient) -> Set[GPGSecretKey]:
+        """List GPG secret keys available for a JID.
+
+        @param client: The client to perform this operation with.
+        @return: The set of secret keys available for this JID.
+        """
+
+        gpg_provider = get_gpg_provider(self.host, client)
+
+        return gpg_provider.list_secret_keys(f"xmpp:{client.jid.userhost()}")
+
+    async def create_key(self, client: SatXMPPClient) -> GPGSecretKey:
+        """Create a new GPG key, capable of signing and encryption.
+
+        The key is generated without password protection and without expiration.
+
+        @param client: The client to perform this operation with.
+        @return: The new key.
+        """
+
+        gpg_provider = get_gpg_provider(self.host, client)
+
+        secret_key = gpg_provider.create_key(f"xmpp:{client.jid.userhost()}")
+
+        await self.publish_public_key(client, secret_key.public_key)
+
+        storage_key = STR_KEY_PUBLIC_KEYS_METADATA.format(client.jid.userhost())
+
+        public_keys_list = cast(
+            Set[PublicKeyMetadata],
+            await self.__storage[client.profile].get(storage_key, set())
+        )
+
+        public_keys_list.add(PublicKeyMetadata(
+            fingerprint=secret_key.public_key.fingerprint,
+            timestamp=datetime.now(timezone.utc)
+        ))
+
+        await self.publish_public_keys_list(client, public_keys_list)
+
+        await self.__storage[client.profile].force(storage_key, public_keys_list)
+
+        return secret_key
+
+    @staticmethod
+    def __build_content_element(
+        element_name: Literal["signcrypt", "sign", "crypt"],
+        recipient_jids: Iterable[jid.JID],
+        include_rpad: bool
+    ) -> Tuple[domish.Element, domish.Element]:
+        """Build a content element.
+
+        @param element_name: The name of the content element.
+        @param recipient_jids: The intended recipients of this content element. Can be
+            bare JIDs.
+        @param include_rpad: Whether to include random-length random-content padding.
+        @return: The content element and the ``<payload/>`` element to add the stanza
+            extension elements to.
+        """
+
+        content_elt = domish.Element((NS_OX, element_name))
+
+        for recipient_jid in recipient_jids:
+            content_elt.addElement("to")["jid"] = recipient_jid.userhost()
+
+        content_elt.addElement("time")["stamp"] = format_datetime()
+
+        if include_rpad:
+            # XEP-0373 doesn't specify bounds for the length of the random padding. This
+            # uses the bounds specified in XEP-0420 for the closely related rpad affix.
+            rpad_length = secrets.randbelow(201)
+            rpad_content = "".join(
+                secrets.choice(string.digits + string.ascii_letters + string.punctuation)
+                for __
+                in range(rpad_length)
+            )
+            content_elt.addElement("rpad", content=rpad_content)
+
+        payload_elt = content_elt.addElement("payload")
+
+        return content_elt, payload_elt
+
+    @staticmethod
+    def build_signcrypt_element(
+        recipient_jids: Iterable[jid.JID]
+    ) -> Tuple[domish.Element, domish.Element]:
+        """Build a ``<signcrypt/>`` content element.
+
+        @param recipient_jids: The intended recipients of this content element. Can be
+            bare JIDs.
+        @return: The ``<signcrypt/>`` element and the ``<payload/>`` element to add the
+            stanza extension elements to.
+        """
+
+        if len(recipient_jids) == 0:
+            raise ValueError("Recipient JIDs must be provided.")
+
+        return XEP_0373.__build_content_element("signcrypt", recipient_jids, True)
+
+    @staticmethod
+    def build_sign_element(
+        recipient_jids: Iterable[jid.JID],
+        include_rpad: bool
+    ) -> Tuple[domish.Element, domish.Element]:
+        """Build a ``<sign/>`` content element.
+
+        @param recipient_jids: The intended recipients of this content element. Can be
+            bare JIDs.
+        @param include_rpad: Whether to include random-length random-content padding,
+            which is OPTIONAL for the ``<sign/>`` content element.
+        @return: The ``<sign/>`` element and the ``<payload/>`` element to add the stanza
+            extension elements to.
+        """
+
+        if len(recipient_jids) == 0:
+            raise ValueError("Recipient JIDs must be provided.")
+
+        return XEP_0373.__build_content_element("sign", recipient_jids, include_rpad)
+
+    @staticmethod
+    def build_crypt_element(
+        recipient_jids: Iterable[jid.JID]
+    ) -> Tuple[domish.Element, domish.Element]:
+        """Build a ``<crypt/>`` content element.
+
+        @param recipient_jids: The intended recipients of this content element. Specifying
+            the intended recipients is OPTIONAL for the ``<crypt/>`` content element. Can
+            be bare JIDs.
+        @return: The ``<crypt/>`` element and the ``<payload/>`` element to add the stanza
+            extension elements to.
+        """
+
+        return XEP_0373.__build_content_element("crypt", recipient_jids, True)
+
+    async def build_openpgp_element(
+        self,
+        client: SatXMPPClient,
+        content_elt: domish.Element,
+        recipient_jids: Set[jid.JID]
+    ) -> domish.Element:
+        """Build an ``<openpgp/>`` element.
+
+        @param client: The client to perform this operation with.
+        @param content_elt: The content element to contain in the ``<openpgp/>`` element.
+        @param recipient_jids: The recipient's JIDs. Can be bare JIDs.
+        @return: The ``<openpgp/>`` element.
+        """
+
+        gpg_provider = get_gpg_provider(self.host, client)
+
+        # TODO: I'm not sure whether we want to sign with all keys by default or choose
+        # just one key/a subset of keys to sign with.
+        signing_keys = set(filter(
+            lambda secret_key: gpg_provider.can_sign(secret_key.public_key),
+            self.list_secret_keys(client)
+        ))
+
+        encryption_keys: Set[GPGPublicKey] = set()
+
+        for recipient_jid in recipient_jids:
+            # import all keys of the recipient
+            all_public_keys = await self.import_all_public_keys(client, recipient_jid)
+
+            # Filter for keys that can encrypt
+            encryption_keys |= set(filter(gpg_provider.can_encrypt, all_public_keys))
+
+        # TODO: Handle trust
+
+        content = content_elt.toXml().encode("utf-8")
+        data: bytes
+
+        if content_elt.name == "signcrypt":
+            data = gpg_provider.encrypt(content, encryption_keys, signing_keys)
+        elif content_elt.name == "sign":
+            data = gpg_provider.sign(content, signing_keys)
+        elif content_elt.name == "crypt":
+            data = gpg_provider.encrypt(content, encryption_keys)
+        else:
+            raise ValueError(f"Unknown content element <{content_elt.name}/>")
+
+        openpgp_elt = domish.Element((NS_OX, "openpgp"))
+        openpgp_elt.addContent(base64.b64encode(data).decode("ASCII"))
+        return openpgp_elt
+
+    async def unpack_openpgp_element(
+        self,
+        client: SatXMPPClient,
+        openpgp_elt: domish.Element,
+        element_name: Literal["signcrypt", "sign", "crypt"],
+        sender_jid: jid.JID
+    ) -> Tuple[domish.Element, datetime]:
+        """Verify, decrypt and unpack an ``<openpgp/>`` element.
+
+        @param client: The client to perform this operation with.
+        @param openpgp_elt: The ``<openpgp/>`` element.
+        @param element_name: The name of the content element.
+        @param sender_jid: The sender's JID. Can be a bare JID.
+        @return: The ``<payload/>`` element containing the decrypted/verified stanza
+            extension elements carried by this ``<openpgp/>`` element, and the timestamp
+            contained in the content element.
+        @raise exceptions.ParsingError: on syntactical verification errors.
+        @raise VerificationError: on semantical verification errors accoding to XEP-0373.
+        @raise DecryptionFailed: on decryption failure.
+        @raise VerificationFailed: if the data could not be verified.
+
+        @warning: The timestamp is not verified for plausibility; this SHOULD be done by
+            the calling code.
+        """
+
+        gpg_provider = get_gpg_provider(self.host, client)
+
+        decryption_keys = set(filter(
+            lambda secret_key: gpg_provider.can_encrypt(secret_key.public_key),
+            self.list_secret_keys(client)
+        ))
+
+        # import all keys of the sender
+        all_public_keys = await self.import_all_public_keys(client, sender_jid)
+
+        # Filter for keys that can sign
+        verification_keys = set(filter(gpg_provider.can_sign, all_public_keys))
+
+        # TODO: Handle trust
+
+        try:
+            OPENPGP_SCHEMA.validate(openpgp_elt.toXml())
+        except xmlschema.XMLSchemaValidationError as e:
+            raise exceptions.ParsingError(
+                "<openpgp/> element doesn't pass schema validation."
+            ) from e
+
+        openpgp_message = base64.b64decode(str(openpgp_elt))
+        content: bytes
+
+        if element_name == "signcrypt":
+            content = gpg_provider.decrypt(
+                openpgp_message,
+                decryption_keys,
+                public_keys=verification_keys
+            )
+        elif element_name == "sign":
+            content = gpg_provider.verify(openpgp_message, verification_keys)
+        elif element_name == "crypt":
+            content = gpg_provider.decrypt(openpgp_message, decryption_keys)
+        else:
+            assert_never(element_name)
+
+        try:
+            content_elt = cast(
+                domish.Element,
+                xml_tools.ElementParser()(content.decode("utf-8"))
+            )
+        except UnicodeDecodeError as e:
+            raise exceptions.ParsingError("UTF-8 decoding error") from e
+
+        try:
+            CONTENT_SCHEMA.validate(content_elt.toXml())
+        except xmlschema.XMLSchemaValidationError as e:
+            raise exceptions.ParsingError(
+                f"<{element_name}/> element doesn't pass schema validation."
+            ) from e
+
+        if content_elt.name != element_name:
+            raise exceptions.ParsingError(f"Not a <{element_name}/> element.")
+
+        recipient_jids = \
+            { jid.JID(to_elt["jid"]) for to_elt in content_elt.elements(NS_OX, "to") }
+
+        if (
+            client.jid.userhostJID() not in { jid.userhostJID() for jid in recipient_jids }
+            and element_name != "crypt"
+        ):
+            raise VerificationError(
+                f"Recipient list in <{element_name}/> element does not list our (bare)"
+                f" JID."
+            )
+
+        time_elt = next(content_elt.elements(NS_OX, "time"))
+
+        timestamp = parse_datetime(time_elt["stamp"])
+
+        payload_elt = next(content_elt.elements(NS_OX, "payload"))
+
+        return payload_elt, timestamp
+
+    async def publish_public_key(
+        self,
+        client: SatXMPPClient,
+        public_key: GPGPublicKey
+    ) -> None:
+        """Publish a public key.
+
+        @param client: The client.
+        @param public_key: The public key to publish.
+        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
+        """
+
+        gpg_provider = get_gpg_provider(self.host, client)
+
+        packet = gpg_provider.export_public_key(public_key)
+
+        node = f"urn:xmpp:openpgp:0:public-keys:{public_key.fingerprint}"
+
+        pubkey_elt = domish.Element((NS_OX, "pubkey"))
+
+        pubkey_elt.addElement("data", content=base64.b64encode(packet).decode("ASCII"))
+
+        try:
+            await self.__xep_0060.send_item(
+                client,
+                client.jid.userhostJID(),
+                node,
+                pubkey_elt,
+                format_datetime(),
+                extra={
+                    XEP_0060.EXTRA_PUBLISH_OPTIONS: {
+                        XEP_0060.OPT_PERSIST_ITEMS: "true",
+                        XEP_0060.OPT_ACCESS_MODEL: "open",
+                        XEP_0060.OPT_MAX_ITEMS: 1
+                    },
+                    # TODO: Do we really want publish_without_options here?
+                    XEP_0060.EXTRA_ON_PRECOND_NOT_MET: "publish_without_options"
+                }
+            )
+        except Exception as e:
+            raise XMPPInteractionFailed("Publishing the public key failed.") from e
+
+    async def import_all_public_keys(
+        self,
+        client: SatXMPPClient,
+        entity_jid: jid.JID
+    ) -> Set[GPGPublicKey]:
+        """import all public keys of a JID that have not been imported before.
+
+        @param client: The client.
+        @param jid: The JID. Can be a bare JID.
+        @return: The public keys.
+        @note: Failure to import a key simply results in the key not being included in the
+            result.
+        """
+
+        available_public_keys = self.list_public_keys(client, entity_jid)
+
+        storage_key = STR_KEY_PUBLIC_KEYS_METADATA.format(entity_jid.userhost())
+
+        public_keys_metadata = cast(
+            Set[PublicKeyMetadata],
+            await self.__storage[client.profile].get(storage_key, set())
+        )
+        if not public_keys_metadata:
+            public_keys_metadata = await self.download_public_keys_list(
+                client, entity_jid
+            )
+            if not public_keys_metadata:
+                raise exceptions.NotFound(
+                    f"Can't find public keys for {entity_jid}"
+                )
+            else:
+                await self.__storage[client.profile].aset(
+                    storage_key, public_keys_metadata
+                )
+
+
+        missing_keys = set(filter(lambda public_key_metadata: all(
+            public_key_metadata.fingerprint != public_key.fingerprint
+            for public_key
+            in available_public_keys
+        ), public_keys_metadata))
+
+        for missing_key in missing_keys:
+            try:
+                available_public_keys.add(
+                    await self.import_public_key(client, entity_jid, missing_key.fingerprint)
+                )
+            except Exception as e:
+                log.warning(
+                    f"import of public key {missing_key.fingerprint} owned by"
+                    f" {entity_jid.userhost()} failed, ignoring: {e}"
+                )
+
+        return available_public_keys
+
+    async def import_public_key(
+        self,
+        client: SatXMPPClient,
+        jid: jid.JID,
+        fingerprint: str
+    ) -> GPGPublicKey:
+        """import a public key.
+
+        @param client: The client.
+        @param jid: The JID owning the public key. Can be a bare JID.
+        @param fingerprint: The fingerprint of the public key.
+        @return: The public key.
+        @raise exceptions.NotFound: if the public key was not found.
+        @raise exceptions.ParsingError: on XML-level parsing errors.
+        @raise InvalidPacket: if the packet is either syntactically or semantically deemed
+            invalid.
+        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
+        """
+
+        gpg_provider = get_gpg_provider(self.host, client)
+
+        node = f"urn:xmpp:openpgp:0:public-keys:{fingerprint}"
+
+        try:
+            items, __ = await self.__xep_0060.get_items(
+                client,
+                jid.userhostJID(),
+                node,
+                max_items=1
+            )
+        except exceptions.NotFound as e:
+            raise exceptions.NotFound(
+                f"No public key with fingerprint {fingerprint} published by JID"
+                f" {jid.userhost()}."
+            ) from e
+        except Exception as e:
+            raise XMPPInteractionFailed("Fetching the public keys list failed.") from e
+
+        try:
+            item_elt = cast(domish.Element, items[0])
+        except IndexError as e:
+            raise exceptions.NotFound(
+                f"No public key with fingerprint {fingerprint} published by JID"
+                f" {jid.userhost()}."
+            ) from e
+
+        pubkey_elt = cast(
+            Optional[domish.Element],
+            next(item_elt.elements(NS_OX, "pubkey"), None)
+        )
+
+        if pubkey_elt is None:
+            raise exceptions.ParsingError(
+                f"Publish-Subscribe item of JID {jid.userhost()} doesn't contain pubkey"
+                f" element."
+            )
+
+        try:
+            PUBKEY_SCHEMA.validate(pubkey_elt.toXml())
+        except xmlschema.XMLSchemaValidationError as e:
+            raise exceptions.ParsingError(
+                f"Publish-Subscribe item of JID {jid.userhost()} doesn't pass pubkey"
+                f" schema validation."
+            ) from e
+
+        public_key = gpg_provider.import_public_key(base64.b64decode(str(
+            next(pubkey_elt.elements(NS_OX, "data"))
+        )))
+
+        return public_key
+
+    async def publish_public_keys_list(
+        self,
+        client: SatXMPPClient,
+        public_keys_list: Iterable[PublicKeyMetadata]
+    ) -> None:
+        """Publish/update the own public keys list.
+
+        @param client: The client.
+        @param public_keys_list: The public keys list.
+        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
+
+        @warning: All public keys referenced in the public keys list MUST be published
+            beforehand.
+        """
+
+        if len({ pkm.fingerprint for pkm in public_keys_list }) != len(public_keys_list):
+            raise ValueError("Public keys list contains duplicate fingerprints.")
+
+        node = "urn:xmpp:openpgp:0:public-keys"
+
+        public_keys_list_elt = domish.Element((NS_OX, "public-keys-list"))
+
+        for public_key_metadata in public_keys_list:
+            pubkey_metadata_elt = public_keys_list_elt.addElement("pubkey-metadata")
+            pubkey_metadata_elt["v4-fingerprint"] = public_key_metadata.fingerprint
+            pubkey_metadata_elt["date"] = format_datetime(public_key_metadata.timestamp)
+
+        try:
+            await self.__xep_0060.send_item(
+                client,
+                client.jid.userhostJID(),
+                node,
+                public_keys_list_elt,
+                item_id=XEP_0060.ID_SINGLETON,
+                extra={
+                    XEP_0060.EXTRA_PUBLISH_OPTIONS: {
+                        XEP_0060.OPT_PERSIST_ITEMS: "true",
+                        XEP_0060.OPT_ACCESS_MODEL: "open",
+                        XEP_0060.OPT_MAX_ITEMS: 1
+                    },
+                    # TODO: Do we really want publish_without_options here?
+                    XEP_0060.EXTRA_ON_PRECOND_NOT_MET: "publish_without_options"
+                }
+            )
+        except Exception as e:
+            raise XMPPInteractionFailed("Publishing the public keys list failed.") from e
+
+    async def download_public_keys_list(
+        self,
+        client: SatXMPPClient,
+        jid: jid.JID
+    ) -> Optional[Set[PublicKeyMetadata]]:
+        """Download the public keys list of a JID.
+
+        @param client: The client.
+        @param jid: The JID. Can be a bare JID.
+        @return: The public keys list or ``None`` if the JID hasn't published a public
+            keys list. An empty list means the JID has published an empty list.
+        @raise exceptions.ParsingError: on XML-level parsing errors.
+        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
+        """
+
+        node = "urn:xmpp:openpgp:0:public-keys"
+
+        try:
+            items, __ = await self.__xep_0060.get_items(
+                client,
+                jid.userhostJID(),
+                node,
+                max_items=1
+            )
+        except exceptions.NotFound:
+            return None
+        except Exception as e:
+            raise XMPPInteractionFailed() from e
+
+        try:
+            item_elt = cast(domish.Element, items[0])
+        except IndexError:
+            return None
+
+        public_keys_list_elt = cast(
+            Optional[domish.Element],
+            next(item_elt.elements(NS_OX, "public-keys-list"), None)
+        )
+
+        if public_keys_list_elt is None:
+            return None
+
+        try:
+            PUBLIC_KEYS_LIST_SCHEMA.validate(public_keys_list_elt.toXml())
+        except xmlschema.XMLSchemaValidationError as e:
+            raise exceptions.ParsingError(
+                f"Publish-Subscribe item of JID {jid.userhost()} doesn't pass public keys"
+                f" list schema validation."
+            ) from e
+
+        return {
+            PublicKeyMetadata(
+                fingerprint=pubkey_metadata_elt["v4-fingerprint"],
+                timestamp=parse_datetime(pubkey_metadata_elt["date"])
+            )
+            for pubkey_metadata_elt
+            in public_keys_list_elt.elements(NS_OX, "pubkey-metadata")
+        }
+
+    async def __prepare_secret_key_synchronization(
+        self,
+        client: SatXMPPClient
+    ) -> Optional[domish.Element]:
+        """Prepare for secret key synchronization.
+
+        Makes sure the relative protocols and protocol extensions are supported by the
+        server and makes sure that the PEP node for secret synchronization exists and is
+        configured correctly. The node is created if necessary.
+
+        @param client: The client.
+        @return: As part of the preparations, the secret key synchronization PEP node is
+            fetched. The result of that fetch is returned here.
+        @raise exceptions.FeatureNotFound: if the server lacks support for the required
+            protocols or protocol extensions.
+        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
+        """
+
+        try:
+            infos = cast(DiscoInfo, await self.host.memory.disco.get_infos(
+                client,
+                client.jid.userhostJID()
+            ))
+        except Exception as e:
+            raise XMPPInteractionFailed(
+                "Error performing service discovery on the own bare JID."
+            ) from e
+
+        identities = cast(Dict[Tuple[str, str], str], infos.identities)
+        features = cast(Set[DiscoFeature], infos.features)
+
+        if ("pubsub", "pep") not in identities:
+            raise exceptions.FeatureNotFound("Server doesn't support PEP.")
+
+        if "http://jabber.org/protocol/pubsub#access-whitelist" not in features:
+            raise exceptions.FeatureNotFound(
+                "Server doesn't support the whitelist access model."
+            )
+
+        persistent_items_supported = \
+            "http://jabber.org/protocol/pubsub#persistent-items" in features
+
+        # TODO: persistent-items is a SHOULD, how do we handle the feature missing?
+
+        node = "urn:xmpp:openpgp:0:secret-key"
+
+        try:
+            items, __ = await self.__xep_0060.get_items(
+                client,
+                client.jid.userhostJID(),
+                node,
+                max_items=1
+            )
+        except exceptions.NotFound:
+            try:
+                await self.__xep_0060.createNode(
+                    client,
+                    client.jid.userhostJID(),
+                    node,
+                    {
+                        XEP_0060.OPT_PERSIST_ITEMS: "true",
+                        XEP_0060.OPT_ACCESS_MODEL: "whitelist",
+                        XEP_0060.OPT_MAX_ITEMS: "1"
+                    }
+                )
+            except Exception as e:
+                raise XMPPInteractionFailed(
+                    "Error creating the secret key synchronization node."
+                ) from e
+        except Exception as e:
+            raise XMPPInteractionFailed(
+                "Error fetching the secret key synchronization node."
+            ) from e
+
+        try:
+            return cast(domish.Element, items[0])
+        except IndexError:
+            return None
+
+    async def export_secret_keys(
+        self,
+        client: SatXMPPClient,
+        secret_keys: Iterable[GPGSecretKey]
+    ) -> str:
+        """Export secret keys to synchronize them with other devices.
+
+        @param client: The client.
+        @param secret_keys: The secret keys to export.
+        @return: The backup code needed to decrypt the exported secret keys.
+        @raise exceptions.FeatureNotFound: if the server lacks support for the required
+            protocols or protocol extensions.
+        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
+        """
+
+        gpg_provider = get_gpg_provider(self.host, client)
+
+        await self.__prepare_secret_key_synchronization(client)
+
+        backup_code = generate_passphrase()
+
+        plaintext = b"".join(
+            gpg_provider.backup_secret_key(secret_key) for secret_key in secret_keys
+        )
+
+        ciphertext = gpg_provider.encrypt_symmetrically(plaintext, backup_code)
+
+        node = "urn:xmpp:openpgp:0:secret-key"
+
+        secretkey_elt = domish.Element((NS_OX, "secretkey"))
+        secretkey_elt.addContent(base64.b64encode(ciphertext).decode("ASCII"))
+
+        try:
+            await self.__xep_0060.send_item(
+                client,
+                client.jid.userhostJID(),
+                node,
+                secretkey_elt
+            )
+        except Exception as e:
+            raise XMPPInteractionFailed("Publishing the secret keys failed.") from e
+
+        return backup_code
+
+    async def download_secret_keys(self, client: SatXMPPClient) -> Optional[bytes]:
+        """Download previously exported secret keys to import them in a second step.
+
+        The downloading and importing steps are separate since a backup code is required
+        for the import and it should be possible to try multiple backup codes without
+        redownloading the data every time. The second half of the import procedure is
+        provided by :meth:`import_secret_keys`.
+
+        @param client: The client.
+        @return: The encrypted secret keys previously exported, if any.
+        @raise exceptions.FeatureNotFound: if the server lacks support for the required
+            protocols or protocol extensions.
+        @raise exceptions.ParsingError: on XML-level parsing errors.
+        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
+        """
+
+        item_elt = await self.__prepare_secret_key_synchronization(client)
+        if item_elt is None:
+            return None
+
+        secretkey_elt = cast(
+            Optional[domish.Element],
+            next(item_elt.elements(NS_OX, "secretkey"), None)
+        )
+
+        if secretkey_elt is None:
+            return None
+
+        try:
+            SECRETKEY_SCHEMA.validate(secretkey_elt.toXml())
+        except xmlschema.XMLSchemaValidationError as e:
+            raise exceptions.ParsingError(
+                "Publish-Subscribe item doesn't pass secretkey schema validation."
+            ) from e
+
+        return base64.b64decode(str(secretkey_elt))
+
+    def import_secret_keys(
+        self,
+        client: SatXMPPClient,
+        ciphertext: bytes,
+        backup_code: str
+    ) -> Set[GPGSecretKey]:
+        """import previously downloaded secret keys.
+
+        The downloading and importing steps are separate since a backup code is required
+        for the import and it should be possible to try multiple backup codes without
+        redownloading the data every time. The first half of the import procedure is
+        provided by :meth:`download_secret_keys`.
+
+        @param client: The client to perform this operation with.
+        @param ciphertext: The ciphertext, i.e. the data returned by
+            :meth:`download_secret_keys`.
+        @param backup_code: The backup code needed to decrypt the data.
+        @raise InvalidPacket: if one of the GPG packets building the secret key data is
+            either syntactically or semantically deemed invalid.
+        @raise DecryptionFailed: on decryption failure.
+        """
+
+        gpg_provider = get_gpg_provider(self.host, client)
+
+        return gpg_provider.restore_secret_keys(gpg_provider.decrypt_symmetrically(
+            ciphertext,
+            backup_code
+        ))
+
+    @staticmethod
+    def __get_joined_muc_users(
+        client: SatXMPPClient,
+        xep_0045: XEP_0045,
+        room_jid: jid.JID
+    ) -> Set[jid.JID]:
+        """
+        @param client: The client.
+        @param xep_0045: A MUC plugin instance.
+        @param room_jid: The room JID.
+        @return: A set containing the bare JIDs of the MUC participants.
+        @raise InternalError: if the MUC is not joined or the entity information of a
+            participant isn't available.
+        """
+        # TODO: This should probably be a global helper somewhere
+
+        bare_jids: Set[jid.JID] = set()
+
+        try:
+            room = cast(muc.Room, xep_0045.get_room(client, room_jid))
+        except exceptions.NotFound as e:
+            raise exceptions.InternalError(
+                "Participant list of unjoined MUC requested."
+            ) from e
+
+        for user in cast(Dict[str, muc.User], room.roster).values():
+            entity = cast(Optional[SatXMPPEntity], user.entity)
+            if entity is None:
+                raise exceptions.InternalError(
+                    f"Participant list of MUC requested, but the entity information of"
+                    f" the participant {user} is not available."
+                )
+
+            bare_jids.add(entity.jid.userhostJID())
+
+        return bare_jids
+
+    async def get_trust(
+        self,
+        client: SatXMPPClient,
+        public_key: GPGPublicKey,
+        owner: jid.JID
+    ) -> TrustLevel:
+        """Query the trust level of a public key.
+
+        @param client: The client to perform this operation under.
+        @param public_key: The public key.
+        @param owner: The owner of the public key. Can be a bare JID.
+        @return: The trust level.
+        """
+
+        key = f"/trust/{owner.userhost()}/{public_key.fingerprint}"
+
+        try:
+            return TrustLevel(await self.__storage[client.profile][key])
+        except KeyError:
+            return TrustLevel.UNDECIDED
+
+    async def set_trust(
+        self,
+        client: SatXMPPClient,
+        public_key: GPGPublicKey,
+        owner: jid.JID,
+        trust_level: TrustLevel
+    ) -> None:
+        """Set the trust level of a public key.
+
+        @param client: The client to perform this operation under.
+        @param public_key: The public key.
+        @param owner: The owner of the public key. Can be a bare JID.
+        @param trust_leve: The trust level.
+        """
+
+        key = f"/trust/{owner.userhost()}/{public_key.fingerprint}"
+
+        await self.__storage[client.profile].force(key, trust_level.name)
+
+    async def get_trust_ui(  # pylint: disable=invalid-name
+        self,
+        client: SatXMPPClient,
+        entity: jid.JID
+    ) -> xml_tools.XMLUI:
+        """
+        @param client: The client.
+        @param entity: The entity whose device trust levels to manage.
+        @return: An XMLUI instance which opens a form to manage the trust level of all
+            devices belonging to the entity.
+        """
+
+        if entity.resource:
+            raise ValueError("A bare JID is expected.")
+
+        bare_jids: Set[jid.JID]
+        if self.__xep_0045 is not None and self.__xep_0045.is_joined_room(client, entity):
+            bare_jids = self.__get_joined_muc_users(client, self.__xep_0045, entity)
+        else:
+            bare_jids = { entity.userhostJID() }
+
+        all_public_keys = list({
+            bare_jid: list(self.list_public_keys(client, bare_jid))
+            for bare_jid
+            in bare_jids
+        }.items())
+
+        async def callback(
+            data: Any,
+            profile: str  # pylint: disable=unused-argument
+        ) -> Dict[Never, Never]:
+            """
+            @param data: The XMLUI result produces by the trust UI form.
+            @param profile: The profile.
+            @return: An empty dictionary. The type of the return value was chosen
+                conservatively since the exact options are neither known not needed here.
+            """
+
+            if C.bool(data.get("cancelled", "false")):
+                return {}
+
+            data_form_result = cast(
+                Dict[str, str],
+                xml_tools.xmlui_result_2_data_form_result(data)
+            )
+            for key, value in data_form_result.items():
+                if not key.startswith("trust_"):
+                    continue
+
+                outer_index, inner_index = key.split("_")[1:]
+
+                owner, public_keys = all_public_keys[int(outer_index)]
+                public_key = public_keys[int(inner_index)]
+                trust = TrustLevel(value)
+
+                if (await self.get_trust(client, public_key, owner)) is not trust:
+                    await self.set_trust(client, public_key, owner, value)
+
+            return {}
+
+        submit_id = self.host.register_callback(callback, with_data=True, one_shot=True)
+
+        result = xml_tools.XMLUI(
+            panel_type=C.XMLUI_FORM,
+            title=D_("OX trust management"),
+            submit_id=submit_id
+        )
+        # Casting this to Any, otherwise all calls on the variable cause type errors
+        # pylint: disable=no-member
+        trust_ui = cast(Any, result)
+        trust_ui.addText(D_(
+            "This is OX trusting system. You'll see below the GPG keys of your "
+            "contacts, and a list selection to trust them or not. A trusted key "
+            "can read your messages in plain text, so be sure to only validate "
+            "keys that you are sure are belonging to your contact. It's better "
+            "to do this when you are next to your contact, so "
+            "you can check the \"fingerprint\" of the key "
+            "yourself. Do *not* validate a key if the fingerprint is wrong!"
+        ))
+
+        own_secret_keys = self.list_secret_keys(client)
+
+        trust_ui.change_container("label")
+        for index, secret_key in enumerate(own_secret_keys):
+            trust_ui.addLabel(D_(f"Own secret key {index} fingerprint"))
+            trust_ui.addText(secret_key.public_key.fingerprint)
+            trust_ui.addEmpty()
+            trust_ui.addEmpty()
+
+        for outer_index, [ owner, public_keys ] in enumerate(all_public_keys):
+            for inner_index, public_key in enumerate(public_keys):
+                trust_ui.addLabel(D_("Contact"))
+                trust_ui.addJid(jid.JID(owner))
+                trust_ui.addLabel(D_("Fingerprint"))
+                trust_ui.addText(public_key.fingerprint)
+                trust_ui.addLabel(D_("Trust this device?"))
+
+                current_trust_level = await self.get_trust(client, public_key, owner)
+                avaiable_trust_levels = \
+                    { TrustLevel.DISTRUSTED, TrustLevel.TRUSTED, current_trust_level }
+
+                trust_ui.addList(
+                    f"trust_{outer_index}_{inner_index}",
+                    options=[ trust_level.name for trust_level in avaiable_trust_levels ],
+                    selected=current_trust_level.name,
+                    styles=[ "inline" ]
+                )
+
+                trust_ui.addEmpty()
+                trust_ui.addEmpty()
+
+        return result
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0374.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,425 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for OpenPGP for XMPP Instant Messaging
+# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Dict, Optional, Set, cast
+
+from typing_extensions import Final
+from wokkel import muc  # type: ignore[import]
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger, Logger
+from libervia.backend.core.sat_main import SAT
+from libervia.backend.core.xmpp import SatXMPPClient
+from libervia.backend.plugins.plugin_xep_0045 import XEP_0045
+from libervia.backend.plugins.plugin_xep_0334 import XEP_0334
+from libervia.backend.plugins.plugin_xep_0373 import NS_OX, XEP_0373, TrustLevel
+from libervia.backend.tools import xml_tools
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
+
+
+__all__ = [  # pylint: disable=unused-variable
+    "PLUGIN_INFO",
+    "XEP_0374",
+    "NS_OXIM"
+]
+
+
+log = cast(Logger, getLogger(__name__))  # type: ignore[no-untyped-call]
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "OXIM",
+    C.PI_IMPORT_NAME: "XEP-0374",
+    C.PI_TYPE: "SEC",
+    C.PI_PROTOCOLS: [ "XEP-0374" ],
+    C.PI_DEPENDENCIES: [ "XEP-0334", "XEP-0373" ],
+    C.PI_RECOMMENDATIONS: [ "XEP-0045" ],
+    C.PI_MAIN: "XEP_0374",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of OXIM"""),
+}
+
+
+# The disco feature
+NS_OXIM: Final = "urn:xmpp:openpgp:im:0"
+
+
+class XEP_0374:
+    """
+    Plugin equipping Libervia with OXIM capabilities under the ``urn:xmpp:openpgp:im:0``
+    namespace. MUC messages are supported next to one to one messages. For trust
+    management, the two trust models "BTBV" and "manual" are supported.
+    """
+
+    def __init__(self, sat: SAT) -> None:
+        """
+        @param sat: The SAT instance.
+        """
+
+        self.__sat = sat
+
+        # Plugins
+        self.__xep_0045 = cast(Optional[XEP_0045], sat.plugins.get("XEP-0045"))
+        self.__xep_0334 = cast(XEP_0334, sat.plugins["XEP-0334"])
+        self.__xep_0373 = cast(XEP_0373, sat.plugins["XEP-0373"])
+
+        # Triggers
+        sat.trigger.add(
+            "message_received",
+            self.__message_received_trigger,
+            priority=100050
+        )
+        sat.trigger.add("send", self.__send_trigger, priority=0)
+
+        # Register the encryption plugin
+        sat.register_encryption_plugin(self, "OXIM", NS_OX, 102)
+
+    async def get_trust_ui(  # pylint: disable=invalid-name
+        self,
+        client: SatXMPPClient,
+        entity: jid.JID
+    ) -> xml_tools.XMLUI:
+        """
+        @param client: The client.
+        @param entity: The entity whose device trust levels to manage.
+        @return: An XMLUI instance which opens a form to manage the trust level of all
+            devices belonging to the entity.
+        """
+
+        return await self.__xep_0373.get_trust_ui(client, entity)
+
+    @staticmethod
+    def __get_joined_muc_users(
+        client: SatXMPPClient,
+        xep_0045: XEP_0045,
+        room_jid: jid.JID
+    ) -> Set[jid.JID]:
+        """
+        @param client: The client.
+        @param xep_0045: A MUC plugin instance.
+        @param room_jid: The room JID.
+        @return: A set containing the bare JIDs of the MUC participants.
+        @raise InternalError: if the MUC is not joined or the entity information of a
+            participant isn't available.
+        """
+
+        bare_jids: Set[jid.JID] = set()
+
+        try:
+            room = cast(muc.Room, xep_0045.get_room(client, room_jid))
+        except exceptions.NotFound as e:
+            raise exceptions.InternalError(
+                "Participant list of unjoined MUC requested."
+            ) from e
+
+        for user in cast(Dict[str, muc.User], room.roster).values():
+            entity = cast(Optional[SatXMPPEntity], user.entity)
+            if entity is None:
+                raise exceptions.InternalError(
+                    f"Participant list of MUC requested, but the entity information of"
+                    f" the participant {user} is not available."
+                )
+
+            bare_jids.add(entity.jid.userhostJID())
+
+        return bare_jids
+
+    async def __message_received_trigger(
+        self,
+        client: SatXMPPClient,
+        message_elt: domish.Element,
+        post_treat: defer.Deferred
+    ) -> bool:
+        """
+        @param client: The client which received the message.
+        @param message_elt: The message element. Can be modified.
+        @param post_treat: A deferred which evaluates to a :class:`MessageData` once the
+            message has fully progressed through the message receiving flow. Can be used
+            to apply treatments to the fully processed message, like marking it as
+            encrypted.
+        @return: Whether to continue the message received flow.
+        """
+        sender_jid = jid.JID(message_elt["from"])
+        feedback_jid: jid.JID
+
+        message_type = message_elt.getAttribute("type", "unknown")
+        is_muc_message = message_type == C.MESS_TYPE_GROUPCHAT
+        if is_muc_message:
+            if self.__xep_0045 is None:
+                log.warning(
+                    "Ignoring MUC message since plugin XEP-0045 is not available."
+                )
+                # Can't handle a MUC message without XEP-0045, let the flow continue
+                # normally
+                return True
+
+            room_jid = feedback_jid = sender_jid.userhostJID()
+
+            try:
+                room = cast(muc.Room, self.__xep_0045.get_room(client, room_jid))
+            except exceptions.NotFound:
+                log.warning(
+                    f"Ignoring MUC message from a room that has not been joined:"
+                    f" {room_jid}"
+                )
+                # Whatever, let the flow continue
+                return True
+
+            sender_user = cast(Optional[muc.User], room.getUser(sender_jid.resource))
+            if sender_user is None:
+                log.warning(
+                    f"Ignoring MUC message from room {room_jid} since the sender's user"
+                    f" wasn't found {sender_jid.resource}"
+                )
+                # Whatever, let the flow continue
+                return True
+
+            sender_user_jid = cast(Optional[jid.JID], sender_user.entity)
+            if sender_user_jid is None:
+                log.warning(
+                    f"Ignoring MUC message from room {room_jid} since the sender's bare"
+                    f" JID couldn't be found from its user information: {sender_user}"
+                )
+                # Whatever, let the flow continue
+                return True
+
+            sender_jid = sender_user_jid
+        else:
+            # I'm not sure why this check is required, this code is copied from XEP-0384
+            if sender_jid.userhostJID() == client.jid.userhostJID():
+                try:
+                    feedback_jid = jid.JID(message_elt["to"])
+                except KeyError:
+                    feedback_jid = client.server_jid
+            else:
+                feedback_jid = sender_jid
+
+        sender_bare_jid = sender_jid.userhost()
+
+        openpgp_elt = cast(Optional[domish.Element], next(
+            message_elt.elements(NS_OX, "openpgp"),
+            None
+        ))
+
+        if openpgp_elt is None:
+            # None of our business, let the flow continue
+            return True
+
+        try:
+            payload_elt, timestamp = await self.__xep_0373.unpack_openpgp_element(
+                client,
+                openpgp_elt,
+                "signcrypt",
+                jid.JID(sender_bare_jid)
+            )
+        except Exception as e:
+            # TODO: More specific exception handling
+            log.warning(_("Can't decrypt message: {reason}\n{xml}").format(
+                reason=e,
+                xml=message_elt.toXml()
+            ))
+            client.feedback(
+                feedback_jid,
+                D_(
+                    f"An OXIM message from {sender_jid.full()} can't be decrypted:"
+                    f" {e}"
+                ),
+                { C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR }
+            )
+            # No point in further processing this message
+            return False
+
+        message_elt.children.remove(openpgp_elt)
+
+        log.debug(f"OXIM message of type {message_type} received from {sender_bare_jid}")
+
+        # Remove all body elements from the original element, since those act as
+        # fallbacks in case the encryption protocol is not supported
+        for child in message_elt.elements():
+            if child.name == "body":
+                message_elt.children.remove(child)
+
+        # Move all extension elements from the payload to the stanza root
+        # TODO: There should probably be explicitly forbidden elements here too, just as
+        # for XEP-0420
+        for child in list(payload_elt.elements()):
+            # Remove the child from the content element
+            payload_elt.children.remove(child)
+
+            # Add the child to the stanza
+            message_elt.addChild(child)
+
+        # Mark the message as trusted or untrusted. Undecided counts as untrusted here.
+        trust_level = TrustLevel.UNDECIDED  # TODO: Load the actual trust level
+        if trust_level is TrustLevel.TRUSTED:
+            post_treat.addCallback(client.encryption.mark_as_trusted)
+        else:
+            post_treat.addCallback(client.encryption.mark_as_untrusted)
+
+        # Mark the message as originally encrypted
+        post_treat.addCallback(
+            client.encryption.mark_as_encrypted,
+            namespace=NS_OX
+        )
+
+        # Message processed successfully, continue with the flow
+        return True
+
+    async def __send_trigger(self, client: SatXMPPClient, stanza: domish.Element) -> bool:
+        """
+        @param client: The client sending this message.
+        @param stanza: The stanza that is about to be sent. Can be modified.
+        @return: Whether the send message flow should continue or not.
+        """
+        # OXIM only handles message stanzas
+        if stanza.name != "message":
+            return True
+
+        # Get the intended recipient
+        recipient = stanza.getAttribute("to", None)
+        if recipient is None:
+            raise exceptions.InternalError(
+                f"Message without recipient encountered. Blocking further processing to"
+                f" avoid leaking plaintext data: {stanza.toXml()}"
+            )
+
+        # Parse the JID
+        recipient_bare_jid = jid.JID(recipient).userhostJID()
+
+        # Check whether encryption with OXIM is requested
+        encryption = client.encryption.getSession(recipient_bare_jid)
+
+        if encryption is None:
+            # Encryption is not requested for this recipient
+            return True
+
+        if encryption["plugin"].namespace != NS_OX:
+            # Encryption is requested for this recipient, but not with OXIM
+            return True
+
+        # All pre-checks done, we can start encrypting!
+        await self.__encrypt(
+            client,
+            stanza,
+            recipient_bare_jid,
+            stanza.getAttribute("type", "unkown") == C.MESS_TYPE_GROUPCHAT
+        )
+
+        # Add a store hint if this is a message stanza
+        self.__xep_0334.add_hint_elements(stanza, [ "store" ])
+
+        # Let the flow continue.
+        return True
+
+    async def __encrypt(
+        self,
+        client: SatXMPPClient,
+        stanza: domish.Element,
+        recipient_jid: jid.JID,
+        is_muc_message: bool
+    ) -> None:
+        """
+        @param client: The client.
+        @param stanza: The stanza, which is modified by this call.
+        @param recipient_jid: The JID of the recipient. Can be a bare (aka "userhost") JID
+            but doesn't have to.
+        @param is_muc_message: Whether the stanza is a message stanza to a MUC room.
+
+        @warning: The calling code MUST take care of adding the store message processing
+            hint to the stanza if applicable! This can be done before or after this call,
+            the order doesn't matter.
+        """
+
+        recipient_bare_jids: Set[jid.JID]
+        feedback_jid: jid.JID
+
+        if is_muc_message:
+            if self.__xep_0045 is None:
+                raise exceptions.InternalError(
+                    "Encryption of MUC message requested, but plugin XEP-0045 is not"
+                    " available."
+                )
+
+            room_jid = feedback_jid = recipient_jid.userhostJID()
+
+            recipient_bare_jids = self.__get_joined_muc_users(
+                client,
+                self.__xep_0045,
+                room_jid
+            )
+        else:
+            recipient_bare_jids = { recipient_jid.userhostJID() }
+            feedback_jid = recipient_jid.userhostJID()
+
+        log.debug(
+            f"Intercepting message that is to be encrypted by {NS_OX} for"
+            f" {recipient_bare_jids}"
+        )
+
+        signcrypt_elt, payload_elt = \
+            self.__xep_0373.build_signcrypt_element(recipient_bare_jids)
+
+        # Move elements from the stanza to the content element.
+        # TODO: There should probably be explicitly forbidden elements here too, just as
+        # for XEP-0420
+        for child in list(stanza.elements()):
+            if child.name == "openpgp" and child.uri == NS_OX:
+                log.debug("not re-encrypting encrypted OX element")
+                continue
+            # Remove the child from the stanza
+            stanza.children.remove(child)
+
+            # A namespace of ``None`` can be used on domish elements to inherit the
+            # namespace from the parent. When moving elements from the stanza root to
+            # the content element, however, we don't want elements to inherit the
+            # namespace of the content element. Thus, check for elements with ``None``
+            # for their namespace and set the namespace to jabber:client, which is the
+            # namespace of the parent element.
+            if child.uri is None:
+                child.uri = C.NS_CLIENT
+                child.defaultUri = C.NS_CLIENT
+
+            # Add the child with corrected namespaces to the content element
+            payload_elt.addChild(child)
+
+        try:
+            openpgp_elt = await self.__xep_0373.build_openpgp_element(
+                client,
+                signcrypt_elt,
+                recipient_bare_jids
+            )
+        except Exception as e:
+            msg = _(
+                # pylint: disable=consider-using-f-string
+                "Can't encrypt message for {entities}: {reason}".format(
+                    entities=', '.join(jid.userhost() for jid in recipient_bare_jids),
+                    reason=e
+                )
+            )
+            log.warning(msg)
+            client.feedback(feedback_jid, msg, {
+                C.MESS_EXTRA_INFO: C.EXTRA_INFO_ENCR_ERR
+            })
+            raise e
+
+        stanza.addChild(openpgp_elt)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0376.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+
+# SàT plugin for XEP-0376
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Dict, List, Tuple, Optional, Any
+from zope.interface import implementer
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from wokkel import disco, iwokkel, pubsub, data_form
+from libervia.backend.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core import exceptions
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Pubsub Account Management",
+    C.PI_IMPORT_NAME: "XEP-0376",
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0376"],
+    C.PI_DEPENDENCIES: ["XEP-0060"],
+    C.PI_MAIN: "XEP_0376",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Pubsub Account Management"""),
+}
+
+NS_PAM = "urn:xmpp:pam:0"
+
+
+class XEP_0376:
+
+    def __init__(self, host):
+        log.info(_("Pubsub Account Management initialization"))
+        self.host = host
+        host.register_namespace("pam", NS_PAM)
+        self._p = self.host.plugins["XEP-0060"]
+        host.trigger.add("XEP-0060_subscribe", self.subscribe)
+        host.trigger.add("XEP-0060_unsubscribe", self.unsubscribe)
+        host.trigger.add("XEP-0060_subscriptions", self.subscriptions)
+
+    def get_handler(self, client):
+        return XEP_0376_Handler()
+
+    async def profile_connected(self, client):
+        if not self.host.hasFeature(client, NS_PAM):
+            log.warning(
+                "Your server doesn't support Pubsub Account Management, this is used to "
+                "track all your subscriptions. You may ask your server administrator to "
+                "install it."
+            )
+
+    async def _sub_request(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        nodeIdentifier: str,
+        sub_jid: Optional[jid.JID],
+        options: Optional[dict],
+        subscribe: bool
+    ) -> None:
+        if sub_jid is None:
+            sub_jid = client.jid.userhostJID()
+        iq_elt = client.IQ()
+        pam_elt = iq_elt.addElement((NS_PAM, "pam"))
+        pam_elt["jid"] = service.full()
+        subscribe_elt = pam_elt.addElement(
+            (pubsub.NS_PUBSUB, "subscribe" if subscribe else "unsubscribe")
+        )
+        subscribe_elt["node"] = nodeIdentifier
+        subscribe_elt["jid"] = sub_jid.full()
+        if options:
+            options_elt = pam_elt.addElement((pubsub.NS_PUBSUB, "options"))
+            options_elt["node"] = nodeIdentifier
+            options_elt["jid"] = sub_jid.full()
+            form = data_form.Form(
+                formType='submit',
+                formNamespace=pubsub.NS_PUBSUB_SUBSCRIBE_OPTIONS
+            )
+            form.makeFields(options)
+            options_elt.addChild(form.toElement())
+
+        await iq_elt.send(client.server_jid.full())
+
+    async def subscribe(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        nodeIdentifier: str,
+        sub_jid: Optional[jid.JID] = None,
+        options: Optional[dict] = None
+    ) -> Tuple[bool, Optional[pubsub.Subscription]]:
+        if not self.host.hasFeature(client, NS_PAM) or client.is_component:
+            return True, None
+
+        await self._sub_request(client, service, nodeIdentifier, sub_jid, options, True)
+
+        # TODO: actual result is sent with <message> stanza, we have to get and use them
+        # to known the actual result. XEP-0376 returns an empty <iq> result, thus we don't
+        # know here is the subscription actually succeeded
+
+        sub_id = None
+        sub = pubsub.Subscription(nodeIdentifier, sub_jid, "subscribed", options, sub_id)
+        return False, sub
+
+    async def unsubscribe(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        nodeIdentifier: str,
+        sub_jid: Optional[jid.JID],
+        subscriptionIdentifier: Optional[str],
+        sender: Optional[jid.JID] = None,
+    ) -> bool:
+        if not self.host.hasFeature(client, NS_PAM) or client.is_component:
+            return True
+        await self._sub_request(client, service, nodeIdentifier, sub_jid, None, False)
+        return False
+
+    async def subscriptions(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str,
+    ) -> Tuple[bool, Optional[List[Dict[str, Any]]]]:
+        if not self.host.hasFeature(client, NS_PAM):
+            return True, None
+        if service is not None or node is not None:
+            # if we have service and/or node subscriptions, it's a regular XEP-0060
+            # subscriptions request
+            return True, None
+
+        iq_elt = client.IQ("get")
+        subscriptions_elt = iq_elt.addElement((NS_PAM, "subscriptions"))
+        result_elt = await iq_elt.send()
+        try:
+            subscriptions_elt = next(result_elt.elements(NS_PAM, "subscriptions"))
+        except StopIteration:
+            raise ValueError(f"invalid PAM response: {result_elt.toXml()}")
+        subs = []
+        for subscription_elt in subscriptions_elt.elements(NS_PAM, "subscription"):
+            sub = {}
+            try:
+                for attr, key in (
+                    ("service", "service"),
+                    ("node", "node"),
+                    ("jid", "subscriber"),
+                    ("subscription", "state")
+                ):
+                    sub[key] = subscription_elt[attr]
+            except KeyError as e:
+                log.warning(
+                    f"Invalid <subscription> element (missing {e.args[0]!r} attribute): "
+                    f"{subscription_elt.toXml()}"
+                )
+                continue
+            sub_id = subscription_elt.getAttribute("subid")
+            if sub_id:
+                sub["id"] = sub_id
+            subs.append(sub)
+
+        return False, subs
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0376_Handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_PAM)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0380.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,102 @@
+#!/usr/bin/env python3
+
+
+# SAT plugin for Explicit Message Encryption
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from twisted.words.protocols.jabber import jid
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Explicit Message Encryption",
+    C.PI_IMPORT_NAME: "XEP-0380",
+    C.PI_TYPE: "SEC",
+    C.PI_PROTOCOLS: ["XEP-0380"],
+    C.PI_DEPENDENCIES: [],
+    C.PI_MAIN: "XEP_0380",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of Explicit Message Encryption"""),
+}
+
+NS_EME = "urn:xmpp:eme:0"
+KNOWN_NAMESPACES = {
+    "urn:xmpp:otr:0": "OTR",
+    "jabber:x:encrypted": "Legacy OpenPGP",
+    "urn:xmpp:openpgp:0": "OpenPGP for XMPP",
+}
+
+
+class XEP_0380(object):
+
+    def __init__(self, host):
+        self.host = host
+        host.trigger.add("sendMessage", self._send_message_trigger)
+        host.trigger.add("message_received", self._message_received_trigger, priority=100)
+        host.register_namespace("eme", NS_EME)
+
+    def _add_eme_element(self, mess_data, namespace, name):
+        message_elt = mess_data['xml']
+        encryption_elt = message_elt.addElement((NS_EME, 'encryption'))
+        encryption_elt['namespace'] = namespace
+        if name is not None:
+            encryption_elt['name'] = name
+        return mess_data
+
+    def _send_message_trigger(self, client, mess_data, __, post_xml_treatments):
+        encryption = mess_data.get(C.MESS_KEY_ENCRYPTION)
+        if encryption is not None:
+            namespace = encryption['plugin'].namespace
+            if namespace not in KNOWN_NAMESPACES:
+                name = encryption['plugin'].name
+            else:
+                name = None
+            post_xml_treatments.addCallback(
+                self._add_eme_element, namespace=namespace, name=name)
+        return True
+
+    def _message_received_trigger(self, client, message_elt, post_treat):
+        try:
+            encryption_elt = next(message_elt.elements(NS_EME, 'encryption'))
+        except StopIteration:
+            return True
+
+        namespace = encryption_elt['namespace']
+        if namespace in client.encryption.get_namespaces():
+            # message is encrypted and we can decrypt it
+            return True
+
+        name = KNOWN_NAMESPACES.get(namespace, encryption_elt.getAttribute("name"))
+
+        # at this point, message is encrypted but we know that we can't decrypt it,
+        # we need to notify the user
+        sender_s = message_elt['from']
+        to_jid = jid.JID(message_elt['from'])
+        algorithm = "{} [{}]".format(name, namespace) if name else namespace
+        log.warning(
+            _("Message from {sender} is encrypted with {algorithm} and we can't "
+              "decrypt it.".format(sender=message_elt['from'], algorithm=algorithm)))
+
+        user_msg = D_(
+            "User {sender} sent you an encrypted message (encrypted with {algorithm}), "
+            "and we can't decrypt it.").format(sender=sender_s, algorithm=algorithm)
+
+        extra = {C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR}
+        client.feedback(to_jid, user_msg, extra)
+        return False
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0384.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,2724 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for OMEMO encryption
+# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import base64
+from datetime import datetime
+import enum
+import logging
+import time
+from typing import \
+    Any, Dict, FrozenSet, List, Literal, NamedTuple, Optional, Set, Type, Union, cast
+import uuid
+import xml.etree.ElementTree as ET
+from xml.sax.saxutils import quoteattr
+
+from typing_extensions import Final, Never, assert_never
+from wokkel import muc, pubsub  # type: ignore[import]
+import xmlschema
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import MessageData, SatXMPPEntity
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger, Logger
+from libervia.backend.core.sat_main import SAT
+from libervia.backend.core.xmpp import SatXMPPClient
+from libervia.backend.memory import persistent
+from libervia.backend.plugins.plugin_misc_text_commands import TextCommands
+from libervia.backend.plugins.plugin_xep_0045 import XEP_0045
+from libervia.backend.plugins.plugin_xep_0060 import XEP_0060
+from libervia.backend.plugins.plugin_xep_0163 import XEP_0163
+from libervia.backend.plugins.plugin_xep_0334 import XEP_0334
+from libervia.backend.plugins.plugin_xep_0359 import XEP_0359
+from libervia.backend.plugins.plugin_xep_0420 import \
+    XEP_0420, SCEAffixPolicy, SCEAffixValues, SCEProfile
+from libervia.backend.tools import xml_tools
+from twisted.internet import defer
+from twisted.words.protocols.jabber import error, jid
+from twisted.words.xish import domish
+
+try:
+    import omemo
+    import omemo.identity_key_pair
+    import twomemo
+    import twomemo.etree
+    import oldmemo
+    import oldmemo.etree
+    import oldmemo.migrations
+    from xmlschema import XMLSchemaValidationError
+
+    # An explicit version check of the OMEMO libraries should not be required here, since
+    # the stored data is fully versioned and the library will complain if a downgrade is
+    # attempted.
+except ImportError as import_error:
+    raise exceptions.MissingModule(
+        "You are missing one or more package required by the OMEMO plugin. Please"
+        " download/install the pip packages 'omemo', 'twomemo', 'oldmemo' and"
+        f" 'xmlschema'.\nexception: {import_error}"
+    ) from import_error
+
+
+__all__ = [  # pylint: disable=unused-variable
+    "PLUGIN_INFO",
+    "OMEMO"
+]
+
+log = cast(Logger, getLogger(__name__))  # type: ignore[no-untyped-call]
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "OMEMO",
+    C.PI_IMPORT_NAME: "XEP-0384",
+    C.PI_TYPE: "SEC",
+    C.PI_PROTOCOLS: [ "XEP-0384" ],
+    C.PI_DEPENDENCIES: [ "XEP-0163", "XEP-0280", "XEP-0334", "XEP-0060", "XEP-0420" ],
+    C.PI_RECOMMENDATIONS: [ "XEP-0045", "XEP-0359", C.TEXT_CMDS ],
+    C.PI_MAIN: "OMEMO",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of OMEMO"""),
+}
+
+
+PARAM_CATEGORY = "Security"
+PARAM_NAME = "omemo_policy"
+
+
+class LogHandler(logging.Handler):
+    """
+    Redirect python-omemo's log output to Libervia's log system.
+    """
+
+    def emit(self, record: logging.LogRecord) -> None:
+        log.log(record.levelname, record.getMessage())
+
+
+sm_logger = logging.getLogger(omemo.SessionManager.LOG_TAG)
+sm_logger.setLevel(logging.DEBUG)
+sm_logger.propagate = False
+sm_logger.addHandler(LogHandler())
+
+
+ikp_logger = logging.getLogger(omemo.identity_key_pair.IdentityKeyPair.LOG_TAG)
+ikp_logger.setLevel(logging.DEBUG)
+ikp_logger.propagate = False
+ikp_logger.addHandler(LogHandler())
+
+
+# TODO: Add handling for device labels, i.e. show device labels in the trust UI and give
+# the user a way to change their own device label.
+
+
+class MUCPlaintextCacheKey(NamedTuple):
+    # pylint: disable=invalid-name
+    """
+    Structure identifying an encrypted message sent to a MUC.
+    """
+
+    client: SatXMPPClient
+    room_jid: jid.JID
+    message_uid: str
+
+
+@enum.unique
+class TrustLevel(enum.Enum):
+    """
+    The trust levels required for ATM and BTBV.
+    """
+
+    TRUSTED: str = "TRUSTED"
+    BLINDLY_TRUSTED: str = "BLINDLY_TRUSTED"
+    UNDECIDED: str = "UNDECIDED"
+    DISTRUSTED: str = "DISTRUSTED"
+
+
+TWOMEMO_DEVICE_LIST_NODE = "urn:xmpp:omemo:2:devices"
+OLDMEMO_DEVICE_LIST_NODE = "eu.siacs.conversations.axolotl.devicelist"
+
+
+class StorageImpl(omemo.Storage):
+    """
+    Storage implementation for OMEMO based on :class:`persistent.LazyPersistentBinaryDict`
+    """
+
+    def __init__(self, profile: str) -> None:
+        """
+        @param profile: The profile this OMEMO data belongs to.
+        """
+
+        # persistent.LazyPersistentBinaryDict does not cache at all, so keep the caching
+        # option of omemo.Storage enabled.
+        super().__init__()
+
+        self.__storage = persistent.LazyPersistentBinaryDict("XEP-0384", profile)
+
+    async def _load(self, key: str) -> omemo.Maybe[omemo.JSONType]:
+        try:
+            return omemo.Just(await self.__storage[key])
+        except KeyError:
+            return omemo.Nothing()
+        except Exception as e:
+            raise omemo.StorageException(f"Error while loading key {key}") from e
+
+    async def _store(self, key: str, value: omemo.JSONType) -> None:
+        try:
+            await self.__storage.force(key, value)
+        except Exception as e:
+            raise omemo.StorageException(f"Error while storing key {key}: {value}") from e
+
+    async def _delete(self, key: str) -> None:
+        try:
+            await self.__storage.remove(key)
+        except KeyError:
+            pass
+        except Exception as e:
+            raise omemo.StorageException(f"Error while deleting key {key}") from e
+
+
+class LegacyStorageImpl(oldmemo.migrations.LegacyStorage):
+    """
+    Legacy storage implementation to migrate data from the old XEP-0384 plugin.
+    """
+
+    KEY_DEVICE_ID = "DEVICE_ID"
+    KEY_STATE = "STATE"
+    KEY_SESSION = "SESSION"
+    KEY_ACTIVE_DEVICES = "DEVICES"
+    KEY_INACTIVE_DEVICES = "INACTIVE_DEVICES"
+    KEY_TRUST = "TRUST"
+    KEY_ALL_JIDS = "ALL_JIDS"
+
+    def __init__(self, profile: str, own_bare_jid: str) -> None:
+        """
+        @param profile: The profile this OMEMO data belongs to.
+        @param own_bare_jid: The own bare JID, to return by the :meth:`load_own_data` call.
+        """
+
+        self.__storage = persistent.LazyPersistentBinaryDict("XEP-0384", profile)
+        self.__own_bare_jid = own_bare_jid
+
+    async def loadOwnData(self) -> Optional[oldmemo.migrations.OwnData]:
+        own_device_id = await self.__storage.get(LegacyStorageImpl.KEY_DEVICE_ID, None)
+        if own_device_id is None:
+            return None
+
+        return oldmemo.migrations.OwnData(
+            own_bare_jid=self.__own_bare_jid,
+            own_device_id=own_device_id
+        )
+
+    async def deleteOwnData(self) -> None:
+        try:
+            await self.__storage.remove(LegacyStorageImpl.KEY_DEVICE_ID)
+        except KeyError:
+            pass
+
+    async def loadState(self) -> Optional[oldmemo.migrations.State]:
+        return cast(
+            Optional[oldmemo.migrations.State],
+            await self.__storage.get(LegacyStorageImpl.KEY_STATE, None)
+        )
+
+    async def deleteState(self) -> None:
+        try:
+            await self.__storage.remove(LegacyStorageImpl.KEY_STATE)
+        except KeyError:
+            pass
+
+    async def loadSession(
+        self,
+        bare_jid: str,
+        device_id: int
+    ) -> Optional[oldmemo.migrations.Session]:
+        key = "\n".join([ LegacyStorageImpl.KEY_SESSION, bare_jid, str(device_id) ])
+
+        return cast(
+            Optional[oldmemo.migrations.Session],
+            await self.__storage.get(key, None)
+        )
+
+    async def deleteSession(self, bare_jid: str, device_id: int) -> None:
+        key = "\n".join([ LegacyStorageImpl.KEY_SESSION, bare_jid, str(device_id) ])
+
+        try:
+            await self.__storage.remove(key)
+        except KeyError:
+            pass
+
+    async def loadActiveDevices(self, bare_jid: str) -> Optional[List[int]]:
+        key = "\n".join([ LegacyStorageImpl.KEY_ACTIVE_DEVICES, bare_jid ])
+
+        return cast(
+            Optional[List[int]],
+            await self.__storage.get(key, None)
+        )
+
+    async def loadInactiveDevices(self, bare_jid: str) -> Optional[Dict[int, int]]:
+        key = "\n".join([ LegacyStorageImpl.KEY_INACTIVE_DEVICES, bare_jid ])
+
+        return cast(
+            Optional[Dict[int, int]],
+            await self.__storage.get(key, None)
+        )
+
+    async def deleteActiveDevices(self, bare_jid: str) -> None:
+        key = "\n".join([ LegacyStorageImpl.KEY_ACTIVE_DEVICES, bare_jid ])
+
+        try:
+            await self.__storage.remove(key)
+        except KeyError:
+            pass
+
+    async def deleteInactiveDevices(self, bare_jid: str) -> None:
+        key = "\n".join([ LegacyStorageImpl.KEY_INACTIVE_DEVICES, bare_jid ])
+
+        try:
+            await self.__storage.remove(key)
+        except KeyError:
+            pass
+
+    async def loadTrust(
+        self,
+        bare_jid: str,
+        device_id: int
+    ) -> Optional[oldmemo.migrations.Trust]:
+        key = "\n".join([ LegacyStorageImpl.KEY_TRUST, bare_jid, str(device_id) ])
+
+        return cast(
+            Optional[oldmemo.migrations.Trust],
+            await self.__storage.get(key, None)
+        )
+
+    async def deleteTrust(self, bare_jid: str, device_id: int) -> None:
+        key = "\n".join([ LegacyStorageImpl.KEY_TRUST, bare_jid, str(device_id) ])
+
+        try:
+            await self.__storage.remove(key)
+        except KeyError:
+            pass
+
+    async def listJIDs(self) -> Optional[List[str]]:
+        bare_jids = await self.__storage.get(LegacyStorageImpl.KEY_ALL_JIDS, None)
+
+        return None if bare_jids is None else list(bare_jids)
+
+    async def deleteJIDList(self) -> None:
+        try:
+            await self.__storage.remove(LegacyStorageImpl.KEY_ALL_JIDS)
+        except KeyError:
+            pass
+
+
+async def download_oldmemo_bundle(
+    client: SatXMPPClient,
+    xep_0060: XEP_0060,
+    bare_jid: str,
+    device_id: int
+) -> oldmemo.oldmemo.BundleImpl:
+    """Download the oldmemo bundle corresponding to a specific device.
+
+    @param client: The client.
+    @param xep_0060: The XEP-0060 plugin instance to use for pubsub interactions.
+    @param bare_jid: The bare JID the device belongs to.
+    @param device_id: The id of the device.
+    @return: The bundle.
+    @raise BundleDownloadFailed: if the download failed. Feel free to raise a subclass
+        instead.
+    """
+    # Bundle downloads are needed by the session manager and for migrations from legacy,
+    # thus it is made a separate function.
+
+    namespace = oldmemo.oldmemo.NAMESPACE
+    node = f"eu.siacs.conversations.axolotl.bundles:{device_id}"
+
+    try:
+        items, __ = await xep_0060.get_items(client, jid.JID(bare_jid), node, max_items=1)
+    except Exception as e:
+        raise omemo.BundleDownloadFailed(
+            f"Bundle download failed for {bare_jid}: {device_id} under namespace"
+            f" {namespace}"
+        ) from e
+
+    if len(items) != 1:
+        raise omemo.BundleDownloadFailed(
+            f"Bundle download failed for {bare_jid}: {device_id} under namespace"
+            f" {namespace}: Unexpected number of items retrieved: {len(items)}."
+        )
+
+    element = \
+        next(iter(xml_tools.domish_elt_2_et_elt(cast(domish.Element, items[0]))), None)
+    if element is None:
+        raise omemo.BundleDownloadFailed(
+            f"Bundle download failed for {bare_jid}: {device_id} under namespace"
+            f" {namespace}: Item download succeeded but parsing failed: {element}."
+        )
+
+    try:
+        return oldmemo.etree.parse_bundle(element, bare_jid, device_id)
+    except Exception as e:
+        raise omemo.BundleDownloadFailed(
+            f"Bundle parsing failed for {bare_jid}: {device_id} under namespace"
+            f" {namespace}"
+        ) from e
+
+
+# ATM only supports protocols based on SCE, which is currently only omemo:2, and relies on
+# so many implementation details of the encryption protocol that it makes more sense to
+# add ATM to the OMEMO plugin directly instead of having it a separate Libervia plugin.
+NS_TM: Final = "urn:xmpp:tm:1"
+NS_ATM: Final = "urn:xmpp:atm:1"
+
+
+TRUST_MESSAGE_SCHEMA = xmlschema.XMLSchema("""<?xml version='1.0' encoding='UTF-8'?>
+<xs:schema xmlns:xs='http://www.w3.org/2001/XMLSchema'
+           targetNamespace='urn:xmpp:tm:1'
+           xmlns='urn:xmpp:tm:1'
+           elementFormDefault='qualified'>
+
+  <xs:element name='trust-message'>
+    <xs:complexType>
+      <xs:sequence>
+        <xs:element ref='key-owner' minOccurs='1' maxOccurs='unbounded'/>
+      </xs:sequence>
+      <xs:attribute name='usage' type='xs:string' use='required'/>
+      <xs:attribute name='encryption' type='xs:string' use='required'/>
+    </xs:complexType>
+  </xs:element>
+
+  <xs:element name='key-owner'>
+    <xs:complexType>
+      <xs:sequence>
+        <xs:element
+            name='trust' type='xs:base64Binary' minOccurs='0' maxOccurs='unbounded'/>
+        <xs:element
+            name='distrust' type='xs:base64Binary' minOccurs='0' maxOccurs='unbounded'/>
+      </xs:sequence>
+      <xs:attribute name='jid' type='xs:string' use='required'/>
+    </xs:complexType>
+  </xs:element>
+</xs:schema>
+""")
+
+
+# This is compatible with omemo:2's SCE profile
+TM_SCE_PROFILE = SCEProfile(
+    rpad_policy=SCEAffixPolicy.REQUIRED,
+    time_policy=SCEAffixPolicy.REQUIRED,
+    to_policy=SCEAffixPolicy.OPTIONAL,
+    from_policy=SCEAffixPolicy.OPTIONAL,
+    custom_policies={}
+)
+
+
+class TrustUpdate(NamedTuple):
+    # pylint: disable=invalid-name
+    """
+    An update to the trust status of an identity key, used by Automatic Trust Management.
+    """
+
+    target_jid: jid.JID
+    target_key: bytes
+    target_trust: bool
+
+
+class TrustMessageCacheEntry(NamedTuple):
+    # pylint: disable=invalid-name
+    """
+    An entry in the trust message cache used by ATM.
+    """
+
+    sender_jid: jid.JID
+    sender_key: bytes
+    timestamp: datetime
+    trust_update: TrustUpdate
+
+
+class PartialTrustMessage(NamedTuple):
+    # pylint: disable=invalid-name
+    """
+    A structure representing a partial trust message, used by :func:`send_trust_messages`
+    to build trust messages.
+    """
+
+    recipient_jid: jid.JID
+    updated_jid: jid.JID
+    trust_updates: FrozenSet[TrustUpdate]
+
+
+async def manage_trust_message_cache(
+    client: SatXMPPClient,
+    session_manager: omemo.SessionManager,
+    applied_trust_updates: FrozenSet[TrustUpdate]
+) -> None:
+    """Manage the ATM trust message cache after trust updates have been applied.
+
+    @param client: The client this operation runs under.
+    @param session_manager: The session manager to use.
+    @param applied_trust_updates: The trust updates that have already been applied,
+        triggering this cache management run.
+    """
+
+    trust_message_cache = persistent.LazyPersistentBinaryDict(
+        "XEP-0384/TM",
+        client.profile
+    )
+
+    # Load cache entries
+    cache_entries = cast(
+        Set[TrustMessageCacheEntry],
+        await trust_message_cache.get("cache", set())
+    )
+
+    # Expire cache entries that were overwritten by the applied trust updates
+    cache_entries_by_target = {
+        (
+            cache_entry.trust_update.target_jid.userhostJID(),
+            cache_entry.trust_update.target_key
+        ): cache_entry
+        for cache_entry
+        in cache_entries
+    }
+
+    for trust_update in applied_trust_updates:
+        cache_entry = cache_entries_by_target.get(
+            (trust_update.target_jid.userhostJID(), trust_update.target_key),
+            None
+        )
+
+        if cache_entry is not None:
+            cache_entries.remove(cache_entry)
+
+    # Apply cached Trust Messages by newly trusted devices
+    new_trust_updates: Set[TrustUpdate] = set()
+
+    for trust_update in applied_trust_updates:
+        if trust_update.target_trust:
+            # Iterate over a copy such that cache_entries can be modified
+            for cache_entry in set(cache_entries):
+                if (
+                    cache_entry.sender_jid.userhostJID()
+                    == trust_update.target_jid.userhostJID()
+                    and cache_entry.sender_key == trust_update.target_key
+                ):
+                    trust_level = (
+                        TrustLevel.TRUSTED
+                        if cache_entry.trust_update.target_trust
+                        else TrustLevel.DISTRUSTED
+                    )
+
+                    # Apply the trust update
+                    await session_manager.set_trust(
+                        cache_entry.trust_update.target_jid.userhost(),
+                        cache_entry.trust_update.target_key,
+                        trust_level.name
+                    )
+
+                    # Track the fact that this trust update has been applied
+                    new_trust_updates.add(cache_entry.trust_update)
+
+                    # Remove the corresponding cache entry
+                    cache_entries.remove(cache_entry)
+
+    # Store the updated cache entries
+    await trust_message_cache.force("cache", cache_entries)
+
+    # TODO: Notify the user ("feedback") about automatically updated trust?
+
+    if len(new_trust_updates) > 0:
+        # If any trust has been updated, recursively perform another run of cache
+        # management
+        await manage_trust_message_cache(
+            client,
+            session_manager,
+            frozenset(new_trust_updates)
+        )
+
+
+async def get_trust_as_trust_updates(
+    session_manager: omemo.SessionManager,
+    target_jid: jid.JID
+) -> FrozenSet[TrustUpdate]:
+    """Get the trust status of all known keys of a JID as trust updates for use with ATM.
+
+    @param session_manager: The session manager to load the trust from.
+    @param target_jid: The JID to load the trust for.
+    @return: The trust updates encoding the trust status of all known keys of the JID that
+        are either explicitly trusted or distrusted. Undecided keys are not included in
+        the trust updates.
+    """
+
+    devices = await session_manager.get_device_information(target_jid.userhost())
+
+    trust_updates: Set[TrustUpdate] = set()
+
+    for device in devices:
+        trust_level = TrustLevel(device.trust_level_name)
+        target_trust: bool
+
+        if trust_level is TrustLevel.TRUSTED:
+            target_trust = True
+        elif trust_level is TrustLevel.DISTRUSTED:
+            target_trust = False
+        else:
+            # Skip devices that are not explicitly trusted or distrusted
+            continue
+
+        trust_updates.add(TrustUpdate(
+            target_jid=target_jid.userhostJID(),
+            target_key=device.identity_key,
+            target_trust=target_trust
+        ))
+
+    return frozenset(trust_updates)
+
+
+async def send_trust_messages(
+    client: SatXMPPClient,
+    session_manager: omemo.SessionManager,
+    applied_trust_updates: FrozenSet[TrustUpdate]
+) -> None:
+    """Send information about updated trust to peers via ATM (XEP-0450).
+
+    @param client: The client.
+    @param session_manager: The session manager.
+    @param applied_trust_updates: The trust updates that have already been applied, to
+        notify other peers about.
+    """
+    # NOTE: This currently sends information about oldmemo trust too. This is not
+    # specified and experimental, but since twomemo and oldmemo share the same identity
+    # keys and trust systems, this could be a cool side effect.
+
+    # Send Trust Messages for newly trusted and distrusted devices
+    own_jid = client.jid.userhostJID()
+    own_trust_updates = await get_trust_as_trust_updates(session_manager, own_jid)
+
+    # JIDs of which at least one device's trust has been updated
+    updated_jids = frozenset({
+        trust_update.target_jid.userhostJID()
+        for trust_update
+        in applied_trust_updates
+    })
+
+    trust_messages: Set[PartialTrustMessage] = set()
+
+    for updated_jid in updated_jids:
+        # Get the trust updates for that JID
+        trust_updates = frozenset({
+            trust_update for trust_update in applied_trust_updates
+            if trust_update.target_jid.userhostJID() == updated_jid
+        })
+
+        if updated_jid == own_jid:
+            # If the own JID is updated, _all_ peers have to be notified
+            # TODO: Using my author's privilege here to shamelessly access private fields
+            # and storage keys until I've added public API to get a list of peers to
+            # python-omemo.
+            storage: omemo.Storage = getattr(session_manager, "_SessionManager__storage")
+            peer_jids = frozenset({
+                jid.JID(bare_jid).userhostJID() for bare_jid in (await storage.load_list(
+                    f"/{OMEMO.NS_TWOMEMO}/bare_jids",
+                    str
+                )).maybe([])
+            })
+
+            if len(peer_jids) == 0:
+                # If there are no peers to notify, notify our other devices about the
+                # changes directly
+                trust_messages.add(PartialTrustMessage(
+                    recipient_jid=own_jid,
+                    updated_jid=own_jid,
+                    trust_updates=trust_updates
+                ))
+            else:
+                # Otherwise, notify all peers about the changes in trust and let carbons
+                # handle the copy to our own JID
+                for peer_jid in peer_jids:
+                    trust_messages.add(PartialTrustMessage(
+                        recipient_jid=peer_jid,
+                        updated_jid=own_jid,
+                        trust_updates=trust_updates
+                    ))
+
+                    # Also send full trust information about _every_ peer to our newly
+                    # trusted devices
+                    peer_trust_updates = \
+                        await get_trust_as_trust_updates(session_manager, peer_jid)
+
+                    trust_messages.add(PartialTrustMessage(
+                        recipient_jid=own_jid,
+                        updated_jid=peer_jid,
+                        trust_updates=peer_trust_updates
+                    ))
+
+            # Send information about our own devices to our newly trusted devices
+            trust_messages.add(PartialTrustMessage(
+                recipient_jid=own_jid,
+                updated_jid=own_jid,
+                trust_updates=own_trust_updates
+            ))
+        else:
+            # Notify our other devices about the changes in trust
+            trust_messages.add(PartialTrustMessage(
+                recipient_jid=own_jid,
+                updated_jid=updated_jid,
+                trust_updates=trust_updates
+            ))
+
+            # Send a summary of our own trust to newly trusted devices
+            trust_messages.add(PartialTrustMessage(
+                recipient_jid=updated_jid,
+                updated_jid=own_jid,
+                trust_updates=own_trust_updates
+            ))
+
+    # All trust messages prepared. Merge all trust messages directed at the same
+    # recipient.
+    recipient_jids = { trust_message.recipient_jid for trust_message in trust_messages }
+
+    for recipient_jid in recipient_jids:
+        updated: Dict[jid.JID, Set[TrustUpdate]] = {}
+
+        for trust_message in trust_messages:
+            # Merge trust messages directed at that recipient
+            if trust_message.recipient_jid == recipient_jid:
+                # Merge the trust updates
+                updated[trust_message.updated_jid] = \
+                    updated.get(trust_message.updated_jid, set())
+
+                updated[trust_message.updated_jid] |= trust_message.trust_updates
+
+        # Build the trust message
+        trust_message_elt = domish.Element((NS_TM, "trust-message"))
+        trust_message_elt["usage"] = NS_ATM
+        trust_message_elt["encryption"] = twomemo.twomemo.NAMESPACE
+
+        for updated_jid, trust_updates in updated.items():
+            key_owner_elt = trust_message_elt.addElement((NS_TM, "key-owner"))
+            key_owner_elt["jid"] = updated_jid.userhost()
+
+            for trust_update in trust_updates:
+                serialized_identity_key = \
+                    base64.b64encode(trust_update.target_key).decode("ASCII")
+
+                if trust_update.target_trust:
+                    key_owner_elt.addElement(
+                        (NS_TM, "trust"),
+                        content=serialized_identity_key
+                    )
+                else:
+                    key_owner_elt.addElement(
+                        (NS_TM, "distrust"),
+                        content=serialized_identity_key
+                    )
+
+        # Finally, encrypt and send the trust message!
+        message_data = client.generate_message_xml(MessageData({
+            "from": own_jid,
+            "to": recipient_jid,
+            "uid": str(uuid.uuid4()),
+            "message": {},
+            "subject": {},
+            "type": C.MESS_TYPE_CHAT,
+            "extra": {},
+            "timestamp": time.time()
+        }))
+
+        message_data["xml"].addChild(trust_message_elt)
+
+        plaintext = XEP_0420.pack_stanza(TM_SCE_PROFILE, message_data["xml"])
+
+        feedback_jid = recipient_jid
+
+        # TODO: The following is mostly duplicate code
+        try:
+            messages, encryption_errors = await session_manager.encrypt(
+                frozenset({ own_jid.userhost(), recipient_jid.userhost() }),
+                { OMEMO.NS_TWOMEMO: plaintext },
+                backend_priority_order=[ OMEMO.NS_TWOMEMO ],
+                identifier=feedback_jid.userhost()
+            )
+        except Exception as e:
+            msg = _(
+                # pylint: disable=consider-using-f-string
+                "Can't encrypt message for {entities}: {reason}".format(
+                    entities=', '.join({ own_jid.userhost(), recipient_jid.userhost() }),
+                    reason=e
+                )
+            )
+            log.warning(msg)
+            client.feedback(feedback_jid, msg, {
+                C.MESS_EXTRA_INFO: C.EXTRA_INFO_ENCR_ERR
+            })
+            raise e
+
+        if len(encryption_errors) > 0:
+            log.warning(
+                f"Ignored the following non-critical encryption errors:"
+                f" {encryption_errors}"
+            )
+
+            encrypted_errors_stringified = ", ".join([
+                f"device {err.device_id} of {err.bare_jid} under namespace"
+                f" {err.namespace}"
+                for err
+                in encryption_errors
+            ])
+
+            client.feedback(
+                feedback_jid,
+                D_(
+                    "There were non-critical errors during encryption resulting in some"
+                    " of your destinees' devices potentially not receiving the message."
+                    " This happens when the encryption data/key material of a device is"
+                    " incomplete or broken, which shouldn't happen for actively used"
+                    " devices, and can usually be ignored. The following devices are"
+                    f" affected: {encrypted_errors_stringified}."
+                )
+            )
+
+        message = next(
+            message for message in messages
+            if message.namespace == OMEMO.NS_TWOMEMO
+        )
+
+        # Add the encrypted element
+        message_data["xml"].addChild(xml_tools.et_elt_2_domish_elt(
+            twomemo.etree.serialize_message(message)
+        ))
+
+        await client.a_send(message_data["xml"])
+
+
+def make_session_manager(sat: SAT, profile: str) -> Type[omemo.SessionManager]:
+    """
+    @param sat: The SAT instance.
+    @param profile: The profile.
+    @return: A non-abstract subclass of :class:`~omemo.session_manager.SessionManager`
+        with XMPP interactions and trust handled via the SAT instance.
+    """
+
+    client = sat.get_client(profile)
+    xep_0060 = cast(XEP_0060, sat.plugins["XEP-0060"])
+
+    class SessionManagerImpl(omemo.SessionManager):
+        """
+        Session manager implementation handling XMPP interactions and trust via an
+        instance of :class:`~sat.core.sat_main.SAT`.
+        """
+
+        @staticmethod
+        async def _upload_bundle(bundle: omemo.Bundle) -> None:
+            if isinstance(bundle, twomemo.twomemo.BundleImpl):
+                element = twomemo.etree.serialize_bundle(bundle)
+
+                node = "urn:xmpp:omemo:2:bundles"
+                try:
+                    await xep_0060.send_item(
+                        client,
+                        client.jid.userhostJID(),
+                        node,
+                        xml_tools.et_elt_2_domish_elt(element),
+                        item_id=str(bundle.device_id),
+                        extra={
+                            XEP_0060.EXTRA_PUBLISH_OPTIONS: {
+                                XEP_0060.OPT_MAX_ITEMS: "max"
+                            },
+                            XEP_0060.EXTRA_ON_PRECOND_NOT_MET: "raise"
+                        }
+                    )
+                except (error.StanzaError, Exception) as e:
+                    if (
+                        isinstance(e, error.StanzaError)
+                        and e.condition == "conflict"
+                        and e.appCondition is not None
+                        # pylint: disable=no-member
+                        and e.appCondition.name == "precondition-not-met"
+                    ):
+                        # publish options couldn't be set on the fly, manually reconfigure
+                        # the node and publish again
+                        raise omemo.BundleUploadFailed(
+                            f"precondition-not-met: {bundle}"
+                        ) from e
+                        # TODO: What can I do here? The correct node configuration is a
+                        # MUST in the XEP.
+
+                    raise omemo.BundleUploadFailed(
+                        f"Bundle upload failed: {bundle}"
+                    ) from e
+
+                return
+
+            if isinstance(bundle, oldmemo.oldmemo.BundleImpl):
+                element = oldmemo.etree.serialize_bundle(bundle)
+
+                node = f"eu.siacs.conversations.axolotl.bundles:{bundle.device_id}"
+                try:
+                    await xep_0060.send_item(
+                        client,
+                        client.jid.userhostJID(),
+                        node,
+                        xml_tools.et_elt_2_domish_elt(element),
+                        item_id=xep_0060.ID_SINGLETON,
+                        extra={
+                            XEP_0060.EXTRA_PUBLISH_OPTIONS: { XEP_0060.OPT_MAX_ITEMS: 1 },
+                            XEP_0060.EXTRA_ON_PRECOND_NOT_MET: "publish_without_options"
+                        }
+                    )
+                except Exception as e:
+                    raise omemo.BundleUploadFailed(
+                        f"Bundle upload failed: {bundle}"
+                    ) from e
+
+                return
+
+            raise omemo.UnknownNamespace(f"Unknown namespace: {bundle.namespace}")
+
+        @staticmethod
+        async def _download_bundle(
+            namespace: str,
+            bare_jid: str,
+            device_id: int
+        ) -> omemo.Bundle:
+            if namespace == twomemo.twomemo.NAMESPACE:
+                node = "urn:xmpp:omemo:2:bundles"
+
+                try:
+                    items, __ = await xep_0060.get_items(
+                        client,
+                        jid.JID(bare_jid),
+                        node,
+                        item_ids=[ str(device_id) ]
+                    )
+                except Exception as e:
+                    raise omemo.BundleDownloadFailed(
+                        f"Bundle download failed for {bare_jid}: {device_id} under"
+                        f" namespace {namespace}"
+                    ) from e
+
+                if len(items) != 1:
+                    raise omemo.BundleDownloadFailed(
+                        f"Bundle download failed for {bare_jid}: {device_id} under"
+                        f" namespace {namespace}: Unexpected number of items retrieved:"
+                        f" {len(items)}."
+                    )
+
+                element = next(
+                    iter(xml_tools.domish_elt_2_et_elt(cast(domish.Element, items[0]))),
+                    None
+                )
+                if element is None:
+                    raise omemo.BundleDownloadFailed(
+                        f"Bundle download failed for {bare_jid}: {device_id} under"
+                        f" namespace {namespace}: Item download succeeded but parsing"
+                        f" failed: {element}."
+                    )
+
+                try:
+                    return twomemo.etree.parse_bundle(element, bare_jid, device_id)
+                except Exception as e:
+                    raise omemo.BundleDownloadFailed(
+                        f"Bundle parsing failed for {bare_jid}: {device_id} under"
+                        f" namespace {namespace}"
+                    ) from e
+
+            if namespace == oldmemo.oldmemo.NAMESPACE:
+                return await download_oldmemo_bundle(
+                    client,
+                    xep_0060,
+                    bare_jid,
+                    device_id
+                )
+
+            raise omemo.UnknownNamespace(f"Unknown namespace: {namespace}")
+
+        @staticmethod
+        async def _delete_bundle(namespace: str, device_id: int) -> None:
+            if namespace == twomemo.twomemo.NAMESPACE:
+                node = "urn:xmpp:omemo:2:bundles"
+
+                try:
+                    await xep_0060.retract_items(
+                        client,
+                        client.jid.userhostJID(),
+                        node,
+                        [ str(device_id) ],
+                        notify=False
+                    )
+                except Exception as e:
+                    raise omemo.BundleDeletionFailed(
+                        f"Bundle deletion failed for {device_id} under namespace"
+                        f" {namespace}"
+                    ) from e
+
+                return
+
+            if namespace == oldmemo.oldmemo.NAMESPACE:
+                node = f"eu.siacs.conversations.axolotl.bundles:{device_id}"
+
+                try:
+                    await xep_0060.deleteNode(client, client.jid.userhostJID(), node)
+                except Exception as e:
+                    raise omemo.BundleDeletionFailed(
+                        f"Bundle deletion failed for {device_id} under namespace"
+                        f" {namespace}"
+                    ) from e
+
+                return
+
+            raise omemo.UnknownNamespace(f"Unknown namespace: {namespace}")
+
+        @staticmethod
+        async def _upload_device_list(
+            namespace: str,
+            device_list: Dict[int, Optional[str]]
+        ) -> None:
+            element: Optional[ET.Element] = None
+            node: Optional[str] = None
+
+            if namespace == twomemo.twomemo.NAMESPACE:
+                element = twomemo.etree.serialize_device_list(device_list)
+                node = TWOMEMO_DEVICE_LIST_NODE
+            if namespace == oldmemo.oldmemo.NAMESPACE:
+                element = oldmemo.etree.serialize_device_list(device_list)
+                node = OLDMEMO_DEVICE_LIST_NODE
+
+            if element is None or node is None:
+                raise omemo.UnknownNamespace(f"Unknown namespace: {namespace}")
+
+            try:
+                await xep_0060.send_item(
+                    client,
+                    client.jid.userhostJID(),
+                    node,
+                    xml_tools.et_elt_2_domish_elt(element),
+                    item_id=xep_0060.ID_SINGLETON,
+                    extra={
+                        XEP_0060.EXTRA_PUBLISH_OPTIONS: {
+                            XEP_0060.OPT_MAX_ITEMS: 1,
+                            XEP_0060.OPT_ACCESS_MODEL: "open"
+                        },
+                        XEP_0060.EXTRA_ON_PRECOND_NOT_MET: "raise"
+                    }
+                )
+            except (error.StanzaError, Exception) as e:
+                if (
+                    isinstance(e, error.StanzaError)
+                    and e.condition == "conflict"
+                    and e.appCondition is not None
+                    # pylint: disable=no-member
+                    and e.appCondition.name == "precondition-not-met"
+                ):
+                    # publish options couldn't be set on the fly, manually reconfigure the
+                    # node and publish again
+                    raise omemo.DeviceListUploadFailed(
+                        f"precondition-not-met for namespace {namespace}"
+                    ) from e
+                    # TODO: What can I do here? The correct node configuration is a MUST
+                    # in the XEP.
+
+                raise omemo.DeviceListUploadFailed(
+                    f"Device list upload failed for namespace {namespace}"
+                ) from e
+
+        @staticmethod
+        async def _download_device_list(
+            namespace: str,
+            bare_jid: str
+        ) -> Dict[int, Optional[str]]:
+            node: Optional[str] = None
+
+            if namespace == twomemo.twomemo.NAMESPACE:
+                node = TWOMEMO_DEVICE_LIST_NODE
+            if namespace == oldmemo.oldmemo.NAMESPACE:
+                node = OLDMEMO_DEVICE_LIST_NODE
+
+            if node is None:
+                raise omemo.UnknownNamespace(f"Unknown namespace: {namespace}")
+
+            try:
+                items, __ = await xep_0060.get_items(client, jid.JID(bare_jid), node)
+            except exceptions.NotFound:
+                return {}
+            except Exception as e:
+                raise omemo.DeviceListDownloadFailed(
+                    f"Device list download failed for {bare_jid} under namespace"
+                    f" {namespace}"
+                ) from e
+
+            if len(items) == 0:
+                return {}
+
+            if len(items) != 1:
+                raise omemo.DeviceListDownloadFailed(
+                    f"Device list download failed for {bare_jid} under namespace"
+                    f" {namespace}: Unexpected number of items retrieved: {len(items)}."
+                )
+
+            element = next(
+                iter(xml_tools.domish_elt_2_et_elt(cast(domish.Element, items[0]))),
+                None
+            )
+
+            if element is None:
+                raise omemo.DeviceListDownloadFailed(
+                    f"Device list download failed for {bare_jid} under namespace"
+                    f" {namespace}: Item download succeeded but parsing failed:"
+                    f" {element}."
+                )
+
+            try:
+                if namespace == twomemo.twomemo.NAMESPACE:
+                    return twomemo.etree.parse_device_list(element)
+                if namespace == oldmemo.oldmemo.NAMESPACE:
+                    return oldmemo.etree.parse_device_list(element)
+            except Exception as e:
+                raise omemo.DeviceListDownloadFailed(
+                    f"Device list download failed for {bare_jid} under namespace"
+                    f" {namespace}"
+                ) from e
+
+            raise omemo.UnknownNamespace(f"Unknown namespace: {namespace}")
+
+        async def _evaluate_custom_trust_level(
+            self,
+            device: omemo.DeviceInformation
+        ) -> omemo.TrustLevel:
+            # Get the custom trust level
+            try:
+                trust_level = TrustLevel(device.trust_level_name)
+            except ValueError as e:
+                raise omemo.UnknownTrustLevel(
+                    f"Unknown trust level name {device.trust_level_name}"
+                ) from e
+
+            # The first three cases are a straight-forward mapping
+            if trust_level is TrustLevel.TRUSTED:
+                return omemo.TrustLevel.TRUSTED
+            if trust_level is TrustLevel.UNDECIDED:
+                return omemo.TrustLevel.UNDECIDED
+            if trust_level is TrustLevel.DISTRUSTED:
+                return omemo.TrustLevel.DISTRUSTED
+
+            # The blindly trusted case is more complicated, since its evaluation depends
+            # on the trust system and phase
+            if trust_level is TrustLevel.BLINDLY_TRUSTED:
+                # Get the name of the active trust system
+                trust_system = cast(str, sat.memory.param_get_a(
+                    PARAM_NAME,
+                    PARAM_CATEGORY,
+                    profile_key=profile
+                ))
+
+                # If the trust model is BTBV, blind trust is always enabled
+                if trust_system == "btbv":
+                    return omemo.TrustLevel.TRUSTED
+
+                # If the trust model is ATM, blind trust is disabled in the second phase
+                # and counts as undecided
+                if trust_system == "atm":
+                    # Find out whether we are in phase one or two
+                    devices = await self.get_device_information(device.bare_jid)
+
+                    phase_one = all(TrustLevel(device.trust_level_name) in {
+                        TrustLevel.UNDECIDED,
+                        TrustLevel.BLINDLY_TRUSTED
+                    } for device in devices)
+
+                    if phase_one:
+                        return omemo.TrustLevel.TRUSTED
+
+                    return omemo.TrustLevel.UNDECIDED
+
+                raise exceptions.InternalError(
+                    f"Unknown trust system active: {trust_system}"
+                )
+
+            assert_never(trust_level)
+
+        async def _make_trust_decision(
+            self,
+            undecided: FrozenSet[omemo.DeviceInformation],
+            identifier: Optional[str]
+        ) -> None:
+            if identifier is None:
+                raise omemo.TrustDecisionFailed(
+                    "The identifier must contain the feedback JID."
+                )
+
+            # The feedback JID is transferred via the identifier
+            feedback_jid = jid.JID(identifier).userhostJID()
+
+            # Both the ATM and the BTBV trust models work with blind trust before the
+            # first manual verification is performed. Thus, we can separate bare JIDs into
+            # two pools here, one pool of bare JIDs for which blind trust is active, and
+            # one pool of bare JIDs for which manual trust is used instead.
+            bare_jids = { device.bare_jid for device in undecided }
+
+            blind_trust_bare_jids: Set[str] = set()
+            manual_trust_bare_jids: Set[str] = set()
+
+            # For each bare JID, decide whether blind trust applies
+            for bare_jid in bare_jids:
+                # Get all known devices belonging to the bare JID
+                devices = await self.get_device_information(bare_jid)
+
+                # If the trust levels of all devices correspond to those used by blind
+                # trust, blind trust applies. Otherwise, fall back to manual trust.
+                if all(TrustLevel(device.trust_level_name) in {
+                    TrustLevel.UNDECIDED,
+                    TrustLevel.BLINDLY_TRUSTED
+                } for device in devices):
+                    blind_trust_bare_jids.add(bare_jid)
+                else:
+                    manual_trust_bare_jids.add(bare_jid)
+
+            # With the JIDs sorted into their respective pools, the undecided devices can
+            # be categorized too
+            blindly_trusted_devices = \
+                { dev for dev in undecided if dev.bare_jid in blind_trust_bare_jids }
+            manually_trusted_devices = \
+                { dev for dev in undecided if dev.bare_jid in manual_trust_bare_jids }
+
+            # Blindly trust devices handled by blind trust
+            if len(blindly_trusted_devices) > 0:
+                for device in blindly_trusted_devices:
+                    await self.set_trust(
+                        device.bare_jid,
+                        device.identity_key,
+                        TrustLevel.BLINDLY_TRUSTED.name
+                    )
+
+                blindly_trusted_devices_stringified = ", ".join([
+                    f"device {device.device_id} of {device.bare_jid} under namespace"
+                    f" {device.namespaces}"
+                    for device
+                    in blindly_trusted_devices
+                ])
+
+                client.feedback(
+                    feedback_jid,
+                    D_(
+                        "Not all destination devices are trusted, unknown devices will be"
+                        " blindly trusted.\nFollowing devices have been automatically"
+                        f" trusted: {blindly_trusted_devices_stringified}."
+                    )
+                )
+
+            # Prompt the user for manual trust decisions on the devices handled by manual
+            # trust
+            if len(manually_trusted_devices) > 0:
+                client.feedback(
+                    feedback_jid,
+                    D_(
+                        "Not all destination devices are trusted, we can't encrypt"
+                        " message in such a situation. Please indicate if you trust"
+                        " those devices or not in the trust manager before we can"
+                        " send this message."
+                    )
+                )
+                await self.__prompt_manual_trust(
+                    frozenset(manually_trusted_devices),
+                    feedback_jid
+                )
+
+        @staticmethod
+        async def _send_message(message: omemo.Message, bare_jid: str) -> None:
+            element: Optional[ET.Element] = None
+
+            if message.namespace == twomemo.twomemo.NAMESPACE:
+                element = twomemo.etree.serialize_message(message)
+            if message.namespace == oldmemo.oldmemo.NAMESPACE:
+                element = oldmemo.etree.serialize_message(message)
+
+            if element is None:
+                raise omemo.UnknownNamespace(f"Unknown namespace: {message.namespace}")
+
+            message_data = client.generate_message_xml(MessageData({
+                "from": client.jid,
+                "to": jid.JID(bare_jid),
+                "uid": str(uuid.uuid4()),
+                "message": {},
+                "subject": {},
+                "type": C.MESS_TYPE_CHAT,
+                "extra": {},
+                "timestamp": time.time()
+            }))
+
+            message_data["xml"].addChild(xml_tools.et_elt_2_domish_elt(element))
+
+            try:
+                await client.a_send(message_data["xml"])
+            except Exception as e:
+                raise omemo.MessageSendingFailed() from e
+
+        async def __prompt_manual_trust(
+            self,
+            undecided: FrozenSet[omemo.DeviceInformation],
+            feedback_jid: jid.JID
+        ) -> None:
+            """Asks the user to decide on the manual trust level of a set of devices.
+
+            Blocks until the user has made a decision and updates the trust levels of all
+            devices using :meth:`set_trust`.
+
+            @param undecided: The set of devices to prompt manual trust for.
+            @param feedback_jid: The bare JID to redirect feedback to. In case of a one to
+                one message, the recipient JID. In case of a MUC message, the room JID.
+            @raise TrustDecisionFailed: if the user cancels the prompt.
+            """
+
+            # This session manager handles encryption with both twomemo and oldmemo, but
+            # both are currently registered as different plugins and the `defer_xmlui`
+            # below requires a single namespace identifying the encryption plugin. Thus,
+            # get the namespace of the requested encryption method from the encryption
+            # session using the feedback JID.
+            encryption = client.encryption.getSession(feedback_jid)
+            if encryption is None:
+                raise omemo.TrustDecisionFailed(
+                    f"Encryption not requested for {feedback_jid.userhost()}."
+                )
+
+            namespace = encryption["plugin"].namespace
+
+            # Casting this to Any, otherwise all calls on the variable cause type errors
+            # pylint: disable=no-member
+            trust_ui = cast(Any, xml_tools.XMLUI(
+                panel_type=C.XMLUI_FORM,
+                title=D_("OMEMO trust management"),
+                submit_id=""
+            ))
+            trust_ui.addText(D_(
+                "This is OMEMO trusting system. You'll see below the devices of your "
+                "contacts, and a checkbox to trust them or not. A trusted device "
+                "can read your messages in plain text, so be sure to only validate "
+                "devices that you are sure are belonging to your contact. It's better "
+                "to do this when you are next to your contact and their device, so "
+                "you can check the \"fingerprint\" (the number next to the device) "
+                "yourself. Do *not* validate a device if the fingerprint is wrong!"
+            ))
+
+            own_device, __ = await self.get_own_device_information()
+
+            trust_ui.change_container("label")
+            trust_ui.addLabel(D_("This device ID"))
+            trust_ui.addText(str(own_device.device_id))
+            trust_ui.addLabel(D_("This device's fingerprint"))
+            trust_ui.addText(" ".join(self.format_identity_key(own_device.identity_key)))
+            trust_ui.addEmpty()
+            trust_ui.addEmpty()
+
+            # At least sort the devices by bare JID such that they aren't listed
+            # completely random
+            undecided_ordered = sorted(undecided, key=lambda device: device.bare_jid)
+
+            for index, device in enumerate(undecided_ordered):
+                trust_ui.addLabel(D_("Contact"))
+                trust_ui.addJid(jid.JID(device.bare_jid))
+                trust_ui.addLabel(D_("Device ID"))
+                trust_ui.addText(str(device.device_id))
+                trust_ui.addLabel(D_("Fingerprint"))
+                trust_ui.addText(" ".join(self.format_identity_key(device.identity_key)))
+                trust_ui.addLabel(D_("Trust this device?"))
+                trust_ui.addBool(f"trust_{index}", value=C.bool_const(False))
+                trust_ui.addEmpty()
+                trust_ui.addEmpty()
+
+            trust_ui_result = await xml_tools.defer_xmlui(
+                sat,
+                trust_ui,
+                action_extra={ "meta_encryption_trust": namespace },
+                profile=profile
+            )
+
+            if C.bool(trust_ui_result.get("cancelled", "false")):
+                raise omemo.TrustDecisionFailed("Trust UI cancelled.")
+
+            data_form_result = cast(Dict[str, str], xml_tools.xmlui_result_2_data_form_result(
+                trust_ui_result
+            ))
+
+            trust_updates: Set[TrustUpdate] = set()
+
+            for key, value in data_form_result.items():
+                if not key.startswith("trust_"):
+                    continue
+
+                device = undecided_ordered[int(key[len("trust_"):])]
+                target_trust = C.bool(value)
+                trust_level = \
+                    TrustLevel.TRUSTED if target_trust else TrustLevel.DISTRUSTED
+
+                await self.set_trust(
+                    device.bare_jid,
+                    device.identity_key,
+                    trust_level.name
+                )
+
+                trust_updates.add(TrustUpdate(
+                    target_jid=jid.JID(device.bare_jid).userhostJID(),
+                    target_key=device.identity_key,
+                    target_trust=target_trust
+                ))
+
+            # Check whether ATM is enabled and handle everything in case it is
+            trust_system = cast(str, sat.memory.param_get_a(
+                PARAM_NAME,
+                PARAM_CATEGORY,
+                profile_key=profile
+            ))
+
+            if trust_system == "atm":
+                await manage_trust_message_cache(client, self, frozenset(trust_updates))
+                await send_trust_messages(client, self, frozenset(trust_updates))
+
+    return SessionManagerImpl
+
+
+async def prepare_for_profile(
+    sat: SAT,
+    profile: str,
+    initial_own_label: Optional[str],
+    signed_pre_key_rotation_period: int = 7 * 24 * 60 * 60,
+    pre_key_refill_threshold: int = 99,
+    max_num_per_session_skipped_keys: int = 1000,
+    max_num_per_message_skipped_keys: Optional[int] = None
+) -> omemo.SessionManager:
+    """Prepare the OMEMO library (storage, backends, core) for a specific profile.
+
+    @param sat: The SAT instance.
+    @param profile: The profile.
+    @param initial_own_label: The initial (optional) label to assign to this device if
+        supported by any of the backends.
+    @param signed_pre_key_rotation_period: The rotation period for the signed pre key, in
+        seconds. The rotation period is recommended to be between one week (the default)
+        and one month.
+    @param pre_key_refill_threshold: The number of pre keys that triggers a refill to 100.
+        Defaults to 99, which means that each pre key gets replaced with a new one right
+        away. The threshold can not be configured to lower than 25.
+    @param max_num_per_session_skipped_keys: The maximum number of skipped message keys to
+        keep around per session. Once the maximum is reached, old message keys are deleted
+        to make space for newer ones. Accessible via
+        :attr:`max_num_per_session_skipped_keys`.
+    @param max_num_per_message_skipped_keys: The maximum number of skipped message keys to
+        accept in a single message. When set to ``None`` (the default), this parameter
+        defaults to the per-session maximum (i.e. the value of the
+        ``max_num_per_session_skipped_keys`` parameter). This parameter may only be 0 if
+        the per-session maximum is 0, otherwise it must be a number between 1 and the
+        per-session maximum. Accessible via :attr:`max_num_per_message_skipped_keys`.
+    @return: A session manager with ``urn:xmpp:omemo:2`` and
+        ``eu.siacs.conversations.axolotl`` capabilities, specifically for the given
+        profile.
+    @raise BundleUploadFailed: if a bundle upload failed. Forwarded from
+        :meth:`~omemo.session_manager.SessionManager.create`.
+    @raise BundleDownloadFailed: if a bundle download failed. Forwarded from
+        :meth:`~omemo.session_manager.SessionManager.create`.
+    @raise BundleDeletionFailed: if a bundle deletion failed. Forwarded from
+        :meth:`~omemo.session_manager.SessionManager.create`.
+    @raise DeviceListUploadFailed: if a device list upload failed. Forwarded from
+        :meth:`~omemo.session_manager.SessionManager.create`.
+    @raise DeviceListDownloadFailed: if a device list download failed. Forwarded from
+        :meth:`~omemo.session_manager.SessionManager.create`.
+    """
+
+    client = sat.get_client(profile)
+    xep_0060 = cast(XEP_0060, sat.plugins["XEP-0060"])
+
+    storage = StorageImpl(profile)
+
+    # TODO: Untested
+    await oldmemo.migrations.migrate(
+        LegacyStorageImpl(profile, client.jid.userhost()),
+        storage,
+        # TODO: Do we want BLINDLY_TRUSTED or TRUSTED here?
+        TrustLevel.BLINDLY_TRUSTED.name,
+        TrustLevel.UNDECIDED.name,
+        TrustLevel.DISTRUSTED.name,
+        lambda bare_jid, device_id: download_oldmemo_bundle(
+            client,
+            xep_0060,
+            bare_jid,
+            device_id
+        )
+    )
+
+    session_manager = await make_session_manager(sat, profile).create(
+        [
+            twomemo.Twomemo(
+                storage,
+                max_num_per_session_skipped_keys,
+                max_num_per_message_skipped_keys
+            ),
+            oldmemo.Oldmemo(
+                storage,
+                max_num_per_session_skipped_keys,
+                max_num_per_message_skipped_keys
+            )
+        ],
+        storage,
+        client.jid.userhost(),
+        initial_own_label,
+        TrustLevel.UNDECIDED.value,
+        signed_pre_key_rotation_period,
+        pre_key_refill_threshold,
+        omemo.AsyncFramework.TWISTED
+    )
+
+    # This shouldn't hurt here since we're not running on overly constrainted devices.
+    # TODO: Consider ensuring data consistency regularly/in response to certain events
+    await session_manager.ensure_data_consistency()
+
+    # TODO: Correct entering/leaving of the history synchronization mode isn't terribly
+    # important for now, since it only prevents an extremely unlikely race condition of
+    # multiple devices choosing the same pre key for new sessions while the device was
+    # offline. I don't believe other clients seriously defend against that race condition
+    # either. In the long run, it might still be cool to have triggers for when history
+    # sync starts and ends (MAM, MUC catch-up, etc.) and to react to those triggers.
+    await session_manager.after_history_sync()
+
+    return session_manager
+
+
+DEFAULT_TRUST_MODEL_PARAM = f"""
+<params>
+<individual>
+<category name="{PARAM_CATEGORY}" label={quoteattr(D_('Security'))}>
+    <param name="{PARAM_NAME}"
+        label={quoteattr(D_('OMEMO default trust policy'))}
+        type="list" security="3">
+        <option value="atm"
+            label={quoteattr(D_('Automatic Trust Management (more secure)'))} />
+        <option value="btbv"
+            label={quoteattr(D_('Blind Trust Before Verification (more user friendly)'))}
+            selected="true" />
+    </param>
+</category>
+</individual>
+</params>
+"""
+
+
+class OMEMO:
+    """
+    Plugin equipping Libervia with OMEMO capabilities under the (modern)
+    ``urn:xmpp:omemo:2`` namespace and the (legacy) ``eu.siacs.conversations.axolotl``
+    namespace. Both versions of the protocol are handled by this plugin and compatibility
+    between the two is maintained. MUC messages are supported next to one to one messages.
+    For trust management, the two trust models "ATM" and "BTBV" are supported.
+    """
+    NS_TWOMEMO = twomemo.twomemo.NAMESPACE
+    NS_OLDMEMO = oldmemo.oldmemo.NAMESPACE
+
+    # For MUC/MIX message stanzas, the <to/> affix is a MUST
+    SCE_PROFILE_GROUPCHAT = SCEProfile(
+        rpad_policy=SCEAffixPolicy.REQUIRED,
+        time_policy=SCEAffixPolicy.OPTIONAL,
+        to_policy=SCEAffixPolicy.REQUIRED,
+        from_policy=SCEAffixPolicy.OPTIONAL,
+        custom_policies={}
+    )
+
+    # For everything but MUC/MIX message stanzas, the <to/> affix is a MAY
+    SCE_PROFILE = SCEProfile(
+        rpad_policy=SCEAffixPolicy.REQUIRED,
+        time_policy=SCEAffixPolicy.OPTIONAL,
+        to_policy=SCEAffixPolicy.OPTIONAL,
+        from_policy=SCEAffixPolicy.OPTIONAL,
+        custom_policies={}
+    )
+
+    def __init__(self, sat: SAT) -> None:
+        """
+        @param sat: The SAT instance.
+        """
+
+        self.__sat = sat
+
+        # Add configuration option to choose between manual trust and BTBV as the trust
+        # model
+        sat.memory.update_params(DEFAULT_TRUST_MODEL_PARAM)
+
+        # Plugins
+        self.__xep_0045 = cast(Optional[XEP_0045], sat.plugins.get("XEP-0045"))
+        self.__xep_0334 = cast(XEP_0334, sat.plugins["XEP-0334"])
+        self.__xep_0359 = cast(Optional[XEP_0359], sat.plugins.get("XEP-0359"))
+        self.__xep_0420 = cast(XEP_0420, sat.plugins["XEP-0420"])
+
+        # In contrast to one to one messages, MUC messages are reflected to the sender.
+        # Thus, the sender does not add messages to their local message log when sending
+        # them, but when the reflection is received. This approach does not pair well with
+        # OMEMO, since for security reasons it is forbidden to encrypt messages for the
+        # own device. Thus, when the reflection of an OMEMO message is received, it can't
+        # be decrypted and added to the local message log as usual. To counteract this,
+        # the plaintext of encrypted messages sent to MUCs are cached in this field, such
+        # that when the reflection is received, the plaintext can be looked up from the
+        # cache and added to the local message log.
+        # TODO: The old plugin expired this cache after some time. I'm not sure that's
+        # really necessary.
+        self.__muc_plaintext_cache: Dict[MUCPlaintextCacheKey, bytes] = {}
+
+        # Mapping from profile name to corresponding session manager
+        self.__session_managers: Dict[str, omemo.SessionManager] = {}
+
+        # Calls waiting for a specific session manager to be built
+        self.__session_manager_waiters: Dict[str, List[defer.Deferred]] = {}
+
+        # These triggers are used by oldmemo, which doesn't do SCE and only applies to
+        # messages. Temporarily, until a more fitting trigger for SCE-based encryption is
+        # added, the message_received trigger is also used for twomemo.
+        sat.trigger.add(
+            "message_received",
+            self._message_received_trigger,
+            priority=100050
+        )
+        sat.trigger.add(
+            "send_message_data",
+            self.__send_message_data_trigger,
+            priority=100050
+        )
+
+        # These triggers are used by twomemo, which does do SCE
+        sat.trigger.add("send", self.__send_trigger, priority=0)
+        # TODO: Add new triggers here for freshly received and about-to-be-sent stanzas,
+        # including IQs.
+
+        # Give twomemo a (slightly) higher priority than oldmemo
+        sat.register_encryption_plugin(self, "TWOMEMO", twomemo.twomemo.NAMESPACE, 101)
+        sat.register_encryption_plugin(self, "OLDMEMO", oldmemo.oldmemo.NAMESPACE, 100)
+
+        xep_0163 = cast(XEP_0163, sat.plugins["XEP-0163"])
+        xep_0163.add_pep_event(
+            "TWOMEMO_DEVICES",
+            TWOMEMO_DEVICE_LIST_NODE,
+            lambda items_event, profile: defer.ensureDeferred(
+                self.__on_device_list_update(items_event, profile)
+            )
+        )
+        xep_0163.add_pep_event(
+            "OLDMEMO_DEVICES",
+            OLDMEMO_DEVICE_LIST_NODE,
+            lambda items_event, profile: defer.ensureDeferred(
+                self.__on_device_list_update(items_event, profile)
+            )
+        )
+
+        try:
+            self.__text_commands = cast(TextCommands, sat.plugins[C.TEXT_CMDS])
+        except KeyError:
+            log.info(_("Text commands not available"))
+        else:
+            self.__text_commands.register_text_commands(self)
+
+    def profile_connected(  # pylint: disable=invalid-name
+        self,
+        client: SatXMPPClient
+    ) -> None:
+        """
+        @param client: The client.
+        """
+
+        defer.ensureDeferred(self.get_session_manager(
+            cast(str, client.profile)
+        ))
+
+    async def cmd_omemo_reset(
+        self,
+        client: SatXMPPClient,
+        mess_data: MessageData
+    ) -> Literal[False]:
+        """Reset all sessions of devices that belong to the recipient of ``mess_data``.
+
+        This must only be callable manually by the user. Use this when a session is
+        apparently broken, i.e. sending and receiving encrypted messages doesn't work and
+        something being wrong has been confirmed manually with the recipient.
+
+        @param client: The client.
+        @param mess_data: The message data, whose ``to`` attribute will be the bare JID to
+            reset all sessions with.
+        @return: The constant value ``False``, indicating to the text commands plugin that
+            the message is not supposed to be sent.
+        """
+
+        twomemo_requested = \
+            client.encryption.is_encryption_requested(mess_data, twomemo.twomemo.NAMESPACE)
+        oldmemo_requested = \
+            client.encryption.is_encryption_requested(mess_data, oldmemo.oldmemo.NAMESPACE)
+
+        if not (twomemo_requested or oldmemo_requested):
+            self.__text_commands.feed_back(
+                client,
+                _("You need to have OMEMO encryption activated to reset the session"),
+                mess_data
+            )
+            return False
+
+        bare_jid = mess_data["to"].userhost()
+
+        session_manager = await self.get_session_manager(client.profile)
+        devices = await session_manager.get_device_information(bare_jid)
+
+        for device in devices:
+            log.debug(f"Replacing sessions with device {device}")
+            await session_manager.replace_sessions(device)
+
+        self.__text_commands.feed_back(
+            client,
+            _("OMEMO session has been reset"),
+            mess_data
+        )
+
+        return False
+
+    async def get_trust_ui(  # pylint: disable=invalid-name
+        self,
+        client: SatXMPPClient,
+        entity: jid.JID
+    ) -> xml_tools.XMLUI:
+        """
+        @param client: The client.
+        @param entity: The entity whose device trust levels to manage.
+        @return: An XMLUI instance which opens a form to manage the trust level of all
+            devices belonging to the entity.
+        """
+
+        if entity.resource:
+            raise ValueError("A bare JID is expected.")
+
+        bare_jids: Set[str]
+        if self.__xep_0045 is not None and self.__xep_0045.is_joined_room(client, entity):
+            bare_jids = self.__get_joined_muc_users(client, self.__xep_0045, entity)
+        else:
+            bare_jids = { entity.userhost() }
+
+        session_manager = await self.get_session_manager(client.profile)
+
+        # At least sort the devices by bare JID such that they aren't listed completely
+        # random
+        devices = sorted(cast(Set[omemo.DeviceInformation], set()).union(*[
+            await session_manager.get_device_information(bare_jid)
+            for bare_jid
+            in bare_jids
+        ]), key=lambda device: device.bare_jid)
+
+        async def callback(
+            data: Any,
+            profile: str
+        ) -> Dict[Never, Never]:
+            """
+            @param data: The XMLUI result produces by the trust UI form.
+            @param profile: The profile.
+            @return: An empty dictionary. The type of the return value was chosen
+                conservatively since the exact options are neither known not needed here.
+            """
+
+            if C.bool(data.get("cancelled", "false")):
+                return {}
+
+            data_form_result = cast(
+                Dict[str, str],
+                xml_tools.xmlui_result_2_data_form_result(data)
+            )
+
+            trust_updates: Set[TrustUpdate] = set()
+
+            for key, value in data_form_result.items():
+                if not key.startswith("trust_"):
+                    continue
+
+                device = devices[int(key[len("trust_"):])]
+                trust_level_name = value
+
+                if device.trust_level_name != trust_level_name:
+                    await session_manager.set_trust(
+                        device.bare_jid,
+                        device.identity_key,
+                        trust_level_name
+                    )
+
+                    target_trust: Optional[bool] = None
+
+                    if TrustLevel(trust_level_name) is TrustLevel.TRUSTED:
+                        target_trust = True
+                    if TrustLevel(trust_level_name) is TrustLevel.DISTRUSTED:
+                        target_trust = False
+
+                    if target_trust is not None:
+                        trust_updates.add(TrustUpdate(
+                            target_jid=jid.JID(device.bare_jid).userhostJID(),
+                            target_key=device.identity_key,
+                            target_trust=target_trust
+                        ))
+
+            # Check whether ATM is enabled and handle everything in case it is
+            trust_system = cast(str, self.__sat.memory.param_get_a(
+                PARAM_NAME,
+                PARAM_CATEGORY,
+                profile_key=profile
+            ))
+
+            if trust_system == "atm":
+                if len(trust_updates) > 0:
+                    await manage_trust_message_cache(
+                        client,
+                        session_manager,
+                        frozenset(trust_updates)
+                    )
+
+                    await send_trust_messages(
+                        client,
+                        session_manager,
+                        frozenset(trust_updates)
+                    )
+
+            return {}
+
+        submit_id = self.__sat.register_callback(callback, with_data=True, one_shot=True)
+
+        result = xml_tools.XMLUI(
+            panel_type=C.XMLUI_FORM,
+            title=D_("OMEMO trust management"),
+            submit_id=submit_id
+        )
+        # Casting this to Any, otherwise all calls on the variable cause type errors
+        # pylint: disable=no-member
+        trust_ui = cast(Any, result)
+        trust_ui.addText(D_(
+            "This is OMEMO trusting system. You'll see below the devices of your"
+            " contacts, and a list selection to trust them or not. A trusted device"
+            " can read your messages in plain text, so be sure to only validate"
+            " devices that you are sure are belonging to your contact. It's better"
+            " to do this when you are next to your contact and their device, so"
+            " you can check the \"fingerprint\" (the number next to the device)"
+            " yourself. Do *not* validate a device if the fingerprint is wrong!"
+            " Note that manually validating a fingerprint disables any form of automatic"
+            " trust."
+        ))
+
+        own_device, __ = await session_manager.get_own_device_information()
+
+        trust_ui.change_container("label")
+        trust_ui.addLabel(D_("This device ID"))
+        trust_ui.addText(str(own_device.device_id))
+        trust_ui.addLabel(D_("This device's fingerprint"))
+        trust_ui.addText(" ".join(session_manager.format_identity_key(
+            own_device.identity_key
+        )))
+        trust_ui.addEmpty()
+        trust_ui.addEmpty()
+
+        for index, device in enumerate(devices):
+            trust_ui.addLabel(D_("Contact"))
+            trust_ui.addJid(jid.JID(device.bare_jid))
+            trust_ui.addLabel(D_("Device ID"))
+            trust_ui.addText(str(device.device_id))
+            trust_ui.addLabel(D_("Fingerprint"))
+            trust_ui.addText(" ".join(session_manager.format_identity_key(
+                device.identity_key
+            )))
+            trust_ui.addLabel(D_("Trust this device?"))
+
+            current_trust_level = TrustLevel(device.trust_level_name)
+            avaiable_trust_levels = \
+                { TrustLevel.DISTRUSTED, TrustLevel.TRUSTED, current_trust_level }
+
+            trust_ui.addList(
+                f"trust_{index}",
+                options=[ trust_level.name for trust_level in avaiable_trust_levels ],
+                selected=current_trust_level.name,
+                styles=[ "inline" ]
+            )
+
+            twomemo_active = dict(device.active).get(twomemo.twomemo.NAMESPACE)
+            if twomemo_active is None:
+                trust_ui.addEmpty()
+                trust_ui.addLabel(D_("(not available for Twomemo)"))
+            if twomemo_active is False:
+                trust_ui.addEmpty()
+                trust_ui.addLabel(D_("(inactive for Twomemo)"))
+
+            oldmemo_active = dict(device.active).get(oldmemo.oldmemo.NAMESPACE)
+            if oldmemo_active is None:
+                trust_ui.addEmpty()
+                trust_ui.addLabel(D_("(not available for Oldmemo)"))
+            if oldmemo_active is False:
+                trust_ui.addEmpty()
+                trust_ui.addLabel(D_("(inactive for Oldmemo)"))
+
+            trust_ui.addEmpty()
+            trust_ui.addEmpty()
+
+        return result
+
+    @staticmethod
+    def __get_joined_muc_users(
+        client: SatXMPPClient,
+        xep_0045: XEP_0045,
+        room_jid: jid.JID
+    ) -> Set[str]:
+        """
+        @param client: The client.
+        @param xep_0045: A MUC plugin instance.
+        @param room_jid: The room JID.
+        @return: A set containing the bare JIDs of the MUC participants.
+        @raise InternalError: if the MUC is not joined or the entity information of a
+            participant isn't available.
+        """
+
+        bare_jids: Set[str] = set()
+
+        try:
+            room = cast(muc.Room, xep_0045.get_room(client, room_jid))
+        except exceptions.NotFound as e:
+            raise exceptions.InternalError(
+                "Participant list of unjoined MUC requested."
+            ) from e
+
+        for user in cast(Dict[str, muc.User], room.roster).values():
+            entity = cast(Optional[SatXMPPEntity], user.entity)
+            if entity is None:
+                raise exceptions.InternalError(
+                    f"Participant list of MUC requested, but the entity information of"
+                    f" the participant {user} is not available."
+                )
+
+            bare_jids.add(entity.jid.userhost())
+
+        return bare_jids
+
+    async def get_session_manager(self, profile: str) -> omemo.SessionManager:
+        """
+        @param profile: The profile to prepare for.
+        @return: A session manager instance for this profile. Creates a new instance if
+            none was prepared before.
+        """
+
+        try:
+            # Try to return the session manager
+            return self.__session_managers[profile]
+        except KeyError:
+            # If a session manager for that profile doesn't exist yet, check whether it is
+            # currently being built. A session manager being built is signified by the
+            # profile key existing on __session_manager_waiters.
+            if profile in self.__session_manager_waiters:
+                # If the session manager is being built, add ourselves to the waiting
+                # queue
+                deferred = defer.Deferred()
+                self.__session_manager_waiters[profile].append(deferred)
+                return cast(omemo.SessionManager, await deferred)
+
+            # If the session manager is not being built, do so here.
+            self.__session_manager_waiters[profile] = []
+
+            # Build and store the session manager
+            try:
+                session_manager = await prepare_for_profile(
+                    self.__sat,
+                    profile,
+                    initial_own_label="Libervia"
+                )
+            except Exception as e:
+                # In case of an error during initalization, notify the waiters accordingly
+                # and delete them
+                for waiter in self.__session_manager_waiters[profile]:
+                    waiter.errback(e)
+                del self.__session_manager_waiters[profile]
+
+                # Re-raise the exception
+                raise
+
+            self.__session_managers[profile] = session_manager
+
+            # Notify the waiters and delete them
+            for waiter in self.__session_manager_waiters[profile]:
+                waiter.callback(session_manager)
+            del self.__session_manager_waiters[profile]
+
+            return session_manager
+
+    async def __message_received_trigger_atm(
+        self,
+        client: SatXMPPClient,
+        message_elt: domish.Element,
+        session_manager: omemo.SessionManager,
+        sender_device_information: omemo.DeviceInformation,
+        timestamp: datetime
+    ) -> None:
+        """Check a newly decrypted message stanza for ATM content and perform ATM in case.
+
+        @param client: The client which received the message.
+        @param message_elt: The message element. Can be modified.
+        @param session_manager: The session manager.
+        @param sender_device_information: Information about the device that sent/encrypted
+            the message.
+        @param timestamp: Timestamp extracted from the SCE time affix.
+        """
+
+        trust_message_cache = persistent.LazyPersistentBinaryDict(
+            "XEP-0384/TM",
+            client.profile
+        )
+
+        new_cache_entries: Set[TrustMessageCacheEntry] = set()
+
+        for trust_message_elt in message_elt.elements(NS_TM, "trust-message"):
+            assert isinstance(trust_message_elt, domish.Element)
+
+            try:
+                TRUST_MESSAGE_SCHEMA.validate(trust_message_elt.toXml())
+            except xmlschema.XMLSchemaValidationError as e:
+                raise exceptions.ParsingError(
+                    "<trust-message/> element doesn't pass schema validation."
+                ) from e
+
+            if trust_message_elt["usage"] != NS_ATM:
+                # Skip non-ATM trust message
+                continue
+
+            if trust_message_elt["encryption"] != OMEMO.NS_TWOMEMO:
+                # Skip non-twomemo trust message
+                continue
+
+            for key_owner_elt in trust_message_elt.elements(NS_TM, "key-owner"):
+                assert isinstance(key_owner_elt, domish.Element)
+
+                key_owner_jid = jid.JID(key_owner_elt["jid"]).userhostJID()
+
+                for trust_elt in key_owner_elt.elements(NS_TM, "trust"):
+                    assert isinstance(trust_elt, domish.Element)
+
+                    new_cache_entries.add(TrustMessageCacheEntry(
+                        sender_jid=jid.JID(sender_device_information.bare_jid),
+                        sender_key=sender_device_information.identity_key,
+                        timestamp=timestamp,
+                        trust_update=TrustUpdate(
+                            target_jid=key_owner_jid,
+                            target_key=base64.b64decode(str(trust_elt)),
+                            target_trust=True
+                        )
+                    ))
+
+                for distrust_elt in key_owner_elt.elements(NS_TM, "distrust"):
+                    assert isinstance(distrust_elt, domish.Element)
+
+                    new_cache_entries.add(TrustMessageCacheEntry(
+                        sender_jid=jid.JID(sender_device_information.bare_jid),
+                        sender_key=sender_device_information.identity_key,
+                        timestamp=timestamp,
+                        trust_update=TrustUpdate(
+                            target_jid=key_owner_jid,
+                            target_key=base64.b64decode(str(distrust_elt)),
+                            target_trust=False
+                        )
+                    ))
+
+        # Load existing cache entries
+        existing_cache_entries = cast(
+            Set[TrustMessageCacheEntry],
+            await trust_message_cache.get("cache", set())
+        )
+
+        # Discard cache entries by timestamp comparison
+        existing_by_target = {
+            (
+                cache_entry.trust_update.target_jid.userhostJID(),
+                cache_entry.trust_update.target_key
+            ): cache_entry
+            for cache_entry
+            in existing_cache_entries
+        }
+
+        # Iterate over a copy here, such that new_cache_entries can be modified
+        for new_cache_entry in set(new_cache_entries):
+            existing_cache_entry = existing_by_target.get(
+                (
+                    new_cache_entry.trust_update.target_jid.userhostJID(),
+                    new_cache_entry.trust_update.target_key
+                ),
+                None
+            )
+
+            if existing_cache_entry is not None:
+                if existing_cache_entry.timestamp > new_cache_entry.timestamp:
+                    # If the existing cache entry is newer than the new cache entry,
+                    # discard the new one in favor of the existing one
+                    new_cache_entries.remove(new_cache_entry)
+                else:
+                    # Otherwise, discard the existing cache entry. This includes the case
+                    # when both cache entries have matching timestamps.
+                    existing_cache_entries.remove(existing_cache_entry)
+
+        # If the sending device is trusted, apply the new cache entries
+        applied_trust_updates: Set[TrustUpdate] = set()
+
+        if TrustLevel(sender_device_information.trust_level_name) is TrustLevel.TRUSTED:
+            # Iterate over a copy such that new_cache_entries can be modified
+            for cache_entry in set(new_cache_entries):
+                trust_update = cache_entry.trust_update
+
+                trust_level = (
+                    TrustLevel.TRUSTED
+                    if trust_update.target_trust
+                    else TrustLevel.DISTRUSTED
+                )
+
+                await session_manager.set_trust(
+                    trust_update.target_jid.userhost(),
+                    trust_update.target_key,
+                    trust_level.name
+                )
+
+                applied_trust_updates.add(trust_update)
+
+                new_cache_entries.remove(cache_entry)
+
+        # Store the remaining existing and new cache entries
+        await trust_message_cache.force(
+            "cache",
+            existing_cache_entries | new_cache_entries
+        )
+
+        # If the trust of at least one device was modified, run the ATM cache update logic
+        if len(applied_trust_updates) > 0:
+            await manage_trust_message_cache(
+                client,
+                session_manager,
+                frozenset(applied_trust_updates)
+            )
+
+    async def _message_received_trigger(
+        self,
+        client: SatXMPPClient,
+        message_elt: domish.Element,
+        post_treat: defer.Deferred
+    ) -> bool:
+        """
+        @param client: The client which received the message.
+        @param message_elt: The message element. Can be modified.
+        @param post_treat: A deferred which evaluates to a :class:`MessageData` once the
+            message has fully progressed through the message receiving flow. Can be used
+            to apply treatments to the fully processed message, like marking it as
+            encrypted.
+        @return: Whether to continue the message received flow.
+        """
+        if client.is_component:
+            return True
+        muc_plaintext_cache_key: Optional[MUCPlaintextCacheKey] = None
+
+        sender_jid = jid.JID(message_elt["from"])
+        feedback_jid: jid.JID
+
+        message_type = message_elt.getAttribute("type", C.MESS_TYPE_NORMAL)
+        is_muc_message = message_type == C.MESS_TYPE_GROUPCHAT
+        if is_muc_message:
+            if self.__xep_0045 is None:
+                log.warning(
+                    "Ignoring MUC message since plugin XEP-0045 is not available."
+                )
+                # Can't handle a MUC message without XEP-0045, let the flow continue
+                # normally
+                return True
+
+            room_jid = feedback_jid = sender_jid.userhostJID()
+
+            try:
+                room = cast(muc.Room, self.__xep_0045.get_room(client, room_jid))
+            except exceptions.NotFound:
+                log.warning(
+                    f"Ignoring MUC message from a room that has not been joined:"
+                    f" {room_jid}"
+                )
+                # Whatever, let the flow continue
+                return True
+
+            sender_user = cast(Optional[muc.User], room.getUser(sender_jid.resource))
+            if sender_user is None:
+                log.warning(
+                    f"Ignoring MUC message from room {room_jid} since the sender's user"
+                    f" wasn't found {sender_jid.resource}"
+                )
+                # Whatever, let the flow continue
+                return True
+
+            sender_user_jid = cast(Optional[jid.JID], sender_user.entity)
+            if sender_user_jid is None:
+                log.warning(
+                    f"Ignoring MUC message from room {room_jid} since the sender's bare"
+                    f" JID couldn't be found from its user information: {sender_user}"
+                )
+                # Whatever, let the flow continue
+                return True
+
+            sender_jid = sender_user_jid
+
+            message_uid: Optional[str] = None
+            if self.__xep_0359 is not None:
+                message_uid = self.__xep_0359.get_origin_id(message_elt)
+            if message_uid is None:
+                message_uid = message_elt.getAttribute("id")
+            if message_uid is not None:
+                muc_plaintext_cache_key = MUCPlaintextCacheKey(
+                    client,
+                    room_jid,
+                    message_uid
+                )
+        else:
+            # I'm not sure why this check is required, this code is copied from the old
+            # plugin.
+            if sender_jid.userhostJID() == client.jid.userhostJID():
+                try:
+                    feedback_jid = jid.JID(message_elt["to"])
+                except KeyError:
+                    feedback_jid = client.server_jid
+            else:
+                feedback_jid = sender_jid
+
+        sender_bare_jid = sender_jid.userhost()
+
+        message: Optional[omemo.Message] = None
+        encrypted_elt: Optional[domish.Element] = None
+
+        twomemo_encrypted_elt = cast(Optional[domish.Element], next(
+            message_elt.elements(twomemo.twomemo.NAMESPACE, "encrypted"),
+            None
+        ))
+
+        oldmemo_encrypted_elt = cast(Optional[domish.Element], next(
+            message_elt.elements(oldmemo.oldmemo.NAMESPACE, "encrypted"),
+            None
+        ))
+
+        try:
+            session_manager = await self.get_session_manager(cast(str, client.profile))
+        except Exception as e:
+            log.error(f"error while preparing profile for {client.profile}: {e}")
+            # we don't want to block the workflow
+            return True
+
+        if twomemo_encrypted_elt is not None:
+            try:
+                message = twomemo.etree.parse_message(
+                    xml_tools.domish_elt_2_et_elt(twomemo_encrypted_elt),
+                    sender_bare_jid
+                )
+            except (ValueError, XMLSchemaValidationError):
+                log.warning(
+                    f"Ingoring malformed encrypted message for namespace"
+                    f" {twomemo.twomemo.NAMESPACE}: {twomemo_encrypted_elt.toXml()}"
+                )
+            else:
+                encrypted_elt = twomemo_encrypted_elt
+
+        if oldmemo_encrypted_elt is not None:
+            try:
+                message = await oldmemo.etree.parse_message(
+                    xml_tools.domish_elt_2_et_elt(oldmemo_encrypted_elt),
+                    sender_bare_jid,
+                    client.jid.userhost(),
+                    session_manager
+                )
+            except (ValueError, XMLSchemaValidationError):
+                log.warning(
+                    f"Ingoring malformed encrypted message for namespace"
+                    f" {oldmemo.oldmemo.NAMESPACE}: {oldmemo_encrypted_elt.toXml()}"
+                )
+            except omemo.SenderNotFound:
+                log.warning(
+                    f"Ingoring encrypted message for namespace"
+                    f" {oldmemo.oldmemo.NAMESPACE} by unknown sender:"
+                    f" {oldmemo_encrypted_elt.toXml()}"
+                )
+            else:
+                encrypted_elt = oldmemo_encrypted_elt
+
+        if message is None or encrypted_elt is None:
+            # None of our business, let the flow continue
+            return True
+
+        message_elt.children.remove(encrypted_elt)
+
+        log.debug(
+            f"{message.namespace} message of type {message_type} received from"
+            f" {sender_bare_jid}"
+        )
+
+        plaintext: Optional[bytes]
+        device_information: omemo.DeviceInformation
+
+        if (
+            muc_plaintext_cache_key is not None
+            and muc_plaintext_cache_key in self.__muc_plaintext_cache
+        ):
+            # Use the cached plaintext
+            plaintext = self.__muc_plaintext_cache.pop(muc_plaintext_cache_key)
+
+            # Since this message was sent by us, use the own device information here
+            device_information, __ = await session_manager.get_own_device_information()
+        else:
+            try:
+                plaintext, device_information, __ = await session_manager.decrypt(message)
+            except omemo.MessageNotForUs:
+                # The difference between this being a debug or a warning is whether there
+                # is a body included in the message. Without a body, we can assume that
+                # it's an empty OMEMO message used for protocol stability reasons, which
+                # is not expected to be sent to all devices of all recipients. If a body
+                # is included, we can assume that the message carries content and we
+                # missed out on something.
+                if len(list(message_elt.elements(C.NS_CLIENT, "body"))) > 0:
+                    client.feedback(
+                        feedback_jid,
+                        D_(
+                            f"An OMEMO message from {sender_jid.full()} has not been"
+                            f" encrypted for our device, we can't decrypt it."
+                        ),
+                        { C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR }
+                    )
+                    log.warning("Message not encrypted for us.")
+                else:
+                    log.debug("Message not encrypted for us.")
+
+                # No point in further processing this message.
+                return False
+            except Exception as e:
+                log.warning(_("Can't decrypt message: {reason}\n{xml}").format(
+                    reason=e,
+                    xml=message_elt.toXml()
+                ))
+                client.feedback(
+                    feedback_jid,
+                    D_(
+                        f"An OMEMO message from {sender_jid.full()} can't be decrypted:"
+                        f" {e}"
+                    ),
+                    { C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR }
+                )
+                # No point in further processing this message
+                return False
+
+        affix_values: Optional[SCEAffixValues] = None
+
+        if message.namespace == twomemo.twomemo.NAMESPACE:
+            if plaintext is not None:
+                # XEP_0420.unpack_stanza handles the whole unpacking, including the
+                # relevant modifications to the element
+                sce_profile = \
+                    OMEMO.SCE_PROFILE_GROUPCHAT if is_muc_message else OMEMO.SCE_PROFILE
+                try:
+                    affix_values = self.__xep_0420.unpack_stanza(
+                        sce_profile,
+                        message_elt,
+                        plaintext
+                    )
+                except Exception as e:
+                    log.warning(D_(
+                        f"Error unpacking SCE-encrypted message: {e}\n{plaintext}"
+                    ))
+                    client.feedback(
+                        feedback_jid,
+                        D_(
+                            f"An OMEMO message from {sender_jid.full()} was rejected:"
+                            f" {e}"
+                        ),
+                        { C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR }
+                    )
+                    # No point in further processing this message
+                    return False
+                else:
+                    if affix_values.timestamp is not None:
+                        # TODO: affix_values.timestamp contains the timestamp included in
+                        # the encrypted element here. The XEP says it SHOULD be displayed
+                        # with the plaintext by clients.
+                        pass
+
+        if message.namespace == oldmemo.oldmemo.NAMESPACE:
+            # Remove all body elements from the original element, since those act as
+            # fallbacks in case the encryption protocol is not supported
+            for child in message_elt.elements():
+                if child.name == "body":
+                    message_elt.children.remove(child)
+
+            if plaintext is not None:
+                # Add the decrypted body
+                message_elt.addElement("body", content=plaintext.decode("utf-8"))
+
+        # Mark the message as trusted or untrusted. Undecided counts as untrusted here.
+        trust_level = \
+            await session_manager._evaluate_custom_trust_level(device_information)
+
+        if trust_level is omemo.TrustLevel.TRUSTED:
+            post_treat.addCallback(client.encryption.mark_as_trusted)
+        else:
+            post_treat.addCallback(client.encryption.mark_as_untrusted)
+
+        # Mark the message as originally encrypted
+        post_treat.addCallback(
+            client.encryption.mark_as_encrypted,
+            namespace=message.namespace
+        )
+
+        # Handle potential ATM trust updates
+        if affix_values is not None and affix_values.timestamp is not None:
+            await self.__message_received_trigger_atm(
+                client,
+                message_elt,
+                session_manager,
+                device_information,
+                affix_values.timestamp
+            )
+
+        # Message processed successfully, continue with the flow
+        return True
+
+    async def __send_trigger(self, client: SatXMPPClient, stanza: domish.Element) -> bool:
+        """
+        @param client: The client sending this message.
+        @param stanza: The stanza that is about to be sent. Can be modified.
+        @return: Whether the send message flow should continue or not.
+        """
+        # SCE is only applicable to message and IQ stanzas
+        # FIXME: temporary disabling IQ stanza encryption
+        if stanza.name not in { "message" }:  # , "iq" }:
+            return True
+
+        # Get the intended recipient
+        recipient = stanza.getAttribute("to", None)
+        if recipient is None:
+            if stanza.name == "message":
+                # Message stanzas must have a recipient
+                raise exceptions.InternalError(
+                    f"Message without recipient encountered. Blocking further processing"
+                    f" to avoid leaking plaintext data: {stanza.toXml()}"
+                )
+
+            # IQs without a recipient are a thing, I believe those simply target the
+            # server and are thus not eligible for e2ee anyway.
+            return True
+
+        # Parse the JID
+        recipient_bare_jid = jid.JID(recipient).userhostJID()
+
+        # Check whether encryption with twomemo is requested
+        encryption = client.encryption.getSession(recipient_bare_jid)
+
+        if encryption is None:
+            # Encryption is not requested for this recipient
+            return True
+
+        if encryption["plugin"].namespace != twomemo.twomemo.NAMESPACE:
+            # Encryption is requested for this recipient, but not with twomemo
+            return True
+
+        # All pre-checks done, we can start encrypting!
+        await self.encrypt(
+            client,
+            twomemo.twomemo.NAMESPACE,
+            stanza,
+            recipient_bare_jid,
+            stanza.getAttribute("type", C.MESS_TYPE_NORMAL) == C.MESS_TYPE_GROUPCHAT,
+            stanza.getAttribute("id", None)
+        )
+
+        # Add a store hint if this is a message stanza
+        if stanza.name == "message":
+            self.__xep_0334.add_hint_elements(stanza, [ "store" ])
+
+        # Let the flow continue.
+        return True
+
+    async def __send_message_data_trigger(
+        self,
+        client: SatXMPPClient,
+        mess_data: MessageData
+    ) -> None:
+        """
+        @param client: The client sending this message.
+        @param mess_data: The message data that is about to be sent. Can be modified.
+        """
+
+        # Check whether encryption is requested for this message
+        try:
+            namespace = mess_data[C.MESS_KEY_ENCRYPTION]["plugin"].namespace
+        except KeyError:
+            return
+
+        # If encryption is requested, check whether it's oldmemo
+        if namespace != oldmemo.oldmemo.NAMESPACE:
+            return
+
+        # All pre-checks done, we can start encrypting!
+        stanza = mess_data["xml"]
+        recipient_jid = mess_data["to"]
+        is_muc_message = mess_data["type"] == C.MESS_TYPE_GROUPCHAT
+        stanza_id = mess_data["uid"]
+
+        await self.encrypt(
+            client,
+            oldmemo.oldmemo.NAMESPACE,
+            stanza,
+            recipient_jid,
+            is_muc_message,
+            stanza_id
+        )
+
+        # Add a store hint
+        self.__xep_0334.add_hint_elements(stanza, [ "store" ])
+
+    async def encrypt(
+        self,
+        client: SatXMPPClient,
+        namespace: Literal["urn:xmpp:omemo:2", "eu.siacs.conversations.axolotl"],
+        stanza: domish.Element,
+        recipient_jids: Union[jid.JID, Set[jid.JID]],
+        is_muc_message: bool,
+        stanza_id: Optional[str]
+    ) -> None:
+        """
+        @param client: The client.
+        @param namespace: The namespace of the OMEMO version to use.
+        @param stanza: The stanza. Twomemo will encrypt the whole stanza using SCE,
+            oldmemo will encrypt only the body. The stanza is modified by this call.
+        @param recipient_jid: The JID of the recipients.
+            Can be a bare (aka "userhost") JIDs but doesn't have to.
+            A single JID can be used.
+        @param is_muc_message: Whether the stanza is a message stanza to a MUC room.
+        @param stanza_id: The id of this stanza. Especially relevant for message stanzas
+            to MUC rooms such that the outgoing plaintext can be cached for MUC message
+            reflection handling.
+
+        @warning: The calling code MUST take care of adding the store message processing
+            hint to the stanza if applicable! This can be done before or after this call,
+            the order doesn't matter.
+        """
+        if isinstance(recipient_jids, jid.JID):
+            recipient_jids = {recipient_jids}
+        if not recipient_jids:
+            raise exceptions.InternalError("At least one JID must be specified")
+        recipient_jid = next(iter(recipient_jids))
+
+        muc_plaintext_cache_key: Optional[MUCPlaintextCacheKey] = None
+
+        recipient_bare_jids: Set[str]
+        feedback_jid: jid.JID
+
+        if is_muc_message:
+            if len(recipient_jids) != 1:
+                raise exceptions.InternalError(
+                    'Only one JID can be set when "is_muc_message" is set'
+                )
+            if self.__xep_0045 is None:
+                raise exceptions.InternalError(
+                    "Encryption of MUC message requested, but plugin XEP-0045 is not"
+                    " available."
+                )
+
+            if stanza_id is None:
+                raise exceptions.InternalError(
+                    "Encryption of MUC message requested, but stanza id not available."
+                )
+
+            room_jid = feedback_jid = recipient_jid.userhostJID()
+
+            recipient_bare_jids = self.__get_joined_muc_users(
+                client,
+                self.__xep_0045,
+                room_jid
+            )
+
+            muc_plaintext_cache_key = MUCPlaintextCacheKey(
+                client=client,
+                room_jid=room_jid,
+                message_uid=stanza_id
+            )
+        else:
+            recipient_bare_jids = {r.userhost() for r in recipient_jids}
+            feedback_jid = recipient_jid.userhostJID()
+
+        log.debug(
+            f"Intercepting message that is to be encrypted by {namespace} for"
+            f" {recipient_bare_jids}"
+        )
+
+        def prepare_stanza() -> Optional[bytes]:
+            """Prepares the stanza for encryption.
+
+            Does so by removing all parts that are not supposed to be sent in plain. Also
+            extracts/prepares the plaintext to encrypt.
+
+            @return: The plaintext to encrypt. Returns ``None`` in case body-only
+                encryption is requested and no body was found. The function should
+                gracefully return in that case, i.e. it's not a critical error that should
+                abort the message sending flow.
+            """
+
+            if namespace == twomemo.twomemo.NAMESPACE:
+                return self.__xep_0420.pack_stanza(
+                    OMEMO.SCE_PROFILE_GROUPCHAT if is_muc_message else OMEMO.SCE_PROFILE,
+                    stanza
+                )
+
+            if namespace == oldmemo.oldmemo.NAMESPACE:
+                plaintext: Optional[bytes] = None
+
+                for child in stanza.elements():
+                    if child.name == "body" and plaintext is None:
+                        plaintext = str(child).encode("utf-8")
+
+                    # Any other sensitive elements to remove here?
+                    if child.name in { "body", "html" }:
+                        stanza.children.remove(child)
+
+                if plaintext is None:
+                    log.warning(
+                        "No body found in intercepted message to be encrypted with"
+                        " oldmemo."
+                    )
+
+                return plaintext
+
+            return assert_never(namespace)
+
+        # The stanza/plaintext preparation was moved into its own little function for type
+        # safety reasons.
+        plaintext = prepare_stanza()
+        if plaintext is None:
+            return
+
+        log.debug(f"Plaintext to encrypt: {plaintext}")
+
+        session_manager = await self.get_session_manager(client.profile)
+
+        try:
+            messages, encryption_errors = await session_manager.encrypt(
+                frozenset(recipient_bare_jids),
+                { namespace: plaintext },
+                backend_priority_order=[ namespace ],
+                identifier=feedback_jid.userhost()
+            )
+        except Exception as e:
+            msg = _(
+                # pylint: disable=consider-using-f-string
+                "Can't encrypt message for {entities}: {reason}".format(
+                    entities=', '.join(recipient_bare_jids),
+                    reason=e
+                )
+            )
+            log.warning(msg)
+            client.feedback(feedback_jid, msg, {
+                C.MESS_EXTRA_INFO: C.EXTRA_INFO_ENCR_ERR
+            })
+            raise e
+
+        if len(encryption_errors) > 0:
+            log.warning(
+                f"Ignored the following non-critical encryption errors:"
+                f" {encryption_errors}"
+            )
+
+            encrypted_errors_stringified = ", ".join([
+                f"device {err.device_id} of {err.bare_jid} under namespace"
+                f" {err.namespace}"
+                for err
+                in encryption_errors
+            ])
+
+            client.feedback(
+                feedback_jid,
+                D_(
+                    "There were non-critical errors during encryption resulting in some"
+                    " of your destinees' devices potentially not receiving the message."
+                    " This happens when the encryption data/key material of a device is"
+                    " incomplete or broken, which shouldn't happen for actively used"
+                    " devices, and can usually be ignored. The following devices are"
+                    f" affected: {encrypted_errors_stringified}."
+                )
+            )
+
+        message = next(message for message in messages if message.namespace == namespace)
+
+        if namespace == twomemo.twomemo.NAMESPACE:
+            # Add the encrypted element
+            stanza.addChild(xml_tools.et_elt_2_domish_elt(
+                twomemo.etree.serialize_message(message)
+            ))
+
+        if namespace == oldmemo.oldmemo.NAMESPACE:
+            # Add the encrypted element
+            stanza.addChild(xml_tools.et_elt_2_domish_elt(
+                oldmemo.etree.serialize_message(message)
+            ))
+
+        if muc_plaintext_cache_key is not None:
+            self.__muc_plaintext_cache[muc_plaintext_cache_key] = plaintext
+
+    async def __on_device_list_update(
+        self,
+        items_event: pubsub.ItemsEvent,
+        profile: str
+    ) -> None:
+        """Handle device list updates fired by PEP.
+
+        @param items_event: The event.
+        @param profile: The profile this event belongs to.
+        """
+
+        sender = cast(jid.JID, items_event.sender)
+        items = cast(List[domish.Element], items_event.items)
+
+        if len(items) > 1:
+            log.warning("Ignoring device list update with more than one element.")
+            return
+
+        item = next(iter(items), None)
+        if item is None:
+            log.debug("Ignoring empty device list update.")
+            return
+
+        item_elt = xml_tools.domish_elt_2_et_elt(item)
+
+        device_list: Dict[int, Optional[str]] = {}
+        namespace: Optional[str] = None
+
+        list_elt = item_elt.find(f"{{{twomemo.twomemo.NAMESPACE}}}devices")
+        if list_elt is not None:
+            try:
+                device_list = twomemo.etree.parse_device_list(list_elt)
+            except XMLSchemaValidationError:
+                pass
+            else:
+                namespace = twomemo.twomemo.NAMESPACE
+
+        list_elt = item_elt.find(f"{{{oldmemo.oldmemo.NAMESPACE}}}list")
+        if list_elt is not None:
+            try:
+                device_list = oldmemo.etree.parse_device_list(list_elt)
+            except XMLSchemaValidationError:
+                pass
+            else:
+                namespace = oldmemo.oldmemo.NAMESPACE
+
+        if namespace is None:
+            log.warning(
+                f"Malformed device list update item:"
+                f" {ET.tostring(item_elt, encoding='unicode')}"
+            )
+            return
+
+        session_manager = await self.get_session_manager(profile)
+
+        await session_manager.update_device_list(
+            namespace,
+            sender.userhost(),
+            device_list
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0391.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,295 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Jingle Encrypted Transports
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from base64 import b64encode
+from functools import partial
+import io
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union
+
+from twisted.words.protocols.jabber import error, jid, xmlstream
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+from cryptography.exceptions import AlreadyFinalized
+from cryptography.hazmat import backends
+from cryptography.hazmat.primitives import ciphers
+from cryptography.hazmat.primitives.ciphers import Cipher, CipherContext, modes
+from cryptography.hazmat.primitives.padding import PKCS7, PaddingContext
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xml_tools
+
+try:
+    import oldmemo
+    import oldmemo.etree
+except ImportError as import_error:
+    raise exceptions.MissingModule(
+        "You are missing one or more package required by the OMEMO plugin. Please"
+        " download/install the pip packages 'oldmemo'."
+    ) from import_error
+
+
+log = getLogger(__name__)
+
+IMPORT_NAME = "XEP-0391"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Jingle Encrypted Transports",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0391", "XEP-0396"],
+    C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0384"],
+    C.PI_MAIN: "JET",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""End-to-end encryption of Jingle transports"""),
+}
+
+NS_JET = "urn:xmpp:jingle:jet:0"
+NS_JET_OMEMO = "urn:xmpp:jingle:jet-omemo:0"
+
+
+class JET:
+    namespace = NS_JET
+
+    def __init__(self, host):
+        log.info(_("XEP-0391 (Pubsub Attachments) plugin initialization"))
+        host.register_namespace("jet", NS_JET)
+        self.host = host
+        self._o = host.plugins["XEP-0384"]
+        self._j = host.plugins["XEP-0166"]
+        host.trigger.add(
+            "XEP-0166_initiate_elt_built",
+            self._on_initiate_elt_build
+        )
+        host.trigger.add(
+            "XEP-0166_on_session_initiate",
+            self._on_session_initiate
+        )
+        host.trigger.add(
+            "XEP-0234_jingle_handler",
+            self._add_encryption_filter
+        )
+        host.trigger.add(
+            "XEP-0234_file_receiving_request_conf",
+            self._add_encryption_filter
+        )
+
+    def get_handler(self, client):
+        return JET_Handler()
+
+    async def _on_initiate_elt_build(
+        self,
+        client: SatXMPPEntity,
+        session: Dict[str, Any],
+        iq_elt: domish.Element,
+        jingle_elt: domish.Element
+    ) -> bool:
+        if client.encryption.get_namespace(
+               session["peer_jid"].userhostJID()
+           ) != self._o.NS_OLDMEMO:
+            return True
+        for content_elt in jingle_elt.elements(self._j.namespace, "content"):
+            content_data = session["contents"][content_elt["name"]]
+            security_elt = content_elt.addElement((NS_JET, "security"))
+            security_elt["name"] = content_elt["name"]
+            # XXX: for now only OLDMEMO is supported, thus we do it directly here. If some
+            #   other are supported in the future, a plugin registering mechanism will be
+            #   implemented.
+            cipher = "urn:xmpp:ciphers:aes-128-gcm-nopadding"
+            enc_type = "eu.siacs.conversations.axolotl"
+            security_elt["cipher"] = cipher
+            security_elt["type"] = enc_type
+            encryption_data = content_data["encryption"] = {
+                "cipher": cipher,
+                "type": enc_type
+            }
+            session_manager = await self._o.get_session_manager(client.profile)
+            try:
+                messages, encryption_errors = await session_manager.encrypt(
+                    frozenset({session["peer_jid"].userhost()}),
+                    # the value seems to be the commonly used value
+                    { self._o.NS_OLDMEMO: b" " },
+                    backend_priority_order=[ self._o.NS_OLDMEMO ],
+                    identifier = client.jid.userhost()
+                )
+            except Exception as e:
+                log.error("Can't generate IV and keys: {e}")
+                raise e
+            message, plain_key_material = next(iter(messages.items()))
+            iv, key = message.content.initialization_vector, plain_key_material.key
+            content_data["encryption"].update({
+                "iv": iv,
+                "key": key
+            })
+            encrypted_elt = xml_tools.et_elt_2_domish_elt(
+                oldmemo.etree.serialize_message(message)
+            )
+            security_elt.addChild(encrypted_elt)
+        return True
+
+    async def _on_session_initiate(
+        self,
+        client: SatXMPPEntity,
+        session: Dict[str, Any],
+        iq_elt: domish.Element,
+        jingle_elt: domish.Element
+    ) -> bool:
+        if client.encryption.get_namespace(
+               session["peer_jid"].userhostJID()
+           ) != self._o.NS_OLDMEMO:
+            return True
+        for content_elt in jingle_elt.elements(self._j.namespace, "content"):
+            content_data = session["contents"][content_elt["name"]]
+            security_elt = next(content_elt.elements(NS_JET, "security"), None)
+            if security_elt is None:
+                continue
+            encrypted_elt = next(
+                security_elt.elements(self._o.NS_OLDMEMO, "encrypted"), None
+            )
+            if encrypted_elt is None:
+                log.warning(
+                    "missing <encrypted> element, can't decrypt: {security_elt.toXml()}"
+                )
+                continue
+            session_manager = await self._o.get_session_manager(client.profile)
+            try:
+                message = await oldmemo.etree.parse_message(
+                    xml_tools.domish_elt_2_et_elt(encrypted_elt, False),
+                    session["peer_jid"].userhost(),
+                    client.jid.userhost(),
+                    session_manager
+                )
+                __, __, plain_key_material = await session_manager.decrypt(message)
+            except Exception as e:
+                log.warning(f"Can't get IV and key: {e}\n{security_elt.toXml()}")
+                continue
+            try:
+                content_data["encryption"] = {
+                    "cipher": security_elt["cipher"],
+                    "type": security_elt["type"],
+                    "iv": message.content.initialization_vector,
+                    "key": plain_key_material.key
+                }
+            except KeyError as e:
+                log.warning(f"missing data, can't decrypt: {e}")
+                continue
+
+        return True
+
+    def __encrypt(
+        self,
+        data: bytes,
+        encryptor: CipherContext,
+        data_cb: Callable
+    ) -> bytes:
+        data_cb(data)
+        if data:
+            return encryptor.update(data)
+        else:
+            try:
+                return encryptor.finalize() + encryptor.tag
+            except AlreadyFinalized:
+                return b''
+
+    def __decrypt(
+        self,
+        data: bytes,
+        buffer: list[bytes],
+        decryptor: CipherContext,
+        data_cb: Callable
+    ) -> bytes:
+        buffer.append(data)
+        data = b''.join(buffer)
+        buffer.clear()
+        if len(data) > 16:
+            decrypted = decryptor.update(data[:-16])
+            data_cb(decrypted)
+        else:
+            decrypted = b''
+        buffer.append(data[-16:])
+        return decrypted
+
+    def __decrypt_finalize(
+        self,
+        file_obj: io.BytesIO,
+        buffer: list[bytes],
+        decryptor: CipherContext,
+    ) -> None:
+        tag = b''.join(buffer)
+        file_obj.write(decryptor.finalize_with_tag(tag))
+
+    async def _add_encryption_filter(
+        self,
+        client: SatXMPPEntity,
+        session: Dict[str, Any],
+        content_data: Dict[str, Any],
+        elt: domish.Element
+    ) -> bool:
+        file_obj = content_data["stream_object"].file_obj
+        try:
+            encryption_data=content_data["encryption"]
+        except KeyError:
+            return True
+        cipher = ciphers.Cipher(
+            ciphers.algorithms.AES(encryption_data["key"]),
+            modes.GCM(encryption_data["iv"]),
+            backend=backends.default_backend(),
+        )
+        if file_obj.mode == "wb":
+            # we are receiving a file
+            buffer = []
+            decryptor = cipher.decryptor()
+            file_obj.pre_close_cb = partial(
+                self.__decrypt_finalize,
+                file_obj=file_obj,
+                buffer=buffer,
+                decryptor=decryptor
+            )
+            file_obj.data_cb = partial(
+                self.__decrypt,
+                buffer=buffer,
+                decryptor=decryptor,
+                data_cb=file_obj.data_cb
+            )
+        else:
+            # we are sending a file
+            file_obj.data_cb = partial(
+                self.__encrypt,
+                encryptor=cipher.encryptor(),
+                data_cb=file_obj.data_cb
+            )
+
+        return True
+
+
+@implementer(iwokkel.IDisco)
+class JET_Handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [
+            disco.DiscoFeature(NS_JET),
+            disco.DiscoFeature(NS_JET_OMEMO),
+        ]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0420.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,578 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Stanza Content Encryption
+# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from abc import ABC, abstractmethod
+from datetime import datetime
+import enum
+import secrets
+import string
+from typing import Dict, NamedTuple, Optional, Set, Tuple, cast
+from typing_extensions import Final
+
+from lxml import etree
+from libervia.backend.core import exceptions
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import D_
+from libervia.backend.core.log import Logger, getLogger
+from libervia.backend.core.sat_main import SAT
+from libervia.backend.tools.xml_tools import ElementParser
+from libervia.backend.plugins.plugin_xep_0033 import NS_ADDRESS
+from libervia.backend.plugins.plugin_xep_0082 import XEP_0082
+from libervia.backend.plugins.plugin_xep_0334 import NS_HINTS
+from libervia.backend.plugins.plugin_xep_0359 import NS_SID
+from libervia.backend.plugins.plugin_xep_0380 import NS_EME
+from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
+
+
+__all__ = [  # pylint: disable=unused-variable
+    "PLUGIN_INFO",
+    "NS_SCE",
+    "XEP_0420",
+    "ProfileRequirementsNotMet",
+    "AffixVerificationFailed",
+    "SCECustomAffix",
+    "SCEAffixPolicy",
+    "SCEProfile",
+    "SCEAffixValues"
+]
+
+
+log = cast(Logger, getLogger(__name__))  # type: ignore[no-untyped-call]
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "SCE",
+    C.PI_IMPORT_NAME: "XEP-0420",
+    C.PI_TYPE: "SEC",
+    C.PI_PROTOCOLS: [ "XEP-0420" ],
+    C.PI_DEPENDENCIES: [ "XEP-0334", "XEP-0082" ],
+    C.PI_RECOMMENDATIONS: [ "XEP-0045", "XEP-0033", "XEP-0359" ],
+    C.PI_MAIN: "XEP_0420",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: D_("Implementation of Stanza Content Encryption"),
+}
+
+
+NS_SCE: Final = "urn:xmpp:sce:1"
+
+
+class ProfileRequirementsNotMet(Exception):
+    """
+    Raised by :meth:`XEP_0420.unpack_stanza` in case the requirements formulated by the
+    profile are not met.
+    """
+
+
+class AffixVerificationFailed(Exception):
+    """
+    Raised by :meth:`XEP_0420.unpack_stanza` in case of affix verification failure.
+    """
+
+
+class SCECustomAffix(ABC):
+    """
+    Interface for custom affixes of SCE profiles.
+    """
+
+    @property
+    @abstractmethod
+    def element_name(self) -> str:
+        """
+        @return: The name of the affix's XML element.
+        """
+
+    @property
+    @abstractmethod
+    def element_schema(self) -> str:
+        """
+        @return: The XML schema definition of the affix element's XML structure, i.e. the
+            ``<xs:element/>`` schema element. This element will be referenced using
+            ``<xs:element ref="{element_name}"/>``.
+        """
+
+    @abstractmethod
+    def create(self, stanza: domish.Element) -> domish.Element:
+        """
+        @param stanza: The stanza element which has been processed by
+            :meth:`XEP_0420.pack_stanza`, i.e. all encryptable children have been removed
+            and only the root ``<message/>`` or ``<iq/>`` and unencryptable children
+            remain. Do not modify.
+        @return: An affix element to include in the envelope. The element must have the
+            name :attr:`element_name` and must validate using :attr:`element_schema`.
+        @raise ValueError: if the affix couldn't be built due to missing information on
+            the stanza.
+        """
+
+    @abstractmethod
+    def verify(self, stanza: domish.Element, element: domish.Element) -> None:
+        """
+        @param stanza: The stanza element before being processed by
+            :meth:`XEP_0420.unpack_stanza`, i.e. all encryptable children have been
+            removed and only the root ``<message/>`` or ``<iq/>`` and unencryptable
+            children remain. Do not modify.
+        @param element: The affix element to verify.
+        @raise AffixVerificationFailed: on verification failure.
+        """
+
+
+@enum.unique
+class SCEAffixPolicy(enum.Enum):
+    """
+    Policy for the presence of an affix in an SCE envelope.
+    """
+
+    REQUIRED: str = "REQUIRED"
+    OPTIONAL: str = "OPTIONAL"
+    NOT_NEEDED: str = "NOT_NEEDED"
+
+
+class SCEProfile(NamedTuple):
+    # pylint: disable=invalid-name
+    """
+    An SCE profile, i.e. the definition which affixes are required, optional or not needed
+    at all by an SCE-enabled encryption protocol.
+    """
+
+    rpad_policy: SCEAffixPolicy
+    time_policy: SCEAffixPolicy
+    to_policy: SCEAffixPolicy
+    from_policy: SCEAffixPolicy
+    custom_policies: Dict[SCECustomAffix, SCEAffixPolicy]
+
+
+class SCEAffixValues(NamedTuple):
+    # pylint: disable=invalid-name
+    """
+    Structure returned by :meth:`XEP_0420.unpack_stanza` with the parsed/processes values
+    of all affixes included in the envelope. For custom affixes, the whole affix element
+    is returned.
+    """
+
+    rpad: Optional[str]
+    timestamp: Optional[datetime]
+    recipient: Optional[jid.JID]
+    sender: Optional[jid.JID]
+    custom: Dict[SCECustomAffix, domish.Element]
+
+
+ENVELOPE_SCHEMA = """<?xml version="1.0" encoding="utf8"?>
+<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
+    targetNamespace="urn:xmpp:sce:1"
+    xmlns="urn:xmpp:sce:1">
+
+    <xs:element name="envelope">
+        <xs:complexType>
+            <xs:all>
+                <xs:element ref="content"/>
+                <xs:element ref="rpad" minOccurs="0"/>
+                <xs:element ref="time" minOccurs="0"/>
+                <xs:element ref="to" minOccurs="0"/>
+                <xs:element ref="from" minOccurs="0"/>
+                {custom_affix_references}
+            </xs:all>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="content">
+        <xs:complexType>
+            <xs:sequence>
+                <xs:any minOccurs="0" maxOccurs="unbounded" processContents="skip"/>
+            </xs:sequence>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="rpad" type="xs:string"/>
+
+    <xs:element name="time">
+        <xs:complexType>
+            <xs:attribute name="stamp" type="xs:dateTime"/>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="to">
+        <xs:complexType>
+            <xs:attribute name="jid" type="xs:string"/>
+        </xs:complexType>
+    </xs:element>
+
+    <xs:element name="from">
+        <xs:complexType>
+            <xs:attribute name="jid" type="xs:string"/>
+        </xs:complexType>
+    </xs:element>
+
+    {custom_affix_definitions}
+</xs:schema>
+"""
+
+
+class XEP_0420:  # pylint: disable=invalid-name
+    """
+    Implementation of XEP-0420: Stanza Content Encryption under namespace
+    ``urn:xmpp:sce:1``.
+
+    This is a passive plugin, i.e. it doesn't hook into any triggers to process stanzas
+    actively, but offers API for other plugins to use.
+    """
+
+    # Set of namespaces whose elements are never allowed to be transferred in an encrypted
+    # envelope.
+    MUST_BE_PLAINTEXT_NAMESPACES: Set[str] = {
+        NS_HINTS,
+        NS_SID,  # TODO: Not sure whether this ban applies to both stanza-id and origin-id
+        NS_ADDRESS,
+        # Not part of the specification (yet), but just doesn't make sense in an encrypted
+        # envelope:
+        NS_EME
+    }
+
+    # Set of (namespace, element name) tuples that define elements which are never allowed
+    # to be transferred in an encrypted envelope. If all elements under a certain
+    # namespace are forbidden, the namespace can be added to
+    # :attr:`MUST_BE_PLAINTEXT_NAMESPACES` instead.
+    # Note: only full namespaces are forbidden by the spec for now, the following is for
+    # potential future use.
+    MUST_BE_PLAINTEXT_ELEMENTS: Set[Tuple[str, str]] = set()
+
+    def __init__(self, sat: SAT) -> None:
+        """
+        @param sat: The SAT instance.
+        """
+
+    @staticmethod
+    def pack_stanza(profile: SCEProfile, stanza: domish.Element) -> bytes:
+        """Pack a stanza according to Stanza Content Encryption.
+
+        Removes all elements from the stanza except for a few exceptions that explicitly
+        need to be transferred in plaintext, e.g. because they contain hints/instructions
+        for the server on how to process the stanza. Together with the affix elements as
+        requested by the profile, the removed elements are added to an envelope XML
+        structure that builds the plaintext to be encrypted by the SCE-enabled encryption
+        scheme. Optional affixes are always added to the structure, i.e. they are treated
+        by the packing code as if they were required.
+
+        Once built, the envelope structure is serialized to a byte string and returned for
+        the encryption scheme to encrypt and add to the stanza.
+
+        @param profile: The SCE profile, i.e. the definition of affixes to include in the
+            envelope.
+        @param stanza: The stanza to process. Will be modified by the call.
+        @return: The serialized envelope structure that builds the plaintext for the
+            encryption scheme to process.
+        @raise ValueError: if the <to/> or <from/> affixes are requested but the stanza
+            doesn't have the "to"/"from" attribute set to extract the value from. Can also
+            be raised by custom affixes.
+
+        @warning: It is up to the calling code to add a <store/> message processing hint
+            if applicable.
+        """
+
+        # Prepare the envelope and content elements
+        envelope = domish.Element((NS_SCE, "envelope"))
+        content = envelope.addElement((NS_SCE, "content"))
+
+        # Note the serialized byte size of the content element before adding any children
+        empty_content_byte_size = len(content.toXml().encode("utf-8"))
+
+        # Move elements that are not explicitly forbidden from being encrypted from the
+        # stanza to the content element.
+        for child in list(stanza.elements()):
+            if (
+                child.uri not in XEP_0420.MUST_BE_PLAINTEXT_NAMESPACES
+                and (child.uri, child.name) not in XEP_0420.MUST_BE_PLAINTEXT_ELEMENTS
+            ):
+                # Remove the child from the stanza
+                stanza.children.remove(child)
+
+                # A namespace of ``None`` can be used on domish elements to inherit the
+                # namespace from the parent. When moving elements from the stanza root to
+                # the content element, however, we don't want elements to inherit the
+                # namespace of the content element. Thus, check for elements with ``None``
+                # for their namespace and set the namespace to jabber:client, which is the
+                # namespace of the parent element.
+                if child.uri is None:
+                    child.uri = C.NS_CLIENT
+                    child.defaultUri = C.NS_CLIENT
+
+                # Add the child with corrected namespaces to the content element
+                content.addChild(child)
+
+        # Add the affixes requested by the profile
+        if profile.rpad_policy is not SCEAffixPolicy.NOT_NEEDED:
+            # The specification defines the rpad affix to contain "[...] a randomly
+            # generated sequence of random length between 0 and 200 characters." This
+            # implementation differs a bit from the specification in that a minimum size
+            # other than 0 is chosen depending on the serialized size of the content
+            # element. This is to prevent the scenario where the encrypted content is
+            # short and the rpad is also randomly chosen to be short, which could allow
+            # guessing the content of a short message. To do so, the rpad length is first
+            # chosen to pad the content to at least 53 bytes, then afterwards another 0 to
+            # 200 bytes are added. Note that single-byte characters are used by this
+            # implementation, thus the number of characters equals the number of bytes.
+            content_byte_size = len(content.toXml().encode("utf-8"))
+            content_byte_size_diff = content_byte_size - empty_content_byte_size
+            rpad_length = max(0, 53 - content_byte_size_diff) + secrets.randbelow(201)
+            rpad_content = "".join(
+                secrets.choice(string.digits + string.ascii_letters + string.punctuation)
+                for __
+                in range(rpad_length)
+            )
+            envelope.addElement((NS_SCE, "rpad"), content=rpad_content)
+
+        if profile.time_policy is not SCEAffixPolicy.NOT_NEEDED:
+            time_element = envelope.addElement((NS_SCE, "time"))
+            time_element["stamp"] = XEP_0082.format_datetime()
+
+        if profile.to_policy is not SCEAffixPolicy.NOT_NEEDED:
+            recipient = stanza.getAttribute("to", None)
+            if recipient is not None:
+                to_element = envelope.addElement((NS_SCE, "to"))
+                to_element["jid"] = jid.JID(recipient).userhost()
+            elif profile.to_policy is SCEAffixPolicy.REQUIRED:
+                raise ValueError(
+                    "<to/> affix requested, but stanza doesn't have the 'to' attribute"
+                    " set."
+                )
+
+        if profile.from_policy is not SCEAffixPolicy.NOT_NEEDED:
+            sender = stanza.getAttribute("from", None)
+            if sender is not None:
+                from_element = envelope.addElement((NS_SCE, "from"))
+                from_element["jid"] = jid.JID(sender).userhost()
+            elif profile.from_policy is SCEAffixPolicy.REQUIRED:
+                raise ValueError(
+                    "<from/> affix requested, but stanza doesn't have the 'from'"
+                    " attribute set."
+                )
+
+        for affix, policy in profile.custom_policies.items():
+            if policy is not SCEAffixPolicy.NOT_NEEDED:
+                envelope.addChild(affix.create(stanza))
+
+        return envelope.toXml().encode("utf-8")
+
+    @staticmethod
+    def unpack_stanza(
+        profile: SCEProfile,
+        stanza: domish.Element,
+        envelope_serialized: bytes
+    ) -> SCEAffixValues:
+        """Unpack a stanza packed according to Stanza Content Encryption.
+
+        Parses the serialized envelope as XML, verifies included affixes and makes sure
+        the requirements of the profile are met, and restores the stanza by moving
+        decrypted elements from the envelope back to the stanza top level.
+
+        @param profile: The SCE profile, i.e. the definition of affixes that have to/may
+            be included in the envelope.
+        @param stanza: The stanza to process. Will be modified by the call.
+        @param envelope_serialized: The serialized envelope, i.e. the plaintext produced
+            by the decryption scheme utilizing SCE.
+        @return: The parsed and processed values of all affixes that were present on the
+            envelope, notably including the timestamp.
+        @raise exceptions.ParsingError: if the serialized envelope element is malformed.
+        @raise ProfileRequirementsNotMet: if one or more affixes required by the profile
+            are missing from the envelope.
+        @raise AffixVerificationFailed: if an affix included in the envelope fails to
+            validate. It doesn't matter whether the affix is required by the profile or
+            not, all affixes included in the envelope are validated and cause this
+            exception to be raised on failure.
+
+        @warning: It is up to the calling code to verify the timestamp, if returned, since
+            the requirements on the timestamp may vary between SCE-enabled protocols.
+        """
+
+        try:
+            envelope_serialized_string = envelope_serialized.decode("utf-8")
+        except UnicodeError as e:
+            raise exceptions.ParsingError(
+                "Serialized envelope can't bare parsed as utf-8."
+            ) from e
+
+        custom_affixes = set(profile.custom_policies.keys())
+
+        # Make sure the envelope adheres to the schema
+        parser = etree.XMLParser(schema=etree.XMLSchema(etree.XML(ENVELOPE_SCHEMA.format(
+            custom_affix_references="".join(
+                f'<xs:element ref="{custom_affix.element_name}" minOccurs="0"/>'
+                for custom_affix
+                in custom_affixes
+            ),
+            custom_affix_definitions="".join(
+                custom_affix.element_schema
+                for custom_affix
+                in custom_affixes
+            )
+        ).encode("utf-8"))))
+
+        try:
+            etree.fromstring(envelope_serialized_string, parser)
+        except etree.XMLSyntaxError as e:
+            raise exceptions.ParsingError(
+                "Serialized envelope doesn't pass schema validation."
+            ) from e
+
+        # Prepare the envelope and content elements
+        envelope = cast(domish.Element, ElementParser()(envelope_serialized_string))
+        content = next(envelope.elements(NS_SCE, "content"))
+
+        # Verify the affixes
+        rpad_element = cast(
+            Optional[domish.Element],
+            next(envelope.elements(NS_SCE, "rpad"), None)
+        )
+        time_element = cast(
+            Optional[domish.Element],
+            next(envelope.elements(NS_SCE, "time"), None)
+        )
+        to_element = cast(
+            Optional[domish.Element],
+            next(envelope.elements(NS_SCE, "to"), None)
+        )
+        from_element = cast(
+            Optional[domish.Element],
+            next(envelope.elements(NS_SCE, "from"), None)
+        )
+
+        # The rpad doesn't need verification.
+        rpad_value = None if rpad_element is None else str(rpad_element)
+
+        # The time affix isn't verified other than that the timestamp is parseable.
+        try:
+            timestamp_value = None if time_element is None else \
+                XEP_0082.parse_datetime(time_element["stamp"])
+        except ValueError as e:
+            raise AffixVerificationFailed("Malformed time affix.") from e
+
+        # The to affix is verified by comparing the to attribute of the stanza with the
+        # JID referenced by the affix. Note that only bare JIDs are compared as per the
+        # specification.
+        recipient_value: Optional[jid.JID] = None
+        if to_element is not None:
+            recipient_value = jid.JID(to_element["jid"])
+
+            recipient_actual = stanza.getAttribute("to", None)
+            if recipient_actual is None:
+                raise AffixVerificationFailed(
+                    "'To' affix is included in the envelope, but the stanza is lacking a"
+                    " 'to' attribute to compare the value to."
+                )
+
+            recipient_actual_bare_jid = jid.JID(recipient_actual).userhost()
+            recipient_target_bare_jid = recipient_value.userhost()
+
+            if recipient_actual_bare_jid != recipient_target_bare_jid:
+                raise AffixVerificationFailed(
+                    f"Mismatch between actual and target recipient bare JIDs:"
+                    f" {recipient_actual_bare_jid} vs {recipient_target_bare_jid}."
+                )
+
+        # The from affix is verified by comparing the from attribute of the stanza with
+        # the JID referenced by the affix. Note that only bare JIDs are compared as per
+        # the specification.
+        sender_value: Optional[jid.JID] = None
+        if from_element is not None:
+            sender_value = jid.JID(from_element["jid"])
+
+            sender_actual = stanza.getAttribute("from", None)
+            if sender_actual is None:
+                raise AffixVerificationFailed(
+                    "'From' affix is included in the envelope, but the stanza is lacking"
+                    " a 'from' attribute to compare the value to."
+                )
+
+            sender_actual_bare_jid = jid.JID(sender_actual).userhost()
+            sender_target_bare_jid = sender_value.userhost()
+
+            if sender_actual_bare_jid != sender_target_bare_jid:
+                raise AffixVerificationFailed(
+                    f"Mismatch between actual and target sender bare JIDs:"
+                    f" {sender_actual_bare_jid} vs {sender_target_bare_jid}."
+                )
+
+        # Find and verify custom affixes
+        custom_values: Dict[SCECustomAffix, domish.Element] = {}
+        for affix in custom_affixes:
+            element_name = affix.element_name
+            element = cast(
+                Optional[domish.Element],
+                next(envelope.elements(NS_SCE, element_name), None)
+            )
+            if element is not None:
+                affix.verify(stanza, element)
+                custom_values[affix] = element
+
+        # Check whether all affixes required by the profile are present
+        rpad_missing = \
+            profile.rpad_policy is SCEAffixPolicy.REQUIRED and rpad_element is None
+        time_missing = \
+            profile.time_policy is SCEAffixPolicy.REQUIRED and time_element is None
+        to_missing = \
+            profile.to_policy is SCEAffixPolicy.REQUIRED and to_element is None
+        from_missing = \
+            profile.from_policy is SCEAffixPolicy.REQUIRED and from_element is None
+        custom_missing = any(
+            affix not in custom_values
+            for affix, policy
+            in profile.custom_policies.items()
+            if policy is SCEAffixPolicy.REQUIRED
+        )
+
+        if rpad_missing or time_missing or to_missing or from_missing or custom_missing:
+            custom_missing_string = ""
+            for custom_affix in custom_affixes:
+                value = "present" if custom_affix in custom_values else "missing"
+                custom_missing_string += f", [custom]{custom_affix.element_name}={value}"
+
+            raise ProfileRequirementsNotMet(
+                f"SCE envelope is missing affixes required by the profile {profile}."
+                f" Affix presence:"
+                f" rpad={'missing' if rpad_missing else 'present'}"
+                f", time={'missing' if time_missing else 'present'}"
+                f", to={'missing' if to_missing else 'present'}"
+                f", from={'missing' if from_missing else 'present'}"
+                + custom_missing_string
+            )
+
+        # Move elements that are not explicitly forbidden from being encrypted from the
+        # content element to the stanza.
+        for child in list(content.elements()):
+            if (
+                child.uri in XEP_0420.MUST_BE_PLAINTEXT_NAMESPACES
+                or (child.uri, child.name) in XEP_0420.MUST_BE_PLAINTEXT_ELEMENTS
+            ):
+                log.warning(
+                    f"An element that MUST be transferred in plaintext was found in an"
+                    f" SCE envelope: {child.toXml()}"
+                )
+            else:
+                # Remove the child from the content element
+                content.children.remove(child)
+
+                # Add the child to the stanza
+                stanza.addChild(child)
+
+        return SCEAffixValues(
+            rpad_value,
+            timestamp_value,
+            recipient_value,
+            sender_value,
+            custom_values
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0422.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional, List, Tuple, Union, NamedTuple
+from collections import namedtuple
+
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.xish import domish
+from wokkel import disco
+from zope.interface import implementer
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.memory.sqla_mapping import History
+from libervia.backend.tools.common.async_utils import async_lru
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Message Fastening",
+    C.PI_IMPORT_NAME: "XEP-0422",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0359", "XEP-0422"],
+    C.PI_MAIN: "XEP_0422",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation Message Fastening"""),
+}
+
+NS_FASTEN = "urn:xmpp:fasten:0"
+
+
+class FastenMetadata(NamedTuple):
+    elements: List[domish.Element]
+    id: str
+    history: Optional[History]
+    clear: bool
+    shell: bool
+
+
+class XEP_0422(object):
+
+    def __init__(self, host):
+        log.info(_("XEP-0422 (Message Fastening) plugin initialization"))
+        self.host = host
+        host.register_namespace("fasten", NS_FASTEN)
+
+    def get_handler(self, __):
+        return XEP_0422_handler()
+
+    def apply_to_elt(
+        self,
+        message_elt: domish.Element,
+        origin_id: str,
+        clear: Optional[bool] = None,
+        shell: Optional[bool] = None,
+        children: Optional[List[domish.Element]] = None,
+        external: Optional[List[Union[str, Tuple[str, str]]]] = None
+    ) -> domish.Element:
+        """Generate, add and return <apply-to> element
+
+        @param message_elt: wrapping <message> element
+        @param origin_id: origin ID of the target message
+        @param clear: set to True to remove a fastening
+        @param shell: set to True when using e2ee shell
+            cf. https://xmpp.org/extensions/xep-0422.html#encryption
+        @param children: element to fasten to the target message
+            <apply-to> element is returned, thus children can also easily be added
+            afterwards
+        @param external: <external> element to add
+            cf. https://xmpp.org/extensions/xep-0422.html#external-payloads
+            the list items can either be a str with only the element name,
+            or a tuple which must then be (namespace, name)
+        @return: <apply-to> element, which is already added to the wrapping message_elt
+        """
+        apply_to_elt = message_elt.addElement((NS_FASTEN, "apply-to"))
+        apply_to_elt["id"] = origin_id
+        if clear is not None:
+            apply_to_elt["clear"] = C.bool_const(clear)
+        if shell is not None:
+            apply_to_elt["shell"] = C.bool_const(shell)
+        if children is not None:
+            for child in children:
+                apply_to_elt.addChild(child)
+        if external is not None:
+            for ext in external:
+                external_elt = apply_to_elt.addElement("external")
+                if isinstance(ext, str):
+                    external_elt["name"] = ext
+                else:
+                    ns, name = ext
+                    external_elt["name"] = name
+                    external_elt["element-namespace"] = ns
+        return apply_to_elt
+
+    @async_lru(maxsize=5)
+    async def get_fastened_elts(
+        self,
+        client: SatXMPPEntity,
+        message_elt: domish.Element
+    ) -> Optional[FastenMetadata]:
+        """Get fastened elements
+
+        if the message contains no <apply-to> element, None is returned
+        """
+        try:
+            apply_to_elt = next(message_elt.elements(NS_FASTEN, "apply-to"))
+        except StopIteration:
+            return None
+        else:
+            origin_id = apply_to_elt.getAttribute("id")
+            if not origin_id:
+                log.warning(
+                    f"Received invalid fastening message: {message_elt.toXml()}"
+                )
+                return None
+            elements = apply_to_elt.children
+            if not elements:
+                log.warning(f"No element to fasten: {message_elt.toXml()}")
+                return None
+            history = await self.host.memory.storage.get(
+                client,
+                History,
+                History.origin_id,
+                origin_id,
+                (History.messages, History.subjects, History.thread)
+            )
+            return FastenMetadata(
+                elements,
+                origin_id,
+                history,
+                C.bool(apply_to_elt.getAttribute("clear", C.BOOL_FALSE)),
+                C.bool(apply_to_elt.getAttribute("shell", C.BOOL_FALSE)),
+            )
+
+
+@implementer(disco.IDisco)
+class XEP_0422_handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, __, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_FASTEN)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0424.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,246 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Dict, Any
+import time
+from copy import deepcopy
+
+from twisted.words.protocols.jabber import xmlstream, jid
+from twisted.words.xish import domish
+from twisted.internet import defer
+from wokkel import disco
+from zope.interface import implementer
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core import exceptions
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.log import getLogger
+from libervia.backend.memory.sqla_mapping import History
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Message Retraction",
+    C.PI_IMPORT_NAME: "XEP-0424",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0334", "XEP-0424", "XEP-0428"],
+    C.PI_DEPENDENCIES: ["XEP-0422"],
+    C.PI_MAIN: "XEP_0424",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation Message Retraction"""),
+}
+
+NS_MESSAGE_RETRACT = "urn:xmpp:message-retract:0"
+
+CATEGORY = "Privacy"
+NAME = "retract_history"
+LABEL = D_("Keep History of Retracted Messages")
+PARAMS = """
+    <params>
+    <individual>
+    <category name="{category_name}">
+        <param name="{name}" label="{label}" type="bool" value="false" />
+    </category>
+    </individual>
+    </params>
+    """.format(
+    category_name=CATEGORY, name=NAME, label=_(LABEL)
+)
+
+
+class XEP_0424(object):
+
+    def __init__(self, host):
+        log.info(_("XEP-0424 (Message Retraction) plugin initialization"))
+        self.host = host
+        host.memory.update_params(PARAMS)
+        self._h = host.plugins["XEP-0334"]
+        self._f = host.plugins["XEP-0422"]
+        host.register_namespace("message-retract", NS_MESSAGE_RETRACT)
+        host.trigger.add("message_received", self._message_received_trigger, 100)
+        host.bridge.add_method(
+            "message_retract",
+            ".plugin",
+            in_sign="ss",
+            out_sign="",
+            method=self._retract,
+            async_=True,
+        )
+
+    def get_handler(self, __):
+        return XEP_0424_handler()
+
+    def _retract(self, message_id: str, profile: str) -> None:
+        client = self.host.get_client(profile)
+        return defer.ensureDeferred(
+            self.retract(client, message_id)
+        )
+
+    def retract_by_origin_id(
+        self,
+        client: SatXMPPEntity,
+        dest_jid: jid.JID,
+        origin_id: str
+    ) -> None:
+        """Send a message retraction using origin-id
+
+        [retract] should be prefered: internal ID should be used as it is independant of
+        XEPs changes. However, in some case messages may not be stored in database
+        (notably for some components), and then this method can be used
+        @param origin_id: origin-id as specified in XEP-0359
+        """
+        message_elt = domish.Element((None, "message"))
+        message_elt["from"] = client.jid.full()
+        message_elt["to"] = dest_jid.full()
+        apply_to_elt = self._f.apply_to_elt(message_elt, origin_id)
+        apply_to_elt.addElement((NS_MESSAGE_RETRACT, "retract"))
+        self.host.plugins["XEP-0428"].add_fallback_elt(
+            message_elt,
+            "[A message retraction has been requested, but your client doesn't support "
+            "it]"
+        )
+        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
+        client.send(message_elt)
+
+    async def retract_by_history(
+        self,
+        client: SatXMPPEntity,
+        history: History
+    ) -> None:
+        """Send a message retraction using History instance
+
+        This method is to use instead of [retract] when the history instance is already
+        retrieved. Note that the instance must have messages and subjets loaded
+        @param history: history instance of the message to retract
+        """
+        try:
+            origin_id = history.origin_id
+        except KeyError:
+            raise exceptions.FeatureNotFound(
+                f"message to retract doesn't have the necessary origin-id, the sending "
+                "client is probably not supporting message retraction."
+            )
+        else:
+            self.retract_by_origin_id(client, history.dest_jid, origin_id)
+            await self.retract_db_history(client, history)
+
+    async def retract(
+        self,
+        client: SatXMPPEntity,
+        message_id: str,
+    ) -> None:
+        """Send a message retraction request
+
+        @param message_id: ID of the message
+            This ID is the Libervia internal ID of the message. It will be retrieve from
+            database to find the ID used by XMPP (i.e. XEP-0359's "origin ID"). If the
+            message is not found in database, an exception will be raised
+        """
+        if not message_id:
+            raise ValueError("message_id can't be empty")
+        history = await self.host.memory.storage.get(
+            client, History, History.uid, message_id,
+            joined_loads=[History.messages, History.subjects]
+        )
+        if history is None:
+            raise exceptions.NotFound(
+                f"message to retract not found in database ({message_id})"
+            )
+        await self.retract_by_history(client, history)
+
+    async def retract_db_history(self, client, history: History) -> None:
+        """Mark an history instance in database as retracted
+
+        @param history: history instance
+            "messages" and "subjects" must be loaded too
+        """
+        # FIXME: should be keep history? This is useful to check why a message has been
+        #   retracted, but if may be bad if the user think it's really deleted
+        # we assign a new object to be sure to trigger an update
+        history.extra = deepcopy(history.extra) if history.extra else {}
+        history.extra["retracted"] = True
+        keep_history = self.host.memory.param_get_a(
+            NAME, CATEGORY, profile_key=client.profile
+        )
+        old_version: Dict[str, Any] = {
+            "timestamp": time.time()
+        }
+        if keep_history:
+            old_version.update({
+                "messages": [m.serialise() for m in history.messages],
+                "subjects": [s.serialise() for s in history.subjects]
+            })
+
+        history.extra.setdefault("old_versions", []).append(old_version)
+        await self.host.memory.storage.delete(
+            history.messages + history.subjects,
+            session_add=[history]
+        )
+
+    async def _message_received_trigger(
+        self,
+        client: SatXMPPEntity,
+        message_elt: domish.Element,
+        post_treat: defer.Deferred
+    ) -> bool:
+        fastened_elts = await self._f.get_fastened_elts(client, message_elt)
+        if fastened_elts is None:
+            return True
+        for elt in fastened_elts.elements:
+            if elt.name == "retract" and elt.uri == NS_MESSAGE_RETRACT:
+                if fastened_elts.history is not None:
+                    source_jid = fastened_elts.history.source_jid
+                    from_jid = jid.JID(message_elt["from"])
+                    if source_jid.userhostJID() != from_jid.userhostJID():
+                        log.warning(
+                            f"Received message retraction from {from_jid.full()}, but "
+                            f"the message to retract is from {source_jid.full()}. This "
+                            f"maybe a hack attempt.\n{message_elt.toXml()}"
+                        )
+                        return False
+                break
+        else:
+            return True
+        if not await self.host.trigger.async_point(
+            "XEP-0424_retractReceived", client, message_elt, elt, fastened_elts
+        ):
+            return False
+        if fastened_elts.history is None:
+            # we check history after the trigger because we may be in a component which
+            # doesn't store messages in database.
+            log.warning(
+                f"No message found with given origin-id: {message_elt.toXml()}"
+            )
+            return False
+        log.info(f"[{client.profile}] retracting message {fastened_elts.id!r}")
+        await self.retract_db_history(client, fastened_elts.history)
+        # TODO: send bridge signal
+
+        return False
+
+
+@implementer(disco.IDisco)
+class XEP_0424_handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, __, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_MESSAGE_RETRACT)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0428.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional
+
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.xish import domish
+from wokkel import disco
+from zope.interface import implementer
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Fallback Indication",
+    C.PI_IMPORT_NAME: "XEP-0428",
+    C.PI_TYPE: "XEP",
+    C.PI_PROTOCOLS: ["XEP-0428"],
+    C.PI_MAIN: "XEP_0428",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of XEP-0428 (Fallback Indication)"""),
+}
+
+NS_FALLBACK = "urn:xmpp:fallback:0"
+
+
+class XEP_0428(object):
+
+    def __init__(self, host):
+        log.info(_("XEP-0428 (Fallback Indication) plugin initialization"))
+        host.register_namespace("fallback", NS_FALLBACK)
+
+    def add_fallback_elt(
+        self,
+        message_elt: domish.Element,
+        msg: Optional[str] = None
+    ) -> None:
+        """Add the fallback indication element
+
+        @param message_elt: <message> element where the indication must be
+            set
+        @param msg: message to show as fallback
+            Will be added as <body>
+        """
+        message_elt.addElement((NS_FALLBACK, "fallback"))
+        if msg is not None:
+            message_elt.addElement("body", content=msg)
+
+    def has_fallback(self, message_elt: domish.Element) -> bool:
+        """Tell if a message has a fallback indication"""
+        try:
+            next(message_elt.elements(NS_FALLBACK, "fallback"))
+        except StopIteration:
+            return False
+        else:
+            return True
+
+    def get_handler(self, __):
+        return XEP_0428_handler()
+
+
+@implementer(disco.IDisco)
+class XEP_0428_handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, __, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_FALLBACK)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0444.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for XEP-0444
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import List, Iterable
+from copy import deepcopy
+
+from twisted.words.protocols.jabber import jid, xmlstream
+from twisted.words.xish import domish
+from twisted.internet import defer
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.memory.sqla_mapping import History
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Message Reactions",
+    C.PI_IMPORT_NAME: "XEP-0444",
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0444"],
+    C.PI_DEPENDENCIES: ["XEP-0334"],
+    C.PI_MAIN: "XEP_0444",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Message Reactions implementation"""),
+}
+
+NS_REACTIONS = "urn:xmpp:reactions:0"
+
+
+class XEP_0444:
+
+    def __init__(self, host):
+        log.info(_("Message Reactions initialization"))
+        host.register_namespace("reactions", NS_REACTIONS)
+        self.host = host
+        self._h = host.plugins["XEP-0334"]
+        host.bridge.add_method(
+            "message_reactions_set",
+            ".plugin",
+            in_sign="ssas",
+            out_sign="",
+            method=self._reactions_set,
+            async_=True,
+        )
+        host.trigger.add("message_received", self._message_received_trigger)
+
+    def get_handler(self, client):
+        return XEP_0444_Handler()
+
+    async def _message_received_trigger(
+        self,
+        client: SatXMPPEntity,
+        message_elt: domish.Element,
+        post_treat: defer.Deferred
+    ) -> bool:
+        return True
+
+    def _reactions_set(self, message_id: str, profile: str, reactions: List[str]) -> None:
+        client = self.host.get_client(profile)
+        return defer.ensureDeferred(
+            self.set_reactions(client, message_id)
+        )
+
+    def send_reactions(
+        self,
+        client: SatXMPPEntity,
+        dest_jid: jid.JID,
+        message_id: str,
+        reactions: Iterable[str]
+    ) -> None:
+        """Send the <message> stanza containing the reactions
+
+        @param dest_jid: recipient of the reaction
+        @param message_id: either <origin-id> or message's ID
+            see https://xmpp.org/extensions/xep-0444.html#business-id
+        """
+        message_elt = domish.Element((None, "message"))
+        message_elt["from"] = client.jid.full()
+        message_elt["to"] = dest_jid.full()
+        reactions_elt = message_elt.addElement((NS_REACTIONS, "reactions"))
+        reactions_elt["id"] = message_id
+        for r in set(reactions):
+            reactions_elt.addElement("reaction", content=r)
+        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
+        client.send(message_elt)
+
+    async def add_reactions_to_history(
+        self,
+        history: History,
+        from_jid: jid.JID,
+        reactions: Iterable[str]
+    ) -> None:
+        """Update History instance with given reactions
+
+        @param history: storage History instance
+            will be updated in DB
+            "summary" field of history.extra["reactions"] will also be updated
+        @param from_jid: author of the reactions
+        @param reactions: list of reactions
+        """
+        history.extra = deepcopy(history.extra) if history.extra else {}
+        h_reactions = history.extra.setdefault("reactions", {})
+        # reactions mapped by originating JID
+        by_jid = h_reactions.setdefault("by_jid", {})
+        # reactions are sorted to in summary to keep a consistent order
+        h_reactions["by_jid"][from_jid.userhost()] = sorted(list(set(reactions)))
+        h_reactions["summary"] = sorted(list(set().union(*by_jid.values())))
+        await self.host.memory.storage.session_add(history)
+
+    async def set_reactions(
+        self,
+        client: SatXMPPEntity,
+        message_id: str,
+        reactions: Iterable[str]
+    ) -> None:
+        """Set and replace reactions to a message
+
+        @param message_id: internal ID of the message
+        @param rections: lsit of emojis to used to react to the message
+            use empty list to remove all reactions
+        """
+        if not message_id:
+            raise ValueError("message_id can't be empty")
+        history = await self.host.memory.storage.get(
+            client, History, History.uid, message_id,
+            joined_loads=[History.messages, History.subjects]
+        )
+        if history is None:
+            raise exceptions.NotFound(
+                f"message to retract not found in database ({message_id})"
+            )
+        mess_id = history.origin_id or history.stanza_id
+        if not mess_id:
+            raise exceptions.DataError(
+                "target message has neither origin-id nor message-id, we can't send a "
+                "reaction"
+            )
+        await self.add_reactions_to_history(history, client.jid, reactions)
+        self.send_reactions(client, history.dest_jid, mess_id, reactions)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0444_Handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_REACTIONS)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0446.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,165 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from logging import exception
+from typing import Optional, Union, Tuple, Dict, Any
+from pathlib import Path
+
+from twisted.words.xish import domish
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.tools import utils
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "File Metadata Element",
+    C.PI_IMPORT_NAME: "XEP-0446",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0446"],
+    C.PI_DEPENDENCIES: ["XEP-0300"],
+    C.PI_MAIN: "XEP_0446",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of XEP-0446 (File Metadata Element)"""),
+}
+
+NS_FILE_METADATA = "urn:xmpp:file:metadata:0"
+
+
+class XEP_0446:
+
+    def __init__(self, host):
+        log.info(_("XEP-0446 (File Metadata Element) plugin initialization"))
+        host.register_namespace("file-metadata", NS_FILE_METADATA)
+        self._hash = host.plugins["XEP-0300"]
+
+    def get_file_metadata_elt(
+        self,
+        name: Optional[str] = None,
+        media_type: Optional[str] = None,
+        desc: Optional[str] = None,
+        size: Optional[int] = None,
+        file_hash: Optional[Tuple[str, str]] = None,
+        date: Optional[Union[float, int]] = None,
+        width: Optional[int] = None,
+        height: Optional[int] = None,
+        length: Optional[int] = None,
+        thumbnail: Optional[str] = None,
+    ) -> domish.Element:
+        """Generate the element describing a file
+
+        @param name: name of the file
+        @param media_type: media-type
+        @param desc: description
+        @param size: size in bytes
+        @param file_hash: (algo, hash)
+        @param date: timestamp of the last modification datetime
+        @param width: image width in pixels
+        @param height: image height in pixels
+        @param length: video length in seconds
+        @param thumbnail: URL to a thumbnail
+        @return: ``<file/>`` element
+        """
+        if name:
+            name = Path(name).name
+        file_elt = domish.Element((NS_FILE_METADATA, "file"))
+        for name, value in (
+            ("name", name),
+            ("media-type", media_type),
+            ("desc", desc),
+            ("size", size),
+            ("width", width),
+            ("height", height),
+            ("length", length),
+        ):
+            if value is not None:
+                file_elt.addElement(name, content=str(value))
+        if file_hash is not None:
+            hash_algo, hash_ = file_hash
+            file_elt.addChild(self._hash.build_hash_elt(hash_, hash_algo))
+        if date is not None:
+            file_elt.addElement("date", utils.xmpp_date(date))
+        if thumbnail is not None:
+            # TODO: implement thumbnails
+            log.warning("thumbnail is not implemented yet")
+        return file_elt
+
+    def parse_file_metadata_elt(
+        self,
+        file_metadata_elt: domish.Element
+    ) -> Dict[str, Any]:
+        """Parse <file/> element
+
+        @param file_metadata_elt: <file/> element
+            a parent element can also be used
+        @return: file metadata. It's a dict whose keys correspond to
+            [get_file_metadata_elt] parameters
+        @raise exceptions.NotFound: no <file/> element has been found
+        """
+
+        if file_metadata_elt.name != "file":
+            try:
+                file_metadata_elt = next(
+                    file_metadata_elt.elements(NS_FILE_METADATA, "file")
+                )
+            except StopIteration:
+                raise exceptions.NotFound
+        data: Dict[str, Any] = {}
+
+        for key, type_ in (
+            ("name", str),
+            ("media-type", str),
+            ("desc", str),
+            ("size", int),
+            ("date", "timestamp"),
+            ("width", int),
+            ("height", int),
+            ("length", int),
+        ):
+            elt = next(file_metadata_elt.elements(NS_FILE_METADATA, key), None)
+            if elt is not None:
+                if type_ in (str, int):
+                    content = str(elt)
+                    if key == "name":
+                        # we avoid malformed names or names containing path elements
+                        content = Path(content).name
+                    elif key == "media-type":
+                        key = "media_type"
+                    data[key] = type_(content)
+                elif type == "timestamp":
+                    data[key] = utils.parse_xmpp_date(str(elt))
+                else:
+                    raise exceptions.InternalError
+
+        try:
+            algo, hash_ = self._hash.parse_hash_elt(file_metadata_elt)
+        except exceptions.NotFound:
+            pass
+        except exceptions.DataError:
+            from libervia.backend.tools.xml_tools import p_fmt_elt
+            log.warning("invalid <hash/> element:\n{p_fmt_elt(file_metadata_elt)}")
+        else:
+            data["file_hash"] = (algo, hash_)
+
+        # TODO: thumbnails
+
+        return data
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0447.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,376 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from collections import namedtuple
+from functools import partial
+import mimetypes
+from pathlib import Path
+from typing import Any, Callable, Dict, List, Optional, Tuple, Union
+
+import treq
+from twisted.internet import defer
+from twisted.words.xish import domish
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import stream
+from libervia.backend.tools.web import treq_client_no_ssl
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Stateless File Sharing",
+    C.PI_IMPORT_NAME: "XEP-0447",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0447"],
+    C.PI_DEPENDENCIES: ["XEP-0103", "XEP-0334", "XEP-0446", "ATTACH", "DOWNLOAD"],
+    C.PI_RECOMMENDATIONS: ["XEP-0363"],
+    C.PI_MAIN: "XEP_0447",
+    C.PI_HANDLER: "no",
+    C.PI_DESCRIPTION: _("""Implementation of XEP-0447 (Stateless File Sharing)"""),
+}
+
+NS_SFS = "urn:xmpp:sfs:0"
+SourceHandler = namedtuple("SourceHandler", ["callback", "encrypted"])
+
+
+class XEP_0447:
+    namespace = NS_SFS
+
+    def __init__(self, host):
+        self.host = host
+        log.info(_("XEP-0447 (Stateless File Sharing) plugin initialization"))
+        host.register_namespace("sfs", NS_SFS)
+        self._sources_handlers = {}
+        self._u = host.plugins["XEP-0103"]
+        self._hints = host.plugins["XEP-0334"]
+        self._m = host.plugins["XEP-0446"]
+        self._http_upload = host.plugins.get("XEP-0363")
+        self._attach = host.plugins["ATTACH"]
+        self._attach.register(
+            self.can_handle_attachment, self.attach, priority=1000
+        )
+        self.register_source_handler(
+            self._u.namespace, "url-data", self._u.parse_url_data_elt
+        )
+        host.plugins["DOWNLOAD"].register_download_handler(self._u.namespace, self.download)
+        host.trigger.add("message_received", self._message_received_trigger)
+
+    def register_source_handler(
+        self, namespace: str, element_name: str,
+        callback: Callable[[domish.Element], Dict[str, Any]],
+        encrypted: bool = False
+    ) -> None:
+        """Register a handler for file source
+
+        @param namespace: namespace of the element supported
+        @param element_name: name of the element supported
+        @param callback: method to call to parse the element
+            get the matching element as argument, must return the parsed data
+        @param encrypted: if True, the source is encrypted (the transmitting channel
+            should then be end2end encrypted to avoir leaking decrypting data to servers).
+        """
+        key = (namespace, element_name)
+        if key in self._sources_handlers:
+            raise exceptions.ConflictError(
+                f"There is already a resource handler for namespace {namespace!r} and "
+                f"name {element_name!r}"
+            )
+        self._sources_handlers[key] = SourceHandler(callback, encrypted)
+
+    async def download(
+        self,
+        client: SatXMPPEntity,
+        attachment: Dict[str, Any],
+        source: Dict[str, Any],
+        dest_path: Union[Path, str],
+        extra: Optional[Dict[str, Any]] = None
+    ) -> Tuple[str, defer.Deferred]:
+        # TODO: handle url-data headers
+        if extra is None:
+            extra = {}
+        try:
+            download_url = source["url"]
+        except KeyError:
+            raise ValueError(f"{source} has missing URL")
+
+        if extra.get('ignore_tls_errors', False):
+            log.warning(
+                "TLS certificate check disabled, this is highly insecure"
+            )
+            treq_client = treq_client_no_ssl
+        else:
+            treq_client = treq
+
+        try:
+            file_size = int(attachment["size"])
+        except (KeyError, ValueError):
+            head_data = await treq_client.head(download_url)
+            file_size = int(head_data.headers.getRawHeaders('content-length')[0])
+
+        file_obj = stream.SatFile(
+            self.host,
+            client,
+            dest_path,
+            mode="wb",
+            size = file_size,
+        )
+
+        progress_id = file_obj.uid
+
+        resp = await treq_client.get(download_url, unbuffered=True)
+        if resp.code == 200:
+            d = treq.collect(resp, file_obj.write)
+            d.addCallback(lambda __: file_obj.close())
+        else:
+            d = defer.Deferred()
+            self.host.plugins["DOWNLOAD"].errback_download(file_obj, d, resp)
+        return progress_id, d
+
+    async def can_handle_attachment(self, client, data):
+        if self._http_upload is None:
+            return False
+        try:
+            await self._http_upload.get_http_upload_entity(client)
+        except exceptions.NotFound:
+            return False
+        else:
+            return True
+
+    def get_sources_elt(
+        self,
+        children: Optional[List[domish.Element]] = None
+    ) -> domish.Element:
+        """Generate <sources> element"""
+        sources_elt = domish.Element((NS_SFS, "sources"))
+        if children:
+            for child in children:
+                sources_elt.addChild(child)
+        return sources_elt
+
+    def get_file_sharing_elt(
+        self,
+        sources: List[Dict[str, Any]],
+        disposition: Optional[str] = None,
+        name: Optional[str] = None,
+        media_type: Optional[str] = None,
+        desc: Optional[str] = None,
+        size: Optional[int] = None,
+        file_hash: Optional[Tuple[str, str]] = None,
+        date: Optional[Union[float, int]] = None,
+        width: Optional[int] = None,
+        height: Optional[int] = None,
+        length: Optional[int] = None,
+        thumbnail: Optional[str] = None,
+        **kwargs,
+    ) -> domish.Element:
+        """Generate the <file-sharing/> element
+
+        @param extra: extra metadata describing how to access the URL
+        @return: ``<sfs/>`` element
+        """
+        file_sharing_elt = domish.Element((NS_SFS, "file-sharing"))
+        if disposition is not None:
+            file_sharing_elt["disposition"] = disposition
+        if media_type is None and name:
+            media_type = mimetypes.guess_type(name, strict=False)[0]
+        file_sharing_elt.addChild(
+            self._m.get_file_metadata_elt(
+                name=name,
+                media_type=media_type,
+                desc=desc,
+                size=size,
+                file_hash=file_hash,
+                date=date,
+                width=width,
+                height=height,
+                length=length,
+                thumbnail=thumbnail,
+            )
+        )
+        sources_elt = self.get_sources_elt()
+        file_sharing_elt.addChild(sources_elt)
+        for source_data in sources:
+            if "url" in source_data:
+                sources_elt.addChild(
+                    self._u.get_url_data_elt(**source_data)
+                )
+            else:
+                raise NotImplementedError(
+                    f"source data not implemented: {source_data}"
+                )
+
+        return file_sharing_elt
+
+    def parse_sources_elt(
+        self,
+        sources_elt: domish.Element
+    ) -> List[Dict[str, Any]]:
+        """Parse <sources/> element
+
+        @param sources_elt: <sources/> element, or a direct parent element
+        @return: list of found sources data
+        @raise: exceptions.NotFound: Can't find <sources/> element
+        """
+        if sources_elt.name != "sources" or sources_elt.uri != NS_SFS:
+            try:
+                sources_elt = next(sources_elt.elements(NS_SFS, "sources"))
+            except StopIteration:
+                raise exceptions.NotFound(
+                    f"<sources/> element is missing: {sources_elt.toXml()}")
+        sources = []
+        for elt in sources_elt.elements():
+            if not elt.uri:
+                log.warning("ignoring source element {elt.toXml()}")
+                continue
+            key = (elt.uri, elt.name)
+            try:
+                source_handler = self._sources_handlers[key]
+            except KeyError:
+                log.warning(f"unmanaged file sharing element: {elt.toXml}")
+                continue
+            else:
+                source_data = source_handler.callback(elt)
+                if source_handler.encrypted:
+                    source_data[C.MESS_KEY_ENCRYPTED] = True
+                if "type" not in source_data:
+                    source_data["type"] = elt.uri
+                sources.append(source_data)
+        return sources
+
+    def parse_file_sharing_elt(
+        self,
+        file_sharing_elt: domish.Element
+    ) -> Dict[str, Any]:
+        """Parse <file-sharing/> element and return file-sharing data
+
+        @param file_sharing_elt: <file-sharing/> element
+        @return: file-sharing data. It a dict whose keys correspond to
+            [get_file_sharing_elt] parameters
+        """
+        if file_sharing_elt.name != "file-sharing" or file_sharing_elt.uri != NS_SFS:
+            try:
+                file_sharing_elt = next(
+                    file_sharing_elt.elements(NS_SFS, "file-sharing")
+                )
+            except StopIteration:
+                raise exceptions.NotFound
+        try:
+            data = self._m.parse_file_metadata_elt(file_sharing_elt)
+        except exceptions.NotFound:
+            data = {}
+        disposition = file_sharing_elt.getAttribute("disposition")
+        if disposition is not None:
+            data["disposition"] = disposition
+        try:
+            data["sources"] = self.parse_sources_elt(file_sharing_elt)
+        except exceptions.NotFound as e:
+            raise ValueError(str(e))
+
+        return data
+
+    def _add_file_sharing_attachments(
+            self,
+            client: SatXMPPEntity,
+            message_elt: domish.Element,
+            data: Dict[str, Any]
+    ) -> Dict[str, Any]:
+        """Check <message> for a shared file, and add it as an attachment"""
+        # XXX: XEP-0447 doesn't support several attachments in a single message, for now
+        #   however that should be fixed in future version, and so we accept several
+        #   <file-sharing> element in a message.
+        for file_sharing_elt in message_elt.elements(NS_SFS, "file-sharing"):
+            attachment = self.parse_file_sharing_elt(message_elt)
+
+            if any(
+                    s.get(C.MESS_KEY_ENCRYPTED, False)
+                    for s in attachment["sources"]
+            ) and client.encryption.isEncrypted(data):
+                # we don't add the encrypted flag if the message itself is not encrypted,
+                # because the decryption key is part of the link, so sending it over
+                # unencrypted channel is like having no encryption at all.
+                attachment[C.MESS_KEY_ENCRYPTED] = True
+
+            attachments = data['extra'].setdefault(C.KEY_ATTACHMENTS, [])
+            attachments.append(attachment)
+
+        return data
+
+    async def attach(self, client, data):
+        # XXX: for now, XEP-0447 only allow to send one file per <message/>, thus we need
+        #   to send each file in a separate message
+        attachments = data["extra"][C.KEY_ATTACHMENTS]
+        if not data['message'] or data['message'] == {'': ''}:
+            extra_attachments = attachments[1:]
+            del attachments[1:]
+        else:
+            # we have a message, we must send first attachment separately
+            extra_attachments = attachments[:]
+            attachments.clear()
+            del data["extra"][C.KEY_ATTACHMENTS]
+
+        if attachments:
+            if len(attachments) > 1:
+                raise exceptions.InternalError(
+                    "There should not be more that one attachment at this point"
+                )
+            await self._attach.upload_files(client, data)
+            self._hints.add_hint_elements(data["xml"], [self._hints.HINT_STORE])
+            for attachment in attachments:
+                try:
+                    file_hash = (attachment["hash_algo"], attachment["hash"])
+                except KeyError:
+                    file_hash = None
+                file_sharing_elt = self.get_file_sharing_elt(
+                    [{"url": attachment["url"]}],
+                    name=attachment.get("name"),
+                    size=attachment.get("size"),
+                    desc=attachment.get("desc"),
+                    media_type=attachment.get("media_type"),
+                    file_hash=file_hash
+                )
+                data["xml"].addChild(file_sharing_elt)
+
+        for attachment in extra_attachments:
+            # we send all remaining attachment in a separate message
+            await client.sendMessage(
+                to_jid=data['to'],
+                message={'': ''},
+                subject=data['subject'],
+                mess_type=data['type'],
+                extra={C.KEY_ATTACHMENTS: [attachment]},
+            )
+
+        if ((not data['extra']
+             and (not data['message'] or data['message'] == {'': ''})
+             and not data['subject'])):
+            # nothing left to send, we can cancel the message
+            raise exceptions.CancelError("Cancelled by XEP_0447 attachment handling")
+
+    def _message_received_trigger(self, client, message_elt, post_treat):
+        # we use a post_treat callback instead of "message_parse" trigger because we need
+        # to check if the "encrypted" flag is set to decide if we add the same flag to the
+        # attachment
+        post_treat.addCallback(
+            partial(self._add_file_sharing_attachments, client, message_elt)
+        )
+        return True
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0448.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,468 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for handling stateless file sharing encryption
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import base64
+from functools import partial
+from pathlib import Path
+import secrets
+from textwrap import dedent
+from typing import Any, Dict, Optional, Tuple, Union
+
+from cryptography.exceptions import AlreadyFinalized
+from cryptography.hazmat import backends
+from cryptography.hazmat.primitives import ciphers
+from cryptography.hazmat.primitives.ciphers import CipherContext, modes
+from cryptography.hazmat.primitives.padding import PKCS7, PaddingContext
+import treq
+from twisted.internet import defer
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import stream
+from libervia.backend.tools.web import treq_client_no_ssl
+
+log = getLogger(__name__)
+
+IMPORT_NAME = "XEP-0448"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Encryption for Stateless File Sharing",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_TYPE: C.PLUG_TYPE_EXP,
+    C.PI_PROTOCOLS: ["XEP-0448"],
+    C.PI_DEPENDENCIES: [
+        "XEP-0103", "XEP-0300", "XEP-0334", "XEP-0363", "XEP-0384", "XEP-0447",
+        "DOWNLOAD", "ATTACH"
+    ],
+    C.PI_MAIN: "XEP_0448",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: dedent(_("""\
+    Implementation of e2e encryption for media sharing
+    """)),
+}
+
+NS_ESFS = "urn:xmpp:esfs:0"
+NS_AES_128_GCM = "urn:xmpp:ciphers:aes-128-gcm-nopadding:0"
+NS_AES_256_GCM = "urn:xmpp:ciphers:aes-256-gcm-nopadding:0"
+NS_AES_256_CBC = "urn:xmpp:ciphers:aes-256-cbc-pkcs7:0"
+
+
+class XEP_0448:
+
+    def __init__(self, host):
+        self.host = host
+        log.info(_("XEP_0448 plugin initialization"))
+        host.register_namespace("esfs", NS_ESFS)
+        self._u = host.plugins["XEP-0103"]
+        self._h = host.plugins["XEP-0300"]
+        self._hints = host.plugins["XEP-0334"]
+        self._http_upload = host.plugins["XEP-0363"]
+        self._o = host.plugins["XEP-0384"]
+        self._sfs = host.plugins["XEP-0447"]
+        self._sfs.register_source_handler(
+            NS_ESFS, "encrypted", self.parse_encrypted_elt, encrypted=True
+        )
+        self._attach = host.plugins["ATTACH"]
+        self._attach.register(
+            self.can_handle_attachment, self.attach, encrypted=True, priority=1000
+        )
+        host.plugins["DOWNLOAD"].register_download_handler(NS_ESFS, self.download)
+        host.trigger.add("XEP-0363_upload_pre_slot", self._upload_pre_slot)
+        host.trigger.add("XEP-0363_upload", self._upload_trigger)
+
+    def get_handler(self, client):
+        return XEP0448Handler()
+
+    def parse_encrypted_elt(self, encrypted_elt: domish.Element) -> Dict[str, Any]:
+        """Parse an <encrypted> element and return corresponding source data
+
+        @param encrypted_elt: element to parse
+        @raise exceptions.DataError: the element is invalid
+
+        """
+        sources = self._sfs.parse_sources_elt(encrypted_elt)
+        if not sources:
+            raise exceptions.NotFound("sources are missing in {encrypted_elt.toXml()}")
+        if len(sources) > 1:
+            log.debug(
+                "more that one sources has been found, this is not expected, only the "
+                "first one will be used"
+            )
+        source = sources[0]
+        source["type"] = NS_ESFS
+        try:
+            encrypted_data = source["encrypted_data"] = {
+                "cipher": encrypted_elt["cipher"],
+                "key": str(next(encrypted_elt.elements(NS_ESFS, "key"))),
+                "iv": str(next(encrypted_elt.elements(NS_ESFS, "iv"))),
+            }
+        except (KeyError, StopIteration):
+            raise exceptions.DataError(
+                "invalid <encrypted/> element: {encrypted_elt.toXml()}"
+            )
+        try:
+            hash_algo, hash_value = self._h.parse_hash_elt(encrypted_elt)
+        except exceptions.NotFound:
+            pass
+        else:
+            encrypted_data["hash_algo"] = hash_algo
+            encrypted_data["hash"] = base64.b64encode(hash_value.encode()).decode()
+        return source
+
+    async def download(
+        self,
+        client: SatXMPPEntity,
+        attachment: Dict[str, Any],
+        source: Dict[str, Any],
+        dest_path: Union[Path, str],
+        extra: Optional[Dict[str, Any]] = None
+    ) -> Tuple[str, defer.Deferred]:
+        # TODO: check hash
+        if extra is None:
+            extra = {}
+        try:
+            encrypted_data = source["encrypted_data"]
+            cipher = encrypted_data["cipher"]
+            iv = base64.b64decode(encrypted_data["iv"])
+            key = base64.b64decode(encrypted_data["key"])
+        except KeyError as e:
+            raise ValueError(f"{source} has incomplete encryption data: {e}")
+        try:
+            download_url = source["url"]
+        except KeyError:
+            raise ValueError(f"{source} has missing URL")
+
+        if extra.get('ignore_tls_errors', False):
+            log.warning(
+                "TLS certificate check disabled, this is highly insecure"
+            )
+            treq_client = treq_client_no_ssl
+        else:
+            treq_client = treq
+
+        try:
+            file_size = int(attachment["size"])
+        except (KeyError, ValueError):
+            head_data = await treq_client.head(download_url)
+            content_length = int(head_data.headers.getRawHeaders('content-length')[0])
+            # the 128 bits tag is put at the end
+            file_size = content_length - 16
+
+        file_obj = stream.SatFile(
+            self.host,
+            client,
+            dest_path,
+            mode="wb",
+            size = file_size,
+        )
+
+        if cipher in (NS_AES_128_GCM, NS_AES_256_GCM):
+            decryptor = ciphers.Cipher(
+                ciphers.algorithms.AES(key),
+                modes.GCM(iv),
+                backend=backends.default_backend(),
+            ).decryptor()
+            decrypt_cb = partial(
+                self.gcm_decrypt,
+                client=client,
+                file_obj=file_obj,
+                decryptor=decryptor,
+            )
+            finalize_cb = None
+        elif cipher == NS_AES_256_CBC:
+            cipher_algo = ciphers.algorithms.AES(key)
+            decryptor = ciphers.Cipher(
+                cipher_algo,
+                modes.CBC(iv),
+                backend=backends.default_backend(),
+            ).decryptor()
+            unpadder = PKCS7(cipher_algo.block_size).unpadder()
+            decrypt_cb = partial(
+                self.cbc_decrypt,
+                client=client,
+                file_obj=file_obj,
+                decryptor=decryptor,
+                unpadder=unpadder
+            )
+            finalize_cb = partial(
+                self.cbc_decrypt_finalize,
+                file_obj=file_obj,
+                decryptor=decryptor,
+                unpadder=unpadder
+            )
+        else:
+            msg = f"cipher {cipher!r} is not supported"
+            file_obj.close(error=msg)
+            log.warning(msg)
+            raise exceptions.CancelError(msg)
+
+        progress_id = file_obj.uid
+
+        resp = await treq_client.get(download_url, unbuffered=True)
+        if resp.code == 200:
+            d = treq.collect(resp, partial(decrypt_cb))
+            if finalize_cb is not None:
+                d.addCallback(lambda __: finalize_cb())
+        else:
+            d = defer.Deferred()
+            self.host.plugins["DOWNLOAD"].errback_download(file_obj, d, resp)
+        return progress_id, d
+
+    async def can_handle_attachment(self, client, data):
+        # FIXME: check if SCE is supported without checking which e2ee algo is used
+        if client.encryption.get_namespace(data["to"]) != self._o.NS_TWOMEMO:
+            # we need SCE, and it is currently supported only by TWOMEMO, thus we can't
+            # handle the attachment if it's not activated
+            return False
+        try:
+            await self._http_upload.get_http_upload_entity(client)
+        except exceptions.NotFound:
+            return False
+        else:
+            return True
+
+    async def _upload_cb(self, client, filepath, filename, extra):
+        attachment = extra["attachment"]
+        extra["encryption"] = IMPORT_NAME
+        attachment["encryption_data"] = extra["encryption_data"] = {
+            "algorithm": C.ENC_AES_GCM,
+            "iv": secrets.token_bytes(12),
+            "key": secrets.token_bytes(32),
+        }
+        attachment["filename"] = filename
+        return await self._http_upload.file_http_upload(
+            client=client,
+            filepath=filepath,
+            filename="encrypted",
+            extra=extra
+        )
+
+    async def attach(self, client, data):
+        # XXX: for now, XEP-0447/XEP-0448 only allow to send one file per <message/>, thus
+        #   we need to send each file in a separate message, in the same way as for
+        #   plugin_sec_aesgcm.
+        attachments = data["extra"][C.KEY_ATTACHMENTS]
+        if not data['message'] or data['message'] == {'': ''}:
+            extra_attachments = attachments[1:]
+            del attachments[1:]
+        else:
+            # we have a message, we must send first attachment separately
+            extra_attachments = attachments[:]
+            attachments.clear()
+            del data["extra"][C.KEY_ATTACHMENTS]
+
+        if attachments:
+            if len(attachments) > 1:
+                raise exceptions.InternalError(
+                    "There should not be more that one attachment at this point"
+                )
+            await self._attach.upload_files(client, data, upload_cb=self._upload_cb)
+            self._hints.add_hint_elements(data["xml"], [self._hints.HINT_STORE])
+            for attachment in attachments:
+                encryption_data = attachment.pop("encryption_data")
+                file_hash = (attachment["hash_algo"], attachment["hash"])
+                file_sharing_elt = self._sfs.get_file_sharing_elt(
+                    [],
+                    name=attachment["filename"],
+                    size=attachment["size"],
+                    file_hash=file_hash
+                )
+                encrypted_elt = file_sharing_elt.sources.addElement(
+                    (NS_ESFS, "encrypted")
+                )
+                encrypted_elt["cipher"] = NS_AES_256_GCM
+                encrypted_elt.addElement(
+                    "key",
+                    content=base64.b64encode(encryption_data["key"]).decode()
+                )
+                encrypted_elt.addElement(
+                    "iv",
+                    content=base64.b64encode(encryption_data["iv"]).decode()
+                )
+                encrypted_elt.addChild(self._h.build_hash_elt(
+                    attachment["encrypted_hash"],
+                    attachment["encrypted_hash_algo"]
+                ))
+                encrypted_elt.addChild(
+                    self._sfs.get_sources_elt(
+                        [self._u.get_url_data_elt(attachment["url"])]
+                    )
+                )
+                data["xml"].addChild(file_sharing_elt)
+
+        for attachment in extra_attachments:
+            # we send all remaining attachment in a separate message
+            await client.sendMessage(
+                to_jid=data['to'],
+                message={'': ''},
+                subject=data['subject'],
+                mess_type=data['type'],
+                extra={C.KEY_ATTACHMENTS: [attachment]},
+            )
+
+        if ((not data['extra']
+             and (not data['message'] or data['message'] == {'': ''})
+             and not data['subject'])):
+            # nothing left to send, we can cancel the message
+            raise exceptions.CancelError("Cancelled by XEP_0448 attachment handling")
+
+    def gcm_decrypt(
+        self,
+        data: bytes,
+        client: SatXMPPEntity,
+        file_obj: stream.SatFile,
+        decryptor: CipherContext
+    ) -> None:
+        if file_obj.tell() + len(data) > file_obj.size:  # type: ignore
+            # we're reaching end of file with this bunch of data
+            # we may still have a last bunch if the tag is incomplete
+            bytes_left = file_obj.size - file_obj.tell()  # type: ignore
+            if bytes_left > 0:
+                decrypted = decryptor.update(data[:bytes_left])
+                file_obj.write(decrypted)
+                tag = data[bytes_left:]
+            else:
+                tag = data
+            if len(tag) < 16:
+                # the tag is incomplete, either we'll get the rest in next data bunch
+                # or we have already the other part from last bunch of data
+                try:
+                    # we store partial tag in decryptor._sat_tag
+                    tag = decryptor._sat_tag + tag
+                except AttributeError:
+                    # no other part, we'll get the rest at next bunch
+                    decryptor.sat_tag = tag
+                else:
+                    # we have the complete tag, it must be 128 bits
+                    if len(tag) != 16:
+                        raise ValueError(f"Invalid tag: {tag}")
+            remain = decryptor.finalize_with_tag(tag)
+            file_obj.write(remain)
+            file_obj.close()
+        else:
+            decrypted = decryptor.update(data)
+            file_obj.write(decrypted)
+
+    def cbc_decrypt(
+        self,
+        data: bytes,
+        client: SatXMPPEntity,
+        file_obj: stream.SatFile,
+        decryptor: CipherContext,
+        unpadder: PaddingContext
+    ) -> None:
+        decrypted = decryptor.update(data)
+        file_obj.write(unpadder.update(decrypted))
+
+    def cbc_decrypt_finalize(
+        self,
+        file_obj: stream.SatFile,
+        decryptor: CipherContext,
+        unpadder: PaddingContext
+    ) -> None:
+        decrypted = decryptor.finalize()
+        file_obj.write(unpadder.update(decrypted))
+        file_obj.write(unpadder.finalize())
+        file_obj.close()
+
+    def _upload_pre_slot(self, client, extra, file_metadata):
+        if extra.get('encryption') != IMPORT_NAME:
+            return True
+        # the tag is appended to the file
+        file_metadata["size"] += 16
+        return True
+
+    def _encrypt(self, data: bytes, encryptor: CipherContext, attachment: dict) -> bytes:
+        if data:
+            attachment["hasher"].update(data)
+            ret = encryptor.update(data)
+            attachment["encrypted_hasher"].update(ret)
+            return ret
+        else:
+            try:
+                # end of file is reached, me must finalize
+                fin = encryptor.finalize()
+                tag = encryptor.tag
+                ret = fin + tag
+                hasher = attachment.pop("hasher")
+                attachment["hash"] = hasher.hexdigest()
+                encrypted_hasher = attachment.pop("encrypted_hasher")
+                encrypted_hasher.update(ret)
+                attachment["encrypted_hash"] = encrypted_hasher.hexdigest()
+                return ret
+            except AlreadyFinalized:
+                # as we have already finalized, we can now send EOF
+                return b''
+
+    def _upload_trigger(self, client, extra, sat_file, file_producer, slot):
+        if extra.get('encryption') != IMPORT_NAME:
+            return True
+        attachment = extra["attachment"]
+        encryption_data = extra["encryption_data"]
+        log.debug("encrypting file with AES-GCM")
+        iv = encryption_data["iv"]
+        key = encryption_data["key"]
+
+        # encrypted data size will be bigger than original file size
+        # so we need to check with final data length to avoid a warning on close()
+        sat_file.check_size_with_read = True
+
+        # file_producer get length directly from file, and this cause trouble as
+        # we have to change the size because of encryption. So we adapt it here,
+        # else the producer would stop reading prematurely
+        file_producer.length = sat_file.size
+
+        encryptor = ciphers.Cipher(
+            ciphers.algorithms.AES(key),
+            modes.GCM(iv),
+            backend=backends.default_backend(),
+        ).encryptor()
+
+        if sat_file.data_cb is not None:
+            raise exceptions.InternalError(
+                f"data_cb was expected to be None, it is set to {sat_file.data_cb}")
+
+        attachment.update({
+            "hash_algo": self._h.ALGO_DEFAULT,
+            "hasher": self._h.get_hasher(),
+            "encrypted_hash_algo": self._h.ALGO_DEFAULT,
+            "encrypted_hasher": self._h.get_hasher(),
+        })
+
+        # with data_cb we encrypt the file on the fly
+        sat_file.data_cb = partial(
+            self._encrypt, encryptor=encryptor, attachment=attachment
+        )
+        return True
+
+
+@implementer(iwokkel.IDisco)
+class XEP0448Handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_ESFS)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0465.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,267 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for XEP-0465
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional, List, Dict, Union
+
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import error
+from twisted.words.xish import domish
+from zope.interface import implementer
+from wokkel import disco, iwokkel
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.tools import utils
+from libervia.backend.tools.common import data_format
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Pubsub Public Subscriptions",
+    C.PI_IMPORT_NAME: "XEP-0465",
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0465"],
+    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0376"],
+    C.PI_MAIN: "XEP_0465",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Pubsub Public Subscriptions implementation"""),
+}
+
+NS_PPS = "urn:xmpp:pps:0"
+NS_PPS_SUBSCRIPTIONS = "urn:xmpp:pps:subscriptions:0"
+NS_PPS_SUBSCRIBERS = "urn:xmpp:pps:subscribers:0"
+SUBSCRIBERS_NODE_PREFIX = f"{NS_PPS_SUBSCRIBERS}/"
+NOT_IMPLEMENTED_MSG = (
+    "The service at {service!s} doesn't seem to support Pubsub Public Subscriptions "
+    "(XEP-0465), please request support from your service administrator."
+)
+
+
+class XEP_0465:
+
+    def __init__(self, host):
+        log.info(_("Pubsub Public Subscriptions initialization"))
+        host.register_namespace("pps", NS_PPS)
+        self.host = host
+        host.bridge.add_method(
+            "ps_public_subscriptions_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._subscriptions,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_public_subscriptions_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="s",
+            method=self._subscriptions,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_public_node_subscriptions_get",
+            ".plugin",
+            in_sign="sss",
+            out_sign="a{ss}",
+            method=self._get_public_node_subscriptions,
+            async_=True,
+        )
+
+    def get_handler(self, client):
+        return XEP_0465_Handler()
+
+    @property
+    def subscriptions_node(self) -> str:
+        return NS_PPS_SUBSCRIPTIONS
+
+    @property
+    def subscribers_node_prefix(self) -> str:
+        return SUBSCRIBERS_NODE_PREFIX
+
+    def build_subscription_elt(self, node: str, service: jid.JID) -> domish.Element:
+        """Generate a <subscriptions> element
+
+        This is the element that a service returns on public subscriptions request
+        """
+        subscription_elt = domish.Element((NS_PPS, "subscription"))
+        subscription_elt["node"] = node
+        subscription_elt["service"] = service.full()
+        return subscription_elt
+
+    def build_subscriber_elt(self, subscriber: jid.JID) -> domish.Element:
+        """Generate a <subscriber> element
+
+        This is the element that a service returns on node public subscriptions request
+        """
+        subscriber_elt = domish.Element((NS_PPS, "subscriber"))
+        subscriber_elt["jid"] = subscriber.full()
+        return subscriber_elt
+
+    @utils.ensure_deferred
+    async def _subscriptions(
+        self,
+        service="",
+        nodeIdentifier="",
+        profile_key=C.PROF_KEY_NONE
+    ) -> str:
+        client = self.host.get_client(profile_key)
+        service = None if not service else jid.JID(service)
+        subs = await self.subscriptions(client, service, nodeIdentifier or None)
+        return data_format.serialise(subs)
+
+    async def subscriptions(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID] = None,
+        node: Optional[str] = None
+    ) -> List[Dict[str, Union[str, bool]]]:
+        """Retrieve public subscriptions from a service
+
+        @param service(jid.JID): PubSub service
+        @param nodeIdentifier(unicode, None): node to filter
+            None to get all subscriptions
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+        try:
+            items, __ = await self.host.plugins["XEP-0060"].get_items(
+                client, service, NS_PPS_SUBSCRIPTIONS
+            )
+        except error.StanzaError as e:
+            if e.condition == "forbidden":
+                log.warning(NOT_IMPLEMENTED_MSG.format(service=service))
+                return []
+            else:
+                raise e
+        ret = []
+        for item in items:
+            try:
+                subscription_elt = next(item.elements(NS_PPS, "subscription"))
+            except StopIteration:
+                log.warning(f"no <subscription> element found: {item.toXml()}")
+                continue
+
+            try:
+                sub_dict = {
+                    "service": subscription_elt["service"],
+                    "node": subscription_elt["node"],
+                    "subscriber": service.full(),
+                    "state": subscription_elt.getAttribute("subscription", "subscribed"),
+                }
+            except KeyError:
+                log.warning(
+                    f"invalid <subscription> element: {subscription_elt.toXml()}"
+                )
+                continue
+            if node is not None and sub_dict["node"] != node:
+                # if not is specified, we filter out any other node
+                # FIXME: should node filtering be done by server?
+                continue
+            ret.append(sub_dict)
+        return ret
+
+    @utils.ensure_deferred
+    async def _get_public_node_subscriptions(
+        self,
+        service: str,
+        node: str,
+        profile_key: str
+    ) -> Dict[str, str]:
+        client = self.host.get_client(profile_key)
+        subs = await self.get_public_node_subscriptions(
+            client, jid.JID(service) if service else None, node
+        )
+        return {j.full(): a for j, a in subs.items()}
+
+    def get_public_subscribers_node(self, node: str) -> str:
+        """Return prefixed node to retrieve public subscribers"""
+        return f"{NS_PPS_SUBSCRIBERS}/{node}"
+
+    async def get_public_node_subscriptions(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        nodeIdentifier: str
+    ) -> Dict[jid.JID, str]:
+        """Retrieve public subscriptions to a node
+
+        @param nodeIdentifier(unicode): node to get subscriptions from
+        """
+        if not nodeIdentifier:
+            raise exceptions.DataError("node identifier can't be empty")
+
+        if service is None:
+            service = client.jid.userhostJID()
+
+        subscribers_node = self.get_public_subscribers_node(nodeIdentifier)
+
+        try:
+            items, __ = await self.host.plugins["XEP-0060"].get_items(
+                client, service, subscribers_node
+            )
+        except error.StanzaError as e:
+            if e.condition == "forbidden":
+                log.warning(NOT_IMPLEMENTED_MSG.format(service=service))
+                return {}
+            else:
+                raise e
+        ret = {}
+        for item in items:
+            try:
+                subscriber_elt = next(item.elements(NS_PPS, "subscriber"))
+            except StopIteration:
+                log.warning(f"no <subscriber> element found: {item.toXml()}")
+                continue
+
+            try:
+                ret[jid.JID(subscriber_elt["jid"])] = "subscribed"
+            except (KeyError, RuntimeError):
+                log.warning(
+                    f"invalid <subscriber> element: {subscriber_elt.toXml()}"
+                )
+                continue
+        return ret
+
+    def set_public_opt(self, options: Optional[dict] = None) -> dict:
+        """Set option to make a subscription public
+
+        @param options: dict where the option must be set
+            if None, a new dict will be created
+
+        @return: the options dict
+        """
+        if options is None:
+            options = {}
+        options[f'{{{NS_PPS}}}public'] = True
+        return options
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0465_Handler(XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_PPS)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0470.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,591 @@
+#!/usr/bin/env python3
+
+# Libervia plugin for Pubsub Attachments
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import List, Tuple, Dict, Any, Callable, Optional
+
+from twisted.words.protocols.jabber import jid, xmlstream, error
+from twisted.words.xish import domish
+from twisted.internet import defer
+from zope.interface import implementer
+from wokkel import pubsub, disco, iwokkel
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core import exceptions
+from libervia.backend.tools.common import uri, data_format, date_utils
+from libervia.backend.tools.utils import as_deferred, xmpp_date
+
+
+log = getLogger(__name__)
+
+IMPORT_NAME = "XEP-0470"
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Pubsub Attachments",
+    C.PI_IMPORT_NAME: IMPORT_NAME,
+    C.PI_TYPE: C.PLUG_TYPE_XEP,
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: ["XEP-0060"],
+    C.PI_MAIN: "PubsubAttachments",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Pubsub Attachments implementation"""),
+}
+NS_PREFIX = "urn:xmpp:pubsub-attachments:"
+NS_PUBSUB_ATTACHMENTS = f"{NS_PREFIX}1"
+NS_PUBSUB_ATTACHMENTS_SUM = f"{NS_PREFIX}summary:1"
+
+
+class PubsubAttachments:
+    namespace = NS_PUBSUB_ATTACHMENTS
+
+    def __init__(self, host):
+        log.info(_("XEP-0470 (Pubsub Attachments) plugin initialization"))
+        host.register_namespace("pubsub-attachments", NS_PUBSUB_ATTACHMENTS)
+        self.host = host
+        self._p = host.plugins["XEP-0060"]
+        self.handlers: Dict[Tuple[str, str], dict[str, Any]] = {}
+        host.trigger.add("XEP-0277_send", self.on_mb_send)
+        self.register_attachment_handler(
+            "noticed", NS_PUBSUB_ATTACHMENTS, self.noticed_get, self.noticed_set
+        )
+        self.register_attachment_handler(
+            "reactions", NS_PUBSUB_ATTACHMENTS, self.reactions_get, self.reactions_set
+        )
+        host.bridge.add_method(
+            "ps_attachments_get",
+            ".plugin",
+            in_sign="sssasss",
+            out_sign="(ss)",
+            method=self._get,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "ps_attachments_set",
+            ".plugin",
+            in_sign="ss",
+            out_sign="",
+            method=self._set,
+            async_=True,
+        )
+
+    def get_handler(self, client):
+        return PubsubAttachments_Handler()
+
+    def register_attachment_handler(
+        self,
+        name: str,
+        namespace: str,
+        get_cb: Callable[
+            [SatXMPPEntity, domish.Element, Dict[str, Any]],
+            None],
+        set_cb: Callable[
+            [SatXMPPEntity, Dict[str, Any], Optional[domish.Element]],
+            Optional[domish.Element]],
+    ) -> None:
+        """Register callbacks to handle an attachment
+
+        @param name: name of the element
+        @param namespace: namespace of the element
+            (name, namespace) couple must be unique
+        @param get: method to call when attachments are retrieved
+            it will be called with (client, element, data) where element is the
+            <attachments> element to parse, and data must be updated in place with
+            parsed data
+        @param set: method to call when the attachment need to be set or udpated
+            it will be called with (client, data, former_elt of None if there was no
+            former element). When suitable, ``operation`` should be used to check if we
+            request an ``update`` or a ``replace``.
+            The callback can be either a blocking method, a Deferred or a coroutine
+        """
+        key = (name, namespace)
+        if key in self.handlers:
+            raise exceptions.ConflictError(
+                f"({name}, {namespace}) attachment handlers are already registered"
+            )
+        self.handlers[(name, namespace)] = {
+            "get": get_cb,
+            "set": set_cb
+        }
+
+    def get_attachment_node_name(self, service: jid.JID, node: str, item: str) -> str:
+        """Generate name to use for attachment node"""
+        target_item_uri = uri.build_xmpp_uri(
+            "pubsub",
+            path=service.userhost(),
+            node=node,
+            item=item
+        )
+        return f"{NS_PUBSUB_ATTACHMENTS}/{target_item_uri}"
+
+    def is_attachment_node(self, node: str) -> bool:
+        """Return True if node name is an attachment node"""
+        return node.startswith(f"{NS_PUBSUB_ATTACHMENTS}/")
+
+    def attachment_node_2_item(self, node: str) -> Tuple[jid.JID, str, str]:
+        """Retrieve service, node and item from attachement node's name"""
+        if not self.is_attachment_node(node):
+            raise ValueError("this is not an attachment node!")
+        prefix_len = len(f"{NS_PUBSUB_ATTACHMENTS}/")
+        item_uri = node[prefix_len:]
+        parsed_uri = uri.parse_xmpp_uri(item_uri)
+        if parsed_uri["type"] != "pubsub":
+            raise ValueError(f"unexpected URI type, it must be a pubsub URI: {item_uri}")
+        try:
+            service = jid.JID(parsed_uri["path"])
+        except RuntimeError:
+            raise ValueError(f"invalid service in pubsub URI: {item_uri}")
+        node = parsed_uri["node"]
+        item = parsed_uri["item"]
+        return (service, node, item)
+
+    async def on_mb_send(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        node: str,
+        item: domish.Element,
+        data: dict
+    ) -> bool:
+        """trigger to create attachment node on each publication"""
+        await self.create_attachments_node(
+            client, service, node, item["id"], autocreate=True
+        )
+        return True
+
+    async def create_attachments_node(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        node: str,
+        item_id: str,
+        autocreate: bool = False
+    ):
+        """Create node for attachements if necessary
+
+        @param service: service of target node
+        @param node: node where target item is published
+        @param item_id: ID of target item
+        @param autocrate: if True, target node is create if it doesn't exist
+        """
+        try:
+            node_config = await self._p.getConfiguration(client, service, node)
+        except error.StanzaError as e:
+            if e.condition == "item-not-found" and autocreate:
+                # we auto-create the missing node
+                await self._p.createNode(
+                    client, service, node
+                )
+                node_config = await self._p.getConfiguration(client, service, node)
+            elif e.condition == "forbidden":
+                node_config = self._p.make_configuration_form({})
+            else:
+                raise e
+        try:
+            # FIXME: check if this is the best publish_model option
+            node_config.fields["pubsub#publish_model"].value = "open"
+        except KeyError:
+            log.warning("pubsub#publish_model field is missing")
+        attachment_node = self.get_attachment_node_name(service, node, item_id)
+        # we use the same options as target node
+        try:
+            await self._p.create_if_new_node(
+                client, service, attachment_node, options=dict(node_config)
+            )
+        except Exception as e:
+            log.warning(f"Can't create attachment node {attachment_node}: {e}")
+
+    def items_2_attachment_data(
+        self,
+        client: SatXMPPEntity,
+        items: List[domish.Element]
+    ) -> List[Dict[str, Any]]:
+        """Convert items from attachment node to attachment data"""
+        list_data = []
+        for item in items:
+            try:
+                attachments_elt = next(
+                    item.elements(NS_PUBSUB_ATTACHMENTS, "attachments")
+                )
+            except StopIteration:
+                log.warning(
+                    "item is missing <attachments> elements, ignoring it: {item.toXml()}"
+                )
+                continue
+            item_id = item["id"]
+            publisher_s = item.getAttribute("publisher")
+            # publisher is not filled by all pubsub service, so we can't count on it
+            if publisher_s:
+                publisher = jid.JID(publisher_s)
+                if publisher.userhost() != item_id:
+                    log.warning(
+                        f"publisher {publisher.userhost()!r} doesn't correspond to item "
+                        f"id {item['id']!r}, ignoring. This may be a hack attempt.\n"
+                        f"{item.toXml()}"
+                    )
+                    continue
+            try:
+                jid.JID(item_id)
+            except RuntimeError:
+                log.warning(
+                    "item ID is not a JID, this is not compliant and is ignored: "
+                    f"{item.toXml}"
+                )
+                continue
+            data = {
+                "from": item_id
+            }
+            for handler in self.handlers.values():
+                handler["get"](client, attachments_elt, data)
+            if len(data) > 1:
+                list_data.append(data)
+        return list_data
+
+    def _get(
+        self,
+        service_s: str,
+        node: str,
+        item: str,
+        senders_s: List[str],
+        extra_s: str,
+        profile_key: str
+    ) -> defer.Deferred:
+        client = self.host.get_client(profile_key)
+        extra = data_format.deserialise(extra_s)
+        senders = [jid.JID(s) for s in senders_s]
+        d = defer.ensureDeferred(
+            self.get_attachments(client, jid.JID(service_s), node, item, senders)
+        )
+        d.addCallback(
+            lambda ret:
+            (data_format.serialise(ret[0]),
+             data_format.serialise(ret[1]))
+        )
+        return d
+
+    async def get_attachments(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        node: str,
+        item: str,
+        senders: Optional[List[jid.JID]],
+        extra: Optional[dict] = None
+    ) -> Tuple[List[Dict[str, Any]], dict]:
+        """Retrieve data attached to a pubsub item
+
+        @param service: pubsub service where the node is
+        @param node: pubsub node containing the item
+        @param item: ID of the item for which attachments will be retrieved
+        @param senders: bare JIDs of entities that are checked. Attachments from those
+            entities will be retrieved.
+            If None, attachments from all entities will be retrieved
+        @param extra: extra data, will be used as ``extra`` argument when doing
+        ``get_items`` call.
+        @return: A tuple with:
+            - the list of attachments data, one item per found sender. The attachments
+              data are dict containing attachment, no ``extra`` field is used here
+              (contrarily to attachments data used with ``set_attachements``).
+            - metadata returned by the call to ``get_items``
+        """
+        if extra is None:
+            extra = {}
+        attachment_node = self.get_attachment_node_name(service, node, item)
+        item_ids = [e.userhost() for e in senders] if senders else None
+        items, metadata = await self._p.get_items(
+            client, service, attachment_node, item_ids=item_ids, extra=extra
+        )
+        list_data = self.items_2_attachment_data(client, items)
+
+        return list_data, metadata
+
+    def _set(
+        self,
+        attachments_s: str,
+        profile_key: str
+    ) -> None:
+        client = self.host.get_client(profile_key)
+        attachments = data_format.deserialise(attachments_s)  or {}
+        return defer.ensureDeferred(self.set_attachements(client, attachments))
+
+    async def apply_set_handler(
+        self,
+        client: SatXMPPEntity,
+        attachments_data: dict,
+        item_elt: Optional[domish.Element],
+        handlers: Optional[List[Tuple[str, str]]] = None,
+        from_jid: Optional[jid.JID] = None,
+    ) -> domish.Element:
+        """Apply all ``set`` callbacks to an attachments item
+
+        @param attachments_data: data describing the attachments
+            ``extra`` key will be used, and created if not found
+        @param from_jid: jid of the author of the attachments
+            ``client.jid.userhostJID()`` will be used if not specified
+        @param item_elt: item containing an <attachments> element
+            will be modified in place
+            if None, a new element will be created
+        @param handlers: list of (name, namespace) of handlers to use.
+            if None, all registered handlers will be used.
+        @return: updated item_elt if given, otherwise a new item_elt
+        """
+        attachments_data.setdefault("extra", {})
+        if item_elt is None:
+            item_id = client.jid.userhost() if from_jid is None else from_jid.userhost()
+            item_elt = pubsub.Item(item_id)
+            item_elt.addElement((NS_PUBSUB_ATTACHMENTS, "attachments"))
+
+        try:
+            attachments_elt = next(
+                item_elt.elements(NS_PUBSUB_ATTACHMENTS, "attachments")
+            )
+        except StopIteration:
+            log.warning(
+                f"no <attachments> element found, creating a new one: {item_elt.toXml()}"
+            )
+            attachments_elt = item_elt.addElement((NS_PUBSUB_ATTACHMENTS, "attachments"))
+
+        if handlers is None:
+            handlers = list(self.handlers.keys())
+
+        for name, namespace in handlers:
+            try:
+                handler = self.handlers[(name, namespace)]
+            except KeyError:
+                log.error(
+                    f"unregistered handler ({name!r}, {namespace!r}) is requested, "
+                    "ignoring"
+                )
+                continue
+            try:
+                former_elt = next(attachments_elt.elements(namespace, name))
+            except StopIteration:
+                former_elt = None
+            new_elt = await as_deferred(
+                handler["set"], client, attachments_data, former_elt
+            )
+            if new_elt != former_elt:
+                if former_elt is not None:
+                    attachments_elt.children.remove(former_elt)
+                if new_elt is not None:
+                    attachments_elt.addChild(new_elt)
+        return item_elt
+
+    async def set_attachements(
+        self,
+        client: SatXMPPEntity,
+        attachments_data: Dict[str, Any]
+    ) -> None:
+        """Set or update attachments
+
+        Former <attachments> element will be retrieved and updated. Individual
+        attachments replace or update their elements individually, according to the
+        "operation" key.
+
+        "operation" key may be "update" or "replace", and defaults to update, it is only
+        used in attachments where "update" makes sense (e.g. it's used for "reactions"
+        but not for "noticed").
+
+        @param attachments_data: data describing attachments. Various keys (usually stored
+            in attachments_data["extra"]) may be used depending on the attachments
+            handlers registered. The keys "service", "node" and "id" MUST be set.
+            ``attachments_data`` is thought to be compatible with microblog data.
+
+        """
+        try:
+            service = jid.JID(attachments_data["service"])
+            node = attachments_data["node"]
+            item = attachments_data["id"]
+        except (KeyError, RuntimeError):
+            raise ValueError(
+                'data must have "service", "node" and "id" set'
+            )
+        attachment_node = self.get_attachment_node_name(service, node, item)
+        try:
+            items, __ = await self._p.get_items(
+                client, service, attachment_node, item_ids=[client.jid.userhost()]
+            )
+        except exceptions.NotFound:
+            item_elt = None
+        else:
+            if not items:
+                item_elt = None
+            else:
+                item_elt = items[0]
+
+        item_elt = await self.apply_set_handler(
+            client,
+            attachments_data,
+            item_elt=item_elt,
+        )
+
+        try:
+            await self._p.send_items(client, service, attachment_node, [item_elt])
+        except error.StanzaError as e:
+            if e.condition == "item-not-found":
+                # the node doesn't exist, we can't publish attachments
+                log.warning(
+                    f"no attachment node found at {service} on {node!r} for item "
+                    f"{item!r}, we can't update attachments."
+                )
+                raise exceptions.NotFound("No attachment node available")
+            else:
+                raise e
+
+    async def subscribe(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        node: str,
+        item: str,
+    ) -> None:
+        """Subscribe to attachment node targeting the item
+
+        @param service: service of target item (will also be used for attachment node)
+        @param node: node of target item (used to get attachment node's name)
+        @param item: name of target item (used to get attachment node's name)
+        """
+        attachment_node = self.get_attachment_node_name(service, node, item)
+        await self._p.subscribe(client, service, attachment_node)
+
+
+    def set_timestamp(self, attachment_elt: domish.Element, data: dict) -> None:
+        """Check if a ``timestamp`` attribute is set, parse it, and fill data
+
+        @param attachments_elt: element where the ``timestamp`` attribute may be set
+        @param data: data specific to the attachment (i.e. not the whole microblog data)
+            ``timestamp`` field will be set there if timestamp exists and is parsable
+        """
+        timestamp_raw = attachment_elt.getAttribute("timestamp")
+        if timestamp_raw:
+            try:
+                timestamp = date_utils.date_parse(timestamp_raw)
+            except date_utils.ParserError:
+                log.warning(f"can't parse timestamp: {timestamp_raw}")
+            else:
+                data["timestamp"] = timestamp
+
+    def noticed_get(
+        self,
+        client: SatXMPPEntity,
+        attachments_elt: domish.Element,
+        data: Dict[str, Any],
+    ) -> None:
+        try:
+            noticed_elt = next(
+                attachments_elt.elements(NS_PUBSUB_ATTACHMENTS, "noticed")
+            )
+        except StopIteration:
+            pass
+        else:
+            noticed_data = {
+                "noticed": True
+            }
+            self.set_timestamp(noticed_elt, noticed_data)
+            data["noticed"] = noticed_data
+
+    def noticed_set(
+        self,
+        client: SatXMPPEntity,
+        data: Dict[str, Any],
+        former_elt: Optional[domish.Element]
+    ) -> Optional[domish.Element]:
+        """add or remove a <noticed> attachment
+
+        if data["noticed"] is True, element is added, if it's False, it's removed, and
+        it's not present or None, the former element is kept.
+        """
+        noticed = data["extra"].get("noticed")
+        if noticed is None:
+            return former_elt
+        elif noticed:
+            return domish.Element(
+                (NS_PUBSUB_ATTACHMENTS, "noticed"),
+                attribs = {
+                    "timestamp": xmpp_date()
+                }
+            )
+        else:
+            return None
+
+    def reactions_get(
+        self,
+        client: SatXMPPEntity,
+        attachments_elt: domish.Element,
+        data: Dict[str, Any],
+    ) -> None:
+        try:
+            reactions_elt = next(
+                attachments_elt.elements(NS_PUBSUB_ATTACHMENTS, "reactions")
+            )
+        except StopIteration:
+            pass
+        else:
+            reactions_data = {"reactions": []}
+            reactions = reactions_data["reactions"]
+            for reaction_elt in reactions_elt.elements(NS_PUBSUB_ATTACHMENTS, "reaction"):
+                reactions.append(str(reaction_elt))
+            self.set_timestamp(reactions_elt, reactions_data)
+            data["reactions"] = reactions_data
+
+    def reactions_set(
+        self,
+        client: SatXMPPEntity,
+        data: Dict[str, Any],
+        former_elt: Optional[domish.Element]
+    ) -> Optional[domish.Element]:
+        """update the <reaction> attachment"""
+        reactions_data = data["extra"].get("reactions")
+        if reactions_data is None:
+            return former_elt
+        operation_type = reactions_data.get("operation", "update")
+        if operation_type == "update":
+            former_reactions = {
+                str(r) for r in former_elt.elements(NS_PUBSUB_ATTACHMENTS, "reaction")
+            } if former_elt is not None else set()
+            added_reactions = set(reactions_data.get("add") or [])
+            removed_reactions = set(reactions_data.get("remove") or [])
+            reactions = list((former_reactions | added_reactions) - removed_reactions)
+        elif operation_type == "replace":
+            reactions = reactions_data.get("reactions") or []
+        else:
+            raise exceptions.DataError(f"invalid reaction operation: {operation_type!r}")
+        if reactions:
+            reactions_elt = domish.Element(
+                (NS_PUBSUB_ATTACHMENTS, "reactions"),
+                attribs = {
+                    "timestamp": xmpp_date()
+                }
+            )
+            for reactions_data in reactions:
+                reactions_elt.addElement("reaction", content=reactions_data)
+            return reactions_elt
+        else:
+            return None
+
+
+@implementer(iwokkel.IDisco)
+class PubsubAttachments_Handler(xmlstream.XMPPHandler):
+
+    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_PUBSUB_ATTACHMENTS)]
+
+    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0471.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1173 @@
+#!/usr/bin/env python3
+
+
+# Libervia plugin to handle events
+# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from random import seed
+from typing import Optional, Final, Dict, List, Union, Any, Optional
+from attr import attr
+
+import shortuuid
+from sqlalchemy.orm.events import event
+from libervia.backend.core.xmpp import SatXMPPClient
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.tools import utils
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools.common import uri as xmpp_uri
+from libervia.backend.tools.common import date_utils
+from libervia.backend.tools.common import data_format
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid, error
+from twisted.words.xish import domish
+from wokkel import disco, iwokkel
+from zope.interface import implementer
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from wokkel import pubsub, data_form
+
+log = getLogger(__name__)
+
+
+PLUGIN_INFO = {
+    C.PI_NAME: "Events",
+    C.PI_IMPORT_NAME: "XEP-0471",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: [],
+    C.PI_DEPENDENCIES: [
+        "XEP-0060", "XEP-0080", "XEP-0447", "XEP-0470", # "INVITATION", "PUBSUB_INVITATION",
+        # "LIST_INTEREST"
+    ],
+    C.PI_RECOMMENDATIONS: ["XEP-0277", "EMAIL_INVITATION"],
+    C.PI_MAIN: "XEP_0471",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Calendar Events"""),
+}
+
+NS_EVENT = "org.salut-a-toi.event:0"
+NS_EVENTS: Final = "urn:xmpp:events:0"
+NS_RSVP: Final = "urn:xmpp:events:rsvp:0"
+NS_EXTRA: Final = "urn:xmpp:events:extra:0"
+
+
+class XEP_0471:
+    namespace = NS_EVENTS
+
+    def __init__(self, host):
+        log.info(_("Events plugin initialization"))
+        self.host = host
+        self._p = host.plugins["XEP-0060"]
+        self._g = host.plugins["XEP-0080"]
+        self._b = host.plugins.get("XEP-0277")
+        self._sfs = host.plugins["XEP-0447"]
+        self._a = host.plugins["XEP-0470"]
+        # self._i = host.plugins.get("EMAIL_INVITATION")
+        host.register_namespace("events", NS_EVENTS)
+        self._a.register_attachment_handler("rsvp", NS_EVENTS, self.rsvp_get, self.rsvp_set)
+        # host.plugins["PUBSUB_INVITATION"].register(NS_EVENTS, self)
+        host.bridge.add_method(
+            "events_get",
+            ".plugin",
+            in_sign="ssasss",
+            out_sign="s",
+            method=self._events_get,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "event_create",
+            ".plugin",
+            in_sign="sssss",
+            out_sign="",
+            method=self._event_create,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "event_modify",
+            ".plugin",
+            in_sign="sssss",
+            out_sign="",
+            method=self._event_modify,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "event_invitee_get",
+            ".plugin",
+            in_sign="sssasss",
+            out_sign="s",
+            method=self._event_invitee_get,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "event_invitee_set",
+            ".plugin",
+            in_sign="sssss",
+            out_sign="",
+            method=self._event_invitee_set,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "event_invitees_list",
+            ".plugin",
+            in_sign="sss",
+            out_sign="a{sa{ss}}",
+            method=self._event_invitees_list,
+            async_=True,
+        ),
+        host.bridge.add_method(
+            "event_invite",
+            ".plugin",
+            in_sign="sssss",
+            out_sign="",
+            method=self._invite,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "event_invite_by_email",
+            ".plugin",
+            in_sign="ssssassssssss",
+            out_sign="",
+            method=self._invite_by_email,
+            async_=True,
+        )
+
+    def get_handler(self, client):
+        return EventsHandler(self)
+
+    def _parse_event_elt(self, event_elt):
+        """Helper method to parse event element
+
+        @param (domish.Element): event_elt
+        @return (tuple[int, dict[unicode, unicode]): timestamp, event_data
+        """
+        try:
+            timestamp = date_utils.date_parse(next(event_elt.elements(NS_EVENT, "date")))
+        except StopIteration:
+            timestamp = -1
+
+        data = {}
+
+        for key in ("name",):
+            try:
+                data[key] = event_elt[key]
+            except KeyError:
+                continue
+
+        for elt_name in ("description",):
+            try:
+                elt = next(event_elt.elements(NS_EVENT, elt_name))
+            except StopIteration:
+                continue
+            else:
+                data[elt_name] = str(elt)
+
+        for elt_name in ("image", "background-image"):
+            try:
+                image_elt = next(event_elt.elements(NS_EVENT, elt_name))
+                data[elt_name] = image_elt["src"]
+            except StopIteration:
+                continue
+            except KeyError:
+                log.warning(_("no src found for image"))
+
+        for uri_type in ("invitees", "blog"):
+            try:
+                elt = next(event_elt.elements(NS_EVENT, uri_type))
+                uri = data[uri_type + "_uri"] = elt["uri"]
+                uri_data = xmpp_uri.parse_xmpp_uri(uri)
+                if uri_data["type"] != "pubsub":
+                    raise ValueError
+            except StopIteration:
+                log.warning(_("no {uri_type} element found!").format(uri_type=uri_type))
+            except KeyError:
+                log.warning(_("incomplete {uri_type} element").format(uri_type=uri_type))
+            except ValueError:
+                log.warning(_("bad {uri_type} element").format(uri_type=uri_type))
+            else:
+                data[uri_type + "_service"] = uri_data["path"]
+                data[uri_type + "_node"] = uri_data["node"]
+
+        for meta_elt in event_elt.elements(NS_EVENT, "meta"):
+            key = meta_elt["name"]
+            if key in data:
+                log.warning(
+                    "Ignoring conflicting meta element: {xml}".format(
+                        xml=meta_elt.toXml()
+                    )
+                )
+                continue
+            data[key] = str(meta_elt)
+        if event_elt.link:
+            link_elt = event_elt.link
+            data["service"] = link_elt["service"]
+            data["node"] = link_elt["node"]
+            data["item"] = link_elt["item"]
+        if event_elt.getAttribute("creator") == "true":
+            data["creator"] = True
+        return timestamp, data
+
+    def event_elt_2_event_data(self, event_elt: domish.Element) -> Dict[str, Any]:
+        """Convert <event/> element to event data
+
+        @param event_elt: <event/> element
+            parent <item/> element can also be used
+        @raise exceptions.NotFound: can't find event payload
+        @raise ValueError: something is missing or badly formed
+        """
+        if event_elt.name == "item":
+            try:
+                event_elt = next(event_elt.elements(NS_EVENTS, "event"))
+            except StopIteration:
+                raise exceptions.NotFound("<event/> payload is missing")
+
+        event_data: Dict[str, Any] = {}
+
+        # id
+
+        parent_elt = event_elt.parent
+        if parent_elt is not None and parent_elt.hasAttribute("id"):
+            event_data["id"] = parent_elt["id"]
+
+        # name
+
+        name_data: Dict[str, str] = {}
+        event_data["name"] = name_data
+        for name_elt in event_elt.elements(NS_EVENTS, "name"):
+            lang = name_elt.getAttribute("xml:lang", "")
+            if lang in name_data:
+                raise ValueError("<name/> elements don't have distinct xml:lang")
+            name_data[lang] = str(name_elt)
+
+        if not name_data:
+            raise exceptions.NotFound("<name/> element is missing")
+
+        # start
+
+        try:
+            start_elt = next(event_elt.elements(NS_EVENTS, "start"))
+        except StopIteration:
+            raise exceptions.NotFound("<start/> element is missing")
+        event_data["start"] = utils.parse_xmpp_date(str(start_elt))
+
+        # end
+
+        try:
+            end_elt = next(event_elt.elements(NS_EVENTS, "end"))
+        except StopIteration:
+            raise exceptions.NotFound("<end/> element is missing")
+        event_data["end"] = utils.parse_xmpp_date(str(end_elt))
+
+        # head-picture
+
+        head_pic_elt = next(event_elt.elements(NS_EVENTS, "head-picture"), None)
+        if head_pic_elt is not None:
+            event_data["head-picture"] = self._sfs.parse_file_sharing_elt(head_pic_elt)
+
+        # description
+
+        seen_desc = set()
+        for description_elt in event_elt.elements(NS_EVENTS, "description"):
+            lang = description_elt.getAttribute("xml:lang", "")
+            desc_type = description_elt.getAttribute("type", "text")
+            lang_type = (lang, desc_type)
+            if lang_type in seen_desc:
+                raise ValueError(
+                    "<description/> elements don't have distinct xml:lang/type"
+                )
+            seen_desc.add(lang_type)
+            descriptions = event_data.setdefault("descriptions", [])
+            description_data = {"description": str(description_elt)}
+            if lang:
+                description_data["language"] = lang
+            if desc_type:
+                description_data["type"] = desc_type
+            descriptions.append(description_data)
+
+        # categories
+
+        for category_elt in event_elt.elements(NS_EVENTS, "category"):
+            try:
+                category_data = {
+                    "term": category_elt["term"]
+                }
+            except KeyError:
+                log.warning(
+                    "<category/> element is missing mandatory term: "
+                    f"{category_elt.toXml()}"
+                )
+                continue
+            wd = category_elt.getAttribute("wd")
+            if wd:
+                category_data["wikidata_id"] = wd
+            lang = category_elt.getAttribute("xml:lang")
+            if lang:
+                category_data["language"] = lang
+            event_data.setdefault("categories", []).append(category_data)
+
+        # locations
+
+        seen_location_ids = set()
+        for location_elt in event_elt.elements(NS_EVENTS, "location"):
+            location_id = location_elt.getAttribute("id", "")
+            if location_id in seen_location_ids:
+                raise ValueError("<location/> elements don't have distinct IDs")
+            seen_location_ids.add(location_id)
+            location_data = self._g.parse_geoloc_elt(location_elt)
+            if location_id:
+                location_data["id"] = location_id
+            lang = location_elt.getAttribute("xml:lang", "")
+            if lang:
+                location_data["language"] = lang
+            event_data.setdefault("locations", []).append(location_data)
+
+        # RSVPs
+
+        seen_rsvp_lang = set()
+        for rsvp_elt in event_elt.elements(NS_EVENTS, "rsvp"):
+            rsvp_lang = rsvp_elt.getAttribute("xml:lang", "")
+            if rsvp_lang in seen_rsvp_lang:
+                raise ValueError("<rsvp/> elements don't have distinct xml:lang")
+            seen_rsvp_lang.add(rsvp_lang)
+            rsvp_form = data_form.findForm(rsvp_elt, NS_RSVP)
+            if rsvp_form is None:
+                log.warning(f"RSVP form is missing: {rsvp_elt.toXml()}")
+                continue
+            rsvp_data = xml_tools.data_form_2_data_dict(rsvp_form)
+            if rsvp_lang:
+                rsvp_data["language"] = rsvp_lang
+            event_data.setdefault("rsvp", []).append(rsvp_data)
+
+        # linked pubsub nodes
+
+        for name in ("invitees", "comments", "blog", "schedule"):
+            elt = next(event_elt.elements(NS_EVENTS, name), None)
+            if elt is not None:
+                try:
+                    event_data[name] = {
+                        "service": elt["jid"],
+                        "node": elt["node"]
+                    }
+                except KeyError:
+                    log.warning(f"invalid {name} element: {elt.toXml()}")
+
+        # attachments
+
+        attachments_elt = next(event_elt.elements(NS_EVENTS, "attachments"), None)
+        if attachments_elt:
+            attachments = event_data["attachments"] = []
+            for file_sharing_elt in attachments_elt.elements(
+                    self._sfs.namespace, "file-sharing"):
+                try:
+                    file_sharing_data = self._sfs.parse_file_sharing_elt(file_sharing_elt)
+                except Exception as e:
+                    log.warning(f"invalid attachment: {e}\n{file_sharing_elt.toXml()}")
+                    continue
+                attachments.append(file_sharing_data)
+
+        # extra
+
+        extra_elt = next(event_elt.elements(NS_EVENTS, "extra"), None)
+        if extra_elt is not None:
+            extra_form = data_form.findForm(extra_elt, NS_EXTRA)
+            if extra_form is None:
+                log.warning(f"extra form is missing: {extra_elt.toXml()}")
+            else:
+                extra_data = event_data["extra"] = {}
+                for name, value in extra_form.items():
+                    if name.startswith("accessibility:"):
+                        extra_data.setdefault("accessibility", {})[name[14:]] = value
+                    elif name == "accessibility":
+                        log.warning(
+                            'ignoring "accessibility" key which is not standard: '
+                            f"{extra_form.toElement().toXml()}"
+                        )
+                    else:
+                        extra_data[name] = value
+
+        # external
+
+        external_elt = next(event_elt.elements(NS_EVENTS, "external"), None)
+        if external_elt:
+            try:
+                event_data["external"] = {
+                    "jid": external_elt["jid"],
+                    "node": external_elt["node"],
+                    "item": external_elt["item"]
+                }
+            except KeyError:
+                log.warning(f"invalid <external/> element: {external_elt.toXml()}")
+
+        return event_data
+
+    def _events_get(
+            self, service: str, node: str, event_ids: List[str], extra: str, profile_key: str
+    ):
+        client = self.host.get_client(profile_key)
+        d = defer.ensureDeferred(
+            self.events_get(
+                client,
+                jid.JID(service) if service else None,
+                node if node else NS_EVENTS,
+                event_ids,
+                data_format.deserialise(extra)
+            )
+        )
+        d.addCallback(data_format.serialise)
+        return d
+
+    async def events_get(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: str = NS_EVENTS,
+        events_ids: Optional[List[str]] = None,
+        extra: Optional[dict] = None,
+    ) -> List[Dict[str, Any]]:
+        """Retrieve event data
+
+        @param service: pubsub service
+        @param node: pubsub node
+        @param event_id: pubsub item ID
+        @return: event data:
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+        items, __ = await self._p.get_items(
+            client, service, node, item_ids=events_ids, extra=extra
+        )
+        events = []
+        for item in items:
+            try:
+                events.append(self.event_elt_2_event_data((item)))
+            except (ValueError, exceptions.NotFound):
+                log.warning(
+                    f"Can't parse event for item {item['id']}: {item.toXml()}"
+                )
+
+        return events
+
+    def _event_create(
+        self,
+        data_s: str,
+        service: str,
+        node: str,
+        event_id: str = "",
+        profile_key: str = C.PROF_KEY_NONE
+    ):
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(
+            self.event_create(
+                client,
+                data_format.deserialise(data_s),
+                jid.JID(service) if service else None,
+                node or None,
+                event_id or None
+            )
+        )
+
+    def event_data_2_event_elt(self, event_data: Dict[str, Any]) -> domish.Element:
+        """Convert Event Data to corresponding Element
+
+        @param event_data: data of the event with keys as follow:
+            name (dict)
+                map of language to name
+                empty string can be used as key if no language is specified
+                this key is mandatory
+            start (int|float)
+                starting time of the event
+                this key is mandatory
+            end (int|float)
+                ending time of the event
+                this key is mandatory
+            head-picture(dict)
+                file sharing data for the main picture to use to represent the event
+            description(list[dict])
+                list of descriptions. If there are several descriptions, they must have
+                distinct (language, type).
+                Description data is dict which following keys:
+                    description(str)
+                        the description itself, either in plain text or xhtml
+                        this key is mandatory
+                    language(str)
+                        ISO-639 language code
+                    type(str)
+                        type of the description, either "text" (default) or "xhtml"
+            categories(list[dict])
+                each category is a dict with following keys:
+                    term(str)
+                        human readable short text of the category
+                        this key is mandatory
+                    wikidata_id(str)
+                        Entity ID from WikiData
+                    language(str)
+                        ISO-639 language code
+            locations(list[dict])
+                list of location dict as used in plugin XEP-0080 [get_geoloc_elt].
+                If several locations are used, they must have distinct IDs
+            rsvp(list[dict])
+                RSVP data. The dict is a data dict as used in
+                sat.tools.xml_tools.data_dict_2_data_form with some extra keys.
+                The "attending" key is automatically added if it's not already present,
+                except if the "no_default" key is present. Thus, an empty dict can be used
+                to use default RSVP.
+                If several dict are present in the list, they must have different "lang"
+                keys.
+                Following extra key can be used:
+                    language(str)
+                        ISO-639 code for language used in the form
+                    no_default(bool)
+                        if True, the "attending" field won't be automatically added
+            invitees(dict)
+                link to pubsub node holding invitees list.
+                Following keys are mandatory:
+                    service(str)
+                        pubsub service where the node is
+                    node (str)
+                        pubsub node to use
+            comments(dict)
+                link to pubsub node holding XEP-0277 comments on the event itself.
+                Following keys are mandatory:
+                    service(str)
+                        pubsub service where the node is
+                    node (str)
+                        pubsub node to use
+            blog(dict)
+                link to pubsub node holding a blog about the event.
+                Following keys are mandatory:
+                    service(str)
+                        pubsub service where the node is
+                    node (str)
+                        pubsub node to use
+            schedule(dict)
+                link to pubsub node holding an events node describing the schedule of this
+                event.
+                Following keys are mandatory:
+                    service(str)
+                        pubsub service where the node is
+                    node (str)
+                        pubsub node to use
+            attachments[list[dict]]
+                list of file sharing data about all kind of attachments of interest for
+                the event.
+            extra(dict)
+                extra information about the event.
+                Keys can be:
+                    website(str)
+                        main website about the event
+                    status(str)
+                        status of the event.
+                        Can be one of "confirmed", "tentative" or "cancelled"
+                    languages(list[str])
+                        ISO-639 codes for languages which will be mainly spoken at the
+                        event
+                    accessibility(dict)
+                        accessibility informations.
+                        Keys can be:
+                            wheelchair
+                                tell if the event is accessible to wheelchair.
+                                Value can be "full", "partial" or "no"
+            external(dict):
+                if present, this event is a link to an external one.
+                Keys (all mandatory) are:
+                    jid: pubsub service
+                    node: pubsub node
+                    item: event id
+        @return: Event element
+        @raise ValueError: some expected data were missing or incorrect
+        """
+        event_elt = domish.Element((NS_EVENTS, "event"))
+        try:
+            for lang, name in event_data["name"].items():
+                name_elt = event_elt.addElement("name", content=name)
+                if lang:
+                    name_elt["xml:lang"] = lang
+        except (KeyError, TypeError):
+            raise ValueError('"name" field is not a dict mapping language to event name')
+        try:
+            event_elt.addElement("start", content=utils.xmpp_date(event_data["start"]))
+            event_elt.addElement("end", content=utils.xmpp_date(event_data["end"]))
+        except (KeyError, TypeError, ValueError):
+            raise ValueError('"start" and "end" fields are mandatory')
+
+        if "head-picture" in event_data:
+            head_pic_data = event_data["head-picture"]
+            head_picture_elt = event_elt.addElement("head-picture")
+            head_picture_elt.addChild(self._sfs.get_file_sharing_elt(**head_pic_data))
+
+        seen_desc = set()
+        if "descriptions" in event_data:
+            for desc_data in event_data["descriptions"]:
+                desc_type = desc_data.get("type", "text")
+                lang = desc_data.get("language") or ""
+                lang_type = (lang, desc_type)
+                if lang_type in seen_desc:
+                    raise ValueError(
+                        '"xml:lang" and "type" is not unique among descriptions: '
+                        f"{desc_data}"
+                    )
+                seen_desc.add(lang_type)
+                try:
+                    description = desc_data["description"]
+                except KeyError:
+                    log.warning(f"description is missing in {desc_data!r}")
+                    continue
+
+                if desc_type == "text":
+                    description_elt = event_elt.addElement(
+                        "description", content=description
+                    )
+                elif desc_type == "xhtml":
+                    description_elt = event_elt.addElement("description")
+                    div_elt = xml_tools.parse(description, namespace=C.NS_XHTML)
+                    description_elt.addChild(div_elt)
+                else:
+                    log.warning(f"unknown description type {desc_type!r}")
+                    continue
+                if lang:
+                    description_elt["xml:lang"] = lang
+        for category_data in event_data.get("categories", []):
+            try:
+                category_term = category_data["term"]
+            except KeyError:
+                log.warning(f'"term" is missing categories data: {category_data}')
+                continue
+            category_elt = event_elt.addElement("category")
+            category_elt["term"] = category_term
+            category_wd = category_data.get("wikidata_id")
+            if category_wd:
+                category_elt["wd"] = category_wd
+            category_lang = category_data.get("language")
+            if category_lang:
+                category_elt["xml:lang"] = category_lang
+
+        seen_location_ids = set()
+        for location_data in event_data.get("locations", []):
+            location_id = location_data.get("id", "")
+            if location_id in seen_location_ids:
+                raise ValueError("locations must have distinct IDs")
+            seen_location_ids.add(location_id)
+            location_elt = event_elt.addElement("location")
+            location_elt.addChild(self._g.get_geoloc_elt(location_data))
+            if location_id:
+                location_elt["id"] = location_id
+
+        rsvp_data_list: Optional[List[dict]] = event_data.get("rsvp")
+        if rsvp_data_list is not None:
+            seen_lang = set()
+            for rsvp_data in rsvp_data_list:
+                if not rsvp_data:
+                    # we use a minimum data if an empty dict is received. It will be later
+                    # filled with defaut "attending" field.
+                    rsvp_data = {"fields": []}
+                rsvp_elt = event_elt.addElement("rsvp")
+                lang = rsvp_data.get("language", "")
+                if lang in seen_lang:
+                    raise ValueError(
+                        "If several RSVP are specified, they must have distinct "
+                        f"languages. {lang!r} language has been used several times."
+                    )
+                seen_lang.add(lang)
+                if lang:
+                    rsvp_elt["xml:lang"] = lang
+                if not rsvp_data.get("no_default", False):
+                    try:
+                        next(f for f in rsvp_data["fields"] if f["name"] == "attending")
+                    except StopIteration:
+                        rsvp_data["fields"].append({
+                            "type": "list-single",
+                            "name": "attending",
+                            "label": "Attending",
+                            "options": [
+                                {"label": "maybe", "value": "maybe"},
+                                {"label": "yes", "value": "yes"},
+                                {"label": "no", "value": "no"}
+                            ],
+                            "required": True
+                        })
+                rsvp_data["namespace"] = NS_RSVP
+                rsvp_form = xml_tools.data_dict_2_data_form(rsvp_data)
+                rsvp_elt.addChild(rsvp_form.toElement())
+
+        for node_type in ("invitees", "comments", "blog", "schedule"):
+            node_data = event_data.get(node_type)
+            if not node_data:
+                continue
+            try:
+                service, node = node_data["service"], node_data["node"]
+            except KeyError:
+                log.warning(f"invalid node data for {node_type}: {node_data}")
+            else:
+                pub_node_elt = event_elt.addElement(node_type)
+                pub_node_elt["jid"] = service
+                pub_node_elt["node"] = node
+
+        attachments = event_data.get("attachments")
+        if attachments:
+            attachments_elt = event_elt.addElement("attachments")
+            for attachment_data in attachments:
+                attachments_elt.addChild(
+                    self._sfs.get_file_sharing_elt(**attachment_data)
+                )
+
+        extra = event_data.get("extra")
+        if extra:
+            extra_form = data_form.Form(
+                "result",
+                formNamespace=NS_EXTRA
+            )
+            for node_type in ("website", "status"):
+                if node_type in extra:
+                    extra_form.addField(
+                        data_form.Field(var=node_type, value=extra[node_type])
+                    )
+            if "languages" in extra:
+                extra_form.addField(
+                    data_form.Field(
+                        "list-multi", var="languages", values=extra["languages"]
+                    )
+                )
+            for node_type, value in extra.get("accessibility", {}).items():
+                extra_form.addField(
+                    data_form.Field(var=f"accessibility:{node_type}", value=value)
+                )
+
+            extra_elt = event_elt.addElement("extra")
+            extra_elt.addChild(extra_form.toElement())
+
+        if "external" in event_data:
+            external_data = event_data["external"]
+            external_elt = event_elt.addElement("external")
+            for node_type in ("jid", "node", "item"):
+                try:
+                    value = external_data[node_type]
+                except KeyError:
+                    raise ValueError(f"Invalid external data: {external_data}")
+                external_elt[node_type] = value
+
+        return event_elt
+
+    async def event_create(
+        self,
+        client: SatXMPPEntity,
+        event_data: Dict[str, Any],
+        service: Optional[jid.JID] = None,
+        node: Optional[str] = None,
+        event_id: Optional[str] = None,
+    ) -> None:
+        """Create or replace an event
+
+        @param event_data: data of the event (cf. [event_data_2_event_elt])
+        @param node: PubSub node of the event
+            None to use default node (default namespace for personal agenda)
+        @param service: PubSub service
+            None to use profile's PEP
+        @param event_id: ID of the item to create.
+        """
+        if not service:
+            service = client.jid.userhostJID()
+        if not node:
+            node = NS_EVENTS
+        if event_id is None:
+            event_id = shortuuid.uuid()
+        event_elt = self.event_data_2_event_elt(event_data)
+
+        item_elt = pubsub.Item(id=event_id, payload=event_elt)
+        options = {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST}
+        await self._p.create_if_new_node(
+            client, service, nodeIdentifier=node, options=options
+        )
+        await self._p.publish(client, service, node, items=[item_elt])
+        if event_data.get("rsvp"):
+            await self._a.create_attachments_node(client, service, node, event_id)
+
+    def _event_modify(
+        self,
+        data_s: str,
+        event_id: str,
+        service: str,
+        node: str,
+        profile_key: str = C.PROF_KEY_NONE
+    ) -> None:
+        client = self.host.get_client(profile_key)
+        defer.ensureDeferred(
+            self.event_modify(
+                client,
+                data_format.deserialise(data_s),
+                event_id,
+                jid.JID(service) if service else None,
+                node or None,
+            )
+        )
+
+    async def event_modify(
+        self,
+        client: SatXMPPEntity,
+        event_data: Dict[str, Any],
+        event_id: str,
+        service: Optional[jid.JID] = None,
+        node: Optional[str] = None,
+    ) -> None:
+        """Update an event
+
+        Similar as create instead that it update existing item instead of
+        creating or replacing it. Params are the same as for [event_create].
+        """
+        if not service:
+            service = client.jid.userhostJID()
+        if not node:
+            node = NS_EVENTS
+        old_event = (await self.events_get(client, service, node, [event_id]))[0]
+        old_event.update(event_data)
+        event_data = old_event
+        await self.event_create(client, event_data, service, node, event_id)
+
+    def rsvp_get(
+        self,
+        client: SatXMPPEntity,
+        attachments_elt: domish.Element,
+        data: Dict[str, Any],
+    ) -> None:
+        """Get RSVP answers from attachments"""
+        try:
+            rsvp_elt = next(
+                attachments_elt.elements(NS_EVENTS, "rsvp")
+            )
+        except StopIteration:
+            pass
+        else:
+            rsvp_form = data_form.findForm(rsvp_elt, NS_RSVP)
+            if rsvp_form is not None:
+                data["rsvp"] = rsvp_data = dict(rsvp_form)
+                self._a.set_timestamp(rsvp_elt, rsvp_data)
+
+    def rsvp_set(
+        self,
+        client: SatXMPPEntity,
+        data: Dict[str, Any],
+        former_elt: Optional[domish.Element]
+    ) -> Optional[domish.Element]:
+        """update the <reaction> attachment"""
+        rsvp_data = data["extra"].get("rsvp")
+        if rsvp_data is None:
+            return former_elt
+        elif rsvp_data:
+            rsvp_elt = domish.Element(
+                (NS_EVENTS, "rsvp"),
+                attribs = {
+                    "timestamp": utils.xmpp_date()
+                }
+            )
+            rsvp_form = data_form.Form("submit", formNamespace=NS_RSVP)
+            rsvp_form.makeFields(rsvp_data)
+            rsvp_elt.addChild(rsvp_form.toElement())
+            return rsvp_elt
+        else:
+            return None
+
+    def _event_invitee_get(
+        self,
+        service: str,
+        node: str,
+        item: str,
+        invitees_s: List[str],
+        extra: str,
+        profile_key: str
+    ) -> defer.Deferred:
+        client = self.host.get_client(profile_key)
+        if invitees_s:
+            invitees = [jid.JID(i) for i in invitees_s]
+        else:
+            invitees = None
+        d = defer.ensureDeferred(
+            self.event_invitee_get(
+                client,
+                jid.JID(service) if service else None,
+                node or None,
+                item,
+                invitees,
+                data_format.deserialise(extra)
+            )
+        )
+        d.addCallback(lambda ret: data_format.serialise(ret))
+        return d
+
+    async def event_invitee_get(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: Optional[str],
+        item: str,
+        invitees: Optional[List[jid.JID]] = None,
+        extra: Optional[Dict[str, Any]] = None,
+    ) -> Dict[str, Dict[str, Any]]:
+        """Retrieve attendance from event node
+
+        @param service: PubSub service
+        @param node: PubSub node of the event
+        @param item: PubSub item of the event
+        @param invitees: if set, only retrieve RSVPs from those guests
+        @param extra: extra data used to retrieve items as for [get_attachments]
+        @return: mapping of invitee bare JID to their RSVP
+            an empty dict is returned if nothing has been answered yed
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+        if node is None:
+            node = NS_EVENTS
+        attachments, metadata = await self._a.get_attachments(
+            client, service, node, item, invitees, extra
+        )
+        ret = {}
+        for attachment in attachments:
+            try:
+                rsvp = attachment["rsvp"]
+            except KeyError:
+                continue
+            ret[attachment["from"]] = rsvp
+
+        return ret
+
+    def _event_invitee_set(
+        self,
+        service: str,
+        node: str,
+        item: str,
+        rsvp_s: str,
+        profile_key: str
+    ):
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(
+            self.event_invitee_set(
+                client,
+                jid.JID(service) if service else None,
+                node or None,
+                item,
+                data_format.deserialise(rsvp_s)
+            )
+        )
+
+    async def event_invitee_set(
+        self,
+        client: SatXMPPEntity,
+        service: Optional[jid.JID],
+        node: Optional[str],
+        item: str,
+        rsvp: Dict[str, Any],
+    ) -> None:
+        """Set or update attendance data in event node
+
+        @param service: PubSub service
+        @param node: PubSub node of the event
+        @param item: PubSub item of the event
+        @param rsvp: RSVP data (values to submit to the form)
+        """
+        if service is None:
+            service = client.jid.userhostJID()
+        if node is None:
+            node = NS_EVENTS
+        await self._a.set_attachements(client, {
+            "service": service.full(),
+            "node": node,
+            "id": item,
+            "extra": {"rsvp": rsvp}
+        })
+
+    def _event_invitees_list(self, service, node, profile_key):
+        service = jid.JID(service) if service else None
+        node = node if node else NS_EVENT
+        client = self.host.get_client(profile_key)
+        return defer.ensureDeferred(
+            self.event_invitees_list(client, service, node)
+        )
+
+    async def event_invitees_list(self, client, service, node):
+        """Retrieve attendance from event node
+
+        @param service(unicode, None): PubSub service
+        @param node(unicode): PubSub node of the event
+        @return (dict): a dict with current attendance status,
+            an empty dict is returned if nothing has been answered yed
+        """
+        items, metadata = await self._p.get_items(client, service, node)
+        invitees = {}
+        for item in items:
+            try:
+                event_elt = next(item.elements(NS_EVENT, "invitee"))
+            except StopIteration:
+                # no item found, event data are not set yet
+                log.warning(_(
+                    "no data found for {item_id} (service: {service}, node: {node})"
+                    .format(item_id=item["id"], service=service, node=node)))
+            else:
+                data = {}
+                for key in ("attend", "guests"):
+                    try:
+                        data[key] = event_elt[key]
+                    except KeyError:
+                        continue
+                invitees[item["id"]] = data
+        return invitees
+
+    async def invite_preflight(
+        self,
+        client: SatXMPPEntity,
+        invitee_jid: jid.JID,
+        service: jid.JID,
+        node: str,
+        item_id: Optional[str] = None,
+        name: str = '',
+        extra: Optional[dict] = None,
+    ) -> None:
+        if self._b is None:
+            raise exceptions.FeatureNotFound(
+                _('"XEP-0277" (blog) plugin is needed for this feature')
+            )
+        if item_id is None:
+            item_id = extra["default_item_id"] = NS_EVENT
+
+        __, event_data = await self.events_get(client, service, node, item_id)
+        log.debug(_("got event data"))
+        invitees_service = jid.JID(event_data["invitees_service"])
+        invitees_node = event_data["invitees_node"]
+        blog_service = jid.JID(event_data["blog_service"])
+        blog_node = event_data["blog_node"]
+        await self._p.set_node_affiliations(
+            client, invitees_service, invitees_node, {invitee_jid: "publisher"}
+        )
+        log.debug(
+            f"affiliation set on invitee node (jid: {invitees_service}, "
+            f"node: {invitees_node!r})"
+        )
+        await self._p.set_node_affiliations(
+            client, blog_service, blog_node, {invitee_jid: "member"}
+        )
+        blog_items, __ = await self._b.mb_get(client, blog_service, blog_node, None)
+
+        for item in blog_items:
+            try:
+                comments_service = jid.JID(item["comments_service"])
+                comments_node = item["comments_node"]
+            except KeyError:
+                log.debug(
+                    "no comment service set for item {item_id}".format(
+                        item_id=item["id"]
+                    )
+                )
+            else:
+                await self._p.set_node_affiliations(
+                    client, comments_service, comments_node, {invitee_jid: "publisher"}
+                )
+        log.debug(_("affiliation set on blog and comments nodes"))
+
+    def _invite(self, invitee_jid, service, node, item_id, profile):
+        return self.host.plugins["PUBSUB_INVITATION"]._send_pubsub_invitation(
+            invitee_jid, service, node, item_id or NS_EVENT, profile_key=profile
+        )
+
+    def _invite_by_email(self, service, node, id_=NS_EVENT, email="", emails_extra=None,
+                       name="", host_name="", language="", url_template="",
+                       message_subject="", message_body="",
+                       profile_key=C.PROF_KEY_NONE):
+        client = self.host.get_client(profile_key)
+        kwargs = {
+            "profile": client.profile,
+            "emails_extra": [str(e) for e in emails_extra],
+        }
+        for key in (
+            "email",
+            "name",
+            "host_name",
+            "language",
+            "url_template",
+            "message_subject",
+            "message_body",
+        ):
+            value = locals()[key]
+            kwargs[key] = str(value)
+        return defer.ensureDeferred(self.invite_by_email(
+            client, jid.JID(service) if service else None, node, id_ or NS_EVENT, **kwargs
+        ))
+
+    async def invite_by_email(self, client, service, node, id_=NS_EVENT, **kwargs):
+        """High level method to create an email invitation to an event
+
+        @param service(unicode, None): PubSub service
+        @param node(unicode): PubSub node of the event
+        @param id_(unicode): id_ with even data
+        """
+        if self._i is None:
+            raise exceptions.FeatureNotFound(
+                _('"Invitations" plugin is needed for this feature')
+            )
+        if self._b is None:
+            raise exceptions.FeatureNotFound(
+                _('"XEP-0277" (blog) plugin is needed for this feature')
+            )
+        service = service or client.jid.userhostJID()
+        event_uri = xmpp_uri.build_xmpp_uri(
+            "pubsub", path=service.full(), node=node, item=id_
+        )
+        kwargs["extra"] = {"event_uri": event_uri}
+        invitation_data = await self._i.create(**kwargs)
+        invitee_jid = invitation_data["jid"]
+        log.debug(_("invitation created"))
+        # now that we have a jid, we can send normal invitation
+        await self.invite(client, invitee_jid, service, node, id_)
+
+    def on_invitation_preflight(
+        self,
+        client: SatXMPPEntity,
+        name: str,
+        extra: dict,
+        service: jid.JID,
+        node: str,
+        item_id: Optional[str],
+        item_elt: domish.Element
+    ) -> None:
+        event_elt = item_elt.event
+        link_elt = event_elt.addElement("link")
+        link_elt["service"] = service.full()
+        link_elt["node"] = node
+        link_elt["item"] = item_id
+        __, event_data = self._parse_event_elt(event_elt)
+        try:
+            name = event_data["name"]
+        except KeyError:
+            pass
+        else:
+            extra["name"] = name
+        if 'image' in event_data:
+            extra["thumb_url"] = event_data['image']
+        extra["element"] = event_elt
+
+
+@implementer(iwokkel.IDisco)
+class EventsHandler(XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [
+            disco.DiscoFeature(NS_EVENTS),
+        ]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/stdui/ui_contact_list.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,308 @@
+#!/usr/bin/env python3
+
+
+# SAT standard user interface for managing contacts
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.tools import xml_tools
+from twisted.words.protocols.jabber import jid
+from xml.dom.minidom import Element
+
+
+class ContactList(object):
+    """Add, update and remove contacts."""
+
+    def __init__(self, host):
+        self.host = host
+        self.__add_id = host.register_callback(self._add_contact, with_data=True)
+        self.__update_id = host.register_callback(self._update_contact, with_data=True)
+        self.__confirm_delete_id = host.register_callback(
+            self._get_confirm_remove_xmlui, with_data=True
+        )
+
+        host.import_menu(
+            (D_("Contacts"), D_("Add contact")),
+            self._get_add_dialog_xmlui,
+            security_limit=2,
+            help_string=D_("Add contact"),
+        )
+        host.import_menu(
+            (D_("Contacts"), D_("Update contact")),
+            self._get_update_dialog_xmlui,
+            security_limit=2,
+            help_string=D_("Update contact"),
+        )
+        host.import_menu(
+            (D_("Contacts"), D_("Remove contact")),
+            self._get_remove_dialog_xmlui,
+            security_limit=2,
+            help_string=D_("Remove contact"),
+        )
+
+        # FIXME: a plugin should not be used here, and current profile's jid host would be better than installation wise host
+        if "MISC-ACCOUNT" in self.host.plugins:
+            self.default_host = self.host.plugins["MISC-ACCOUNT"].account_domain_new_get()
+        else:
+            self.default_host = "example.net"
+
+    def contacts_get(self, profile):
+        """Return a sorted list of the contacts for that profile
+
+        @param profile: %(doc_profile)s
+        @return: list[string]
+        """
+        client = self.host.get_client(profile)
+        ret = [contact.full() for contact in client.roster.get_jids()]
+        ret.sort()
+        return ret
+
+    def get_groups(self, new_groups=None, profile=C.PROF_KEY_NONE):
+        """Return a sorted list of the groups for that profile
+
+        @param new_group (list): add these groups to the existing ones
+        @param profile: %(doc_profile)s
+        @return: list[string]
+        """
+        client = self.host.get_client(profile)
+        ret = client.roster.get_groups()
+        ret.sort()
+        ret.extend([group for group in new_groups if group not in ret])
+        return ret
+
+    def get_groups_of_contact(self, user_jid_s, profile):
+        """Return all the groups of the given contact
+
+        @param user_jid_s (string)
+        @param profile: %(doc_profile)s
+        @return: list[string]
+        """
+        client = self.host.get_client(profile)
+        return client.roster.get_item(jid.JID(user_jid_s)).groups
+
+    def get_groups_of_all_contacts(self, profile):
+        """Return a mapping between the contacts and their groups
+
+        @param profile: %(doc_profile)s
+        @return: dict (key: string, value: list[string]):
+            - key: the JID userhost
+            - value: list of groups
+        """
+        client = self.host.get_client(profile)
+        return {item.jid.userhost(): item.groups for item in client.roster.get_items()}
+
+    def _data2elts(self, data):
+        """Convert a contacts data dict to minidom Elements
+
+        @param data (dict)
+        @return list[Element]
+        """
+        elts = []
+        for key in data:
+            key_elt = Element("jid")
+            key_elt.setAttribute("name", key)
+            for value in data[key]:
+                value_elt = Element("group")
+                value_elt.setAttribute("name", value)
+                key_elt.childNodes.append(value_elt)
+            elts.append(key_elt)
+        return elts
+
+    def get_dialog_xmlui(self, options, data, profile):
+        """Generic method to return the XMLUI dialog for adding or updating a contact
+
+        @param options (dict): parameters for the dialog, with the keys:
+                               - 'id': the menu callback id
+                               - 'title': deferred localized string
+                               - 'contact_text': deferred localized string
+        @param data (dict)
+        @param profile: %(doc_profile)s
+        @return dict
+        """
+        form_ui = xml_tools.XMLUI("form", title=options["title"], submit_id=options["id"])
+        if "message" in data:
+            form_ui.addText(data["message"])
+            form_ui.addDivider("dash")
+
+        form_ui.addText(options["contact_text"])
+        if options["id"] == self.__add_id:
+            contact = data.get(
+                xml_tools.form_escape("contact_jid"), "@%s" % self.default_host
+            )
+            form_ui.addString("contact_jid", value=contact)
+        elif options["id"] == self.__update_id:
+            contacts = self.contacts_get(profile)
+            list_ = form_ui.addList("contact_jid", options=contacts, selected=contacts[0])
+            elts = self._data2elts(self.get_groups_of_all_contacts(profile))
+            list_.set_internal_callback(
+                "groups_of_contact", fields=["contact_jid", "groups_list"], data_elts=elts
+            )
+
+        form_ui.addDivider("blank")
+
+        form_ui.addText(_("Select in which groups your contact is:"))
+        selected_groups = []
+        if "selected_groups" in data:
+            selected_groups = data["selected_groups"]
+        elif options["id"] == self.__update_id:
+            try:
+                selected_groups = self.get_groups_of_contact(contacts[0], profile)
+            except IndexError:
+                pass
+        groups = self.get_groups(selected_groups, profile)
+        form_ui.addList(
+            "groups_list", options=groups, selected=selected_groups, styles=["multi"]
+        )
+
+        adv_list = form_ui.change_container("advanced_list", columns=3, selectable="no")
+        form_ui.addLabel(D_("Add group"))
+        form_ui.addString("add_group")
+        button = form_ui.addButton("", value=D_("Add"))
+        button.set_internal_callback("move", fields=["add_group", "groups_list"])
+        adv_list.end()
+
+        form_ui.addDivider("blank")
+        return {"xmlui": form_ui.toXml()}
+
+    def _get_add_dialog_xmlui(self, data, profile):
+        """Get the dialog for adding contact
+
+        @param data (dict)
+        @param profile: %(doc_profile)s
+        @return dict
+        """
+        options = {
+            "id": self.__add_id,
+            "title": D_("Add contact"),
+            "contact_text": D_("New contact identifier (JID):"),
+        }
+        return self.get_dialog_xmlui(options, {}, profile)
+
+    def _get_update_dialog_xmlui(self, data, profile):
+        """Get the dialog for updating contact
+
+        @param data (dict)
+        @param profile: %(doc_profile)s
+        @return dict
+        """
+        if not self.contacts_get(profile):
+            _dialog = xml_tools.XMLUI("popup", title=D_("Nothing to update"))
+            _dialog.addText(_("Your contact list is empty."))
+            return {"xmlui": _dialog.toXml()}
+
+        options = {
+            "id": self.__update_id,
+            "title": D_("Update contact"),
+            "contact_text": D_("Which contact do you want to update?"),
+        }
+        return self.get_dialog_xmlui(options, {}, profile)
+
+    def _get_remove_dialog_xmlui(self, data, profile):
+        """Get the dialog for removing contact
+
+        @param data (dict)
+        @param profile: %(doc_profile)s
+        @return dict
+        """
+        if not self.contacts_get(profile):
+            _dialog = xml_tools.XMLUI("popup", title=D_("Nothing to delete"))
+            _dialog.addText(_("Your contact list is empty."))
+            return {"xmlui": _dialog.toXml()}
+
+        form_ui = xml_tools.XMLUI(
+            "form",
+            title=D_("Who do you want to remove from your contacts?"),
+            submit_id=self.__confirm_delete_id,
+        )
+        form_ui.addList("contact_jid", options=self.contacts_get(profile))
+        return {"xmlui": form_ui.toXml()}
+
+    def _get_confirm_remove_xmlui(self, data, profile):
+        """Get the confirmation dialog for removing contact
+
+        @param data (dict)
+        @param profile: %(doc_profile)s
+        @return dict
+        """
+        if C.bool(data.get("cancelled", "false")):
+            return {}
+        contact = data[xml_tools.form_escape("contact_jid")]
+
+        def delete_cb(data, profile):
+            if not C.bool(data.get("cancelled", "false")):
+                self._delete_contact(jid.JID(contact), profile)
+            return {}
+
+        delete_id = self.host.register_callback(delete_cb, with_data=True, one_shot=True)
+        form_ui = xml_tools.XMLUI("form", title=D_("Delete contact"), submit_id=delete_id)
+        form_ui.addText(
+            D_("Are you sure you want to remove %s from your contact list?") % contact
+        )
+        return {"xmlui": form_ui.toXml()}
+
+    def _add_contact(self, data, profile):
+        """Add the selected contact
+
+        @param data (dict)
+        @param profile: %(doc_profile)s
+        @return dict
+        """
+        if C.bool(data.get("cancelled", "false")):
+            return {}
+        contact_jid_s = data[xml_tools.form_escape("contact_jid")]
+        try:
+            contact_jid = jid.JID(contact_jid_s)
+        except (RuntimeError, jid.InvalidFormat, AttributeError):
+            # TODO: replace '\t' by a constant (see tools.xmlui.XMLUI.on_form_submitted)
+            data["selected_groups"] = data[xml_tools.form_escape("groups_list")].split(
+                "\t"
+            )
+            options = {
+                "id": self.__add_id,
+                "title": D_("Add contact"),
+                "contact_text": D_('Please enter a valid JID (like "contact@%s"):')
+                % self.default_host,
+            }
+            return self.get_dialog_xmlui(options, data, profile)
+        self.host.contact_add(contact_jid, profile_key=profile)
+        return self._update_contact(data, profile)  # after adding, updating
+
+    def _update_contact(self, data, profile):
+        """Update the selected contact
+
+        @param data (dict)
+        @param profile: %(doc_profile)s
+        @return dict
+        """
+        client = self.host.get_client(profile)
+        if C.bool(data.get("cancelled", "false")):
+            return {}
+        contact_jid = jid.JID(data[xml_tools.form_escape("contact_jid")])
+        # TODO: replace '\t' by a constant (see tools.xmlui.XMLUI.on_form_submitted)
+        groups = data[xml_tools.form_escape("groups_list")].split("\t")
+        self.host.contact_update(client, contact_jid, name="", groups=groups)
+        return {}
+
+    def _delete_contact(self, contact_jid, profile):
+        """Delete the selected contact
+
+        @param contact_jid (JID)
+        @param profile: %(doc_profile)s
+        @return dict
+        """
+        self.host.contact_del(contact_jid, profile_key=profile)
+        return {}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/stdui/ui_profile_manager.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+
+
+# SAT standard user interface for managing contacts
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import D_
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.tools import xml_tools
+from libervia.backend.memory.memory import ProfileSessions
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+
+
+log = getLogger(__name__)
+
+
+class ProfileManager(object):
+    """Manage profiles."""
+
+    def __init__(self, host):
+        self.host = host
+        self.profile_ciphers = {}
+        self._sessions = ProfileSessions()
+        host.register_callback(
+            self._authenticate_profile, force_id=C.AUTHENTICATE_PROFILE_ID, with_data=True
+        )
+        host.register_callback(
+            self._change_xmpp_password, force_id=C.CHANGE_XMPP_PASSWD_ID, with_data=True
+        )
+        self.__new_xmpp_passwd_id = host.register_callback(
+            self._change_xmpp_password_cb, with_data=True
+        )
+
+    def _start_session_eb(self, fail, first, profile):
+        """Errback method for start_session during profile authentication
+
+        @param first(bool): if True, this is the first try and we have tryied empty password
+            in this case we ask for a password to the user.
+        @param profile(unicode, None): %(doc_profile)s
+            must only be used if first is True
+        """
+        if first:
+            # first call, we ask for the password
+            form_ui = xml_tools.XMLUI(
+                "form", title=D_("Profile password for {}").format(profile), submit_id=""
+            )
+            form_ui.addPassword("profile_password", value="")
+            d = xml_tools.deferred_ui(self.host, form_ui, chained=True)
+            d.addCallback(self._authenticate_profile, profile)
+            return {"xmlui": form_ui.toXml()}
+
+        assert profile is None
+
+        if fail.check(exceptions.PasswordError):
+            dialog = xml_tools.XMLUI("popup", title=D_("Connection error"))
+            dialog.addText(D_("The provided profile password doesn't match."))
+        else:
+            log.error("Unexpected exceptions: {}".format(fail))
+            dialog = xml_tools.XMLUI("popup", title=D_("Internal error"))
+            dialog.addText(D_("Internal error: {}".format(fail)))
+        return {"xmlui": dialog.toXml(), "validated": C.BOOL_FALSE}
+
+    def _authenticate_profile(self, data, profile):
+        if C.bool(data.get("cancelled", "false")):
+            return {}
+        if self.host.memory.is_session_started(profile):
+            return {"validated": C.BOOL_TRUE}
+        try:
+            password = data[xml_tools.form_escape("profile_password")]
+        except KeyError:
+            # first request, we try empty password
+            password = ""
+            first = True
+            eb_profile = profile
+        else:
+            first = False
+            eb_profile = None
+        d = self.host.memory.start_session(password, profile)
+        d.addCallback(lambda __: {"validated": C.BOOL_TRUE})
+        d.addErrback(self._start_session_eb, first, eb_profile)
+        return d
+
+    def _change_xmpp_password(self, data, profile):
+        session_data = self._sessions.profile_get_unique(profile)
+        if not session_data:
+            server = self.host.memory.param_get_a(
+                C.FORCE_SERVER_PARAM, "Connection", profile_key=profile
+            )
+            if not server:
+                server = jid.parse(
+                    self.host.memory.param_get_a(
+                        "JabberID", "Connection", profile_key=profile
+                    )
+                )[1]
+            session_id, session_data = self._sessions.new_session(
+                {"count": 0, "server": server}, profile=profile
+            )
+        if (
+            session_data["count"] > 2
+        ):  # 3 attempts with a new password after the initial try
+            self._sessions.profile_del_unique(profile)
+            _dialog = xml_tools.XMLUI("popup", title=D_("Connection error"))
+            _dialog.addText(
+                D_("Can't connect to %s. Please check your connection details.")
+                % session_data["server"]
+            )
+            return {"xmlui": _dialog.toXml()}
+        session_data["count"] += 1
+        counter = " (%d)" % session_data["count"] if session_data["count"] > 1 else ""
+        title = D_("XMPP password for %(profile)s%(counter)s") % {
+            "profile": profile,
+            "counter": counter,
+        }
+        form_ui = xml_tools.XMLUI(
+            "form", title=title, submit_id=self.__new_xmpp_passwd_id
+        )
+        form_ui.addText(
+            D_(
+                "Can't connect to %s. Please check your connection details or try with another password."
+            )
+            % session_data["server"]
+        )
+        form_ui.addPassword("xmpp_password", value="")
+        return {"xmlui": form_ui.toXml()}
+
+    def _change_xmpp_password_cb(self, data, profile):
+        xmpp_password = data[xml_tools.form_escape("xmpp_password")]
+        d = self.host.memory.param_set(
+            "Password", xmpp_password, "Connection", profile_key=profile
+        )
+        d.addCallback(lambda __: defer.ensureDeferred(self.host.connect(profile)))
+        d.addCallback(lambda __: {})
+        d.addErrback(lambda __: self._change_xmpp_password({}, profile))
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/constants.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+
+
+# Primitivus: a SAT frontend
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _, D_
+from twisted.words.protocols.jabber import jid
+
+
+class Const(object):
+
+    PROF_KEY_NONE = "@NONE@"
+
+    PROFILE = [
+        "test_profile",
+        "test_profile2",
+        "test_profile3",
+        "test_profile4",
+        "test_profile5",
+    ]
+    JID_STR = [
+        "test@example.org/SàT",
+        "sender@example.net/house",
+        "sender@example.net/work",
+        "sender@server.net/res",
+        "xxx@server.net/res",
+    ]
+    JID = [jid.JID(jid_s) for jid_s in JID_STR]
+
+    PROFILE_DICT = {}
+    for i in range(0, len(PROFILE)):
+        PROFILE_DICT[PROFILE[i]] = JID[i]
+
+    MUC_STR = ["room@chat.server.domain", "sat_game@chat.server.domain"]
+    MUC = [jid.JID(jid_s) for jid_s in MUC_STR]
+
+    NO_SECURITY_LIMIT = -1
+    SECURITY_LIMIT = 0
+
+    # To test frontend parameters
+    APP_NAME = "dummy_frontend"
+    COMPOSITION_KEY = D_("Composition")
+    ENABLE_UNIBOX_PARAM = D_("Enable unibox")
+    PARAM_IN_QUOTES = D_("'Wysiwyg' edition")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/helpers.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,482 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+## logging configuration for tests ##
+from libervia.backend.core import log_config
+log_config.sat_configure()
+
+import logging
+from libervia.backend.core.log import getLogger
+getLogger().setLevel(logging.WARNING)  # put this to DEBUG when needed
+
+from libervia.backend.core import exceptions
+from libervia.backend.tools import config as tools_config
+from .constants import Const as C
+from wokkel.xmppim import RosterItem
+from wokkel.generic import parseXml
+from libervia.backend.core.xmpp import SatRosterProtocol
+from libervia.backend.memory.memory import Params, Memory
+from twisted.trial.unittest import FailTest
+from twisted.trial import unittest
+from twisted.internet import defer
+from twisted.words.protocols.jabber.jid import JID
+from twisted.words.xish import domish
+from xml.etree import cElementTree as etree
+from collections import Counter
+import re
+
+
+def b2s(value):
+    """Convert a bool to a unicode string used in bridge
+    @param value: boolean value
+    @return: unicode conversion, according to bridge convention
+
+    """
+    return  "True" if value else "False"
+
+
+def mute_logging():
+    """Temporarily set the logging level to CRITICAL to not pollute the output with expected errors."""
+    logger = getLogger()
+    logger.original_level = logger.getEffectiveLevel()
+    logger.setLevel(logging.CRITICAL)
+
+
+def unmute_logging():
+    """Restore the logging level after it has been temporarily disabled."""
+    logger = getLogger()
+    logger.setLevel(logger.original_level)
+
+
+class DifferentArgsException(FailTest):
+    pass
+
+
+class DifferentXMLException(FailTest):
+    pass
+
+
+class DifferentListException(FailTest):
+    pass
+
+
+class FakeSAT(object):
+    """Class to simulate a SAT instance"""
+
+    def __init__(self):
+        self.bridge = FakeBridge()
+        self.memory = FakeMemory(self)
+        self.trigger = FakeTriggerManager()
+        self.profiles = {}
+        self.reinit()
+
+    def reinit(self):
+        """This can be called by tests that check for sent and stored messages,
+        uses FakeClient or get/set some other data that need to be cleaned"""
+        for profile in self.profiles:
+            self.profiles[profile].reinit()
+        self.memory.reinit()
+        self.stored_messages = []
+        self.plugins = {}
+        self.profiles = {}
+
+    def contact_del(self, to, profile_key):
+        #TODO
+        pass
+
+    def register_callback(self, callback, *args, **kwargs):
+        pass
+
+    def message_send(self, to_s, msg, subject=None, mess_type='auto', extra={}, profile_key='@NONE@'):
+        self.send_and_store_message({"to": JID(to_s)})
+
+    def _send_message_to_stream(self, mess_data, client):
+        """Save the information to check later to whom messages have been sent.
+
+        @param mess_data: message data dictionnary
+        @param client: profile's client
+        """
+        client.xmlstream.send(mess_data['xml'])
+        return mess_data
+
+    def _store_message(self, mess_data, client):
+        """Save the information to check later if entries have been added to the history.
+
+        @param mess_data: message data dictionnary
+        @param client: profile's client
+        """
+        self.stored_messages.append(mess_data["to"])
+        return mess_data
+
+    def send_message_to_bridge(self, mess_data, client):
+        """Simulate the message being sent to the frontends.
+
+        @param mess_data: message data dictionnary
+        @param client: profile's client
+        """
+        return mess_data  # TODO
+
+    def get_profile_name(self, profile_key):
+        """Get the profile name from the profile_key"""
+        return profile_key
+
+    def get_client(self, profile_key):
+        """Convenient method to get client from profile key
+        @return: client or None if it doesn't exist"""
+        profile = self.memory.get_profile_name(profile_key)
+        if not profile:
+            raise exceptions.ProfileKeyUnknown
+        if profile not in self.profiles:
+            self.profiles[profile] = FakeClient(self, profile)
+        return self.profiles[profile]
+
+    def get_jid_n_stream(self, profile_key):
+        """Convenient method to get jid and stream from profile key
+        @return: tuple (jid, xmlstream) from profile, can be None"""
+        return (C.PROFILE_DICT[profile_key], None)
+
+    def is_connected(self, profile):
+        return True
+
+    def get_sent_messages(self, profile_index):
+        """Return all the sent messages (in the order they have been sent) and
+        empty the list. Called by tests. FakeClient instances associated to each
+        profile must have been previously initialized with the method
+        FakeSAT.get_client.
+
+        @param profile_index: index of the profile to consider (cf. C.PROFILE)
+        @return: the sent messages for given profile, or None"""
+        try:
+            tmp = self.profiles[C.PROFILE[profile_index]].xmlstream.sent
+            self.profiles[C.PROFILE[profile_index]].xmlstream.sent = []
+            return tmp
+        except IndexError:
+            return None
+
+    def get_sent_message(self, profile_index):
+        """Pop and return the sent message in first position (works like a FIFO).
+        Called by tests. FakeClient instances associated to each profile must have
+        been previously initialized with the method FakeSAT.get_client.
+
+        @param profile_index: index of the profile to consider (cf. C.PROFILE)
+        @return: the sent message for given profile, or None"""
+        try:
+            return self.profiles[C.PROFILE[profile_index]].xmlstream.sent.pop(0)
+        except IndexError:
+            return None
+
+    def get_sent_message_xml(self, profile_index):
+        """Pop and return the sent message in first position (works like a FIFO).
+        Called by tests. FakeClient instances associated to each profile must have
+        been previously initialized with the method FakeSAT.get_client.
+        @return: XML representation of the sent message for given profile, or None"""
+        entry = self.get_sent_message(profile_index)
+        return entry.toXml() if entry else None
+
+    def find_features_set(self, features, identity=None, jid_=None, profile=C.PROF_KEY_NONE):
+        """Call self.add_feature from your tests to change the return value.
+
+        @return: a set of entities
+        """
+        client = self.get_client(profile)
+        if jid_ is None:
+            jid_ = JID(client.jid.host)
+        try:
+            if set(features).issubset(client.features[jid_]):
+                return defer.succeed(set([jid_]))
+        except (TypeError, AttributeError, KeyError):
+            pass
+        return defer.succeed(set())
+
+    def add_feature(self, jid_, feature, profile_key):
+        """Add a feature to an entity.
+
+        To be called from your tests when needed.
+        """
+        client = self.get_client(profile_key)
+        if not hasattr(client, 'features'):
+            client.features = {}
+        if jid_ not in client.features:
+            client.features[jid_] = set()
+        client.features[jid_].add(feature)
+
+
+class FakeBridge(object):
+    """Class to simulate and test bridge calls"""
+
+    def __init__(self):
+        self.expected_calls = {}
+
+    def expect_call(self, name, *check_args, **check_kwargs):
+        if hasattr(self, name):  # queue this new call as one already exists
+            self.expected_calls.setdefault(name, [])
+            self.expected_calls[name].append((check_args, check_kwargs))
+            return
+
+        def check_call(*args, **kwargs):
+            if args != check_args or kwargs != check_kwargs:
+                print("\n\n--------------------")
+                print("Args are not equals:")
+                print("args\n----\n%s (sent)\n%s (wanted)" % (args, check_args))
+                print("kwargs\n------\n%s (sent)\n%s (wanted)" % (kwargs, check_kwargs))
+                print("--------------------\n\n")
+                raise DifferentArgsException
+            delattr(self, name)
+
+            if name in self.expected_calls:  # register the next call
+                args, kwargs = self.expected_calls[name].pop(0)
+                if len(self.expected_calls[name]) == 0:
+                    del self.expected_calls[name]
+                self.expect_call(name, *args, **kwargs)
+
+        setattr(self, name, check_call)
+
+    def add_method(self, name, int_suffix, in_sign, out_sign, method, async_=False, doc=None):
+        pass
+
+    def add_signal(self, name, int_suffix, signature):
+        pass
+
+    def add_test_callback(self, name, method):
+        """This can be used to register callbacks for bridge methods AND signals.
+        Contrary to expect_call, this will not check if the method or signal is
+        called/sent with the correct arguments, it will instead run the callback
+        of your choice."""
+        setattr(self, name, method)
+
+
+class FakeParams(Params):
+    """Class to simulate and test params object. The methods of Params that could
+    not be run (for example those using the storage attribute must be overwritten
+    by a naive simulation of what they should do."""
+
+    def __init__(self, host, storage):
+        Params.__init__(self, host, storage)
+        self.params = {}  # naive simulation of values storage
+
+    def param_set(self, name, value, category, security_limit=-1, profile_key='@NONE@'):
+        profile = self.get_profile_name(profile_key)
+        self.params.setdefault(profile, {})
+        self.params[profile_key][(category, name)] = value
+
+    def param_get_a(self, name, category, attr="value", profile_key='@NONE@'):
+        profile = self.get_profile_name(profile_key)
+        return self.params[profile][(category, name)]
+
+    def get_profile_name(self, profile_key, return_profile_keys=False):
+        if profile_key == '@DEFAULT@':
+            return C.PROFILE[0]
+        elif profile_key == '@NONE@':
+            raise exceptions.ProfileNotSetError
+        else:
+            return profile_key
+
+    def load_ind_params(self, profile, cache=None):
+        self.params[profile] = {}
+        return defer.succeed(None)
+
+
+class FakeMemory(Memory):
+    """Class to simulate and test memory object"""
+
+    def __init__(self, host):
+        # do not call Memory.__init__, we just want to call the methods that are
+        # manipulating basic stuff, the others should be overwritten when needed
+        self.host = host
+        self.params = FakeParams(host, None)
+        self.config = tools_config.parse_main_conf()
+        self.reinit()
+
+    def reinit(self):
+        """Tests that manipulate params, entities, features should
+        re-initialise the memory first to not fake the result."""
+        self.params.load_default_params()
+        self.params.params.clear()
+        self.params.frontends_cache = []
+        self.entities_data = {}
+
+    def get_profile_name(self, profile_key, return_profile_keys=False):
+        return self.params.get_profile_name(profile_key, return_profile_keys)
+
+    def add_to_history(self, from_jid, to_jid, message, _type='chat', extra=None, timestamp=None, profile="@NONE@"):
+        pass
+
+    def contact_add(self, contact_jid, attributes, groups, profile_key='@DEFAULT@'):
+        pass
+
+    def set_presence_status(self, contact_jid, show, priority, statuses, profile_key='@DEFAULT@'):
+        pass
+
+    def add_waiting_sub(self, type_, contact_jid, profile_key):
+        pass
+
+    def del_waiting_sub(self, contact_jid, profile_key):
+        pass
+
+    def update_entity_data(self, entity_jid, key, value, silent=False, profile_key="@NONE@"):
+        self.entities_data.setdefault(entity_jid, {})
+        self.entities_data[entity_jid][key] = value
+
+    def entity_data_get(self, entity_jid, keys, profile_key):
+        result = {}
+        for key in keys:
+            result[key] = self.entities_data[entity_jid][key]
+        return result
+
+
+class FakeTriggerManager(object):
+
+    def add(self, point_name, callback, priority=0):
+        pass
+
+    def point(self, point_name, *args, **kwargs):
+        """We always return true to continue the action"""
+        return True
+
+
+class FakeRosterProtocol(SatRosterProtocol):
+    """This class is used by FakeClient (one instance per profile)"""
+
+    def __init__(self, host, parent):
+        SatRosterProtocol.__init__(self, host)
+        self.parent = parent
+        self._jids = {}
+        self.add_item(parent.jid.userhostJID())
+
+    def add_item(self, jid, *args, **kwargs):
+        if not args and not kwargs:
+            # defaults values setted for the tests only
+            kwargs["subscriptionTo"] = True
+            kwargs["subscriptionFrom"] = True
+        roster_item = RosterItem(jid, *args, **kwargs)
+        attrs = {'to': b2s(roster_item.subscriptionTo), 'from': b2s(roster_item.subscriptionFrom), 'ask': b2s(roster_item.pendingOut)}
+        if roster_item.name:
+            attrs['name'] = roster_item.name
+        self.host.bridge.expect_call("contact_new", jid.full(), attrs, roster_item.groups, self.parent.profile)
+        self._jids[jid] = roster_item
+        self._register_item(roster_item)
+
+
+class FakeXmlStream(object):
+    """This class is used by FakeClient (one instance per profile)"""
+
+    def __init__(self):
+        self.sent = []
+
+    def send(self, obj):
+        """Save the sent messages to compare them later.
+
+        @param obj (domish.Element, str or unicode): message to send
+        """
+        if not isinstance(obj, domish.Element):
+            assert(isinstance(obj, str) or isinstance(obj, str))
+            obj = parseXml(obj)
+
+        if obj.name == 'iq':
+            # IQ request expects an answer, return the request itself so
+            # you can check if it has been well built by your plugin.
+            self.iqDeferreds[obj['id']].callback(obj)
+
+        self.sent.append(obj)
+        return defer.succeed(None)
+
+    def addObserver(self, *argv):
+        pass
+
+
+class FakeClient(object):
+    """Tests involving more than one profile need one instance of this class per profile"""
+
+    def __init__(self, host, profile=None):
+        self.host = host
+        self.profile = profile if profile else C.PROFILE[0]
+        self.jid = C.PROFILE_DICT[self.profile]
+        self.roster = FakeRosterProtocol(host, self)
+        self.xmlstream = FakeXmlStream()
+
+    def reinit(self):
+        self.xmlstream = FakeXmlStream()
+
+    def send(self, obj):
+        return self.xmlstream.send(obj)
+
+
+class SatTestCase(unittest.TestCase):
+
+    def assert_equal_xml(self, xml, expected, ignore_blank=False):
+        def equal_elt(got_elt, exp_elt):
+            if ignore_blank:
+                for elt in got_elt, exp_elt:
+                    for attr in ('text', 'tail'):
+                        value = getattr(elt, attr)
+                        try:
+                            value = value.strip() or None
+                        except AttributeError:
+                            value = None
+                        setattr(elt, attr, value)
+            if (got_elt.tag != exp_elt.tag):
+                print("XML are not equals (elt %s/%s):" % (got_elt, exp_elt))
+                print("tag: got [%s] expected: [%s]" % (got_elt.tag, exp_elt.tag))
+                return False
+            if (got_elt.attrib != exp_elt.attrib):
+                print("XML are not equals (elt %s/%s):" % (got_elt, exp_elt))
+                print("attribs: got %s expected %s" % (got_elt.attrib, exp_elt.attrib))
+                return False
+            if (got_elt.tail != exp_elt.tail or got_elt.text != exp_elt.text):
+                print("XML are not equals (elt %s/%s):" % (got_elt, exp_elt))
+                print("text: got [%s] expected: [%s]" % (got_elt.text, exp_elt.text))
+                print("tail: got [%s] expected: [%s]" % (got_elt.tail, exp_elt.tail))
+                return False
+            if (len(got_elt) != len(exp_elt)):
+                print("XML are not equals (elt %s/%s):" % (got_elt, exp_elt))
+                print("children len: got %d expected: %d" % (len(got_elt), len(exp_elt)))
+                return False
+            for idx, child in enumerate(got_elt):
+                if not equal_elt(child, exp_elt[idx]):
+                    return False
+            return True
+
+        def remove_blank(xml):
+            lines = [line.strip() for line in re.sub(r'[ \t\r\f\v]+', ' ', xml).split('\n')]
+            return '\n'.join([line for line in lines if line])
+
+        xml_elt = etree.fromstring(remove_blank(xml) if ignore_blank else xml)
+        expected_elt = etree.fromstring(remove_blank(expected) if ignore_blank else expected)
+
+        if not equal_elt(xml_elt, expected_elt):
+            print("---")
+            print("XML are not equals:")
+            print("got:\n-\n%s\n-\n\n" % etree.tostring(xml_elt, encoding='utf-8'))
+            print("was expecting:\n-\n%s\n-\n\n" % etree.tostring(expected_elt, encoding='utf-8'))
+            print("---")
+            raise DifferentXMLException
+
+    def assert_equal_unsorted_list(self, a, b, msg):
+        counter_a = Counter(a)
+        counter_b = Counter(b)
+        if counter_a != counter_b:
+            print("---")
+            print("Unsorted lists are not equals:")
+            print("got          : %s" % counter_a)
+            print("was expecting: %s" % counter_b)
+            if msg:
+                print(msg)
+            print("---")
+            raise DifferentListException
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/helpers_plugins.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,301 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Helpers class for plugin dependencies """
+
+from twisted.internet import defer
+
+from wokkel.muc import Room, User
+from wokkel.generic import parseXml
+from wokkel.disco import DiscoItem, DiscoItems
+
+# temporary until the changes are integrated to Wokkel
+from sat_tmp.wokkel.rsm import RSMResponse
+
+from .constants import Const as C
+from libervia.backend.plugins import plugin_xep_0045
+from collections import OrderedDict
+
+
+class FakeMUCClient(object):
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+        self.joined_rooms = {}
+
+    def join(self, room_jid, nick, options=None, profile_key=C.PROF_KEY_NONE):
+        """
+        @param room_jid: the room JID
+        @param nick: nick to be used in the room
+        @param options: joining options
+        @param profile_key: the profile key of the user joining the room
+        @return: the deferred joined wokkel.muc.Room instance
+        """
+        profile = self.host.memory.get_profile_name(profile_key)
+        roster = {}
+
+        # ask the other profiles to fill our roster
+        for i in range(0, len(C.PROFILE)):
+            other_profile = C.PROFILE[i]
+            if other_profile == profile:
+                continue
+            try:
+                other_room = self.plugin_parent.clients[other_profile].joined_rooms[
+                    room_jid
+                ]
+                roster.setdefault(
+                    other_room.nick, User(other_room.nick, C.PROFILE_DICT[other_profile])
+                )
+                for other_nick in other_room.roster:
+                    roster.setdefault(other_nick, other_room.roster[other_nick])
+            except (AttributeError, KeyError):
+                pass
+
+        # rename our nick if it already exists
+        while nick in list(roster.keys()):
+            if C.PROFILE_DICT[profile].userhost() == roster[nick].entity.userhost():
+                break  # same user with different resource --> same nickname
+            nick = nick + "_"
+
+        room = Room(room_jid, nick)
+        room.roster = roster
+        self.joined_rooms[room_jid] = room
+
+        # fill the other rosters with the new entry
+        for i in range(0, len(C.PROFILE)):
+            other_profile = C.PROFILE[i]
+            if other_profile == profile:
+                continue
+            try:
+                other_room = self.plugin_parent.clients[other_profile].joined_rooms[
+                    room_jid
+                ]
+                other_room.roster.setdefault(
+                    room.nick, User(room.nick, C.PROFILE_DICT[profile])
+                )
+            except (AttributeError, KeyError):
+                pass
+
+        return defer.succeed(room)
+
+    def leave(self, roomJID, profile_key=C.PROF_KEY_NONE):
+        """
+        @param roomJID: the room JID
+        @param profile_key: the profile key of the user joining the room
+        @return: a dummy deferred
+        """
+        profile = self.host.memory.get_profile_name(profile_key)
+        room = self.joined_rooms[roomJID]
+        # remove ourself from the other rosters
+        for i in range(0, len(C.PROFILE)):
+            other_profile = C.PROFILE[i]
+            if other_profile == profile:
+                continue
+            try:
+                other_room = self.plugin_parent.clients[other_profile].joined_rooms[
+                    roomJID
+                ]
+                del other_room.roster[room.nick]
+            except (AttributeError, KeyError):
+                pass
+        del self.joined_rooms[roomJID]
+        return defer.Deferred()
+
+
+class FakeXEP_0045(plugin_xep_0045.XEP_0045):
+    def __init__(self, host):
+        self.host = host
+        self.clients = {}
+        for profile in C.PROFILE:
+            self.clients[profile] = FakeMUCClient(self)
+
+    def join(self, room_jid, nick, options={}, profile_key="@DEFAULT@"):
+        """
+        @param roomJID: the room JID
+        @param nick: nick to be used in the room
+        @param options: ignore
+        @param profile_key: the profile of the user joining the room
+        @return: the deferred joined wokkel.muc.Room instance or None
+        """
+        profile = self.host.memory.get_profile_name(profile_key)
+        if room_jid in self.clients[profile].joined_rooms:
+            return defer.succeed(None)
+        room = self.clients[profile].join(room_jid, nick, profile_key=profile)
+        return room
+
+    def join_room(self, muc_index, user_index):
+        """Called by tests
+        @return: the nickname of the user who joined room"""
+        muc_jid = C.MUC[muc_index]
+        nick = C.JID[user_index].user
+        profile = C.PROFILE[user_index]
+        self.join(muc_jid, nick, profile_key=profile)
+        return self.get_nick(muc_index, user_index)
+
+    def leave(self, room_jid, profile_key="@DEFAULT@"):
+        """
+        @param roomJID: the room JID
+        @param profile_key: the profile of the user leaving the room
+        @return: a dummy deferred
+        """
+        profile = self.host.memory.get_profile_name(profile_key)
+        if room_jid not in self.clients[profile].joined_rooms:
+            raise plugin_xep_0045.UnknownRoom("This room has not been joined")
+        return self.clients[profile].leave(room_jid, profile)
+
+    def leave_room(self, muc_index, user_index):
+        """Called by tests
+        @return: the nickname of the user who left the room"""
+        muc_jid = C.MUC[muc_index]
+        nick = self.get_nick(muc_index, user_index)
+        profile = C.PROFILE[user_index]
+        self.leave(muc_jid, profile_key=profile)
+        return nick
+
+    def get_room(self, muc_index, user_index):
+        """Called by tests
+        @return: a wokkel.muc.Room instance"""
+        profile = C.PROFILE[user_index]
+        muc_jid = C.MUC[muc_index]
+        try:
+            return self.clients[profile].joined_rooms[muc_jid]
+        except (AttributeError, KeyError):
+            return None
+
+    def get_nick(self, muc_index, user_index):
+        try:
+            return self.get_room_nick(C.MUC[muc_index], C.PROFILE[user_index])
+        except (KeyError, AttributeError):
+            return ""
+
+    def get_nick_of_user(self, muc_index, user_index, profile_index, secure=True):
+        try:
+            room = self.clients[C.PROFILE[profile_index]].joined_rooms[C.MUC[muc_index]]
+            return self.getRoomNickOfUser(room, C.JID[user_index])
+        except (KeyError, AttributeError):
+            return None
+
+
+class FakeXEP_0249(object):
+    def __init__(self, host):
+        self.host = host
+
+    def invite(self, target, room, options={}, profile_key="@DEFAULT@"):
+        """
+        Invite a user to a room. To accept the invitation from a test,
+        just call FakeXEP_0045.join_room (no need to have a dedicated method).
+        @param target: jid of the user to invite
+        @param room: jid of the room where the user is invited
+        @options: attribute with extra info (reason, password) as in #XEP-0249
+        @profile_key: %(doc_profile_key)s
+        """
+        pass
+
+
+class FakeSatPubSubClient(object):
+    def __init__(self, host, parent_plugin):
+        self.host = host
+        self.parent_plugin = parent_plugin
+        self.__items = OrderedDict()
+        self.__rsm_responses = {}
+
+    def createNode(self, service, nodeIdentifier=None, options=None, sender=None):
+        return defer.succeed(None)
+
+    def deleteNode(self, service, nodeIdentifier, sender=None):
+        try:
+            del self.__items[nodeIdentifier]
+        except KeyError:
+            pass
+        return defer.succeed(None)
+
+    def subscribe(self, service, nodeIdentifier, subscriber, options=None, sender=None):
+        return defer.succeed(None)
+
+    def unsubscribe(
+        self,
+        service,
+        nodeIdentifier,
+        subscriber,
+        subscriptionIdentifier=None,
+        sender=None,
+    ):
+        return defer.succeed(None)
+
+    def publish(self, service, nodeIdentifier, items=None, sender=None):
+        node = self.__items.setdefault(nodeIdentifier, [])
+
+        def replace(item_obj):
+            index = 0
+            for current in node:
+                if current["id"] == item_obj["id"]:
+                    node[index] = item_obj
+                    return True
+                index += 1
+            return False
+
+        for item in items:
+            item_obj = parseXml(item) if isinstance(item, str) else item
+            if not replace(item_obj):
+                node.append(item_obj)
+        return defer.succeed(None)
+
+    def items(
+        self,
+        service,
+        nodeIdentifier,
+        maxItems=None,
+        itemIdentifiers=None,
+        subscriptionIdentifier=None,
+        sender=None,
+        ext_data=None,
+    ):
+        try:
+            items = self.__items[nodeIdentifier]
+        except KeyError:
+            items = []
+        if ext_data:
+            assert "id" in ext_data
+            if "rsm" in ext_data:
+                args = (0, items[0]["id"], items[-1]["id"]) if items else ()
+                self.__rsm_responses[ext_data["id"]] = RSMResponse(len(items), *args)
+        return defer.succeed(items)
+
+    def retract_items(self, service, nodeIdentifier, itemIdentifiers, sender=None):
+        node = self.__items[nodeIdentifier]
+        for item in [item for item in node if item["id"] in itemIdentifiers]:
+            node.remove(item)
+        return defer.succeed(None)
+
+    def get_rsm_response(self, id):
+        if id not in self.__rsm_responses:
+            return {}
+        result = self.__rsm_responses[id].toDict()
+        del self.__rsm_responses[id]
+        return result
+
+    def subscriptions(self, service, nodeIdentifier, sender=None):
+        return defer.succeed([])
+
+    def service_get_disco_items(self, service, nodeIdentifier, profile_key=C.PROF_KEY_NONE):
+        items = DiscoItems()
+        for item in list(self.__items.keys()):
+            items.append(DiscoItem(service, item))
+        return defer.succeed(items)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_core_xmpp.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.test import helpers
+from .constants import Const
+from twisted.trial import unittest
+from libervia.backend.core import xmpp
+from twisted.words.protocols.jabber.jid import JID
+from wokkel.generic import parseXml
+from wokkel.xmppim import RosterItem
+
+
+class SatXMPPClientTest(unittest.TestCase):
+
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+        self.client = xmpp.SatXMPPClient(self.host, Const.PROFILE[0], JID("test@example.org"), "test")
+
+    def test_init(self):
+        """Check that init values are correctly initialised"""
+        self.assertEqual(self.client.profile, Const.PROFILE[0])
+        print(self.client.jid.host)
+        self.assertEqual(self.client.host_app, self.host)
+
+
+class SatMessageProtocolTest(unittest.TestCase):
+
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+        self.message = xmpp.SatMessageProtocol(self.host)
+        self.message.parent = helpers.FakeClient(self.host)
+
+    def test_on_message(self):
+        xml = """
+        <message type="chat" from="sender@example.net/house" to="test@example.org/SàT" id="test_1">
+        <body>test</body>
+        </message>
+        """
+        stanza = parseXml(xml)
+        self.host.bridge.expect_call("message_new", "sender@example.net/house", "test", "chat", "test@example.org/SàT", {}, profile=Const.PROFILE[0])
+        self.message.onMessage(stanza)
+
+
+class SatRosterProtocolTest(unittest.TestCase):
+
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+        self.roster = xmpp.SatRosterProtocol(self.host)
+        self.roster.parent = helpers.FakeClient(self.host)
+
+    def test_register_item(self):
+        roster_item = RosterItem(Const.JID[0])
+        roster_item.name = "Test Man"
+        roster_item.subscriptionTo = True
+        roster_item.subscriptionFrom = True
+        roster_item.ask = False
+        roster_item.groups = set(["Test Group 1", "Test Group 2", "Test Group 3"])
+        self.host.bridge.expect_call("contact_new", Const.JID_STR[0], {'to': 'True', 'from': 'True', 'ask': 'False', 'name': 'Test Man'}, set(["Test Group 1", "Test Group 2", "Test Group 3"]), Const.PROFILE[0])
+        self.roster._register_item(roster_item)
+
+
+class SatPresenceProtocolTest(unittest.TestCase):
+
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+        self.presence = xmpp.SatPresenceProtocol(self.host)
+        self.presence.parent = helpers.FakeClient(self.host)
+
+    def test_availableReceived(self):
+        self.host.bridge.expect_call("presence_update", Const.JID_STR[0], "xa", 15, {'default': "test status", 'fr': 'statut de test'}, Const.PROFILE[0])
+        self.presence.availableReceived(Const.JID[0], 'xa', {None: "test status", 'fr': 'statut de test'}, 15)
+
+    def test_available_received_empty_statuses(self):
+        self.host.bridge.expect_call("presence_update", Const.JID_STR[0], "xa", 15, {}, Const.PROFILE[0])
+        self.presence.availableReceived(Const.JID[0], 'xa', None, 15)
+
+    def test_unavailableReceived(self):
+        self.host.bridge.expect_call("presence_update", Const.JID_STR[0], "unavailable", 0, {}, Const.PROFILE[0])
+        self.presence.unavailableReceived(Const.JID[0], None)
+
+    def test_subscribedReceived(self):
+        self.host.bridge.expect_call("subscribe", "subscribed", Const.JID[0].userhost(), Const.PROFILE[0])
+        self.presence.subscribedReceived(Const.JID[0])
+
+    def test_unsubscribedReceived(self):
+        self.host.bridge.expect_call("subscribe", "unsubscribed", Const.JID[0].userhost(), Const.PROFILE[0])
+        self.presence.unsubscribedReceived(Const.JID[0])
+
+    def test_subscribeReceived(self):
+        self.host.bridge.expect_call("subscribe", "subscribe", Const.JID[0].userhost(), Const.PROFILE[0])
+        self.presence.subscribeReceived(Const.JID[0])
+
+    def test_unsubscribeReceived(self):
+        self.host.bridge.expect_call("subscribe", "unsubscribe", Const.JID[0].userhost(), Const.PROFILE[0])
+        self.presence.unsubscribeReceived(Const.JID[0])
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_helpers_plugins.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Test the helper classes to see if they behave well"""
+
+from libervia.backend.test import helpers
+from libervia.backend.test import helpers_plugins
+
+
+class FakeXEP_0045Test(helpers.SatTestCase):
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+        self.plugin = helpers_plugins.FakeXEP_0045(self.host)
+
+    def test_join_room(self):
+        self.plugin.join_room(0, 0)
+        self.assertEqual("test", self.plugin.get_nick(0, 0))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 0))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 0))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 0))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 0))
+        self.assertEqual("", self.plugin.get_nick(0, 1))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 1))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 1))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 1))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 1))
+        self.assertEqual("", self.plugin.get_nick(0, 2))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 2))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 2))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 2))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 2))
+        self.assertEqual("", self.plugin.get_nick(0, 3))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 3))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 3))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 3))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 3))
+        self.plugin.join_room(0, 1)
+        self.assertEqual("test", self.plugin.get_nick(0, 0))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 0))
+        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 1, 0))
+        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 2, 0))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 0))
+        self.assertEqual("sender", self.plugin.get_nick(0, 1))
+        self.assertEqual("test", self.plugin.get_nick_of_user(0, 0, 1))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 1))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 1))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 1))
+        self.assertEqual("", self.plugin.get_nick(0, 2))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 2))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 2))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 2))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 2))
+        self.assertEqual("", self.plugin.get_nick(0, 3))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 3))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 3))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 3))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 3))
+        self.plugin.join_room(0, 2)
+        self.assertEqual("test", self.plugin.get_nick(0, 0))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 0))
+        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 1, 0))
+        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 2, 0))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 0))
+        self.assertEqual("sender", self.plugin.get_nick(0, 1))
+        self.assertEqual("test", self.plugin.get_nick_of_user(0, 0, 1))
+        self.assertEqual(
+            "sender", self.plugin.get_nick_of_user(0, 1, 1)
+        )  # Const.JID[2] is in the roster for Const.PROFILE[1]
+        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 2, 1))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 1))
+        self.assertEqual("sender", self.plugin.get_nick(0, 2))
+        self.assertEqual("test", self.plugin.get_nick_of_user(0, 0, 2))
+        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 1, 2))
+        self.assertEqual(
+            "sender", self.plugin.get_nick_of_user(0, 2, 2)
+        )  # Const.JID[1] is in the roster for Const.PROFILE[2]
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 2))
+        self.assertEqual("", self.plugin.get_nick(0, 3))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 3))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 3))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 3))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 3))
+        self.plugin.join_room(0, 3)
+        self.assertEqual("test", self.plugin.get_nick(0, 0))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 0))
+        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 1, 0))
+        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 2, 0))
+        self.assertEqual("sender_", self.plugin.get_nick_of_user(0, 3, 0))
+        self.assertEqual("sender", self.plugin.get_nick(0, 1))
+        self.assertEqual("test", self.plugin.get_nick_of_user(0, 0, 1))
+        self.assertEqual(
+            "sender", self.plugin.get_nick_of_user(0, 1, 1)
+        )  # Const.JID[2] is in the roster for Const.PROFILE[1]
+        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 2, 1))
+        self.assertEqual("sender_", self.plugin.get_nick_of_user(0, 3, 1))
+        self.assertEqual("sender", self.plugin.get_nick(0, 2))
+        self.assertEqual("test", self.plugin.get_nick_of_user(0, 0, 2))
+        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 1, 2))
+        self.assertEqual(
+            "sender", self.plugin.get_nick_of_user(0, 2, 2)
+        )  # Const.JID[1] is in the roster for Const.PROFILE[2]
+        self.assertEqual("sender_", self.plugin.get_nick_of_user(0, 3, 2))
+        self.assertEqual("sender_", self.plugin.get_nick(0, 3))
+        self.assertEqual("test", self.plugin.get_nick_of_user(0, 0, 3))
+        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 1, 3))
+        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 2, 3))
+        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 3))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_memory.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,313 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.core.i18n import _
+from libervia.backend.test import helpers
+from twisted.trial import unittest
+import traceback
+from .constants import Const
+from xml.dom import minidom
+
+
+class MemoryTest(unittest.TestCase):
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+
+    def _get_param_xml(self, param="1", security_level=None):
+        """Generate XML for testing parameters
+
+        @param param (str): a subset of "123"
+        @param security_level: security level of the parameters
+        @return (str)
+        """
+
+        def get_param(name):
+            return """
+            <param name="%(param_name)s" label="%(param_label)s" value="true" type="bool" %(security)s/>
+            """ % {
+                "param_name": name,
+                "param_label": _(name),
+                "security": ""
+                if security_level is None
+                else ('security="%d"' % security_level),
+            }
+
+        params = ""
+        if "1" in param:
+            params += get_param(Const.ENABLE_UNIBOX_PARAM)
+        if "2" in param:
+            params += get_param(Const.PARAM_IN_QUOTES)
+        if "3" in param:
+            params += get_param("Dummy param")
+        return """
+        <params>
+        <individual>
+        <category name="%(category_name)s" label="%(category_label)s">
+            %(params)s
+         </category>
+        </individual>
+        </params>
+        """ % {
+            "category_name": Const.COMPOSITION_KEY,
+            "category_label": _(Const.COMPOSITION_KEY),
+            "params": params,
+        }
+
+    def _param_exists(self, param="1", src=None):
+        """
+
+        @param param (str): a character in "12"
+        @param src (DOM element): the top-level element to look in
+        @return: True is the param exists
+        """
+        if param == "1":
+            name = Const.ENABLE_UNIBOX_PARAM
+        else:
+            name = Const.PARAM_IN_QUOTES
+        category = Const.COMPOSITION_KEY
+        if src is None:
+            src = self.host.memory.params.dom.documentElement
+        for type_node in src.childNodes:
+            # when src comes self.host.memory.params.dom, we have here
+            # some "individual" or "general" elements, when it comes
+            # from Memory.get_params we have here a "params" elements
+            if type_node.nodeName not in ("individual", "general", "params"):
+                continue
+            for cat_node in type_node.childNodes:
+                if (
+                    cat_node.nodeName != "category"
+                    or cat_node.getAttribute("name") != category
+                ):
+                    continue
+                for param in cat_node.childNodes:
+                    if param.nodeName == "param" and param.getAttribute("name") == name:
+                        return True
+        return False
+
+    def assert_param_generic(self, param="1", src=None, exists=True, deferred=False):
+        """
+        @param param (str): a character in "12"
+        @param src (DOM element): the top-level element to look in
+        @param exists (boolean): True to assert the param exists, False to assert it doesn't
+        @param deferred (boolean): True if this method is called from a Deferred callback
+        """
+        msg = (
+            "Expected parameter not found!\n"
+            if exists
+            else "Unexpected parameter found!\n"
+        )
+        if deferred:
+            # in this stack we can see the line where the error came from,
+            # if limit=5, 6 is not enough you can increase the value
+            msg += "\n".join(traceback.format_stack(limit=5 if exists else 6))
+        assertion = self._param_exists(param, src)
+        getattr(self, "assert%s" % exists)(assertion, msg)
+
+    def assert_param_exists(self, param="1", src=None):
+        self.assert_param_generic(param, src, True)
+
+    def assert_param_not_exists(self, param="1", src=None):
+        self.assert_param_generic(param, src, False)
+
+    def assert_param_exists_async(self, src, param="1"):
+        """@param src: a deferred result from Memory.get_params"""
+        self.assert_param_generic(
+            param, minidom.parseString(src.encode("utf-8")), True, True
+        )
+
+    def assert_param_not_exists_async(self, src, param="1"):
+        """@param src: a deferred result from Memory.get_params"""
+        self.assert_param_generic(
+            param, minidom.parseString(src.encode("utf-8")), False, True
+        )
+
+    def _get_params(self, security_limit, app="", profile_key="@NONE@"):
+        """Get the parameters accessible with the given security limit and application name.
+
+        @param security_limit (int): the security limit
+        @param app (str): empty string or "libervia"
+        @param profile_key
+        """
+        if profile_key == "@NONE@":
+            profile_key = "@DEFAULT@"
+        return self.host.memory.params.get_params(security_limit, app, profile_key)
+
+    def test_update_params(self):
+        self.host.memory.reinit()
+        # check if the update works
+        self.host.memory.update_params(self._get_param_xml())
+        self.assert_param_exists()
+        previous = self.host.memory.params.dom.cloneNode(True)
+        # now check if it is really updated and not duplicated
+        self.host.memory.update_params(self._get_param_xml())
+        self.assertEqual(
+            previous.toxml().encode("utf-8"),
+            self.host.memory.params.dom.toxml().encode("utf-8"),
+        )
+
+        self.host.memory.reinit()
+        # check successive updates (without intersection)
+        self.host.memory.update_params(self._get_param_xml("1"))
+        self.assert_param_exists("1")
+        self.assert_param_not_exists("2")
+        self.host.memory.update_params(self._get_param_xml("2"))
+        self.assert_param_exists("1")
+        self.assert_param_exists("2")
+
+        previous = self.host.memory.params.dom.cloneNode(True)  # save for later
+
+        self.host.memory.reinit()
+        # check successive updates (with intersection)
+        self.host.memory.update_params(self._get_param_xml("1"))
+        self.assert_param_exists("1")
+        self.assert_param_not_exists("2")
+        self.host.memory.update_params(self._get_param_xml("12"))
+        self.assert_param_exists("1")
+        self.assert_param_exists("2")
+
+        # successive updates with or without intersection should have the same result
+        self.assertEqual(
+            previous.toxml().encode("utf-8"),
+            self.host.memory.params.dom.toxml().encode("utf-8"),
+        )
+
+        self.host.memory.reinit()
+        # one update with two params in a new category
+        self.host.memory.update_params(self._get_param_xml("12"))
+        self.assert_param_exists("1")
+        self.assert_param_exists("2")
+
+    def test_get_params(self):
+        # tests with no security level on the parameter (most secure)
+        params = self._get_param_xml()
+        self.host.memory.reinit()
+        self.host.memory.update_params(params)
+        self._get_params(Const.NO_SECURITY_LIMIT).addCallback(self.assert_param_exists_async)
+        self._get_params(0).addCallback(self.assert_param_not_exists_async)
+        self._get_params(1).addCallback(self.assert_param_not_exists_async)
+        # tests with security level 0 on the parameter (not secure)
+        params = self._get_param_xml(security_level=0)
+        self.host.memory.reinit()
+        self.host.memory.update_params(params)
+        self._get_params(Const.NO_SECURITY_LIMIT).addCallback(self.assert_param_exists_async)
+        self._get_params(0).addCallback(self.assert_param_exists_async)
+        self._get_params(1).addCallback(self.assert_param_exists_async)
+        # tests with security level 1 on the parameter (more secure)
+        params = self._get_param_xml(security_level=1)
+        self.host.memory.reinit()
+        self.host.memory.update_params(params)
+        self._get_params(Const.NO_SECURITY_LIMIT).addCallback(self.assert_param_exists_async)
+        self._get_params(0).addCallback(self.assert_param_not_exists_async)
+        return self._get_params(1).addCallback(self.assert_param_exists_async)
+
+    def test_params_register_app(self):
+        def register(xml, security_limit, app):
+            """
+            @param xml: XML definition of the parameters to be added
+            @param security_limit: -1 means no security, 0 is the maximum security then the higher the less secure
+            @param app: name of the frontend registering the parameters
+            """
+            helpers.mute_logging()
+            self.host.memory.params_register_app(xml, security_limit, app)
+            helpers.unmute_logging()
+
+        # tests with no security level on the parameter (most secure)
+        params = self._get_param_xml()
+        self.host.memory.reinit()
+        register(params, Const.NO_SECURITY_LIMIT, Const.APP_NAME)
+        self.assert_param_exists()
+        self.host.memory.reinit()
+        register(params, 0, Const.APP_NAME)
+        self.assert_param_not_exists()
+        self.host.memory.reinit()
+        register(params, 1, Const.APP_NAME)
+        self.assert_param_not_exists()
+
+        # tests with security level 0 on the parameter (not secure)
+        params = self._get_param_xml(security_level=0)
+        self.host.memory.reinit()
+        register(params, Const.NO_SECURITY_LIMIT, Const.APP_NAME)
+        self.assert_param_exists()
+        self.host.memory.reinit()
+        register(params, 0, Const.APP_NAME)
+        self.assert_param_exists()
+        self.host.memory.reinit()
+        register(params, 1, Const.APP_NAME)
+        self.assert_param_exists()
+
+        # tests with security level 1 on the parameter (more secure)
+        params = self._get_param_xml(security_level=1)
+        self.host.memory.reinit()
+        register(params, Const.NO_SECURITY_LIMIT, Const.APP_NAME)
+        self.assert_param_exists()
+        self.host.memory.reinit()
+        register(params, 0, Const.APP_NAME)
+        self.assert_param_not_exists()
+        self.host.memory.reinit()
+        register(params, 1, Const.APP_NAME)
+        self.assert_param_exists()
+
+        # tests with security level 1 and several parameters being registered
+        params = self._get_param_xml("12", security_level=1)
+        self.host.memory.reinit()
+        register(params, Const.NO_SECURITY_LIMIT, Const.APP_NAME)
+        self.assert_param_exists()
+        self.assert_param_exists("2")
+        self.host.memory.reinit()
+        register(params, 0, Const.APP_NAME)
+        self.assert_param_not_exists()
+        self.assert_param_not_exists("2")
+        self.host.memory.reinit()
+        register(params, 1, Const.APP_NAME)
+        self.assert_param_exists()
+        self.assert_param_exists("2")
+
+        # tests with several parameters being registered in an existing category
+        self.host.memory.reinit()
+        self.host.memory.update_params(self._get_param_xml("3"))
+        register(self._get_param_xml("12"), Const.NO_SECURITY_LIMIT, Const.APP_NAME)
+        self.assert_param_exists()
+        self.assert_param_exists("2")
+        self.host.memory.reinit()
+
+    def test_params_register_app_get_params(self):
+        # test retrieving the parameter for a specific frontend
+        self.host.memory.reinit()
+        params = self._get_param_xml(security_level=1)
+        self.host.memory.params_register_app(params, 1, Const.APP_NAME)
+        self._get_params(1, "").addCallback(self.assert_param_exists_async)
+        self._get_params(1, Const.APP_NAME).addCallback(self.assert_param_exists_async)
+        self._get_params(1, "another_dummy_frontend").addCallback(
+            self.assert_param_not_exists_async
+        )
+
+        # the same with several parameters registered at the same time
+        self.host.memory.reinit()
+        params = self._get_param_xml("12", security_level=0)
+        self.host.memory.params_register_app(params, 5, Const.APP_NAME)
+        self._get_params(5, "").addCallback(self.assert_param_exists_async)
+        self._get_params(5, "").addCallback(self.assert_param_exists_async, "2")
+        self._get_params(5, Const.APP_NAME).addCallback(self.assert_param_exists_async)
+        self._get_params(5, Const.APP_NAME).addCallback(self.assert_param_exists_async, "2")
+        self._get_params(5, "another_dummy_frontend").addCallback(
+            self.assert_param_not_exists_async
+        )
+        return self._get_params(5, "another_dummy_frontend").addCallback(
+            self.assert_param_not_exists_async, "2"
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_memory_crypto.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2016  Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016  Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+""" Tests for the plugin radiocol """
+
+from libervia.backend.test import helpers
+from libervia.backend.memory.crypto import BlockCipher, PasswordHasher
+import random
+import string
+from twisted.internet import defer
+
+
+def get_random_unicode(len):
+    """Return a random unicode string"""
+    return "".join(random.choice(string.letters + "éáúóâêûôßüöä") for i in range(len))
+
+
+class CryptoTest(helpers.SatTestCase):
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+
+    def test_encrypt_decrypt(self):
+        d_list = []
+
+        def test(key, message):
+            d = BlockCipher.encrypt(key, message)
+            d.addCallback(lambda ciphertext: BlockCipher.decrypt(key, ciphertext))
+            d.addCallback(lambda decrypted: self.assertEqual(message, decrypted))
+            d_list.append(d)
+
+        for key_len in (0, 2, 8, 10, 16, 24, 30, 32, 40):
+            key = get_random_unicode(key_len)
+            for message_len in (0, 2, 16, 24, 32, 100):
+                message = get_random_unicode(message_len)
+                test(key, message)
+        return defer.DeferredList(d_list)
+
+    def test_hash_verify(self):
+        d_list = []
+        for password in (0, 2, 8, 10, 16, 24, 30, 32, 40):
+            d = PasswordHasher.hash(password)
+
+            def cb(hashed):
+                d1 = PasswordHasher.verify(password, hashed)
+                d1.addCallback(lambda result: self.assertTrue(result))
+                d_list.append(d1)
+                attempt = get_random_unicode(10)
+                d2 = PasswordHasher.verify(attempt, hashed)
+                d2.addCallback(lambda result: self.assertFalse(result))
+                d_list.append(d2)
+
+            d.addCallback(cb)
+        return defer.DeferredList(d_list)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_plugin_misc_groupblog.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,615 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Plugin groupblogs """
+
+from .constants import Const as C
+from libervia.backend.test import helpers, helpers_plugins
+from libervia.backend.plugins import plugin_misc_groupblog
+from libervia.backend.plugins import plugin_xep_0060
+from libervia.backend.plugins import plugin_xep_0277
+from libervia.backend.plugins import plugin_xep_0163
+from libervia.backend.plugins import plugin_misc_text_syntaxes
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+import importlib
+
+
+NS_PUBSUB = "http://jabber.org/protocol/pubsub"
+
+DO_NOT_COUNT_COMMENTS = -1
+
+SERVICE = "pubsub.example.com"
+PUBLISHER = "test@example.org"
+OTHER_PUBLISHER = "other@xmpp.net"
+NODE_ID = "urn:xmpp:groupblog:{publisher}".format(publisher=PUBLISHER)
+OTHER_NODE_ID = "urn:xmpp:groupblog:{publisher}".format(publisher=OTHER_PUBLISHER)
+ITEM_ID_1 = "c745a688-9b02-11e3-a1a3-c0143dd4fe51"
+COMMENT_ID_1 = "d745a688-9b02-11e3-a1a3-c0143dd4fe52"
+COMMENT_ID_2 = "e745a688-9b02-11e3-a1a3-c0143dd4fe53"
+
+
+def COMMENTS_NODE_ID(publisher=PUBLISHER):
+    return "urn:xmpp:comments:_{id}__urn:xmpp:groupblog:{publisher}".format(
+        id=ITEM_ID_1, publisher=publisher
+    )
+
+
+def COMMENTS_NODE_URL(publisher=PUBLISHER):
+    return "xmpp:{service}?node={node}".format(
+        service=SERVICE,
+        id=ITEM_ID_1,
+        node=COMMENTS_NODE_ID(publisher).replace(":", "%3A").replace("@", "%40"),
+    )
+
+
+def ITEM(publisher=PUBLISHER):
+    return """
+          <item id='{id}' xmlns='{ns}'>
+            <entry>
+              <title type='text'>The Uses of This World</title>
+              <id>{id}</id>
+              <updated>2003-12-12T17:47:23Z</updated>
+              <published>2003-12-12T17:47:23Z</published>
+              <link href='{comments_node_url}' rel='replies' title='comments'/>
+              <author>
+                <name>{publisher}</name>
+              </author>
+            </entry>
+          </item>
+        """.format(
+        ns=NS_PUBSUB,
+        id=ITEM_ID_1,
+        publisher=publisher,
+        comments_node_url=COMMENTS_NODE_URL(publisher),
+    )
+
+
+def COMMENT(id_=COMMENT_ID_1):
+    return """
+          <item id='{id}' xmlns='{ns}'>
+            <entry>
+              <title type='text'>The Uses of This World</title>
+              <id>{id}</id>
+              <updated>2003-12-12T17:47:23Z</updated>
+              <published>2003-12-12T17:47:23Z</published>
+              <author>
+                <name>{publisher}</name>
+              </author>
+            </entry>
+          </item>
+        """.format(
+        ns=NS_PUBSUB, id=id_, publisher=PUBLISHER
+    )
+
+
+def ITEM_DATA(id_=ITEM_ID_1, count=0):
+    res = {
+        "id": ITEM_ID_1,
+        "type": "main_item",
+        "content": "The Uses of This World",
+        "author": PUBLISHER,
+        "updated": "1071251243.0",
+        "published": "1071251243.0",
+        "service": SERVICE,
+        "comments": COMMENTS_NODE_URL_1,
+        "comments_service": SERVICE,
+        "comments_node": COMMENTS_NODE_ID_1,
+    }
+    if count != DO_NOT_COUNT_COMMENTS:
+        res.update({"comments_count": str(count)})
+    return res
+
+
+def COMMENT_DATA(id_=COMMENT_ID_1):
+    return {
+        "id": id_,
+        "type": "comment",
+        "content": "The Uses of This World",
+        "author": PUBLISHER,
+        "updated": "1071251243.0",
+        "published": "1071251243.0",
+        "service": SERVICE,
+        "node": COMMENTS_NODE_ID_1,
+        "verified_publisher": "false",
+    }
+
+
+COMMENTS_NODE_ID_1 = COMMENTS_NODE_ID()
+COMMENTS_NODE_ID_2 = COMMENTS_NODE_ID(OTHER_PUBLISHER)
+COMMENTS_NODE_URL_1 = COMMENTS_NODE_URL()
+COMMENTS_NODE_URL_2 = COMMENTS_NODE_URL(OTHER_PUBLISHER)
+ITEM_1 = ITEM()
+ITEM_2 = ITEM(OTHER_PUBLISHER)
+COMMENT_1 = COMMENT(COMMENT_ID_1)
+COMMENT_2 = COMMENT(COMMENT_ID_2)
+
+
+def ITEM_DATA_1(count=0):
+    return ITEM_DATA(count=count)
+
+
+COMMENT_DATA_1 = COMMENT_DATA()
+COMMENT_DATA_2 = COMMENT_DATA(COMMENT_ID_2)
+
+
+class XEP_groupblogTest(helpers.SatTestCase):
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+        self.host.plugins["XEP-0060"] = plugin_xep_0060.XEP_0060(self.host)
+        self.host.plugins["XEP-0163"] = plugin_xep_0163.XEP_0163(self.host)
+        importlib.reload(plugin_misc_text_syntaxes)  # reload the plugin to avoid conflict error
+        self.host.plugins["TEXT_SYNTAXES"] = plugin_misc_text_syntaxes.TextSyntaxes(
+            self.host
+        )
+        self.host.plugins["XEP-0277"] = plugin_xep_0277.XEP_0277(self.host)
+        self.plugin = plugin_misc_groupblog.GroupBlog(self.host)
+        self.plugin._initialise = self._initialise
+        self.__initialised = False
+        self._initialise(C.PROFILE[0])
+
+    def _initialise(self, profile_key):
+        profile = profile_key
+        client = self.host.get_client(profile)
+        if not self.__initialised:
+            client.item_access_pubsub = jid.JID(SERVICE)
+            xep_0060 = self.host.plugins["XEP-0060"]
+            client.pubsub_client = helpers_plugins.FakeSatPubSubClient(
+                self.host, xep_0060
+            )
+            client.pubsub_client.parent = client
+            self.psclient = client.pubsub_client
+            helpers.FakeSAT.getDiscoItems = self.psclient.service_get_disco_items
+            self.__initialised = True
+        return defer.succeed((profile, client))
+
+    def _add_item(self, profile, item, parent_node=None):
+        client = self.host.get_client(profile)
+        client.pubsub_client._add_item(item, parent_node)
+
+    def test_send_group_blog(self):
+        self._initialise(C.PROFILE[0])
+        d = self.psclient.items(SERVICE, NODE_ID)
+        d.addCallback(lambda items: self.assertEqual(len(items), 0))
+        d.addCallback(
+            lambda __: self.plugin.sendGroupBlog(
+                "PUBLIC", [], "test", {}, C.PROFILE[0]
+            )
+        )
+        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
+        return d.addCallback(lambda items: self.assertEqual(len(items), 1))
+
+    def test_delete_group_blog(self):
+        pub_data = (SERVICE, NODE_ID, ITEM_ID_1)
+        self.host.bridge.expect_call(
+            "personalEvent",
+            C.JID_STR[0],
+            "MICROBLOG_DELETE",
+            {"type": "main_item", "id": ITEM_ID_1},
+            C.PROFILE[0],
+        )
+
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.plugin.deleteGroupBlog(
+                pub_data, COMMENTS_NODE_URL_1, profile_key=C.PROFILE[0]
+            )
+        )
+        return d.addCallback(self.assertEqual, None)
+
+    def test_update_group_blog(self):
+        pub_data = (SERVICE, NODE_ID, ITEM_ID_1)
+        new_text = "silfu23RFWUP)IWNOEIOEFÖ"
+
+        self._initialise(C.PROFILE[0])
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.plugin.updateGroupBlog(
+                pub_data, COMMENTS_NODE_URL_1, new_text, {}, profile_key=C.PROFILE[0]
+            )
+        )
+        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
+        return d.addCallback(
+            lambda items: self.assertEqual(
+                "".join(items[0].entry.title.children), new_text
+            )
+        )
+
+    def test_send_group_blog_comment(self):
+        self._initialise(C.PROFILE[0])
+        d = self.psclient.items(SERVICE, NODE_ID)
+        d.addCallback(lambda items: self.assertEqual(len(items), 0))
+        d.addCallback(
+            lambda __: self.plugin.sendGroupBlogComment(
+                COMMENTS_NODE_URL_1, "test", {}, profile_key=C.PROFILE[0]
+            )
+        )
+        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
+        return d.addCallback(lambda items: self.assertEqual(len(items), 1))
+
+    def test_get_group_blogs(self):
+        self._initialise(C.PROFILE[0])
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.plugin.getGroupBlogs(PUBLISHER, profile_key=C.PROFILE[0])
+        )
+        result = (
+            [ITEM_DATA_1()],
+            {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
+        )
+        return d.addCallback(self.assertEqual, result)
+
+    def test_get_group_blogs_no_count(self):
+        self._initialise(C.PROFILE[0])
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.plugin.getGroupBlogs(
+                PUBLISHER, count_comments=False, profile_key=C.PROFILE[0]
+            )
+        )
+        result = (
+            [ITEM_DATA_1(DO_NOT_COUNT_COMMENTS)],
+            {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
+        )
+        return d.addCallback(self.assertEqual, result)
+
+    def test_get_group_blogs_with_i_ds(self):
+        self._initialise(C.PROFILE[0])
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.plugin.getGroupBlogs(
+                PUBLISHER, [ITEM_ID_1], profile_key=C.PROFILE[0]
+            )
+        )
+        result = (
+            [ITEM_DATA_1()],
+            {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
+        )
+        return d.addCallback(self.assertEqual, result)
+
+    def test_get_group_blogs_with_rsm(self):
+        self._initialise(C.PROFILE[0])
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.plugin.getGroupBlogs(
+                PUBLISHER, rsm_data={"max_": 1}, profile_key=C.PROFILE[0]
+            )
+        )
+        result = (
+            [ITEM_DATA_1()],
+            {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
+        )
+        return d.addCallback(self.assertEqual, result)
+
+    def test_get_group_blogs_with_comments(self):
+        self._initialise(C.PROFILE[0])
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1])
+        )
+        d.addCallback(
+            lambda __: self.plugin.getGroupBlogsWithComments(
+                PUBLISHER, [], profile_key=C.PROFILE[0]
+            )
+        )
+        result = (
+            [
+                (
+                    ITEM_DATA_1(1),
+                    (
+                        [COMMENT_DATA_1],
+                        {
+                            "count": "1",
+                            "index": "0",
+                            "first": COMMENT_ID_1,
+                            "last": COMMENT_ID_1,
+                        },
+                    ),
+                )
+            ],
+            {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
+        )
+        return d.addCallback(self.assertEqual, result)
+
+    def test_get_group_blogs_with_comments_2(self):
+        self._initialise(C.PROFILE[0])
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.psclient.publish(
+                SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2]
+            )
+        )
+        d.addCallback(
+            lambda __: self.plugin.getGroupBlogsWithComments(
+                PUBLISHER, [], profile_key=C.PROFILE[0]
+            )
+        )
+        result = (
+            [
+                (
+                    ITEM_DATA_1(2),
+                    (
+                        [COMMENT_DATA_1, COMMENT_DATA_2],
+                        {
+                            "count": "2",
+                            "index": "0",
+                            "first": COMMENT_ID_1,
+                            "last": COMMENT_ID_2,
+                        },
+                    ),
+                )
+            ],
+            {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
+        )
+
+        return d.addCallback(self.assertEqual, result)
+
+    def test_get_group_blogs_atom(self):
+        self._initialise(C.PROFILE[0])
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.plugin.getGroupBlogsAtom(
+                PUBLISHER, {"max_": 1}, profile_key=C.PROFILE[0]
+            )
+        )
+
+        def cb(atom):
+            self.assertIsInstance(atom, str)
+            self.assertTrue(atom.startswith('<?xml version="1.0" encoding="utf-8"?>'))
+
+        return d.addCallback(cb)
+
+    def test_get_massive_group_blogs(self):
+        self._initialise(C.PROFILE[0])
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.plugin.getMassiveGroupBlogs(
+                "JID", [jid.JID(PUBLISHER)], {"max_": 1}, profile_key=C.PROFILE[0]
+            )
+        )
+        result = {
+            PUBLISHER: (
+                [ITEM_DATA_1()],
+                {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
+            )
+        }
+
+        def clean(res):
+            del self.host.plugins["XEP-0060"].node_cache[
+                C.PROFILE[0] + "@found@" + SERVICE
+            ]
+            return res
+
+        d.addCallback(clean)
+        d.addCallback(self.assertEqual, result)
+
+    def test_get_massive_group_blogs_with_comments(self):
+        self._initialise(C.PROFILE[0])
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.psclient.publish(
+                SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2]
+            )
+        )
+        d.addCallback(
+            lambda __: self.plugin.getMassiveGroupBlogs(
+                "JID", [jid.JID(PUBLISHER)], {"max_": 1}, profile_key=C.PROFILE[0]
+            )
+        )
+        result = {
+            PUBLISHER: (
+                [ITEM_DATA_1(2)],
+                {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
+            )
+        }
+
+        def clean(res):
+            del self.host.plugins["XEP-0060"].node_cache[
+                C.PROFILE[0] + "@found@" + SERVICE
+            ]
+            return res
+
+        d.addCallback(clean)
+        d.addCallback(self.assertEqual, result)
+
+    def test_get_group_blog_comments(self):
+        self._initialise(C.PROFILE[0])
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1])
+        )
+        d.addCallback(
+            lambda __: self.plugin.getGroupBlogComments(
+                SERVICE, COMMENTS_NODE_ID_1, {"max_": 1}, profile_key=C.PROFILE[0]
+            )
+        )
+        result = (
+            [COMMENT_DATA_1],
+            {"count": "1", "index": "0", "first": COMMENT_ID_1, "last": COMMENT_ID_1},
+        )
+        return d.addCallback(self.assertEqual, result)
+
+    def test_subscribe_group_blog(self):
+        self._initialise(C.PROFILE[0])
+        d = self.plugin.subscribeGroupBlog(PUBLISHER, profile_key=C.PROFILE[0])
+        return d.addCallback(self.assertEqual, None)
+
+    def test_massive_subscribe_group_blogs(self):
+        self._initialise(C.PROFILE[0])
+        d = self.plugin.massiveSubscribeGroupBlogs(
+            "JID", [jid.JID(PUBLISHER)], profile_key=C.PROFILE[0]
+        )
+
+        def clean(res):
+            del self.host.plugins["XEP-0060"].node_cache[
+                C.PROFILE[0] + "@found@" + SERVICE
+            ]
+            del self.host.plugins["XEP-0060"].node_cache[
+                C.PROFILE[0] + "@subscriptions@" + SERVICE
+            ]
+            return res
+
+        d.addCallback(clean)
+        return d.addCallback(self.assertEqual, None)
+
+    def test_delete_all_group_blogs(self):
+        """Delete our main node and associated comments node"""
+        self._initialise(C.PROFILE[0])
+        self.host.profiles[C.PROFILE[0]].roster.add_item(jid.JID(OTHER_PUBLISHER))
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.psclient.publish(
+                SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2]
+            )
+        )
+        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
+        d.addCallback(lambda items: self.assertEqual(len(items), 1))
+        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
+        d.addCallback(lambda items: self.assertEqual(len(items), 2))
+
+        d.addCallback(
+            lambda __: self.psclient.publish(SERVICE, OTHER_NODE_ID, [ITEM_2])
+        )
+        d.addCallback(
+            lambda __: self.psclient.publish(
+                SERVICE, COMMENTS_NODE_ID_2, [COMMENT_1, COMMENT_2]
+            )
+        )
+        d.addCallback(lambda __: self.psclient.items(SERVICE, OTHER_NODE_ID))
+        d.addCallback(lambda items: self.assertEqual(len(items), 1))
+        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2))
+        d.addCallback(lambda items: self.assertEqual(len(items), 2))
+
+        def clean(res):
+            del self.host.plugins["XEP-0060"].node_cache[
+                C.PROFILE[0] + "@found@" + SERVICE
+            ]
+            return res
+
+        d.addCallback(lambda __: self.plugin.deleteAllGroupBlogs(C.PROFILE[0]))
+        d.addCallback(clean)
+
+        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
+        d.addCallback(lambda items: self.assertEqual(len(items), 0))
+        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
+        d.addCallback(lambda items: self.assertEqual(len(items), 0))
+
+        d.addCallback(lambda __: self.psclient.items(SERVICE, OTHER_NODE_ID))
+        d.addCallback(lambda items: self.assertEqual(len(items), 1))
+        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2))
+        d.addCallback(lambda items: self.assertEqual(len(items), 2))
+        return d
+
+    def test_delete_all_group_blogs_comments(self):
+        """Delete the comments we posted on other node's"""
+        self._initialise(C.PROFILE[0])
+        self.host.profiles[C.PROFILE[0]].roster.add_item(jid.JID(OTHER_PUBLISHER))
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.psclient.publish(
+                SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2]
+            )
+        )
+        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
+        d.addCallback(lambda items: self.assertEqual(len(items), 1))
+        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
+        d.addCallback(lambda items: self.assertEqual(len(items), 2))
+
+        d.addCallback(
+            lambda __: self.psclient.publish(SERVICE, OTHER_NODE_ID, [ITEM_2])
+        )
+        d.addCallback(
+            lambda __: self.psclient.publish(
+                SERVICE, COMMENTS_NODE_ID_2, [COMMENT_1, COMMENT_2]
+            )
+        )
+        d.addCallback(lambda __: self.psclient.items(SERVICE, OTHER_NODE_ID))
+        d.addCallback(lambda items: self.assertEqual(len(items), 1))
+        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2))
+        d.addCallback(lambda items: self.assertEqual(len(items), 2))
+
+        def clean(res):
+            del self.host.plugins["XEP-0060"].node_cache[
+                C.PROFILE[0] + "@found@" + SERVICE
+            ]
+            return res
+
+        d.addCallback(lambda __: self.plugin.deleteAllGroupBlogsComments(C.PROFILE[0]))
+        d.addCallback(clean)
+
+        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
+        d.addCallback(lambda items: self.assertEqual(len(items), 1))
+        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
+        d.addCallback(lambda items: self.assertEqual(len(items), 2))
+
+        d.addCallback(lambda __: self.psclient.items(SERVICE, OTHER_NODE_ID))
+        d.addCallback(lambda items: self.assertEqual(len(items), 1))
+        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2))
+        d.addCallback(lambda items: self.assertEqual(len(items), 0))
+        return d
+
+    def test_delete_all_group_blogs_and_comments(self):
+        self._initialise(C.PROFILE[0])
+        self.host.profiles[C.PROFILE[0]].roster.add_item(jid.JID(OTHER_PUBLISHER))
+        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
+        d.addCallback(
+            lambda __: self.psclient.publish(
+                SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2]
+            )
+        )
+        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
+        d.addCallback(lambda items: self.assertEqual(len(items), 1))
+        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
+        d.addCallback(lambda items: self.assertEqual(len(items), 2))
+
+        d.addCallback(
+            lambda __: self.psclient.publish(SERVICE, OTHER_NODE_ID, [ITEM_2])
+        )
+        d.addCallback(
+            lambda __: self.psclient.publish(
+                SERVICE, COMMENTS_NODE_ID_2, [COMMENT_1, COMMENT_2]
+            )
+        )
+        d.addCallback(lambda __: self.psclient.items(SERVICE, OTHER_NODE_ID))
+        d.addCallback(lambda items: self.assertEqual(len(items), 1))
+        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2))
+        d.addCallback(lambda items: self.assertEqual(len(items), 2))
+
+        def clean(res):
+            del self.host.plugins["XEP-0060"].node_cache[
+                C.PROFILE[0] + "@found@" + SERVICE
+            ]
+            return res
+
+        d.addCallback(
+            lambda __: self.plugin.deleteAllGroupBlogsAndComments(C.PROFILE[0])
+        )
+        d.addCallback(clean)
+
+        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
+        d.addCallback(lambda items: self.assertEqual(len(items), 0))
+        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
+        d.addCallback(lambda items: self.assertEqual(len(items), 0))
+
+        d.addCallback(lambda __: self.psclient.items(SERVICE, OTHER_NODE_ID))
+        d.addCallback(lambda items: self.assertEqual(len(items), 1))
+        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2))
+        d.addCallback(lambda items: self.assertEqual(len(items), 0))
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_plugin_misc_radiocol.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,518 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009, 2010, 2011, 2012, 2013  Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013  Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Tests for the plugin radiocol """
+
+from libervia.backend.core import exceptions
+from libervia.backend.test import helpers, helpers_plugins
+from libervia.backend.plugins import plugin_misc_radiocol as plugin
+from libervia.backend.plugins import plugin_misc_room_game as plugin_room_game
+from .constants import Const
+
+from twisted.words.protocols.jabber.jid import JID
+from twisted.words.xish import domish
+from twisted.internet import reactor
+from twisted.internet import defer
+from twisted.python.failure import Failure
+from twisted.trial.unittest import SkipTest
+
+try:
+    from mutagen.oggvorbis import OggVorbis
+    from mutagen.mp3 import MP3
+    from mutagen.easyid3 import EasyID3
+    from mutagen.id3 import ID3NoHeaderError
+except ImportError:
+    raise exceptions.MissingModule(
+        "Missing module Mutagen, please download/install from https://bitbucket.org/lazka/mutagen"
+    )
+
+import uuid
+import os
+import copy
+import shutil
+
+
+ROOM_JID = JID(Const.MUC_STR[0])
+PROFILE = Const.PROFILE[0]
+REFEREE_FULL = JID(ROOM_JID.userhost() + "/" + Const.JID[0].user)
+PLAYERS_INDICES = [0, 1, 3]  # referee included
+OTHER_PROFILES = [Const.PROFILE[1], Const.PROFILE[3]]
+OTHER_PLAYERS = [Const.JID[1], Const.JID[3]]
+
+
+class RadiocolTest(helpers.SatTestCase):
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+
+    def reinit(self):
+        self.host.reinit()
+        self.host.plugins["ROOM-GAME"] = plugin_room_game.RoomGame(self.host)
+        self.plugin = plugin.Radiocol(self.host)  # must be init after ROOM-GAME
+        self.plugin.testing = True
+        self.plugin_0045 = self.host.plugins["XEP-0045"] = helpers_plugins.FakeXEP_0045(
+            self.host
+        )
+        self.plugin_0249 = self.host.plugins["XEP-0249"] = helpers_plugins.FakeXEP_0249(
+            self.host
+        )
+        for profile in Const.PROFILE:
+            self.host.get_client(profile)  # init self.host.profiles[profile]
+        self.songs = []
+        self.playlist = []
+        self.sound_dir = self.host.memory.config_get("", "media_dir") + "/test/sound/"
+        try:
+            for filename in os.listdir(self.sound_dir):
+                if filename.endswith(".ogg") or filename.endswith(".mp3"):
+                    self.songs.append(filename)
+        except OSError:
+            raise SkipTest("The sound samples in sat_media/test/sound were not found")
+
+    def _build_players(self, players=[]):
+        """@return: the "started" content built with the given players"""
+        content = "<started"
+        if not players:
+            content += "/>"
+        else:
+            content += ">"
+            for i in range(0, len(players)):
+                content += "<player index='%s'>%s</player>" % (i, players[i])
+            content += "</started>"
+        return content
+
+    def _expected_message(self, to_jid, type_, content):
+        """
+        @param to_jid: recipient full jid
+        @param type_: message type ('normal' or 'groupchat')
+        @param content: content as unicode or list of domish elements
+        @return: the message XML built from the given recipient, message type and content
+        """
+        if isinstance(content, list):
+            new_content = copy.deepcopy(content)
+            for element in new_content:
+                if not element.hasAttribute("xmlns"):
+                    element["xmlns"] = ""
+            content = "".join([element.toXml() for element in new_content])
+        return "<message to='%s' type='%s'><%s xmlns='%s'>%s</%s></message>" % (
+            to_jid.full(),
+            type_,
+            plugin.RADIOC_TAG,
+            plugin.NC_RADIOCOL,
+            content,
+            plugin.RADIOC_TAG,
+        )
+
+    def _reject_song_cb(self, profile_index):
+        """Check if the message "song_rejected" has been sent by the referee
+        and process the command with the profile of the uploader
+        @param profile_index: uploader's profile"""
+        sent = self.host.get_sent_message(0)
+        content = "<song_rejected xmlns='' reason='Too many songs in queue'/>"
+        self.assert_equal_xml(
+            sent.toXml(),
+            self._expected_message(
+                JID(
+                    ROOM_JID.userhost()
+                    + "/"
+                    + self.plugin_0045.get_nick(0, profile_index),
+                    "normal",
+                    content,
+                )
+            ),
+        )
+        self._room_game_cmd(
+            sent, ["radiocol_song_rejected", ROOM_JID.full(), "Too many songs in queue"]
+        )
+
+    def _no_upload_cb(self):
+        """Check if the message "no_upload" has been sent by the referee
+        and process the command with the profiles of each room users"""
+        sent = self.host.get_sent_message(0)
+        content = "<no_upload xmlns=''/>"
+        self.assert_equal_xml(
+            sent.toXml(), self._expected_message(ROOM_JID, "groupchat", content)
+        )
+        self._room_game_cmd(sent, ["radiocol_no_upload", ROOM_JID.full()])
+
+    def _upload_ok_cb(self):
+        """Check if the message "upload_ok" has been sent by the referee
+        and process the command with the profiles of each room users"""
+        sent = self.host.get_sent_message(0)
+        content = "<upload_ok xmlns=''/>"
+        self.assert_equal_xml(
+            sent.toXml(), self._expected_message(ROOM_JID, "groupchat", content)
+        )
+        self._room_game_cmd(sent, ["radiocol_upload_ok", ROOM_JID.full()])
+
+    def _preload_cb(self, attrs, profile_index):
+        """Check if the message "preload" has been sent by the referee
+        and process the command with the profiles of each room users
+        @param attrs: information dict about the song
+        @param profile_index: profile index of the uploader
+        """
+        sent = self.host.get_sent_message(0)
+        attrs["sender"] = self.plugin_0045.get_nick(0, profile_index)
+        radiocol_elt = next(domish.generateElementsNamed(sent.elements(), "radiocol"))
+        preload_elt = next(domish.generateElementsNamed(
+            radiocol_elt.elements(), "preload"
+        ))
+        attrs["timestamp"] = preload_elt["timestamp"]  # we could not guess it...
+        content = "<preload xmlns='' %s/>" % " ".join(
+            ["%s='%s'" % (attr, attrs[attr]) for attr in attrs]
+        )
+        if sent.hasAttribute("from"):
+            del sent["from"]
+        self.assert_equal_xml(
+            sent.toXml(), self._expected_message(ROOM_JID, "groupchat", content)
+        )
+        self._room_game_cmd(
+            sent,
+            [
+                "radiocol_preload",
+                ROOM_JID.full(),
+                attrs["timestamp"],
+                attrs["filename"],
+                attrs["title"],
+                attrs["artist"],
+                attrs["album"],
+                attrs["sender"],
+            ],
+        )
+
+    def _play_next_song_cb(self):
+        """Check if the message "play" has been sent by the referee
+        and process the command with the profiles of each room users"""
+        sent = self.host.get_sent_message(0)
+        filename = self.playlist.pop(0)
+        content = "<play xmlns='' filename='%s' />" % filename
+        self.assert_equal_xml(
+            sent.toXml(), self._expected_message(ROOM_JID, "groupchat", content)
+        )
+        self._room_game_cmd(sent, ["radiocol_play", ROOM_JID.full(), filename])
+
+        game_data = self.plugin.games[ROOM_JID]
+        if len(game_data["queue"]) == plugin.QUEUE_LIMIT - 1:
+            self._upload_ok_cb()
+
+    def _add_song_cb(self, d, filepath, profile_index):
+        """Check if the message "song_added" has been sent by the uploader
+        and process the command with the profile of the referee
+        @param d: deferred value or failure got from self.plugin.radiocol_song_added
+        @param filepath: full path to the sound file
+        @param profile_index: the profile index of the uploader
+        """
+        if isinstance(d, Failure):
+            self.fail("OGG or MP3 song could not be added!")
+
+        game_data = self.plugin.games[ROOM_JID]
+
+        # this is copied from the plugin
+        if filepath.lower().endswith(".mp3"):
+            actual_song = MP3(filepath)
+            try:
+                song = EasyID3(filepath)
+
+                class Info(object):
+                    def __init__(self, length):
+                        self.length = length
+
+                song.info = Info(actual_song.info.length)
+            except ID3NoHeaderError:
+                song = actual_song
+        else:
+            song = OggVorbis(filepath)
+
+        attrs = {
+            "filename": os.path.basename(filepath),
+            "title": song.get("title", ["Unknown"])[0],
+            "artist": song.get("artist", ["Unknown"])[0],
+            "album": song.get("album", ["Unknown"])[0],
+            "length": str(song.info.length),
+        }
+        self.assertEqual(game_data["to_delete"][attrs["filename"]], filepath)
+
+        content = "<song_added xmlns='' %s/>" % " ".join(
+            ["%s='%s'" % (attr, attrs[attr]) for attr in attrs]
+        )
+        sent = self.host.get_sent_message(profile_index)
+        self.assert_equal_xml(
+            sent.toXml(), self._expected_message(REFEREE_FULL, "normal", content)
+        )
+
+        reject_song = len(game_data["queue"]) >= plugin.QUEUE_LIMIT
+        no_upload = len(game_data["queue"]) + 1 >= plugin.QUEUE_LIMIT
+        play_next = (
+            not game_data["playing"]
+            and len(game_data["queue"]) + 1 == plugin.QUEUE_TO_START
+        )
+
+        self._room_game_cmd(sent, profile_index)  # queue unchanged or +1
+        if reject_song:
+            self._reject_song_cb(profile_index)
+            return
+        if no_upload:
+            self._no_upload_cb()
+        self._preload_cb(attrs, profile_index)
+        self.playlist.append(attrs["filename"])
+        if play_next:
+            self._play_next_song_cb()  # queue -1
+
+    def _room_game_cmd(self, sent, from_index=0, call=[]):
+        """Process a command. It is also possible to call this method as
+        _room_game_cmd(sent, call) instead of _room_game_cmd(sent, from_index, call).
+        If from index is a list, it is assumed that it is containing the value
+        for call and from_index will take its default value.
+        @param sent: the sent message that we need to process
+        @param from_index: index of the message sender
+        @param call: list containing the name of the expected bridge call
+        followed by its arguments, or empty list if no call is expected
+        """
+        if isinstance(from_index, list):
+            call = from_index
+            from_index = 0
+
+        sent["from"] = ROOM_JID.full() + "/" + self.plugin_0045.get_nick(0, from_index)
+        recipient = JID(sent["to"]).resource
+
+        # The message could have been sent to a room user (room_jid + '/' + nick),
+        # but when it is received, the 'to' attribute of the message has been
+        # changed to the recipient own JID. We need to simulate that here.
+        if recipient:
+            room = self.plugin_0045.get_room(0, 0)
+            sent["to"] = (
+                Const.JID_STR[0]
+                if recipient == room.nick
+                else room.roster[recipient].entity.full()
+            )
+
+        for index in range(0, len(Const.PROFILE)):
+            nick = self.plugin_0045.get_nick(0, index)
+            if nick:
+                if not recipient or nick == recipient:
+                    if call and (
+                        self.plugin.is_player(ROOM_JID, nick)
+                        or call[0] == "radiocol_started"
+                    ):
+                        args = copy.deepcopy(call)
+                        args.append(Const.PROFILE[index])
+                        self.host.bridge.expect_call(*args)
+                    self.plugin.room_game_cmd(sent, Const.PROFILE[index])
+
+    def _sync_cb(self, sync_data, profile_index):
+        """Synchronize one player when he joins a running game.
+        @param sync_data: result from self.plugin.getSyncData
+        @param profile_index: index of the profile to be synchronized
+        """
+        for nick in sync_data:
+            expected = self._expected_message(
+                JID(ROOM_JID.userhost() + "/" + nick), "normal", sync_data[nick]
+            )
+            sent = self.host.get_sent_message(0)
+            self.assert_equal_xml(sent.toXml(), expected)
+            for elt in sync_data[nick]:
+                if elt.name == "preload":
+                    self.host.bridge.expect_call(
+                        "radiocol_preload",
+                        ROOM_JID.full(),
+                        elt["timestamp"],
+                        elt["filename"],
+                        elt["title"],
+                        elt["artist"],
+                        elt["album"],
+                        elt["sender"],
+                        Const.PROFILE[profile_index],
+                    )
+                elif elt.name == "play":
+                    self.host.bridge.expect_call(
+                        "radiocol_play",
+                        ROOM_JID.full(),
+                        elt["filename"],
+                        Const.PROFILE[profile_index],
+                    )
+                elif elt.name == "no_upload":
+                    self.host.bridge.expect_call(
+                        "radiocol_no_upload", ROOM_JID.full(), Const.PROFILE[profile_index]
+                    )
+            sync_data[nick]
+            self._room_game_cmd(sent, [])
+
+    def _join_room(self, room, nicks, player_index, sync=True):
+        """Make a player join a room and update the list of nicks
+        @param room: wokkel.muc.Room instance from the referee perspective
+        @param nicks: list of the players which will be updated
+        @param player_index: profile index of the new player
+        @param sync: set to True to synchronize data
+        """
+        user_nick = self.plugin_0045.join_room(0, player_index)
+        self.plugin.user_joined_trigger(room, room.roster[user_nick], PROFILE)
+        if player_index not in PLAYERS_INDICES:
+            # this user is actually not a player
+            self.assertFalse(self.plugin.is_player(ROOM_JID, user_nick))
+            to_jid, type_ = (JID(ROOM_JID.userhost() + "/" + user_nick), "normal")
+        else:
+            # this user is a player
+            self.assertTrue(self.plugin.is_player(ROOM_JID, user_nick))
+            nicks.append(user_nick)
+            to_jid, type_ = (ROOM_JID, "groupchat")
+
+        # Check that the message "players" has been sent by the referee
+        expected = self._expected_message(to_jid, type_, self._build_players(nicks))
+        sent = self.host.get_sent_message(0)
+        self.assert_equal_xml(sent.toXml(), expected)
+
+        # Process the command with the profiles of each room users
+        self._room_game_cmd(
+            sent,
+            [
+                "radiocol_started",
+                ROOM_JID.full(),
+                REFEREE_FULL.full(),
+                nicks,
+                [plugin.QUEUE_TO_START, plugin.QUEUE_LIMIT],
+            ],
+        )
+
+        if sync:
+            self._sync_cb(self.plugin._get_sync_data(ROOM_JID, [user_nick]), player_index)
+
+    def _leave_room(self, room, nicks, player_index):
+        """Make a player leave a room and update the list of nicks
+        @param room: wokkel.muc.Room instance from the referee perspective
+        @param nicks: list of the players which will be updated
+        @param player_index: profile index of the new player
+        """
+        user_nick = self.plugin_0045.get_nick(0, player_index)
+        user = room.roster[user_nick]
+        self.plugin_0045.leave_room(0, player_index)
+        self.plugin.user_left_trigger(room, user, PROFILE)
+        nicks.remove(user_nick)
+
+    def _upload_song(self, song_index, profile_index):
+        """Upload the song of index song_index (modulo self.songs size) from the profile of index profile_index.
+
+        @param song_index: index of the song or None to test with non existing file
+        @param profile_index: index of the uploader's profile
+        """
+        if song_index is None:
+            dst_filepath = str(uuid.uuid1())
+            expect_io_error = True
+        else:
+            song_index = song_index % len(self.songs)
+            src_filename = self.songs[song_index]
+            dst_filepath = "/tmp/%s%s" % (uuid.uuid1(), os.path.splitext(src_filename)[1])
+            shutil.copy(self.sound_dir + src_filename, dst_filepath)
+            expect_io_error = False
+
+        try:
+            d = self.plugin.radiocol_song_added(
+                REFEREE_FULL, dst_filepath, Const.PROFILE[profile_index]
+            )
+        except IOError:
+            self.assertTrue(expect_io_error)
+            return
+
+        self.assertFalse(expect_io_error)
+        cb = lambda defer: self._add_song_cb(defer, dst_filepath, profile_index)
+
+        def eb(failure):
+            if not isinstance(failure, Failure):
+                self.fail("Adding a song which is not OGG nor MP3 should fail!")
+            self.assertEqual(failure.value.__class__, exceptions.DataError)
+
+        if src_filename.endswith(".ogg") or src_filename.endswith(".mp3"):
+            d.addCallbacks(cb, cb)
+        else:
+            d.addCallbacks(eb, eb)
+
+    def test_init(self):
+        self.reinit()
+        self.assertEqual(self.plugin.invite_mode, self.plugin.FROM_PLAYERS)
+        self.assertEqual(self.plugin.wait_mode, self.plugin.FOR_NONE)
+        self.assertEqual(self.plugin.join_mode, self.plugin.INVITED)
+        self.assertEqual(self.plugin.ready_mode, self.plugin.FORCE)
+
+    def test_game(self):
+        self.reinit()
+
+        # create game
+        self.plugin.prepare_room(OTHER_PLAYERS, ROOM_JID, PROFILE)
+        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
+        room = self.plugin_0045.get_room(0, 0)
+        nicks = [self.plugin_0045.get_nick(0, 0)]
+
+        sent = self.host.get_sent_message(0)
+        self.assert_equal_xml(
+            sent.toXml(),
+            self._expected_message(ROOM_JID, "groupchat", self._build_players(nicks)),
+        )
+        self._room_game_cmd(
+            sent,
+            [
+                "radiocol_started",
+                ROOM_JID.full(),
+                REFEREE_FULL.full(),
+                nicks,
+                [plugin.QUEUE_TO_START, plugin.QUEUE_LIMIT],
+            ],
+        )
+
+        self._join_room(room, nicks, 1)  # player joins
+        self._join_room(room, nicks, 4)  # user not playing joins
+
+        song_index = 0
+        self._upload_song(
+            song_index, 0
+        )  # ogg or mp3 file should exist in sat_media/test/song
+        self._upload_song(None, 0)  # non existing file
+
+        # another songs are added by Const.JID[1] until the radio starts + 1 to fill the queue
+        # when the first song starts + 1 to be rejected because the queue is full
+        for song_index in range(1, plugin.QUEUE_TO_START + 1):
+            self._upload_song(song_index, 1)
+
+        self.plugin.play_next(Const.MUC[0], PROFILE)  # simulate the end of the first song
+        self._play_next_song_cb()
+        self._upload_song(
+            song_index, 1
+        )  # now the song is accepted and the queue is full again
+
+        self._join_room(room, nicks, 3)  # new player joins
+
+        self.plugin.play_next(Const.MUC[0], PROFILE)  # the second song finishes
+        self._play_next_song_cb()
+        self._upload_song(0, 3)  # the player who recently joined re-upload the first file
+
+        self._leave_room(room, nicks, 1)  # one player leaves
+        self._join_room(room, nicks, 1)  # and join again
+
+        self.plugin.play_next(Const.MUC[0], PROFILE)  # empty the queue
+        self._play_next_song_cb()
+        self.plugin.play_next(Const.MUC[0], PROFILE)
+        self._play_next_song_cb()
+
+        for filename in self.playlist:
+            self.plugin.delete_file("/tmp/" + filename)
+
+        return defer.succeed(None)
+
+    def tearDown(self, *args, **kwargs):
+        """Clean the reactor"""
+        helpers.SatTestCase.tearDown(self, *args, **kwargs)
+        for delayed_call in reactor.getDelayedCalls():
+            delayed_call.cancel()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_plugin_misc_room_game.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,654 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Tests for the plugin room game (base class for MUC games) """
+
+from libervia.backend.core.i18n import _
+from .constants import Const
+from libervia.backend.test import helpers, helpers_plugins
+from libervia.backend.plugins import plugin_misc_room_game as plugin
+from twisted.words.protocols.jabber.jid import JID
+from wokkel.muc import User
+
+from logging import WARNING
+
+# Data used for test initialization
+NAMESERVICE = "http://www.goffi.org/protocol/dummy"
+TAG = "dummy"
+PLUGIN_INFO = {
+    "name": "Dummy plugin",
+    "import_name": "DUMMY",
+    "type": "MISC",
+    "protocols": [],
+    "dependencies": [],
+    "main": "Dummy",
+    "handler": "no",  # handler MUST be "no" (dynamic inheritance)
+    "description": _("""Dummy plugin to test room game"""),
+}
+
+ROOM_JID = JID(Const.MUC_STR[0])
+PROFILE = Const.PROFILE[0]
+OTHER_PROFILE = Const.PROFILE[1]
+
+
+class RoomGameTest(helpers.SatTestCase):
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+
+    def reinit(self, game_init={}, player_init={}):
+        self.host.reinit()
+        self.plugin = plugin.RoomGame(self.host)
+        self.plugin._init_(
+            self.host, PLUGIN_INFO, (NAMESERVICE, TAG), game_init, player_init
+        )
+        self.plugin_0045 = self.host.plugins["XEP-0045"] = helpers_plugins.FakeXEP_0045(
+            self.host
+        )
+        self.plugin_0249 = self.host.plugins["XEP-0249"] = helpers_plugins.FakeXEP_0249(
+            self.host
+        )
+        for profile in Const.PROFILE:
+            self.host.get_client(profile)  # init self.host.profiles[profile]
+
+    def init_game(self, muc_index, user_index):
+        self.plugin_0045.join_room(user_index, muc_index)
+        self.plugin._init_game(JID(Const.MUC_STR[muc_index]), Const.JID[user_index].user)
+
+    def _expected_message(self, to, type_, tag, players=[]):
+        content = "<%s" % tag
+        if not players:
+            content += "/>"
+        else:
+            content += ">"
+            for i in range(0, len(players)):
+                content += "<player index='%s'>%s</player>" % (i, players[i])
+            content += "</%s>" % tag
+        return "<message to='%s' type='%s'><%s xmlns='%s'>%s</dummy></message>" % (
+            to.full(),
+            type_,
+            TAG,
+            NAMESERVICE,
+            content,
+        )
+
+    def test_create_or_invite_solo(self):
+        self.reinit()
+        self.plugin_0045.join_room(0, 0)
+        self.plugin._create_or_invite(self.plugin_0045.get_room(0, 0), [], Const.PROFILE[0])
+        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
+
+    def test_create_or_invite_multi_not_waiting(self):
+        self.reinit()
+        self.plugin_0045.join_room(0, 0)
+        other_players = [Const.JID[1], Const.JID[2]]
+        self.plugin._create_or_invite(
+            self.plugin_0045.get_room(0, 0), other_players, Const.PROFILE[0]
+        )
+        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
+
+    def test_create_or_invite_multi_waiting(self):
+        self.reinit(player_init={"score": 0})
+        self.plugin_0045.join_room(0, 0)
+        other_players = [Const.JID[1], Const.JID[2]]
+        self.plugin._create_or_invite(
+            self.plugin_0045.get_room(0, 0), other_players, Const.PROFILE[0]
+        )
+        self.assertTrue(self.plugin._game_exists(ROOM_JID, False))
+        self.assertFalse(self.plugin._game_exists(ROOM_JID, True))
+
+    def test_init_game(self):
+        self.reinit()
+        self.init_game(0, 0)
+        self.assertTrue(self.plugin.is_referee(ROOM_JID, Const.JID[0].user))
+        self.assertEqual([], self.plugin.games[ROOM_JID]["players"])
+
+    def test_check_join_auth(self):
+        self.reinit()
+        check = lambda value: getattr(self, "assert%s" % value)(
+            self.plugin._check_join_auth(ROOM_JID, Const.JID[0], Const.JID[0].user)
+        )
+        check(False)
+        # to test the "invited" mode, the referee must be different than the user to test
+        self.init_game(0, 1)
+        self.plugin.join_mode = self.plugin.ALL
+        check(True)
+        self.plugin.join_mode = self.plugin.INVITED
+        check(False)
+        self.plugin.invitations[ROOM_JID] = [(None, [Const.JID[0].userhostJID()])]
+        check(True)
+        self.plugin.join_mode = self.plugin.NONE
+        check(False)
+        self.plugin.games[ROOM_JID]["players"].append(Const.JID[0].user)
+        check(True)
+
+    def test_update_players(self):
+        self.reinit()
+        self.init_game(0, 0)
+        self.assertEqual(self.plugin.games[ROOM_JID]["players"], [])
+        self.plugin._update_players(ROOM_JID, [], True, Const.PROFILE[0])
+        self.assertEqual(self.plugin.games[ROOM_JID]["players"], [])
+        self.plugin._update_players(ROOM_JID, ["user1"], True, Const.PROFILE[0])
+        self.assertEqual(self.plugin.games[ROOM_JID]["players"], ["user1"])
+        self.plugin._update_players(ROOM_JID, ["user2", "user3"], True, Const.PROFILE[0])
+        self.assertEqual(
+            self.plugin.games[ROOM_JID]["players"], ["user1", "user2", "user3"]
+        )
+        self.plugin._update_players(
+            ROOM_JID, ["user2", "user3"], True, Const.PROFILE[0]
+        )  # should not be stored twice
+        self.assertEqual(
+            self.plugin.games[ROOM_JID]["players"], ["user1", "user2", "user3"]
+        )
+
+    def test_synchronize_room(self):
+        self.reinit()
+        self.init_game(0, 0)
+        self.plugin._synchronize_room(ROOM_JID, [Const.MUC[0]], Const.PROFILE[0])
+        self.assertEqual(
+            self.host.get_sent_message_xml(0),
+            self._expected_message(ROOM_JID, "groupchat", "players", []),
+        )
+        self.plugin.games[ROOM_JID]["players"].append("test1")
+        self.plugin._synchronize_room(ROOM_JID, [Const.MUC[0]], Const.PROFILE[0])
+        self.assertEqual(
+            self.host.get_sent_message_xml(0),
+            self._expected_message(ROOM_JID, "groupchat", "players", ["test1"]),
+        )
+        self.plugin.games[ROOM_JID]["started"] = True
+        self.plugin.games[ROOM_JID]["players"].append("test2")
+        self.plugin._synchronize_room(ROOM_JID, [Const.MUC[0]], Const.PROFILE[0])
+        self.assertEqual(
+            self.host.get_sent_message_xml(0),
+            self._expected_message(ROOM_JID, "groupchat", "started", ["test1", "test2"]),
+        )
+        self.plugin.games[ROOM_JID]["players"].append("test3")
+        self.plugin.games[ROOM_JID]["players"].append("test4")
+        user1 = JID(ROOM_JID.userhost() + "/" + Const.JID[0].user)
+        user2 = JID(ROOM_JID.userhost() + "/" + Const.JID[1].user)
+        self.plugin._synchronize_room(ROOM_JID, [user1, user2], Const.PROFILE[0])
+        self.assert_equal_xml(
+            self.host.get_sent_message_xml(0),
+            self._expected_message(
+                user1, "normal", "started", ["test1", "test2", "test3", "test4"]
+            ),
+        )
+        self.assert_equal_xml(
+            self.host.get_sent_message_xml(0),
+            self._expected_message(
+                user2, "normal", "started", ["test1", "test2", "test3", "test4"]
+            ),
+        )
+
+    def test_invite_players(self):
+        self.reinit()
+        self.init_game(0, 0)
+        self.plugin_0045.join_room(0, 1)
+        self.assertEqual(self.plugin.invitations[ROOM_JID], [])
+        room = self.plugin_0045.get_room(0, 0)
+        nicks = self.plugin._invite_players(
+            room, [Const.JID[1], Const.JID[2]], Const.JID[0].user, Const.PROFILE[0]
+        )
+        self.assertEqual(
+            self.plugin.invitations[ROOM_JID][0][1],
+            [Const.JID[1].userhostJID(), Const.JID[2].userhostJID()],
+        )
+        # the following assertion is True because Const.JID[1] and Const.JID[2] have the same userhost
+        self.assertEqual(nicks, [Const.JID[1].user, Const.JID[2].user])
+
+        nicks = self.plugin._invite_players(
+            room, [Const.JID[1], Const.JID[3]], Const.JID[0].user, Const.PROFILE[0]
+        )
+        self.assertEqual(
+            self.plugin.invitations[ROOM_JID][1][1],
+            [Const.JID[1].userhostJID(), Const.JID[3].userhostJID()],
+        )
+        # this time Const.JID[1] and Const.JID[3] have the same user but the host differs
+        self.assertEqual(nicks, [Const.JID[1].user])
+
+    def test_check_invite_auth(self):
+        def check(value, index):
+            nick = self.plugin_0045.get_nick(0, index)
+            getattr(self, "assert%s" % value)(
+                self.plugin._check_invite_auth(ROOM_JID, nick)
+            )
+
+        self.reinit()
+
+        for mode in [
+            self.plugin.FROM_ALL,
+            self.plugin.FROM_NONE,
+            self.plugin.FROM_REFEREE,
+            self.plugin.FROM_PLAYERS,
+        ]:
+            self.plugin.invite_mode = mode
+            check(True, 0)
+
+        self.init_game(0, 0)
+        self.plugin.invite_mode = self.plugin.FROM_ALL
+        check(True, 0)
+        check(True, 1)
+        self.plugin.invite_mode = self.plugin.FROM_NONE
+        check(True, 0)  # game initialized but not started yet, referee can invite
+        check(False, 1)
+        self.plugin.invite_mode = self.plugin.FROM_REFEREE
+        check(True, 0)
+        check(False, 1)
+        user_nick = self.plugin_0045.join_room(0, 1)
+        self.plugin.games[ROOM_JID]["players"].append(user_nick)
+        self.plugin.invite_mode = self.plugin.FROM_PLAYERS
+        check(True, 0)
+        check(True, 1)
+        check(False, 2)
+
+    def test_is_referee(self):
+        self.reinit()
+        self.init_game(0, 0)
+        self.assertTrue(self.plugin.is_referee(ROOM_JID, self.plugin_0045.get_nick(0, 0)))
+        self.assertFalse(self.plugin.is_referee(ROOM_JID, self.plugin_0045.get_nick(0, 1)))
+
+    def test_is_player(self):
+        self.reinit()
+        self.init_game(0, 0)
+        self.assertTrue(self.plugin.is_player(ROOM_JID, self.plugin_0045.get_nick(0, 0)))
+        user_nick = self.plugin_0045.join_room(0, 1)
+        self.plugin.games[ROOM_JID]["players"].append(user_nick)
+        self.assertTrue(self.plugin.is_player(ROOM_JID, user_nick))
+        self.assertFalse(self.plugin.is_player(ROOM_JID, self.plugin_0045.get_nick(0, 2)))
+
+    def test_check_wait_auth(self):
+        def check(value, other_players, confirmed, rest):
+            room = self.plugin_0045.get_room(0, 0)
+            self.assertEqual(
+                (value, confirmed, rest), self.plugin._check_wait_auth(room, other_players)
+            )
+
+        self.reinit()
+        self.init_game(0, 0)
+        other_players = [Const.JID[1], Const.JID[3]]
+        self.plugin.wait_mode = self.plugin.FOR_NONE
+        check(True, [], [], [])
+        check(
+            True, [Const.JID[0]], [], [Const.JID[0]]
+        )  # getRoomNickOfUser checks for the other users only
+        check(True, other_players, [], other_players)
+        self.plugin.wait_mode = self.plugin.FOR_ALL
+        check(True, [], [], [])
+        check(False, [Const.JID[0]], [], [Const.JID[0]])
+        check(False, other_players, [], other_players)
+        self.plugin_0045.join_room(0, 1)
+        check(False, other_players, [], other_players)
+        self.plugin_0045.join_room(0, 4)
+        check(
+            False,
+            other_players,
+            [self.plugin_0045.get_nick_of_user(0, 1, 0)],
+            [Const.JID[3]],
+        )
+        self.plugin_0045.join_room(0, 3)
+        check(
+            True,
+            other_players,
+            [
+                self.plugin_0045.get_nick_of_user(0, 1, 0),
+                self.plugin_0045.get_nick_of_user(0, 3, 0),
+            ],
+            [],
+        )
+
+        other_players = [Const.JID[1], Const.JID[3], Const.JID[2]]
+        # the following assertion is True because Const.JID[1] and Const.JID[2] have the same userhost
+        check(
+            True,
+            other_players,
+            [
+                self.plugin_0045.get_nick_of_user(0, 1, 0),
+                self.plugin_0045.get_nick_of_user(0, 3, 0),
+                self.plugin_0045.get_nick_of_user(0, 2, 0),
+            ],
+            [],
+        )
+
+    def test_prepare_room_trivial(self):
+        self.reinit()
+        other_players = []
+        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
+        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
+        self.assertTrue(
+            self.plugin._check_join_auth(ROOM_JID, Const.JID[0], Const.JID[0].user)
+        )
+        self.assertTrue(self.plugin._check_invite_auth(ROOM_JID, Const.JID[0].user))
+        self.assertEqual((True, [], []), self.plugin._check_wait_auth(ROOM_JID, []))
+        self.assertTrue(self.plugin.is_referee(ROOM_JID, Const.JID[0].user))
+        self.assertTrue(self.plugin.is_player(ROOM_JID, Const.JID[0].user))
+        self.assertEqual(
+            (False, True), self.plugin._check_create_game_and_init(ROOM_JID, PROFILE)
+        )
+
+    def test_prepare_room_invite(self):
+        self.reinit()
+        other_players = [Const.JID[1], Const.JID[2]]
+        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
+        room = self.plugin_0045.get_room(0, 0)
+
+        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
+        self.assertTrue(
+            self.plugin._check_join_auth(ROOM_JID, Const.JID[1], Const.JID[1].user)
+        )
+        self.assertFalse(
+            self.plugin._check_join_auth(ROOM_JID, Const.JID[3], Const.JID[3].user)
+        )
+        self.assertFalse(self.plugin._check_invite_auth(ROOM_JID, Const.JID[1].user))
+        self.assertEqual(
+            (True, [], other_players), self.plugin._check_wait_auth(room, other_players)
+        )
+
+        player2_nick = self.plugin_0045.join_room(0, 1)
+        self.plugin.user_joined_trigger(room, room.roster[player2_nick], PROFILE)
+        self.assertTrue(self.plugin.is_player(ROOM_JID, player2_nick))
+        self.assertTrue(self.plugin._check_invite_auth(ROOM_JID, player2_nick))
+        self.assertFalse(self.plugin.is_referee(ROOM_JID, player2_nick))
+        self.assertTrue(self.plugin.is_player(ROOM_JID, player2_nick))
+        self.assertTrue(
+            self.plugin.is_player(ROOM_JID, self.plugin_0045.get_nick_of_user(0, 2, 0))
+        )
+        self.assertFalse(self.plugin.is_player(ROOM_JID, "xxx"))
+        self.assertEqual(
+            (False, False),
+            self.plugin._check_create_game_and_init(ROOM_JID, Const.PROFILE[1]),
+        )
+
+    def test_prepare_room_score_1(self):
+        self.reinit(player_init={"score": 0})
+        other_players = [Const.JID[1], Const.JID[2]]
+        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
+        room = self.plugin_0045.get_room(0, 0)
+
+        self.assertFalse(self.plugin._game_exists(ROOM_JID, True))
+        self.assertTrue(
+            self.plugin._check_join_auth(ROOM_JID, Const.JID[1], Const.JID[1].user)
+        )
+        self.assertFalse(
+            self.plugin._check_join_auth(ROOM_JID, Const.JID[3], Const.JID[3].user)
+        )
+        self.assertFalse(self.plugin._check_invite_auth(ROOM_JID, Const.JID[1].user))
+        self.assertEqual(
+            (False, [], other_players), self.plugin._check_wait_auth(room, other_players)
+        )
+
+        user_nick = self.plugin_0045.join_room(0, 1)
+        self.plugin.user_joined_trigger(room, room.roster[user_nick], PROFILE)
+        self.assertTrue(self.plugin.is_player(ROOM_JID, user_nick))
+        self.assertFalse(self.plugin._check_invite_auth(ROOM_JID, user_nick))
+        self.assertFalse(self.plugin.is_referee(ROOM_JID, user_nick))
+        self.assertTrue(self.plugin.is_player(ROOM_JID, user_nick))
+        # the following assertion is True because Const.JID[1] and Const.JID[2] have the same userhost
+        self.assertTrue(
+            self.plugin.is_player(ROOM_JID, self.plugin_0045.get_nick_of_user(0, 2, 0))
+        )
+        # the following assertion is True because Const.JID[1] nick in the room is equal to Const.JID[3].user
+        self.assertTrue(self.plugin.is_player(ROOM_JID, Const.JID[3].user))
+        # but Const.JID[3] is actually not in the room
+        self.assertEqual(self.plugin_0045.get_nick_of_user(0, 3, 0), None)
+        self.assertEqual(
+            (True, False), self.plugin._check_create_game_and_init(ROOM_JID, Const.PROFILE[0])
+        )
+
+    def test_prepare_room_score_2(self):
+        self.reinit(player_init={"score": 0})
+        other_players = [Const.JID[1], Const.JID[4]]
+        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
+        room = self.plugin_0045.get_room(0, 0)
+
+        user_nick = self.plugin_0045.join_room(0, 1)
+        self.plugin.user_joined_trigger(room, room.roster[user_nick], PROFILE)
+        self.assertEqual(
+            (True, False), self.plugin._check_create_game_and_init(ROOM_JID, PROFILE)
+        )
+        user_nick = self.plugin_0045.join_room(0, 4)
+        self.plugin.user_joined_trigger(room, room.roster[user_nick], PROFILE)
+        self.assertEqual(
+            (False, True), self.plugin._check_create_game_and_init(ROOM_JID, PROFILE)
+        )
+
+    def test_user_joined_trigger(self):
+        self.reinit(player_init={"xxx": "xyz"})
+        other_players = [Const.JID[1], Const.JID[3]]
+        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
+        nicks = [self.plugin_0045.get_nick(0, 0)]
+
+        self.assertEqual(
+            self.host.get_sent_message_xml(0),
+            self._expected_message(ROOM_JID, "groupchat", "players", nicks),
+        )
+        self.assertTrue(len(self.plugin.invitations[ROOM_JID]) == 1)
+
+        # wrong profile
+        user_nick = self.plugin_0045.join_room(0, 1)
+        room = self.plugin_0045.get_room(0, 1)
+        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[1]), OTHER_PROFILE)
+        self.assertEqual(
+            self.host.get_sent_message(0), None
+        )  # no new message has been sent
+        self.assertFalse(self.plugin._game_exists(ROOM_JID, True))  # game not started
+
+        # referee profile, user is allowed, wait for one more
+        room = self.plugin_0045.get_room(0, 0)
+        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[1]), PROFILE)
+        nicks.append(user_nick)
+        self.assertEqual(
+            self.host.get_sent_message_xml(0),
+            self._expected_message(ROOM_JID, "groupchat", "players", nicks),
+        )
+        self.assertFalse(self.plugin._game_exists(ROOM_JID, True))  # game not started
+
+        # referee profile, user is not allowed
+        user_nick = self.plugin_0045.join_room(0, 4)
+        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[4]), PROFILE)
+        self.assertEqual(
+            self.host.get_sent_message_xml(0),
+            self._expected_message(
+                JID(ROOM_JID.userhost() + "/" + user_nick), "normal", "players", nicks
+            ),
+        )
+        self.assertFalse(self.plugin._game_exists(ROOM_JID, True))  # game not started
+
+        # referee profile, user is allowed, everybody here
+        user_nick = self.plugin_0045.join_room(0, 3)
+        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[3]), PROFILE)
+        nicks.append(user_nick)
+        self.assertEqual(
+            self.host.get_sent_message_xml(0),
+            self._expected_message(ROOM_JID, "groupchat", "started", nicks),
+        )
+        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))  # game started
+        self.assertTrue(len(self.plugin.invitations[ROOM_JID]) == 0)
+
+        # wait for none
+        self.reinit()
+        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
+        self.assertNotEqual(self.host.get_sent_message(0), None)  # init messages
+        room = self.plugin_0045.get_room(0, 0)
+        nicks = [self.plugin_0045.get_nick(0, 0)]
+        user_nick = self.plugin_0045.join_room(0, 3)
+        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[3]), PROFILE)
+        nicks.append(user_nick)
+        self.assertEqual(
+            self.host.get_sent_message_xml(0),
+            self._expected_message(ROOM_JID, "groupchat", "started", nicks),
+        )
+        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
+
+    def test_user_left_trigger(self):
+        self.reinit(player_init={"xxx": "xyz"})
+        other_players = [Const.JID[1], Const.JID[3], Const.JID[4]]
+        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
+        room = self.plugin_0045.get_room(0, 0)
+        nicks = [self.plugin_0045.get_nick(0, 0)]
+        self.assertEqual(
+            self.plugin.invitations[ROOM_JID][0][1],
+            [
+                Const.JID[1].userhostJID(),
+                Const.JID[3].userhostJID(),
+                Const.JID[4].userhostJID(),
+            ],
+        )
+
+        # one user joins
+        user_nick = self.plugin_0045.join_room(0, 1)
+        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[1]), PROFILE)
+        nicks.append(user_nick)
+
+        # the user leaves
+        self.assertEqual(self.plugin.games[ROOM_JID]["players"], nicks)
+        room = self.plugin_0045.get_room(0, 1)
+        # to not call self.plugin_0045.leave_room(0, 1) here, we are testing the trigger with a wrong profile
+        self.plugin.user_left_trigger(
+            room, User(user_nick, Const.JID[1]), Const.PROFILE[1]
+        )  # not the referee
+        self.assertEqual(self.plugin.games[ROOM_JID]["players"], nicks)
+        room = self.plugin_0045.get_room(0, 0)
+        user_nick = self.plugin_0045.leave_room(0, 1)
+        self.plugin.user_left_trigger(
+            room, User(user_nick, Const.JID[1]), PROFILE
+        )  # referee
+        nicks.pop()
+        self.assertEqual(self.plugin.games[ROOM_JID]["players"], nicks)
+
+        # all the users join
+        user_nick = self.plugin_0045.join_room(0, 1)
+        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[1]), PROFILE)
+        nicks.append(user_nick)
+        user_nick = self.plugin_0045.join_room(0, 3)
+        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[3]), PROFILE)
+        nicks.append(user_nick)
+        user_nick = self.plugin_0045.join_room(0, 4)
+        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[4]), PROFILE)
+        nicks.append(user_nick)
+        self.assertEqual(self.plugin.games[ROOM_JID]["players"], nicks)
+        self.assertTrue(len(self.plugin.invitations[ROOM_JID]) == 0)
+
+        # one user leaves
+        user_nick = self.plugin_0045.leave_room(0, 4)
+        self.plugin.user_left_trigger(room, User(user_nick, Const.JID[4]), PROFILE)
+        nicks.pop()
+        self.assertEqual(
+            self.plugin.invitations[ROOM_JID][0][1], [Const.JID[4].userhostJID()]
+        )
+
+        # another leaves
+        user_nick = self.plugin_0045.leave_room(0, 3)
+        self.plugin.user_left_trigger(room, User(user_nick, Const.JID[3]), PROFILE)
+        nicks.pop()
+        self.assertEqual(
+            self.plugin.invitations[ROOM_JID][0][1],
+            [Const.JID[4].userhostJID(), Const.JID[3].userhostJID()],
+        )
+
+        # they can join again
+        user_nick = self.plugin_0045.join_room(0, 3)
+        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[3]), PROFILE)
+        nicks.append(user_nick)
+        user_nick = self.plugin_0045.join_room(0, 4)
+        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[4]), PROFILE)
+        nicks.append(user_nick)
+        self.assertEqual(self.plugin.games[ROOM_JID]["players"], nicks)
+        self.assertTrue(len(self.plugin.invitations[ROOM_JID]) == 0)
+
+    def test_check_create_game_and_init(self):
+        self.reinit()
+        helpers.mute_logging()
+        self.assertEqual(
+            (False, False), self.plugin._check_create_game_and_init(ROOM_JID, PROFILE)
+        )
+        helpers.unmute_logging()
+
+        nick = self.plugin_0045.join_room(0, 0)
+        self.assertEqual(
+            (True, False), self.plugin._check_create_game_and_init(ROOM_JID, PROFILE)
+        )
+        self.assertTrue(self.plugin._game_exists(ROOM_JID, False))
+        self.assertFalse(self.plugin._game_exists(ROOM_JID, True))
+        self.assertTrue(self.plugin.is_referee(ROOM_JID, nick))
+
+        helpers.mute_logging()
+        self.assertEqual(
+            (False, False), self.plugin._check_create_game_and_init(ROOM_JID, OTHER_PROFILE)
+        )
+        helpers.unmute_logging()
+
+        self.plugin_0045.join_room(0, 1)
+        self.assertEqual(
+            (False, False), self.plugin._check_create_game_and_init(ROOM_JID, OTHER_PROFILE)
+        )
+
+        self.plugin.create_game(ROOM_JID, [Const.JID[1]], PROFILE)
+        self.assertEqual(
+            (False, True), self.plugin._check_create_game_and_init(ROOM_JID, PROFILE)
+        )
+        self.assertEqual(
+            (False, False), self.plugin._check_create_game_and_init(ROOM_JID, OTHER_PROFILE)
+        )
+
+    def test_create_game(self):
+
+        self.reinit(player_init={"xxx": "xyz"})
+        nicks = []
+        for i in [0, 1, 3, 4]:
+            nicks.append(self.plugin_0045.join_room(0, i))
+
+        # game not exists
+        self.plugin.create_game(ROOM_JID, nicks, PROFILE)
+        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
+        self.assertEqual(self.plugin.games[ROOM_JID]["players"], nicks)
+        self.assertEqual(
+            self.host.get_sent_message_xml(0),
+            self._expected_message(ROOM_JID, "groupchat", "started", nicks),
+        )
+        for nick in nicks:
+            self.assertEqual("init", self.plugin.games[ROOM_JID]["status"][nick])
+            self.assertEqual(
+                self.plugin.player_init, self.plugin.games[ROOM_JID]["players_data"][nick]
+            )
+            self.plugin.games[ROOM_JID]["players_data"][nick]["xxx"] = nick
+        for nick in nicks:
+            # checks that a copy of self.player_init has been done and not a reference
+            self.assertEqual(
+                nick, self.plugin.games[ROOM_JID]["players_data"][nick]["xxx"]
+            )
+
+        # game exists, current profile is referee
+        self.reinit(player_init={"xxx": "xyz"})
+        self.init_game(0, 0)
+        self.plugin.games[ROOM_JID]["started"] = True
+        self.plugin.create_game(ROOM_JID, nicks, PROFILE)
+        self.assertEqual(
+            self.host.get_sent_message_xml(0),
+            self._expected_message(ROOM_JID, "groupchat", "started", nicks),
+        )
+
+        # game exists, current profile is not referee
+        self.reinit(player_init={"xxx": "xyz"})
+        self.init_game(0, 0)
+        self.plugin.games[ROOM_JID]["started"] = True
+        self.plugin_0045.join_room(0, 1)
+        self.plugin.create_game(ROOM_JID, nicks, OTHER_PROFILE)
+        self.assertEqual(
+            self.host.get_sent_message(0), None
+        )  # no sync message has been sent by other_profile
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_plugin_misc_text_syntaxes.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Plugin text syntaxes tests """
+
+from libervia.backend.test import helpers
+from libervia.backend.plugins import plugin_misc_text_syntaxes
+from twisted.trial.unittest import SkipTest
+import re
+import importlib
+
+
+class SanitisationTest(helpers.SatTestCase):
+
+    EVIL_HTML1 = """
+   <html>
+    <head>
+      <script type="text/javascript" src="evil-site"></script>
+      <link rel="alternate" type="text/rss" src="evil-rss">
+      <style>
+        body {background-image: url(javascript:do_evil)};
+        div {color: expression(evil)};
+      </style>
+    </head>
+    <body onload="evil_function()">
+      <!-- I am interpreted for EVIL! -->
+      <a href="javascript:evil_function()">a link</a>
+      <a href="#" onclick="evil_function()">another link</a>
+      <p onclick="evil_function()">a paragraph</p>
+      <div style="display: none">secret EVIL!</div>
+      <object> of EVIL! </object>
+      <iframe src="evil-site"></iframe>
+      <form action="evil-site">
+        Password: <input type="password" name="password">
+      </form>
+      <blink>annoying EVIL!</blink>
+      <a href="evil-site">spam spam SPAM!</a>
+      <image src="evil!">
+    </body>
+   </html>"""  # example from lxml: /usr/share/doc/python-lxml-doc/html/lxmlhtml.html#cleaning-up-html
+
+    EVIL_HTML2 = """<p style='display: None; test: blah; background: url(: alert()); color: blue;'>test <strong>retest</strong><br><span style="background-color: (alert('bouh')); titi; color: #cf2828; font-size: 3px; direction: !important; color: red; color: red !important; font-size: 100px       !important; font-size: 100px  ! important; font-size: 100%; font-size: 100ox; font-size: 100px; font-size: 100;;;; font-size: 100 %; color: 100 px 1.7em; color: rgba(0, 0, 0, 0.1); color: rgb(35,79,255); background-color: no-repeat; background-color: :alert(1); color: (alert('XSS')); color: (window.location='http://example.org/'); color: url(:window.location='http://example.org/'); "> toto </span></p>"""
+
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+        importlib.reload(plugin_misc_text_syntaxes)  # reload the plugin to avoid conflict error
+        self.text_syntaxes = plugin_misc_text_syntaxes.TextSyntaxes(self.host)
+
+    def test_xhtml_sanitise(self):
+        expected = """<div>
+      <style>/* deleted */</style>
+    <body>
+      <a href="">a link</a>
+      <a href="#">another link</a>
+      <p>a paragraph</p>
+      <div style="">secret EVIL!</div>
+       of EVIL!
+        Password:
+      annoying EVIL!
+      <a href="evil-site">spam spam SPAM!</a>
+      <img src="evil!">
+    </img></body>
+   </div>"""
+
+        d = self.text_syntaxes.clean_xhtml(self.EVIL_HTML1)
+        d.addCallback(self.assert_equal_xml, expected, ignore_blank=True)
+        return d
+
+    def test_styles_sanitise(self):
+        expected = """<p style="color: blue">test <strong>retest</strong><br/><span style="color: #cf2828; font-size: 3px; color: red; color: red !important; font-size: 100px       !important; font-size: 100%; font-size: 100px; font-size: 100; font-size: 100 %; color: rgba(0, 0, 0, 0.1); color: rgb(35,79,255); background-color: no-repeat"> toto </span></p>"""
+
+        d = self.text_syntaxes.clean_xhtml(self.EVIL_HTML2)
+        d.addCallback(self.assert_equal_xml, expected)
+        return d
+
+    def test_html2text(self):
+        """Check that html2text is not inserting \n in the middle of that link.
+        By default lines are truncated after the 79th characters."""
+        source = '<img src="http://sat.goffi.org/static/images/screenshots/libervia/libervia_discussions.png" alt="sat"/>'
+        expected = "![sat](http://sat.goffi.org/static/images/screenshots/libervia/libervia_discussions.png)"
+        try:
+            d = self.text_syntaxes.convert(
+                source,
+                self.text_syntaxes.SYNTAX_XHTML,
+                self.text_syntaxes.SYNTAX_MARKDOWN,
+            )
+        except plugin_misc_text_syntaxes.UnknownSyntax:
+            raise SkipTest("Markdown syntax is not available.")
+        d.addCallback(self.assertEqual, expected)
+        return d
+
+    def test_remove_xhtml_markups(self):
+        expected = """ a link another link a paragraph secret EVIL! of EVIL! Password: annoying EVIL! spam spam SPAM! """
+        result = self.text_syntaxes._remove_markups(self.EVIL_HTML1)
+        self.assertEqual(re.sub(r"\s+", " ", result).rstrip(), expected.rstrip())
+
+        expected = """test retest toto"""
+        result = self.text_syntaxes._remove_markups(self.EVIL_HTML2)
+        self.assertEqual(re.sub(r"\s+", " ", result).rstrip(), expected.rstrip())
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_plugin_xep_0033.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,211 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Plugin extended addressing stanzas """
+
+from .constants import Const
+from libervia.backend.test import helpers
+from libervia.backend.plugins import plugin_xep_0033 as plugin
+from libervia.backend.core.exceptions import CancelError
+from twisted.internet import defer
+from wokkel.generic import parseXml
+from twisted.words.protocols.jabber.jid import JID
+
+PROFILE_INDEX = 0
+PROFILE = Const.PROFILE[PROFILE_INDEX]
+JID_STR_FROM = Const.JID_STR[1]
+JID_STR_TO = Const.PROFILE_DICT[PROFILE].host
+JID_STR_X_TO = Const.JID_STR[0]
+JID_STR_X_CC = Const.JID_STR[1]
+JID_STR_X_BCC = Const.JID_STR[2]
+
+ADDRS = ("to", JID_STR_X_TO, "cc", JID_STR_X_CC, "bcc", JID_STR_X_BCC)
+
+
+class XEP_0033Test(helpers.SatTestCase):
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+        self.plugin = plugin.XEP_0033(self.host)
+
+    def test_message_received(self):
+        self.host.memory.reinit()
+        xml = """
+        <message type="chat" from="%s" to="%s" id="test_1">
+            <body>test</body>
+            <addresses xmlns='http://jabber.org/protocol/address'>
+                <address type='to' jid='%s'/>
+                <address type='cc' jid='%s'/>
+                <address type='bcc' jid='%s'/>
+            </addresses>
+        </message>
+        """ % (
+            JID_STR_FROM,
+            JID_STR_TO,
+            JID_STR_X_TO,
+            JID_STR_X_CC,
+            JID_STR_X_BCC,
+        )
+        stanza = parseXml(xml.encode("utf-8"))
+        treatments = defer.Deferred()
+        self.plugin.message_received_trigger(
+            self.host.get_client(PROFILE), stanza, treatments
+        )
+        data = {"extra": {}}
+
+        def cb(data):
+            expected = ("to", JID_STR_X_TO, "cc", JID_STR_X_CC, "bcc", JID_STR_X_BCC)
+            msg = "Expected: %s\nGot:      %s" % (expected, data["extra"]["addresses"])
+            self.assertEqual(
+                data["extra"]["addresses"], "%s:%s\n%s:%s\n%s:%s\n" % expected, msg
+            )
+
+        treatments.addCallback(cb)
+        return treatments.callback(data)
+
+    def _get_mess_data(self):
+        mess_data = {
+            "to": JID(JID_STR_TO),
+            "type": "chat",
+            "message": "content",
+            "extra": {},
+        }
+        mess_data["extra"]["address"] = "%s:%s\n%s:%s\n%s:%s\n" % ADDRS
+        original_stanza = """
+        <message type="chat" from="%s" to="%s" id="test_1">
+            <body>content</body>
+        </message>
+        """ % (
+            JID_STR_FROM,
+            JID_STR_TO,
+        )
+        mess_data["xml"] = parseXml(original_stanza.encode("utf-8"))
+        return mess_data
+
+    def _assert_addresses(self, mess_data):
+        """The mess_data that we got here has been modified by self.plugin.messageSendTrigger,
+        check that the addresses element has been added to the stanza."""
+        expected = self._get_mess_data()["xml"]
+        addresses_extra = (
+            """
+        <addresses xmlns='http://jabber.org/protocol/address'>
+            <address type='%s' jid='%s'/>
+            <address type='%s' jid='%s'/>
+            <address type='%s' jid='%s'/>
+        </addresses>"""
+            % ADDRS
+        )
+        addresses_element = parseXml(addresses_extra.encode("utf-8"))
+        expected.addChild(addresses_element)
+        self.assert_equal_xml(
+            mess_data["xml"].toXml().encode("utf-8"), expected.toXml().encode("utf-8")
+        )
+
+    def _check_sent_and_stored(self):
+        """Check that all the recipients got their messages and that the history has been filled.
+        /!\ see the comments in XEP_0033.send_and_store_message"""
+        sent = []
+        stored = []
+        d_list = []
+
+        def cb(entities, to_jid):
+            if host in entities:
+                if (
+                    host not in sent
+                ):  # send the message to the entity offering the feature
+                    sent.append(host)
+                    stored.append(host)
+                stored.append(to_jid)  # store in history for each recipient
+            else:  # feature not supported, use normal behavior
+                sent.append(to_jid)
+                stored.append(to_jid)
+            helpers.unmute_logging()
+
+        for to_s in (JID_STR_X_TO, JID_STR_X_CC, JID_STR_X_BCC):
+            to_jid = JID(to_s)
+            host = JID(to_jid.host)
+            helpers.mute_logging()
+            d = self.host.find_features_set([plugin.NS_ADDRESS], jid_=host, profile=PROFILE)
+            d.addCallback(cb, to_jid)
+            d_list.append(d)
+
+        def cb_list(__):
+            msg = "/!\ see the comments in XEP_0033.send_and_store_message"
+            sent_recipients = [
+                JID(elt["to"]) for elt in self.host.get_sent_messages(PROFILE_INDEX)
+            ]
+            self.assert_equal_unsorted_list(sent_recipients, sent, msg)
+            self.assert_equal_unsorted_list(self.host.stored_messages, stored, msg)
+
+        return defer.DeferredList(d_list).addCallback(cb_list)
+
+    def _trigger(self, data):
+        """Execute self.plugin.messageSendTrigger with a different logging
+        level to not pollute the output, then check that the plugin did its
+        job. It should abort sending the message or add the extended
+        addressing information to the stanza.
+        @param data: the data to be processed by self.plugin.messageSendTrigger
+        """
+        pre_treatments = defer.Deferred()
+        post_treatments = defer.Deferred()
+        helpers.mute_logging()
+        self.plugin.messageSendTrigger(
+            self.host.get_client[PROFILE], data, pre_treatments, post_treatments
+        )
+        post_treatments.callback(data)
+        helpers.unmute_logging()
+        post_treatments.addCallbacks(
+            self._assert_addresses, lambda failure: failure.trap(CancelError)
+        )
+        return post_treatments
+
+    def test_message_send_trigger_feature_not_supported(self):
+        # feature is not supported, abort the message
+        self.host.memory.reinit()
+        data = self._get_mess_data()
+        return self._trigger(data)
+
+    def test_message_send_trigger_feature_supported(self):
+        # feature is supported by the main target server
+        self.host.reinit()
+        self.host.add_feature(JID(JID_STR_TO), plugin.NS_ADDRESS, PROFILE)
+        data = self._get_mess_data()
+        d = self._trigger(data)
+        return d.addCallback(lambda __: self._check_sent_and_stored())
+
+    def test_message_send_trigger_feature_fully_supported(self):
+        # feature is supported by all target servers
+        self.host.reinit()
+        self.host.add_feature(JID(JID_STR_TO), plugin.NS_ADDRESS, PROFILE)
+        for dest in (JID_STR_X_TO, JID_STR_X_CC, JID_STR_X_BCC):
+            self.host.add_feature(JID(JID(dest).host), plugin.NS_ADDRESS, PROFILE)
+        data = self._get_mess_data()
+        d = self._trigger(data)
+        return d.addCallback(lambda __: self._check_sent_and_stored())
+
+    def test_message_send_trigger_fix_wrong_entity(self):
+        # check that a wrong recipient entity is fixed by the backend
+        self.host.reinit()
+        self.host.add_feature(JID(JID_STR_TO), plugin.NS_ADDRESS, PROFILE)
+        for dest in (JID_STR_X_TO, JID_STR_X_CC, JID_STR_X_BCC):
+            self.host.add_feature(JID(JID(dest).host), plugin.NS_ADDRESS, PROFILE)
+        data = self._get_mess_data()
+        data["to"] = JID(JID_STR_X_TO)
+        d = self._trigger(data)
+        return d.addCallback(lambda __: self._check_sent_and_stored())
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_plugin_xep_0085.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Plugin chat states notification tests """
+
+from .constants import Const
+from libervia.backend.test import helpers
+from libervia.backend.core.constants import Const as C
+from libervia.backend.plugins import plugin_xep_0085 as plugin
+from copy import deepcopy
+from twisted.internet import defer
+from wokkel.generic import parseXml
+
+
+class XEP_0085Test(helpers.SatTestCase):
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+        self.plugin = plugin.XEP_0085(self.host)
+        self.host.memory.param_set(
+            plugin.PARAM_NAME,
+            True,
+            plugin.PARAM_KEY,
+            C.NO_SECURITY_LIMIT,
+            Const.PROFILE[0],
+        )
+
+    def test_message_received(self):
+        for state in plugin.CHAT_STATES:
+            xml = """
+            <message type="chat" from="%s" to="%s" id="test_1">
+            %s
+            <%s xmlns='%s'/>
+            </message>
+            """ % (
+                Const.JID_STR[1],
+                Const.JID_STR[0],
+                "<body>test</body>" if state == "active" else "",
+                state,
+                plugin.NS_CHAT_STATES,
+            )
+            stanza = parseXml(xml.encode("utf-8"))
+            self.host.bridge.expect_call(
+                "chat_state_received", Const.JID_STR[1], state, Const.PROFILE[0]
+            )
+            self.plugin.message_received_trigger(
+                self.host.get_client(Const.PROFILE[0]), stanza, None
+            )
+
+    def test_message_send_trigger(self):
+        def cb(data):
+            xml = data["xml"].toXml().encode("utf-8")
+            self.assert_equal_xml(xml, expected.toXml().encode("utf-8"))
+
+        d_list = []
+
+        for state in plugin.CHAT_STATES:
+            mess_data = {
+                "to": Const.JID[0],
+                "type": "chat",
+                "message": "content",
+                "extra": {} if state == "active" else {"chat_state": state},
+            }
+            stanza = """
+            <message type="chat" from="%s" to="%s" id="test_1">
+            %s
+            </message>
+            """ % (
+                Const.JID_STR[1],
+                Const.JID_STR[0],
+                ("<body>%s</body>" % mess_data["message"]) if state == "active" else "",
+            )
+            mess_data["xml"] = parseXml(stanza.encode("utf-8"))
+            expected = deepcopy(mess_data["xml"])
+            expected.addElement(state, plugin.NS_CHAT_STATES)
+            post_treatments = defer.Deferred()
+            self.plugin.messageSendTrigger(
+                self.host.get_client(Const.PROFILE[0]), mess_data, None, post_treatments
+            )
+
+            post_treatments.addCallback(cb)
+            post_treatments.callback(mess_data)
+            d_list.append(post_treatments)
+
+        def cb_list(__):  # cancel the timer to not block the process
+            self.plugin.map[Const.PROFILE[0]][Const.JID[0]].timer.cancel()
+
+        return defer.DeferredList(d_list).addCallback(cb_list)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_plugin_xep_0203.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Plugin XEP-0203 """
+
+from libervia.backend.test import helpers
+from libervia.backend.plugins.plugin_xep_0203 import XEP_0203
+from twisted.words.xish import domish
+from twisted.words.protocols.jabber.jid import JID
+from dateutil.tz import tzutc
+import datetime
+
+NS_PUBSUB = "http://jabber.org/protocol/pubsub"
+
+
+class XEP_0203Test(helpers.SatTestCase):
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+        self.plugin = XEP_0203(self.host)
+
+    def test_delay(self):
+        delay_xml = """
+          <delay xmlns='urn:xmpp:delay'
+             from='capulet.com'
+             stamp='2002-09-10T23:08:25Z'>
+            Offline Storage
+          </delay>
+        """
+        message_xml = (
+            """
+        <message
+            from='romeo@montague.net/orchard'
+            to='juliet@capulet.com'
+            type='chat'>
+          <body>text</body>
+          %s
+        </message>
+        """
+            % delay_xml
+        )
+
+        parent = domish.Element((None, "message"))
+        parent["from"] = "romeo@montague.net/orchard"
+        parent["to"] = "juliet@capulet.com"
+        parent["type"] = "chat"
+        parent.addElement("body", None, "text")
+        stamp = datetime.datetime(2002, 9, 10, 23, 8, 25, tzinfo=tzutc())
+        elt = self.plugin.delay(stamp, JID("capulet.com"), "Offline Storage", parent)
+        self.assert_equal_xml(elt.toXml(), delay_xml, True)
+        self.assert_equal_xml(parent.toXml(), message_xml, True)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_plugin_xep_0277.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Plugin XEP-0277 tests """
+
+from libervia.backend.test import helpers
+from libervia.backend.plugins import plugin_xep_0277
+from libervia.backend.plugins import plugin_xep_0060
+from libervia.backend.plugins import plugin_misc_text_syntaxes
+from libervia.backend.tools.xml_tools import ElementParser
+from wokkel.pubsub import NS_PUBSUB
+import importlib
+
+
+class XEP_0277Test(helpers.SatTestCase):
+
+    PUBSUB_ENTRY_1 = (
+        """
+    <item id="c745a688-9b02-11e3-a1a3-c0143dd4fe51">
+        <entry xmlns="%s">
+            <title type="text">&lt;span&gt;titre&lt;/span&gt;</title>
+            <id>c745a688-9b02-11e3-a1a3-c0143dd4fe51</id>
+            <updated>2014-02-21T16:16:39+02:00</updated>
+            <published>2014-02-21T16:16:38+02:00</published>
+            <content type="text">&lt;p&gt;contenu&lt;/p&gt;texte sans balise&lt;p&gt;autre contenu&lt;/p&gt;</content>
+            <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>contenu</p>texte sans balise<p>autre contenu</p></div></content>
+        <author>
+            <name>test1@souliane.org</name>
+        </author>
+    </entry>
+    </item>
+    """
+        % plugin_xep_0277.NS_ATOM
+    )
+
+    PUBSUB_ENTRY_2 = (
+        """
+    <item id="c745a688-9b02-11e3-a1a3-c0143dd4fe51">
+        <entry xmlns='%s'>
+            <title type="text">&lt;div&gt;titre&lt;/div&gt;</title>
+            <title type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><div style="background-image: url('xxx');">titre</div></div></title>
+            <id>c745a688-9b02-11e3-a1a3-c0143dd4fe51</id>
+            <updated>2014-02-21T16:16:39+02:00</updated>
+            <published>2014-02-21T16:16:38+02:00</published>
+            <content type="text">&lt;div&gt;&lt;p&gt;contenu&lt;/p&gt;texte dans balise&lt;p&gt;autre contenu&lt;/p&gt;&lt;/div&gt;</content>
+            <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>contenu</p>texte dans balise<p>autre contenu</p></div></content>
+        <author>
+            <name>test1@souliane.org</name>
+            <nick>test1</nick>
+        </author>
+    </entry>
+    </item>
+    """
+        % plugin_xep_0277.NS_ATOM
+    )
+
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+
+        class XEP_0163(object):
+            def __init__(self, host):
+                pass
+
+            def add_pep_event(self, *args):
+                pass
+
+        self.host.plugins["XEP-0060"] = plugin_xep_0060.XEP_0060(self.host)
+        self.host.plugins["XEP-0163"] = XEP_0163(self.host)
+        importlib.reload(plugin_misc_text_syntaxes)  # reload the plugin to avoid conflict error
+        self.host.plugins["TEXT_SYNTAXES"] = plugin_misc_text_syntaxes.TextSyntaxes(
+            self.host
+        )
+        self.plugin = plugin_xep_0277.XEP_0277(self.host)
+
+    def test_item2mbdata_1(self):
+        expected = {
+            "id": "c745a688-9b02-11e3-a1a3-c0143dd4fe51",
+            "atom_id": "c745a688-9b02-11e3-a1a3-c0143dd4fe51",
+            "title": "<span>titre</span>",
+            "updated": "1392992199.0",
+            "published": "1392992198.0",
+            "content": "<p>contenu</p>texte sans balise<p>autre contenu</p>",
+            "content_xhtml": "<div><p>contenu</p>texte sans balise<p>autre contenu</p></div>",
+            "author": "test1@souliane.org",
+        }
+        item_elt = (
+            next(ElementParser()(self.PUBSUB_ENTRY_1, namespace=NS_PUBSUB).elements())
+        )
+        d = self.plugin.item2mbdata(item_elt)
+        d.addCallback(self.assertEqual, expected)
+        return d
+
+    def test_item2mbdata_2(self):
+        expected = {
+            "id": "c745a688-9b02-11e3-a1a3-c0143dd4fe51",
+            "atom_id": "c745a688-9b02-11e3-a1a3-c0143dd4fe51",
+            "title": "<div>titre</div>",
+            "title_xhtml": '<div><div style="">titre</div></div>',
+            "updated": "1392992199.0",
+            "published": "1392992198.0",
+            "content": "<div><p>contenu</p>texte dans balise<p>autre contenu</p></div>",
+            "content_xhtml": "<div><p>contenu</p>texte dans balise<p>autre contenu</p></div>",
+            "author": "test1@souliane.org",
+        }
+        item_elt = (
+            next(ElementParser()(self.PUBSUB_ENTRY_2, namespace=NS_PUBSUB).elements())
+        )
+        d = self.plugin.item2mbdata(item_elt)
+        d.addCallback(self.assertEqual, expected)
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_plugin_xep_0297.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Plugin XEP-0297 """
+
+from .constants import Const as C
+from libervia.backend.test import helpers
+from libervia.backend.plugins.plugin_xep_0203 import XEP_0203
+from libervia.backend.plugins.plugin_xep_0297 import XEP_0297
+from twisted.words.protocols.jabber.jid import JID
+from dateutil.tz import tzutc
+import datetime
+from wokkel.generic import parseXml
+
+
+NS_PUBSUB = "http://jabber.org/protocol/pubsub"
+
+
+class XEP_0297Test(helpers.SatTestCase):
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+        self.plugin = XEP_0297(self.host)
+        self.host.plugins["XEP-0203"] = XEP_0203(self.host)
+
+    def test_delay(self):
+        stanza = parseXml(
+            """
+          <message from='juliet@capulet.lit/orchard'
+                   id='0202197'
+                   to='romeo@montague.lit'
+                   type='chat'>
+            <body>Yet I should kill thee with much cherishing.</body>
+            <mood xmlns='http://jabber.org/protocol/mood'>
+                <amorous/>
+            </mood>
+          </message>
+        """.encode(
+                "utf-8"
+            )
+        )
+        output = """
+          <message to='mercutio@verona.lit' type='chat'>
+            <body>A most courteous exposition!</body>
+            <forwarded xmlns='urn:xmpp:forward:0'>
+              <delay xmlns='urn:xmpp:delay' stamp='2010-07-10T23:08:25Z'/>
+              <message from='juliet@capulet.lit/orchard'
+                       id='0202197'
+                       to='romeo@montague.lit'
+                       type='chat'
+                       xmlns='jabber:client'>
+                  <body>Yet I should kill thee with much cherishing.</body>
+                  <mood xmlns='http://jabber.org/protocol/mood'>
+                      <amorous/>
+                  </mood>
+              </message>
+            </forwarded>
+          </message>
+        """
+        stamp = datetime.datetime(2010, 7, 10, 23, 8, 25, tzinfo=tzutc())
+        d = self.plugin.forward(
+            stanza,
+            JID("mercutio@verona.lit"),
+            stamp,
+            body="A most courteous exposition!",
+            profile_key=C.PROFILE[0],
+        )
+        d.addCallback(
+            lambda __: self.assert_equal_xml(
+                self.host.get_sent_message_xml(0), output, True
+            )
+        )
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_plugin_xep_0313.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,314 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Plugin XEP-0313 """
+
+from .constants import Const as C
+from libervia.backend.test import helpers
+from libervia.backend.plugins.plugin_xep_0313 import XEP_0313
+from twisted.words.protocols.jabber.jid import JID
+from twisted.words.xish import domish
+from wokkel.data_form import Field
+from dateutil.tz import tzutc
+import datetime
+
+# TODO: change this when RSM and MAM are in wokkel
+from sat_tmp.wokkel.rsm import RSMRequest
+from sat_tmp.wokkel.mam import buildForm, MAMRequest
+
+NS_PUBSUB = "http://jabber.org/protocol/pubsub"
+SERVICE = "sat-pubsub.tazar.int"
+SERVICE_JID = JID(SERVICE)
+
+
+class XEP_0313Test(helpers.SatTestCase):
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+        self.plugin = XEP_0313(self.host)
+        self.client = self.host.get_client(C.PROFILE[0])
+        mam_client = self.plugin.get_handler(C.PROFILE[0])
+        mam_client.makeConnection(self.host.get_client(C.PROFILE[0]).xmlstream)
+
+    def test_query_archive(self):
+        xml = """
+        <iq type='set' id='%s' to='%s'>
+          <query xmlns='urn:xmpp:mam:1'/>
+        </iq>
+        """ % (
+            ("H_%d" % domish.Element._idCounter),
+            SERVICE,
+        )
+        d = self.plugin.queryArchive(self.client, MAMRequest(), SERVICE_JID)
+        d.addCallback(
+            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
+        )
+        return d
+
+    def test_query_archive_pubsub(self):
+        xml = """
+        <iq type='set' id='%s' to='%s'>
+          <query xmlns='urn:xmpp:mam:1' node='fdp/submitted/capulet.lit/sonnets' />
+        </iq>
+        """ % (
+            ("H_%d" % domish.Element._idCounter),
+            SERVICE,
+        )
+        d = self.plugin.queryArchive(
+            self.client, MAMRequest(node="fdp/submitted/capulet.lit/sonnets"), SERVICE_JID
+        )
+        d.addCallback(
+            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
+        )
+        return d
+
+    def test_query_archive_with(self):
+        xml = """
+        <iq type='set' id='%s' to='%s'>
+          <query xmlns='urn:xmpp:mam:1'>
+            <x xmlns='jabber:x:data' type='submit'>
+              <field var='FORM_TYPE' type='hidden'>
+                <value>urn:xmpp:mam:1</value>
+              </field>
+              <field var='with' type='jid-single'>
+                <value>juliet@capulet.lit</value>
+              </field>
+            </x>
+          </query>
+        </iq>
+        """ % (
+            ("H_%d" % domish.Element._idCounter),
+            SERVICE,
+        )
+        form = buildForm(with_jid=JID("juliet@capulet.lit"))
+        d = self.plugin.queryArchive(self.client, MAMRequest(form), SERVICE_JID)
+        d.addCallback(
+            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
+        )
+        return d
+
+    def test_query_archive_start_end(self):
+        xml = """
+        <iq type='set' id='%s' to='%s'>
+          <query xmlns='urn:xmpp:mam:1'>
+            <x xmlns='jabber:x:data' type='submit'>
+              <field var='FORM_TYPE' type='hidden'>
+                <value>urn:xmpp:mam:1</value>
+              </field>
+              <field var='start' type='text-single'>
+                <value>2010-06-07T00:00:00Z</value>
+              </field>
+              <field var='end' type='text-single'>
+                <value>2010-07-07T13:23:54Z</value>
+              </field>
+            </x>
+          </query>
+        </iq>
+        """ % (
+            ("H_%d" % domish.Element._idCounter),
+            SERVICE,
+        )
+        start = datetime.datetime(2010, 6, 7, 0, 0, 0, tzinfo=tzutc())
+        end = datetime.datetime(2010, 7, 7, 13, 23, 54, tzinfo=tzutc())
+        form = buildForm(start=start, end=end)
+        d = self.plugin.queryArchive(self.client, MAMRequest(form), SERVICE_JID)
+        d.addCallback(
+            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
+        )
+        return d
+
+    def test_query_archive_start(self):
+        xml = """
+        <iq type='set' id='%s' to='%s'>
+          <query xmlns='urn:xmpp:mam:1'>
+            <x xmlns='jabber:x:data' type='submit'>
+              <field var='FORM_TYPE' type='hidden'>
+                <value>urn:xmpp:mam:1</value>
+              </field>
+              <field var='start' type='text-single'>
+                <value>2010-08-07T00:00:00Z</value>
+              </field>
+            </x>
+          </query>
+        </iq>
+        """ % (
+            ("H_%d" % domish.Element._idCounter),
+            SERVICE,
+        )
+        start = datetime.datetime(2010, 8, 7, 0, 0, 0, tzinfo=tzutc())
+        form = buildForm(start=start)
+        d = self.plugin.queryArchive(self.client, MAMRequest(form), SERVICE_JID)
+        d.addCallback(
+            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
+        )
+        return d
+
+    def test_query_archive_rsm(self):
+        xml = """
+        <iq type='set' id='%s' to='%s'>
+          <query xmlns='urn:xmpp:mam:1'>
+            <x xmlns='jabber:x:data' type='submit'>
+              <field var='FORM_TYPE' type='hidden'>
+                <value>urn:xmpp:mam:1</value>
+              </field>
+              <field var='start' type='text-single'>
+                <value>2010-08-07T00:00:00Z</value>
+              </field>
+            </x>
+            <set xmlns='http://jabber.org/protocol/rsm'>
+              <max>10</max>
+            </set>
+          </query>
+        </iq>
+        """ % (
+            ("H_%d" % domish.Element._idCounter),
+            SERVICE,
+        )
+        start = datetime.datetime(2010, 8, 7, 0, 0, 0, tzinfo=tzutc())
+        form = buildForm(start=start)
+        rsm = RSMRequest(max_=10)
+        d = self.plugin.queryArchive(self.client, MAMRequest(form, rsm), SERVICE_JID)
+        d.addCallback(
+            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
+        )
+        return d
+
+    def test_query_archive_rsm_paging(self):
+        xml = """
+        <iq type='set' id='%s' to='%s'>
+          <query xmlns='urn:xmpp:mam:1'>
+              <x xmlns='jabber:x:data' type='submit'>
+                <field var='FORM_TYPE' type='hidden'><value>urn:xmpp:mam:1</value></field>
+                <field var='start' type='text-single'><value>2010-08-07T00:00:00Z</value></field>
+              </x>
+              <set xmlns='http://jabber.org/protocol/rsm'>
+                 <max>10</max>
+                 <after>09af3-cc343-b409f</after>
+              </set>
+          </query>
+        </iq>
+        """ % (
+            ("H_%d" % domish.Element._idCounter),
+            SERVICE,
+        )
+        start = datetime.datetime(2010, 8, 7, 0, 0, 0, tzinfo=tzutc())
+        form = buildForm(start=start)
+        rsm = RSMRequest(max_=10, after="09af3-cc343-b409f")
+        d = self.plugin.queryArchive(self.client, MAMRequest(form, rsm), SERVICE_JID)
+        d.addCallback(
+            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
+        )
+        return d
+
+    def test_query_fields(self):
+        xml = """
+        <iq type='get' id="%s" to='%s'>
+          <query xmlns='urn:xmpp:mam:1'/>
+        </iq>
+        """ % (
+            ("H_%d" % domish.Element._idCounter),
+            SERVICE,
+        )
+        d = self.plugin.queryFields(self.client, SERVICE_JID)
+        d.addCallback(
+            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
+        )
+        return d
+
+    def test_query_archive_fields(self):
+        xml = """
+        <iq type='set' id='%s' to='%s'>
+          <query xmlns='urn:xmpp:mam:1'>
+            <x xmlns='jabber:x:data' type='submit'>
+              <field type='hidden' var='FORM_TYPE'>
+                <value>urn:xmpp:mam:1</value>
+              </field>
+              <field type='text-single' var='urn:example:xmpp:free-text-search'>
+                <value>Where arth thou, my Juliet?</value>
+              </field>
+              <field type='text-single' var='urn:example:xmpp:stanza-content'>
+                <value>{http://jabber.org/protocol/mood}mood/lonely</value>
+              </field>
+            </x>
+          </query>
+        </iq>
+        """ % (
+            ("H_%d" % domish.Element._idCounter),
+            SERVICE,
+        )
+        extra_fields = [
+            Field(
+                "text-single",
+                "urn:example:xmpp:free-text-search",
+                "Where arth thou, my Juliet?",
+            ),
+            Field(
+                "text-single",
+                "urn:example:xmpp:stanza-content",
+                "{http://jabber.org/protocol/mood}mood/lonely",
+            ),
+        ]
+        form = buildForm(extra_fields=extra_fields)
+        d = self.plugin.queryArchive(self.client, MAMRequest(form), SERVICE_JID)
+        d.addCallback(
+            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
+        )
+        return d
+
+    def test_query_prefs(self):
+        xml = """
+        <iq type='get' id='%s' to='%s'>
+          <prefs xmlns='urn:xmpp:mam:1'>
+            <always/>
+            <never/>
+          </prefs>
+        </iq>
+        """ % (
+            ("H_%d" % domish.Element._idCounter),
+            SERVICE,
+        )
+        d = self.plugin.get_prefs(self.client, SERVICE_JID)
+        d.addCallback(
+            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
+        )
+        return d
+
+    def test_set_prefs(self):
+        xml = """
+        <iq type='set' id='%s' to='%s'>
+          <prefs xmlns='urn:xmpp:mam:1' default='roster'>
+            <always>
+              <jid>romeo@montague.lit</jid>
+            </always>
+            <never>
+              <jid>montague@montague.lit</jid>
+            </never>
+          </prefs>
+        </iq>
+        """ % (
+            ("H_%d" % domish.Element._idCounter),
+            SERVICE,
+        )
+        always = [JID("romeo@montague.lit")]
+        never = [JID("montague@montague.lit")]
+        d = self.plugin.setPrefs(self.client, SERVICE_JID, always=always, never=never)
+        d.addCallback(
+            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
+        )
+        return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/test/test_plugin_xep_0334.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Plugin XEP-0334 """
+
+from .constants import Const as C
+from libervia.backend.test import helpers
+from libervia.backend.plugins.plugin_xep_0334 import XEP_0334
+from twisted.internet import defer
+from wokkel.generic import parseXml
+from libervia.backend.core import exceptions
+
+HINTS = ("no-permanent-storage", "no-storage", "no-copy")
+
+
+class XEP_0334Test(helpers.SatTestCase):
+    def setUp(self):
+        self.host = helpers.FakeSAT()
+        self.plugin = XEP_0334(self.host)
+
+    def test_message_send_trigger(self):
+        template_xml = """
+        <message
+            from='romeo@montague.net/orchard'
+            to='juliet@capulet.com'
+            type='chat'>
+          <body>text</body>
+          %s
+        </message>
+        """
+        original_xml = template_xml % ""
+
+        d_list = []
+
+        def cb(data, expected_xml):
+            result_xml = data["xml"].toXml().encode("utf-8")
+            self.assert_equal_xml(result_xml, expected_xml, True)
+
+        for key in HINTS + ("", "dummy_hint"):
+            mess_data = {
+                "xml": parseXml(original_xml.encode("utf-8")),
+                "extra": {key: True},
+            }
+            treatments = defer.Deferred()
+            self.plugin.messageSendTrigger(
+                self.host.get_client(C.PROFILE[0]), mess_data, defer.Deferred(), treatments
+            )
+            if treatments.callbacks:  # the trigger added a callback
+                expected_xml = template_xml % ('<%s xmlns="urn:xmpp:hints"/>' % key)
+                treatments.addCallback(cb, expected_xml)
+                treatments.callback(mess_data)
+                d_list.append(treatments)
+
+        return defer.DeferredList(d_list)
+
+    def test_message_received_trigger(self):
+        template_xml = """
+        <message
+            from='romeo@montague.net/orchard'
+            to='juliet@capulet.com'
+            type='chat'>
+          <body>text</body>
+          %s
+        </message>
+        """
+
+        def cb(__):
+            raise Exception("Errback should not be ran instead of callback!")
+
+        def eb(failure):
+            failure.trap(exceptions.SkipHistory)
+
+        d_list = []
+
+        for key in HINTS + ("dummy_hint",):
+            message = parseXml(template_xml % ('<%s xmlns="urn:xmpp:hints"/>' % key))
+            post_treat = defer.Deferred()
+            self.plugin.message_received_trigger(
+                self.host.get_client(C.PROFILE[0]), message, post_treat
+            )
+            if post_treat.callbacks:
+                assert key in ("no-permanent-storage", "no-storage")
+                post_treat.addCallbacks(cb, eb)
+                post_treat.callback(None)
+                d_list.append(post_treat)
+            else:
+                assert key not in ("no-permanent-storage", "no-storage")
+
+        return defer.DeferredList(d_list)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/async_trigger.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Misc usefull classes"""
+
+from typing import Tuple, Any
+from . import trigger as sync_trigger
+from . import utils
+from twisted.internet import defer
+
+class TriggerManager(sync_trigger.TriggerManager):
+    """This is a TriggerManager with an new async_point method"""
+
+    @defer.inlineCallbacks
+    def async_point(self, point_name, *args, **kwargs):
+        """This put a trigger point with potentially async Deferred
+
+        All the triggers for that point will be run
+        @param point_name: name of the trigger point
+        @param *args: args to transmit to trigger
+        @param *kwargs: kwargs to transmit to trigger
+            if "triggers_no_cancel" is present, it will be popped out
+                when set to True, this argument don't let triggers stop
+                the workflow
+        @return D(bool): True if the action must be continued, False else
+        """
+        if point_name not in self.__triggers:
+            defer.returnValue(True)
+
+        can_cancel = not kwargs.pop('triggers_no_cancel', False)
+
+        for priority, trigger in self.__triggers[point_name]:
+            try:
+                cont = yield utils.as_deferred(trigger, *args, **kwargs)
+                if can_cancel and not cont:
+                    defer.returnValue(False)
+            except sync_trigger.SkipOtherTriggers:
+                break
+        defer.returnValue(True)
+
+    async def async_return_point(
+        self,
+        point_name: str,
+        *args, **kwargs
+    ) -> Tuple[bool, Any]:
+        """Async version of return_point"""
+        if point_name not in self.__triggers:
+            return True, None
+
+        for priority, trigger in self.__triggers[point_name]:
+            try:
+                cont, ret_value = await utils.as_deferred(trigger, *args, **kwargs)
+                if not cont:
+                    return False, ret_value
+            except sync_trigger.SkipOtherTriggers:
+                break
+        return True, None
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/ansi.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import sys
+
+
+class ANSI(object):
+
+    ## ANSI escape sequences ##
+    RESET = "\033[0m"
+    NORMAL_WEIGHT = "\033[22m"
+    FG_BLACK, FG_RED, FG_GREEN, FG_YELLOW, FG_BLUE, FG_MAGENTA, FG_CYAN, FG_WHITE = (
+        "\033[3%dm" % nb for nb in range(8)
+    )
+    BOLD = "\033[1m"
+    BLINK = "\033[5m"
+    BLINK_OFF = "\033[25m"
+
+    @classmethod
+    def color(cls, *args):
+        """output text using ANSI codes
+
+        this method simply merge arguments, and add RESET if is not the last arguments
+        """
+        # XXX: we expect to have at least one argument
+        if args[-1] != cls.RESET:
+            args = list(args)
+            args.append(cls.RESET)
+        return "".join(args)
+
+
+try:
+    tty = sys.stdout.isatty()
+except (
+    AttributeError,
+    TypeError,
+):  # FIXME: TypeError is here for Pyjamas, need to be removed
+    tty = False
+if not tty:
+    #  we don't want ANSI escape codes if we are not outputing to a tty!
+    for attr in dir(ANSI):
+        if isinstance(getattr(ANSI, attr), str):
+            setattr(ANSI, attr, "")
+del tty
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/async_process.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""tools to launch process in a async way (using Twisted)"""
+
+import os.path
+from twisted.internet import defer, reactor, protocol
+from twisted.python.failure import Failure
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+log = getLogger(__name__)
+
+
+class CommandProtocol(protocol.ProcessProtocol):
+    """handle an external command"""
+    # name of the command (unicode)
+    name = None
+    # full path to the command (bytes)
+    command = None
+    # True to activate logging of command outputs (bool)
+    log = False
+
+    def __init__(self, deferred, stdin=None):
+        """
+        @param deferred(defer.Deferred): will be called when command is completed
+        @param stdin(str, None): if not None, will be push to standard input
+        """
+        self._stdin = stdin
+        self._deferred = deferred
+        self.data = []
+        self.err_data = []
+
+    @property
+    def command_name(self):
+        """returns command name or empty string if it can't be guessed"""
+        if self.name is not None:
+            return self.name
+        elif self.command is not None:
+            return os.path.splitext(os.path.basename(self.command))[0].decode('utf-8',
+                                                                              'ignore')
+        else:
+            return ''
+
+    def connectionMade(self):
+        if self._stdin is not None:
+            self.transport.write(self._stdin)
+            self.transport.closeStdin()
+
+    def outReceived(self, data):
+        if self.log:
+            log.info(data.decode('utf-8', 'replace'))
+        self.data.append(data)
+
+    def errReceived(self, data):
+        if self.log:
+            log.warning(data.decode('utf-8', 'replace'))
+        self.err_data.append(data)
+
+    def processEnded(self, reason):
+        data = b''.join(self.data)
+        if (reason.value.exitCode == 0):
+            log.debug(f'{self.command_name!r} command succeed')
+            # we don't use "replace" on purpose, we want an exception if decoding
+            # is not working properly
+            self._deferred.callback(data)
+        else:
+            err_data = b''.join(self.err_data)
+
+            msg = (_("Can't complete {name} command (error code: {code}):\n"
+                     "stderr:\n{stderr}\n{stdout}\n")
+                   .format(name = self.command_name,
+                           code = reason.value.exitCode,
+                           stderr= err_data.decode(errors='replace'),
+                           stdout = "stdout: " + data.decode(errors='replace')
+                                    if data else '',
+                           ))
+            self._deferred.errback(Failure(exceptions.CommandException(
+                msg, data, err_data)))
+
+    @classmethod
+    def run(cls, *args, **kwargs):
+        """Create a new CommandProtocol and execute the given command.
+
+        @param *args(unicode): command arguments
+            if cls.command is specified, it will be the path to the command to execute
+            otherwise, first argument must be the path
+        @param **kwargs: can be:
+            - stdin(unicode, None): data to push to standard input
+            - verbose(bool): if True stdout and stderr will be logged
+            other keyword arguments will be used in reactor.spawnProcess
+        @return ((D)bytes): stdout in case of success
+        @raise RuntimeError: command returned a non zero status
+            stdin and stdout will be given as arguments
+
+        """
+        stdin = kwargs.pop('stdin', None)
+        if stdin is not None:
+            stdin = stdin.encode('utf-8')
+        verbose = kwargs.pop('verbose', False)
+        args = list(args)
+        d = defer.Deferred()
+        prot = cls(d, stdin=stdin)
+        if verbose:
+            prot.log = True
+        if cls.command is None:
+            if not args:
+                raise ValueError(
+                    "You must either specify cls.command or use a full path to command "
+                    "to execute as first argument")
+            command = args.pop(0)
+            if prot.name is None:
+                name = os.path.splitext(os.path.basename(command))[0]
+                prot.name = name
+        else:
+            command = cls.command
+        cmd_args = [command] + args
+        if "env" not in kwargs:
+            # we pass parent environment by default
+            kwargs["env"] = None
+        reactor.spawnProcess(prot,
+                             command,
+                             cmd_args,
+                             **kwargs)
+        return d
+
+
+run = CommandProtocol.run
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/async_utils.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""tools to launch process in a async way (using Twisted)"""
+
+from collections import OrderedDict
+from typing import Optional, Callable, Awaitable
+from libervia.backend.core.log import getLogger
+
+
+log = getLogger(__name__)
+
+
+def async_lru(maxsize: Optional[int] = 50) -> Callable:
+    """Decorator to cache async function results using LRU algorithm
+
+        @param maxsize: maximum number of items to keep in cache.
+            None to have no limit
+
+    """
+    def decorator(func: Callable) -> Callable:
+        cache = OrderedDict()
+        async def wrapper(*args) -> Awaitable:
+            if args in cache:
+                log.debug(f"using result in cache for {args}")
+                cache.move_to_end(args)
+                result = cache[args]
+                return result
+            log.debug(f"caching result for {args}")
+            result = await func(*args)
+            cache[args] = result
+            if maxsize is not None and len(cache) > maxsize:
+                value = cache.popitem(False)
+                log.debug(f"Removing LRU value: {value}")
+            return result
+        return wrapper
+    return decorator
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/data_format.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" tools common to backend and frontends """
+#  FIXME: json may be more appropriate than manual serialising like done here
+
+from typing import Any
+
+from libervia.backend.core import exceptions
+import json
+
+
+def dict2iter(name, dict_, pop=False):
+    """iterate into a list serialised in a dict
+
+    name is the name of the key.
+    Serialisation is done with [name] [name#1] [name#2] and so on
+    e.g.: if name is 'group', keys are group, group#1, group#2, ...
+    iteration stop at first missing increment
+    Empty values are possible
+    @param name(unicode): name of the key
+    @param dict_(dict): dictionary with the serialised list
+    @param pop(bool): if True, remove the value from dict
+    @return iter: iterate through the deserialised list
+    """
+    if pop:
+        get = lambda d, k: d.pop(k)
+    else:
+        get = lambda d, k: d[k]
+
+    try:
+        yield get(dict_, name)
+    except KeyError:
+        return
+    else:
+        idx = 1
+        while True:
+            try:
+                yield get(dict_, "{}#{}".format(name, idx))
+            except KeyError:
+                return
+            else:
+                idx += 1
+
+
+def dict2iterdict(name, dict_, extra_keys, pop=False):
+    """like dict2iter but yield dictionaries
+
+    params are like in [dict2iter], extra_keys is used for extra dict keys.
+    e.g. dict2iterdict(comments, mb_data, ('node', 'service')) will yield dicts like:
+        {u'comments': u'value1', u'node': u'value2', u'service': u'value3'}
+    """
+    #  FIXME: this format seem overcomplicated, it may be more appropriate to use json here
+    if pop:
+        get = lambda d, k: d.pop(k)
+    else:
+        get = lambda d, k: d[k]
+    for idx, main_value in enumerate(dict2iter(name, dict_, pop=pop)):
+        ret = {name: main_value}
+        for k in extra_keys:
+            ret[k] = get(
+                dict_, "{}{}_{}".format(name, ("#" + str(idx)) if idx else "", k)
+            )
+        yield ret
+
+
+def iter2dict(name, iter_, dict_=None, check_conflict=True):
+    """Fill a dict with values from an iterable
+
+    name is used to serialise iter_, in the same way as in [dict2iter]
+    Build from the tags a dict using the microblog data format.
+
+    @param name(unicode): key to use for serialisation
+        e.g. "group" to have keys "group", "group#1", "group#2", ...
+    @param iter_(iterable): values to store
+    @param dict_(None, dict): dictionary to fill, or None to create one
+    @param check_conflict(bool): if True, raise an exception in case of existing key
+    @return (dict): filled dict, or newly created one
+    @raise exceptions.ConflictError: a needed key already exists
+    """
+    if dict_ is None:
+        dict_ = {}
+    for idx, value in enumerate(iter_):
+        if idx == 0:
+            key = name
+        else:
+            key = "{}#{}".format(name, idx)
+        if check_conflict and key in dict_:
+            raise exceptions.ConflictError
+        dict_[key] = value
+    return dict
+
+
+def get_sub_dict(name, dict_, sep="_"):
+    """get a sub dictionary from a serialised dictionary
+
+    look for keys starting with name, and create a dict with it
+    eg.: if "key" is looked for, {'html': 1, 'key_toto': 2, 'key_titi': 3} will return:
+        {None: 1, toto: 2, titi: 3}
+    @param name(unicode): name of the key
+    @param dict_(dict): dictionary with the serialised list
+    @param sep(unicode): separator used between name and subkey
+    @return iter: iterate through the deserialised items
+    """
+    for k, v in dict_.items():
+        if k.startswith(name):
+            if k == name:
+                yield None, v
+            else:
+                if k[len(name)] != sep:
+                    continue
+                else:
+                    yield k[len(name) + 1 :], v
+
+def serialise(data):
+    """Serialise data so it can be sent to bridge
+
+    @return(unicode): serialised data, can be transmitted as string to the bridge
+    """
+    return json.dumps(data, ensure_ascii=False, default=str)
+
+def deserialise(serialised_data: str, default: Any = None, type_check: type = dict):
+    """Deserialize data from bridge
+
+    @param serialised_data(unicode): data to deserialise
+    @default (object): value to use when serialised data is empty string
+    @param type_check(type): if not None, the deserialised data must be of this type
+    @return(object): deserialised data
+    @raise ValueError: serialised_data is of wrong type
+    """
+    if serialised_data == "":
+        return default
+    ret = json.loads(serialised_data)
+    if type_check is not None and not isinstance(ret, type_check):
+        raise ValueError("Bad data type, was expecting {type_check}, got {real_type}"
+            .format(type_check=type_check, real_type=type(ret)))
+    return ret
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/data_objects.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,213 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Objects handling bridge data, with jinja2 safe markup handling"""
+
+from libervia.backend.core.constants import Const as C
+from libervia.backend.tools.common import data_format
+from os.path import basename
+
+try:
+    from jinja2 import Markup as safe
+except ImportError:
+    safe = str
+
+from libervia.backend.tools.common import uri as xmpp_uri
+import urllib.request, urllib.parse, urllib.error
+
+q = lambda value: urllib.parse.quote(value.encode("utf-8"), safe="@")
+
+
+class Message(object):
+    def __init__(self, msg_data):
+        self._uid = msg_data[0]
+        self._timestamp = msg_data[1]
+        self._from_jid = msg_data[2]
+        self._to_jid = msg_data[3]
+        self._message_data = msg_data[4]
+        self._subject_data = msg_data[5]
+        self._type = msg_data[6]
+        self._extra = data_format.deserialise(msg_data[7])
+        self._html = dict(data_format.get_sub_dict("xhtml", self._extra))
+
+    @property
+    def id(self):
+        return self._uid
+
+    @property
+    def timestamp(self):
+        return self._timestamp
+
+    @property
+    def from_(self):
+        return self._from_jid
+
+    @property
+    def text(self):
+        try:
+            return self._message_data[""]
+        except KeyError:
+            return next(iter(self._message_data.values()))
+
+    @property
+    def subject(self):
+        try:
+            return self._subject_data[""]
+        except KeyError:
+            return next(iter(self._subject_data.values()))
+
+    @property
+    def type(self):
+        return self._type
+
+    @property
+    def thread(self):
+        return self._extra.get("thread")
+
+    @property
+    def thread_parent(self):
+        return self._extra.get("thread_parent")
+
+    @property
+    def received(self):
+        return self._extra.get("received_timestamp", self._timestamp)
+
+    @property
+    def delay_sender(self):
+        return self._extra.get("delay_sender")
+
+    @property
+    def info_type(self):
+        return self._extra.get("info_type")
+
+    @property
+    def html(self):
+        if not self._html:
+            return None
+        try:
+            return safe(self._html[""])
+        except KeyError:
+            return safe(next(iter(self._html.values())))
+
+
+class Messages(object):
+    def __init__(self, msgs_data):
+        self.messages = [Message(m) for m in msgs_data]
+
+    def __len__(self):
+        return self.messages.__len__()
+
+    def __missing__(self, key):
+        return self.messages.__missing__(key)
+
+    def __getitem__(self, key):
+        return self.messages.__getitem__(key)
+
+    def __iter__(self):
+        return self.messages.__iter__()
+
+    def __reversed__(self):
+        return self.messages.__reversed__()
+
+    def __contains__(self, item):
+        return self.messages.__contains__(item)
+
+
+class Room(object):
+    def __init__(self, jid, name=None, url=None):
+        self.jid = jid
+        self.name = name or jid
+        if url is not None:
+            self.url = url
+
+
+class Identity(object):
+    def __init__(self, jid_str, data=None):
+        self.jid_str = jid_str
+        self.data = data if data is not None else {}
+
+    @property
+    def avatar_basename(self):
+        try:
+            return basename(self.data['avatar']['path'])
+        except (TypeError, KeyError):
+            return None
+
+    def __getitem__(self, key):
+        return self.data[key]
+
+    def __getattr__(self, key):
+        try:
+            return self.data[key]
+        except KeyError:
+            raise AttributeError(key)
+
+
+class Identities:
+    def __init__(self):
+        self.identities = {}
+
+    def __iter__(self):
+        return iter(self.identities)
+
+    def __getitem__(self, jid_str):
+        try:
+            return self.identities[jid_str]
+        except KeyError:
+            return None
+
+    def __setitem__(self, jid_str, data):
+        self.identities[jid_str] = Identity(jid_str, data)
+
+    def __contains__(self, jid_str):
+        return jid_str in self.identities
+
+
+class ObjectQuoter(object):
+    """object wrapper which quote attribues (to be used in templates)"""
+
+    def __init__(self, obj):
+        self.obj = obj
+
+    def __unicode__(self):
+        return q(self.obj.__unicode__())
+
+    def __str__(self):
+        return self.__unicode__()
+
+    def __getattr__(self, name):
+        return q(self.obj.__getattr__(name))
+
+    def __getitem__(self, key):
+        return q(self.obj.__getitem__(key))
+
+
+class OnClick(object):
+    """Class to handle clickable elements targets"""
+
+    def __init__(self, url=None):
+        self.url = url
+
+    def format_url(self, *args, **kwargs):
+        """format URL using Python formatting
+
+        values will be quoted before being used
+        """
+        return self.url.format(
+            *[q(a) for a in args], **{k: ObjectQuoter(v) for k, v in kwargs.items()}
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/date_utils.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,267 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""tools to help manipulating time and dates"""
+
+from typing import Union
+import calendar
+import datetime
+import re
+import time
+
+from babel import dates
+from dateutil import parser, tz
+from dateutil.parser import ParserError
+from dateutil.relativedelta import relativedelta
+from dateutil.utils import default_tzinfo
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+
+RELATIVE_RE = re.compile(
+    r"\s*(?P<in>\bin\b)?"
+    r"(?P<date>[^+-].+[^\s+-])?\s*(?P<direction>[-+])?\s*"
+    r"\s*(?P<quantity>\d+)\s*"
+    r"(?P<unit>(second|sec|s|minute|min|month|mo|m|hour|hr|h|day|d|week|w|year|yr|y))s?"
+    r"(?P<ago>\s+ago)?\s*",
+    re.I
+)
+TIME_SYMBOL_MAP = {
+    "s": "second",
+    "sec": "second",
+    "m": "minute",
+    "min": "minute",
+    "h": "hour",
+    "hr": "hour",
+    "d": "day",
+    "w": "week",
+    "mo": "month",
+    "y": "year",
+    "yr": "year",
+}
+YEAR_FIRST_RE = re.compile(r"\d{4}[^\d]+")
+TZ_UTC = tz.tzutc()
+TZ_LOCAL = tz.gettz()
+
+
+def date_parse(value, default_tz=TZ_UTC):
+    """Parse a date and return corresponding unix timestamp
+
+    @param value(unicode): date to parse, in any format supported by parser
+    @param default_tz(datetime.tzinfo): default timezone
+    @return (int): timestamp
+    """
+    value = str(value).strip()
+    dayfirst = False if YEAR_FIRST_RE.match(value) else True
+
+    try:
+        dt = default_tzinfo(
+            parser.parse(value, dayfirst=dayfirst),
+            default_tz)
+    except ParserError as e:
+        if value == "now":
+            dt = datetime.datetime.now(tz.tzutc())
+        else:
+            try:
+                # the date may already be a timestamp
+                return int(value)
+            except ValueError:
+                raise e
+    return calendar.timegm(dt.utctimetuple())
+
+def date_parse_ext(value, default_tz=TZ_UTC):
+    """Extended date parse which accept relative date
+
+    @param value(unicode): date to parse, in any format supported by parser
+        and with the hability to specify X days/weeks/months/years in the past or future.
+        Relative date are specified either with something like `[main_date] +1 week`
+        or with something like `3 days ago`, and it is case insensitive. [main_date] is
+        a date parsable by parser, or empty to specify current date/time.
+        "now" can also be used to specify current date/time.
+    @param default_tz(datetime.tzinfo): same as for date_parse
+    @return (int): timestamp
+    """
+    m = RELATIVE_RE.match(value)
+    if m is None:
+        return date_parse(value, default_tz=default_tz)
+
+    if sum(1 for g in ("direction", "in", "ago") if m.group(g)) > 1:
+        raise ValueError(
+            _('You can use only one of direction (+ or -), "in" and "ago"'))
+
+    if m.group("direction") == '-' or m.group("ago"):
+        direction = -1
+    else:
+        direction = 1
+
+    date = m.group("date")
+    if date is not None:
+        date = date.strip()
+    if not date or date == "now":
+        dt = datetime.datetime.now(tz.tzutc())
+    else:
+        try:
+            dt = default_tzinfo(parser.parse(date, dayfirst=True), default_tz)
+        except ParserError as e:
+            try:
+                timestamp = int(date)
+            except ValueError:
+                raise e
+            else:
+                dt = datetime.datetime.fromtimestamp(timestamp, tz.tzutc())
+
+    quantity = int(m.group("quantity"))
+    unit = m.group("unit").lower()
+    try:
+        unit = TIME_SYMBOL_MAP[unit]
+    except KeyError:
+        pass
+    delta_kw = {f"{unit}s": direction * quantity}
+    dt = dt + relativedelta(**delta_kw)
+    return calendar.timegm(dt.utctimetuple())
+
+
+def date_fmt(
+    timestamp: Union[float, int, str],
+    fmt: str = "short",
+    date_only: bool = False,
+    auto_limit: int = 7,
+    auto_old_fmt: str = "short",
+    auto_new_fmt: str = "relative",
+    locale_str: str = C.DEFAULT_LOCALE,
+    tz_info: Union[datetime.tzinfo, str] = TZ_UTC
+) -> str:
+    """Format date according to locale
+
+    @param timestamp: unix time
+    @param fmt: one of:
+        - short: e.g. u'31/12/17'
+        - medium: e.g. u'Apr 1, 2007'
+        - long: e.g. u'April 1, 2007'
+        - full: e.g. u'Sunday, April 1, 2007'
+        - relative: format in relative time
+            e.g.: 3 hours
+            note that this format is not precise
+        - iso: ISO 8601 format
+            e.g.: u'2007-04-01T19:53:23Z'
+        - auto: use auto_old_fmt if date is older than auto_limit
+            else use auto_new_fmt
+        - auto_day: shorcut to set auto format with change on day
+            old format will be short, and new format will be time only
+        or a free value which is passed to babel.dates.format_datetime
+        (see http://babel.pocoo.org/en/latest/dates.html?highlight=pattern#pattern-syntax)
+    @param date_only: if True, only display date (not datetime)
+    @param auto_limit: limit in days before using auto_old_fmt
+        use 0 to have a limit at last midnight (day change)
+    @param auto_old_fmt: format to use when date is older than limit
+    @param auto_new_fmt: format to use when date is equal to or more recent
+        than limit
+    @param locale_str: locale to use (as understood by babel)
+    @param tz_info: time zone to use
+
+    """
+    timestamp = float(timestamp)
+    if isinstance(tz_info, str):
+        tz_info = tz.gettz(tz_info)
+    if fmt == "auto_day":
+        fmt, auto_limit, auto_old_fmt, auto_new_fmt = "auto", 0, "short", "HH:mm"
+    if fmt == "auto":
+        if auto_limit == 0:
+            now = datetime.datetime.now(tz_info)
+            # we want to use given tz_info, so we don't use date() or today()
+            today = datetime.datetime(year=now.year, month=now.month, day=now.day,
+                                      tzinfo=now.tzinfo)
+            today = calendar.timegm(today.utctimetuple())
+            if timestamp < today:
+                fmt = auto_old_fmt
+            else:
+                fmt = auto_new_fmt
+        else:
+            days_delta = (time.time() - timestamp) / 3600
+            if days_delta > (auto_limit or 7):
+                fmt = auto_old_fmt
+            else:
+                fmt = auto_new_fmt
+
+    if fmt == "relative":
+        delta = timestamp - time.time()
+        return dates.format_timedelta(
+            delta, granularity="minute", add_direction=True, locale=locale_str
+        )
+    elif fmt in ("short", "long", "full"):
+        if date_only:
+            dt = datetime.datetime.fromtimestamp(timestamp, tz_info)
+            return dates.format_date(dt, format=fmt, locale=locale_str)
+        else:
+            return dates.format_datetime(timestamp, format=fmt, locale=locale_str,
+                                        tzinfo=tz_info)
+    elif fmt == "iso":
+        if date_only:
+            fmt = "yyyy-MM-dd"
+        else:
+            fmt = "yyyy-MM-ddTHH:mm:ss'Z'"
+        return dates.format_datetime(timestamp, format=fmt)
+    else:
+        return dates.format_datetime(timestamp, format=fmt, locale=locale_str,
+                                     tzinfo=tz_info)
+
+
+def delta2human(start_ts: Union[float, int], end_ts: Union[float, int]) -> str:
+    """Convert delta of 2 unix times to human readable text
+
+    @param start_ts: timestamp of starting time
+    @param end_ts: timestamp of ending time
+    """
+    if end_ts < start_ts:
+        raise exceptions.InternalError(
+            "end timestamp must be bigger or equal to start timestamp !"
+        )
+    rd = relativedelta(
+        datetime.datetime.fromtimestamp(end_ts),
+        datetime.datetime.fromtimestamp(start_ts)
+    )
+    text_elems = []
+    for unit in ("years", "months", "days", "hours", "minutes"):
+        value = getattr(rd, unit)
+        if value == 1:
+            # we remove final "s" when there is only 1
+            text_elems.append(f"1 {unit[:-1]}")
+        elif value > 1:
+            text_elems.append(f"{value} {unit}")
+
+    return ", ".join(text_elems)
+
+
+def get_timezone_name(tzinfo, timestamp: Union[float, int]) -> str:
+    """
+    Get the DST-aware timezone name for a given timezone and timestamp.
+
+    @param tzinfo: The timezone to get the name for
+    @param timestamp: The timestamp to use, as a Unix timestamp (number of seconds since
+        the Unix epoch).
+    @return: The DST-aware timezone name.
+    """
+
+    dt = datetime.datetime.fromtimestamp(timestamp)
+    dt_tz = dt.replace(tzinfo=tzinfo)
+    tz_name = dt_tz.tzname()
+    if tz_name is None:
+        raise exceptions.InternalError("tz_name should not be None")
+    return tz_name
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/dynamic_import.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,43 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" tools dynamic import """
+
+from importlib import import_module
+from libervia.backend.core.log import getLogger
+
+
+log = getLogger(__name__)
+
+
+def bridge(name, module_path="libervia.backend.bridge"):
+    """import bridge module
+
+    @param module_path(str): path of the module to import
+    @param name(str): name of the bridge to import (e.g.: dbus)
+    @return (module, None): imported module or None if nothing is found
+    """
+    try:
+        bridge_module = import_module(module_path + "." + name)
+    except ImportError:
+        try:
+            bridge_module = import_module(module_path + "." + name + "_bridge")
+        except ImportError as e:
+            log.warning(f"Can't import bridge {name!r}: {e}")
+            bridge_module = None
+    return bridge_module
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/email.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+
+
+# SàT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""email sending facilities"""
+
+
+from email.mime.text import MIMEText
+from twisted.mail import smtp
+from libervia.backend.core.constants import Const as C
+from libervia.backend.tools import config as tools_config
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+def send_email(config, to_emails, subject="", body="", from_email=None):
+    """Send an email using SàT configuration
+
+    @param config (SafeConfigParser): the configuration instance
+    @param to_emails(list[unicode], unicode): list of recipients
+        if unicode, it will be split to get emails
+    @param subject(unicode): subject of the message
+    @param body(unicode): body of the message
+    @param from_email(unicode): address of the sender
+    @return (D): same as smtp.sendmail
+    """
+    if isinstance(to_emails, str):
+        to_emails = to_emails.split()
+    email_host = tools_config.config_get(config, None, "email_server") or "localhost"
+    email_from = from_email or tools_config.config_get(config, None, "email_from")
+
+    # we suppose that email domain and XMPP domain are identical
+    domain = tools_config.config_get(config, None, "xmpp_domain")
+    if domain is None:
+        if email_from is not None:
+            domain = email_from.split("@", 1)[-1]
+        else:
+            domain = "example.net"
+
+    if email_from is None:
+        email_from = "no_reply@" + domain
+    email_sender_domain = tools_config.config_get(
+        config, None, "email_sender_domain", domain
+    )
+    email_port = int(tools_config.config_get(config, None, "email_port", 25))
+    email_username = tools_config.config_get(config, None, "email_username")
+    email_password = tools_config.config_get(config, None, "email_password")
+    email_auth = C.bool(tools_config.config_get(config, None, "email_auth", C.BOOL_FALSE))
+    email_starttls = C.bool(tools_config.config_get(config, None, "email_starttls",
+                            C.BOOL_FALSE))
+
+    msg = MIMEText(body, "plain", "UTF-8")
+    msg["Subject"] = subject
+    msg["From"] = email_from
+    msg["To"] = ", ".join(to_emails)
+
+    return smtp.sendmail(
+        email_host.encode("utf-8"),
+        email_from.encode("utf-8"),
+        [email.encode("utf-8") for email in to_emails],
+        msg.as_bytes(),
+        senderDomainName=email_sender_domain if email_sender_domain else None,
+        port=email_port,
+        username=email_username.encode("utf-8") if email_username else None,
+        password=email_password.encode("utf-8") if email_password else None,
+        requireAuthentication=email_auth,
+        requireTransportSecurity=email_starttls,
+    )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/files_utils.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+
+# SaT: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""tools to help manipulating files"""
+from pathlib import Path
+
+
+def get_unique_name(path):
+    """Generate a path with a name not conflicting with existing file
+
+    @param path(str, Path): path to the file to create
+    @return (Path): unique path (can be the same as path if there is no conflict)
+    """
+    ori_path = path = Path(path)
+    idx = 1
+    while path.exists():
+        path = ori_path.with_name(f"{ori_path.stem}_{idx}{ori_path.suffix}")
+        idx += 1
+    return path
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/regex.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+
+
+# Salut à Toi: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" regex tools common to backend and frontends """
+
+import re
+import unicodedata
+
+path_escape = {"%": "%25", "/": "%2F", "\\": "%5c"}
+path_escape_rev = {re.escape(v): k for k, v in path_escape.items()}
+path_escape = {re.escape(k): v for k, v in path_escape.items()}
+#  thanks to Martijn Pieters (https://stackoverflow.com/a/14693789)
+RE_ANSI_REMOVE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
+RE_TEXT_URL = re.compile(r'[^a-zA-Z0-9,_]+')
+TEXT_MAX_LEN = 60
+# min lenght is currently deactivated
+TEXT_WORD_MIN_LENGHT = 0
+
+
+def re_join(exps):
+    """Join (OR) various regexes"""
+    return re.compile("|".join(exps))
+
+
+def re_sub_dict(pattern, repl_dict, string):
+    """Replace key, value found in dict according to pattern
+
+    @param pattern(basestr): pattern using keys found in repl_dict
+    @repl_dict(dict): keys found in this dict will be replaced by
+        corresponding values
+    @param string(basestr): string to use for the replacement
+    """
+    return pattern.sub(lambda m: repl_dict[re.escape(m.group(0))], string)
+
+
+path_escape_re = re_join(list(path_escape.keys()))
+path_escape_rev_re = re_join(list(path_escape_rev.keys()))
+
+
+def path_escape(string):
+    """Escape string so it can be use in a file path
+
+    @param string(basestr): string to escape
+    @return (str, unicode): escaped string, usable in a file path
+    """
+    return re_sub_dict(path_escape_re, path_escape, string)
+
+
+def path_unescape(string):
+    """Unescape string from value found in file path
+
+    @param string(basestr): string found in file path
+    @return (str, unicode): unescaped string
+    """
+    return re_sub_dict(path_escape_rev_re, path_escape_rev, string)
+
+
+def ansi_remove(string):
+    """Remove ANSI escape codes from string
+
+    @param string(basestr): string to filter
+    @return (str, unicode): string without ANSI escape codes
+    """
+    return RE_ANSI_REMOVE.sub("", string)
+
+
+def url_friendly_text(text):
+    """Convert text to url-friendly one"""
+    # we change special chars to ascii one,
+    # trick found at https://stackoverflow.com/a/3194567
+    text = unicodedata.normalize('NFD', text).encode('ascii', 'ignore').decode('utf-8')
+    text = RE_TEXT_URL.sub(' ', text).lower()
+    text = '-'.join([t for t in text.split() if t and len(t)>=TEXT_WORD_MIN_LENGHT])
+    while len(text) > TEXT_MAX_LEN:
+        if '-' in text:
+            text = text.rsplit('-', 1)[0]
+        else:
+            text = text[:TEXT_MAX_LEN]
+    return text
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/template.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1064 @@
+#!/usr/bin/env python3
+
+# SAT: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Template generation"""
+
+import os.path
+import time
+import re
+import json
+from datetime import datetime
+from pathlib import Path
+from collections import namedtuple
+from typing import Optional, List, Tuple, Union
+from xml.sax.saxutils import quoteattr
+from babel import support
+from babel import Locale
+from babel.core import UnknownLocaleError
+import pygments
+from pygments import lexers
+from pygments import formatters
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.tools import config
+from libervia.backend.tools.common import date_utils
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+try:
+    import sat_templates
+except ImportError:
+    raise exceptions.MissingModule(
+        "sat_templates module is not available, please install it or check your path to "
+        "use template engine"
+    )
+else:
+    sat_templates  # to avoid pyflakes warning
+
+try:
+    import jinja2
+except:
+    raise exceptions.MissingModule(
+        "Missing module jinja2, please install it from http://jinja.pocoo.org or with "
+        "pip install jinja2"
+    )
+
+from lxml import etree
+from jinja2 import Markup as safe
+from jinja2 import is_undefined
+from jinja2 import utils
+from jinja2 import TemplateNotFound
+from jinja2 import contextfilter
+from jinja2.loaders import split_template_path
+
+HTML_EXT = ("html", "xhtml")
+RE_ATTR_ESCAPE = re.compile(r"[^a-z_-]")
+SITE_RESERVED_NAMES = ("sat",)
+TPL_RESERVED_CHARS = r"()/."
+RE_TPL_RESERVED_CHARS = re.compile("[" + TPL_RESERVED_CHARS + "]")
+BROWSER_DIR = "_browser"
+BROWSER_META_FILE = "browser_meta.json"
+
+TemplateData = namedtuple("TemplateData", ['site', 'theme', 'path'])
+
+
+class TemplateLoader(jinja2.BaseLoader):
+    """A template loader which handle site, theme and absolute paths"""
+    # TODO: list_templates should be implemented
+
+    def __init__(self, sites_paths, sites_themes, trusted=False):
+        """
+        @param trusted(bool): if True, absolue template paths will be allowed
+            be careful when using this option and sure that you can trust the template,
+            as this allow the template to open any file on the system that the
+            launching user can access.
+        """
+        if not sites_paths or not "" in sites_paths:
+            raise exceptions.InternalError("Invalid sites_paths")
+        super(jinja2.BaseLoader, self).__init__()
+        self.sites_paths = sites_paths
+        self.sites_themes = sites_themes
+        self.trusted = trusted
+
+    @staticmethod
+    def parse_template(template):
+        """Parse template path and return site, theme and path
+
+        @param template_path(unicode): path to template with parenthesis syntax
+            The site and/or theme can be specified in parenthesis just before the path
+            e.g.: (some_theme)path/to/template.html
+                  (/some_theme)path/to/template.html (equivalent to previous one)
+                  (other_site/other_theme)path/to/template.html
+                  (other_site/)path/to/template.html (defaut theme for other_site)
+                  /absolute/path/to/template.html (in trusted environment only)
+        @return (TemplateData):
+            site, theme and template_path.
+            if site is empty, SàT Templates are used
+            site and theme can be both None if absolute path is used
+            Relative path is the path from theme root dir e.g. blog/articles.html
+        """
+        if template.startswith("("):
+            # site and/or theme are specified
+            try:
+                theme_end = template.index(")")
+            except IndexError:
+                raise ValueError("incorrect site/theme in template")
+            theme_data = template[1:theme_end]
+            theme_splitted = theme_data.split('/')
+            if len(theme_splitted) == 1:
+                site, theme = "", theme_splitted[0]
+            elif len(theme_splitted) == 2:
+                site, theme = theme_splitted
+            else:
+                raise ValueError("incorrect site/theme in template")
+            template_path = template[theme_end+1:]
+            if not template_path or template_path.startswith("/"):
+                raise ValueError("incorrect template path")
+        elif template.startswith("/"):
+            # this is an absolute path, so we have no site and no theme
+            site = None
+            theme = None
+            template_path = template
+        else:
+            # a default template
+            site = ""
+            theme = C.TEMPLATE_THEME_DEFAULT
+            template_path = template
+
+        if site is not None:
+            site = site.strip()
+            if not site:
+                site = ""
+            elif site in SITE_RESERVED_NAMES:
+                raise ValueError(_("{site} can't be used as site name, "
+                                   "it's reserved.").format(site=site))
+
+        if theme is not None:
+            theme = theme.strip()
+            if not theme:
+                theme = C.TEMPLATE_THEME_DEFAULT
+            if RE_TPL_RESERVED_CHARS.search(theme):
+                raise ValueError(_("{theme} contain forbidden char. Following chars "
+                                   "are forbidden: {reserved}").format(
+                                   theme=theme, reserved=TPL_RESERVED_CHARS))
+
+        return TemplateData(site, theme, template_path)
+
+    @staticmethod
+    def get_sites_and_themes(
+            site: str,
+            theme: str,
+            settings: Optional[dict] = None,
+        ) -> List[Tuple[str, str]]:
+        """Get sites and themes to check for template/file
+
+        Will add default theme and default site in search list when suitable. Settings'
+        `fallback` can be used to modify behaviour: themes in this list will then be used
+        instead of default (it can also be empty list or None, in which case no fallback
+        is used).
+
+        @param site: site requested
+        @param theme: theme requested
+        @return: site and theme couples to check
+        """
+        if settings is None:
+            settings = {}
+        sites_and_themes = [[site, theme]]
+        fallback = settings.get("fallback", [C.TEMPLATE_THEME_DEFAULT])
+        for fb_theme in fallback:
+            if theme != fb_theme:
+                sites_and_themes.append([site, fb_theme])
+        if site:
+            for fb_theme in fallback:
+                sites_and_themes.append(["", fb_theme])
+        return sites_and_themes
+
+    def _get_template_f(self, site, theme, path_elts):
+        """Look for template and return opened file if found
+
+        @param site(unicode): names of site to check
+            (default site will also checked)
+        @param theme(unicode): theme to check (default theme will also be checked)
+        @param path_elts(iterable[str]): elements of template path
+        @return (tuple[(File, None), (str, None)]): a tuple with:
+            - opened template, or None if not found
+            - absolute file path, or None if not found
+        """
+        if site is None:
+            raise exceptions.InternalError(
+                "_get_template_f must not be used with absolute path")
+        settings = self.sites_themes[site][theme]['settings']
+        for site_to_check, theme_to_check in self.get_sites_and_themes(
+                site, theme, settings):
+            try:
+                base_path = self.sites_paths[site_to_check]
+            except KeyError:
+                log.warning(_("Unregistered site requested: {site_to_check}").format(
+                    site_to_check=site_to_check))
+            filepath = os.path.join(
+                base_path,
+                C.TEMPLATE_TPL_DIR,
+                theme_to_check,
+                *path_elts
+            )
+            f = utils.open_if_exists(filepath, 'r')
+            if f is not None:
+                return f, filepath
+        return None, None
+
+    def get_source(self, environment, template):
+        """Retrieve source handling site and themes
+
+        If the path is absolute it is used directly if in trusted environment
+        else and exception is raised.
+        if the path is just relative, "default" theme is used.
+        @raise PermissionError: absolute path used in untrusted environment
+        """
+        site, theme, template_path = self.parse_template(template)
+
+        if site is None:
+            # we have an abolute template
+            if theme is not None:
+                raise exceptions.InternalError("We can't have a theme with absolute "
+                                               "template.")
+            if not self.trusted:
+                log.error(_("Absolute template used while unsecure is disabled, hack "
+                            "attempt? Template: {template}").format(template=template))
+                raise exceptions.PermissionError("absolute template is not allowed")
+            filepath = template_path
+            f = utils.open_if_exists(filepath, 'r')
+        else:
+            # relative path, we have to deal with site and theme
+            assert theme and template_path
+            path_elts = split_template_path(template_path)
+            # if we have non default site, we check it first, else we only check default
+            f, filepath = self._get_template_f(site, theme, path_elts)
+
+        if f is None:
+            if (site is not None and path_elts[0] == "error"
+                and os.path.splitext(template_path)[1][1:] in HTML_EXT):
+                # if an HTML error is requested but doesn't exist, we try again
+                # with base error.
+                f, filepath = self._get_template_f(
+                    site, theme, ("error", "base.html"))
+                if f is None:
+                    raise exceptions.InternalError("error/base.html should exist")
+            else:
+                raise TemplateNotFound(template)
+
+        try:
+            contents = f.read()
+        finally:
+            f.close()
+
+        mtime = os.path.getmtime(filepath)
+
+        def uptodate():
+            try:
+                return os.path.getmtime(filepath) == mtime
+            except OSError:
+                return False
+
+        return contents, filepath, uptodate
+
+
+class Indexer(object):
+    """Index global to a page"""
+
+    def __init__(self):
+        self._indexes = {}
+
+    def next(self, value):
+        if value not in self._indexes:
+            self._indexes[value] = 0
+            return 0
+        self._indexes[value] += 1
+        return self._indexes[value]
+
+    def current(self, value):
+        return self._indexes.get(value)
+
+
+class ScriptsHandler(object):
+    def __init__(self, renderer, template_data):
+        self.renderer = renderer
+        self.template_data = template_data
+        self.scripts = []  #  we don't use a set because order may be important
+
+    def include(self, library_name, attribute="defer"):
+        """Mark that a script need to be imported.
+
+        Must be used before base.html is extended, as <script> are generated there.
+        If called several time with the same library, it will be imported once.
+        @param library_name(unicode): name of the library to import
+        @param loading:
+        """
+        if attribute not in ("defer", "async", ""):
+            raise exceptions.DataError(
+                _('Invalid attribute, please use one of "defer", "async" or ""')
+            )
+        if not library_name.endswith(".js"):
+            library_name = library_name + ".js"
+        if (library_name, attribute) not in self.scripts:
+            self.scripts.append((library_name, attribute))
+        return ""
+
+    def generate_scripts(self):
+        """Generate the <script> elements
+
+        @return (unicode): <scripts> HTML tags
+        """
+        scripts = []
+        tpl = "<script src={src} {attribute}></script>"
+        for library, attribute in self.scripts:
+            library_path = self.renderer.get_static_path(self.template_data, library)
+            if library_path is None:
+                log.warning(_("Can't find {libary} javascript library").format(
+                    library=library))
+                continue
+            path = self.renderer.get_front_url(library_path)
+            scripts.append(tpl.format(src=quoteattr(path), attribute=attribute))
+        return safe("\n".join(scripts))
+
+
+class Environment(jinja2.Environment):
+
+    def get_template(self, name, parent=None, globals=None):
+        if name[0] not in ('/', '('):
+            # if name is not an absolute path or a full template name (this happen on
+            # extend or import during rendering), we convert it to a full template name.
+            # This is needed to handle cache correctly when a base template is overriden.
+            # Without that, we could not distinguish something like base/base.html if
+            # it's launched from some_site/some_theme or from [default]/default
+            name = "({site}/{theme}){template}".format(
+                site=self._template_data.site,
+                theme=self._template_data.theme,
+                template=name)
+
+        return super(Environment, self).get_template(name, parent, globals)
+
+
+class Renderer(object):
+
+    def __init__(self, host, front_url_filter=None, trusted=False, private=False):
+        """
+        @param front_url_filter(callable): filter to retrieve real url of a directory/file
+            The callable will get a two arguments:
+                - a dict with a "template_data" key containing TemplateData instance of
+                  current template. Only site and theme should be necessary.
+                - the relative URL of the file to retrieve, relative from theme root
+            None to use default filter which return real path on file
+            Need to be specified for web rendering, to reflect URL seen by end user
+        @param trusted(bool): if True, allow to access absolute path
+            Only set to True if environment is safe (e.g. command line tool)
+        @param private(bool): if True, also load sites from sites_path_private_dict
+        """
+        self.host = host
+        self.trusted = trusted
+        self.sites_paths = {
+            "": os.path.dirname(sat_templates.__file__),
+        }
+        self.sites_themes = {
+        }
+        conf = config.parse_main_conf()
+        public_sites = config.config_get(conf, None, "sites_path_public_dict", {})
+        sites_data = [public_sites]
+        if private:
+            private_sites = config.config_get(conf, None, "sites_path_private_dict", {})
+            sites_data.append(private_sites)
+        for sites in sites_data:
+            normalised = {}
+            for name, path in sites.items():
+                if RE_TPL_RESERVED_CHARS.search(name):
+                    log.warning(_("Can't add \"{name}\" site, it contains forbidden "
+                                  "characters. Forbidden characters are {forbidden}.")
+                                .format(name=name, forbidden=TPL_RESERVED_CHARS))
+                    continue
+                path = os.path.expanduser(os.path.normpath(path))
+                if not path or not path.startswith("/"):
+                    log.warning(_("Can't add \"{name}\" site, it should map to an "
+                                  "absolute path").format(name=name))
+                    continue
+                normalised[name] = path
+            self.sites_paths.update(normalised)
+
+        for site, site_path in self.sites_paths.items():
+            tpl_path = Path(site_path) / C.TEMPLATE_TPL_DIR
+            for p in tpl_path.iterdir():
+                if not p.is_dir():
+                    continue
+                log.debug(f"theme found for {site or 'default site'}: {p.name}")
+                theme_data = self.sites_themes.setdefault(site, {})[p.name] = {
+                    'path': p,
+                    'settings': {}}
+                theme_settings = p / "settings.json"
+                if theme_settings.is_file:
+                    try:
+                        with theme_settings.open() as f:
+                            settings = json.load(f)
+                    except Exception as e:
+                        log.warning(_(
+                            "Can't load theme settings at {path}: {e}").format(
+                            path=theme_settings, e=e))
+                    else:
+                        log.debug(
+                            f"found settings for theme {p.name!r} at {theme_settings}")
+                        fallback = settings.get("fallback")
+                        if fallback is None:
+                            settings["fallback"] = []
+                        elif isinstance(fallback, str):
+                            settings["fallback"] = [fallback]
+                        elif not isinstance(fallback, list):
+                            raise ValueError(
+                                'incorrect type for "fallback" in settings '
+                                f'({type(fallback)}) at {theme_settings}: {fallback}'
+                            )
+                        theme_data['settings'] = settings
+                browser_path = p / BROWSER_DIR
+                if browser_path.is_dir():
+                    theme_data['browser_path'] = browser_path
+                browser_meta_path = browser_path / BROWSER_META_FILE
+                if browser_meta_path.is_file():
+                    try:
+                        with browser_meta_path.open() as f:
+                            theme_data['browser_meta'] = json.load(f)
+                    except Exception as e:
+                        log.error(
+                            f"Can't parse browser metadata at {browser_meta_path}: {e}"
+                        )
+                        continue
+
+        self.env = Environment(
+            loader=TemplateLoader(
+                sites_paths=self.sites_paths,
+                sites_themes=self.sites_themes,
+                trusted=trusted
+            ),
+            autoescape=jinja2.select_autoescape(["html", "xhtml", "xml"]),
+            trim_blocks=True,
+            lstrip_blocks=True,
+            extensions=["jinja2.ext.i18n"],
+        )
+        self.env._template_data = None
+        self._locale_str = C.DEFAULT_LOCALE
+        self._locale = Locale.parse(self._locale_str)
+        self.install_translations()
+
+        # we want to have access to SàT constants in templates
+        self.env.globals["C"] = C
+
+        # custom filters
+        self.env.filters["next_gidx"] = self._next_gidx
+        self.env.filters["cur_gidx"] = self._cur_gidx
+        self.env.filters["date_fmt"] = self._date_fmt
+        self.env.filters["timestamp_to_hour"] = self._timestamp_to_hour
+        self.env.filters["delta_to_human"] = date_utils.delta2human
+        self.env.filters["xmlui_class"] = self._xmlui_class
+        self.env.filters["attr_escape"] = self.attr_escape
+        self.env.filters["item_filter"] = self._item_filter
+        self.env.filters["adv_format"] = self._adv_format
+        self.env.filters["dict_ext"] = self._dict_ext
+        self.env.filters["highlight"] = self.highlight
+        self.env.filters["front_url"] = (self._front_url if front_url_filter is None
+                                         else front_url_filter)
+        # custom tests
+        self.env.tests["in_the_past"] = self._in_the_past
+        self.icons_path = os.path.join(host.media_dir, "fonts/fontello/svg")
+
+        # policies
+        self.env.policies["ext.i18n.trimmed"] = True
+        self.env.policies["json.dumps_kwargs"] = {
+            "sort_keys": True,
+            # if object can't be serialised, we use None
+            "default": lambda o: o.to_json() if hasattr(o, "to_json") else None
+        }
+
+    def get_front_url(self, template_data, path=None):
+        """Give front URL (i.e. URL seen by end-user) of a path
+
+        @param template_data[TemplateData]: data of current template
+        @param path(unicode, None): relative path of file to get,
+            if set, will remplate template_data.path
+        """
+        return self.env.filters["front_url"]({"template_data": template_data},
+                                path or template_data.path)
+
+    def install_translations(self):
+        # TODO: support multi translation
+        #       for now, only translations in sat_templates are handled
+        self.translations = {}
+        for site_key, site_path in self.sites_paths.items():
+            site_prefix = "[{}] ".format(site_key) if site_key else ''
+            i18n_dir = os.path.join(site_path, "i18n")
+            for lang_dir in os.listdir(i18n_dir):
+                lang_path = os.path.join(i18n_dir, lang_dir)
+                if not os.path.isdir(lang_path):
+                    continue
+                po_path = os.path.join(lang_path, "LC_MESSAGES/sat.mo")
+                try:
+                    locale = Locale.parse(lang_dir)
+                    with open(po_path, "rb") as f:
+                        try:
+                            translations = self.translations[locale]
+                        except KeyError:
+                            self.translations[locale] = support.Translations(f, "sat")
+                        else:
+                            translations.merge(support.Translations(f, "sat"))
+                except EnvironmentError:
+                    log.error(
+                        _("Can't find template translation at {path}").format(
+                            path=po_path))
+                except UnknownLocaleError as e:
+                    log.error(_("{site}Invalid locale name: {msg}").format(
+                        site=site_prefix, msg=e))
+                else:
+                    log.info(_("{site}loaded {lang} templates translations").format(
+                        site = site_prefix,
+                        lang=lang_dir))
+
+        default_locale = Locale.parse(self._locale_str)
+        if default_locale not in self.translations:
+            # default locale disable gettext,
+            # so we can use None instead of a Translations instance
+            self.translations[default_locale] = None
+
+        self.env.install_null_translations(True)
+        # we generate a tuple of locales ordered by display name that templates can access
+        # through the "locales" variable
+        self.locales = tuple(sorted(list(self.translations.keys()),
+                                    key=lambda l: l.language_name.lower()))
+
+
+    def set_locale(self, locale_str):
+        """set current locale
+
+        change current translation locale and self self._locale and self._locale_str
+        """
+        if locale_str == self._locale_str:
+            return
+        if locale_str == "en":
+            # we default to GB English when it's not specified
+            # one of the main reason is to avoid the nonsense U.S. short date format
+            locale_str = "en_GB"
+        try:
+            locale = Locale.parse(locale_str)
+        except ValueError as e:
+            log.warning(_("invalid locale value: {msg}").format(msg=e))
+            locale_str = self._locale_str = C.DEFAULT_LOCALE
+            locale = Locale.parse(locale_str)
+
+        locale_str = str(locale)
+        if locale_str != C.DEFAULT_LOCALE:
+            try:
+                translations = self.translations[locale]
+            except KeyError:
+                log.warning(_("Can't find locale {locale}".format(locale=locale)))
+                locale_str = C.DEFAULT_LOCALE
+                locale = Locale.parse(self._locale_str)
+            else:
+                self.env.install_gettext_translations(translations, True)
+                log.debug(_("Switched to {lang}").format(lang=locale.english_name))
+
+        if locale_str == C.DEFAULT_LOCALE:
+            self.env.install_null_translations(True)
+
+        self._locale = locale
+        self._locale_str = locale_str
+
+    def get_theme_and_root(self, template):
+        """retrieve theme and root dir of a given template
+
+        @param template(unicode): template to parse
+        @return (tuple[unicode, unicode]): theme and absolute path to theme's root dir
+        @raise NotFound: requested site has not been found
+        """
+        # FIXME: check use in jp, and include site
+        site, theme, __ = self.env.loader.parse_template(template)
+        if site is None:
+            # absolute template
+            return  "", os.path.dirname(template)
+        try:
+            site_root_dir = self.sites_paths[site]
+        except KeyError:
+            raise exceptions.NotFound
+        return theme, os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR, theme)
+
+    def get_themes_data(self, site_name):
+        try:
+            return self.sites_themes[site_name]
+        except KeyError:
+            raise exceptions.NotFound(f"no theme found for {site_name}")
+
+    def get_static_path(
+            self,
+            template_data: TemplateData,
+            filename: str,
+            settings: Optional[dict]=None
+        ) -> Optional[TemplateData]:
+        """Retrieve path of a static file if it exists with current theme or default
+
+        File will be looked at <site_root_dir>/<theme_dir>/<static_dir>/filename,
+        then <site_root_dir>/<default_theme_dir>/<static_dir>/filename anf finally
+        <default_site>/<default_theme_dir>/<static_dir> (i.e. sat_templates).
+        In case of absolute URL, base dir of template is used as base. For instance if
+        template is an absolute template to "/some/path/template.html", file will be
+        checked at "/some/path/<filename>"
+        @param template_data: data of current template
+        @param filename: name of the file to retrieve
+        @param settings: theme settings, can be used to modify behaviour
+        @return: built template data instance where .path is
+            the relative path to the file, from theme root dir.
+            None if not found.
+        """
+        if template_data.site is None:
+            # we have an absolue path
+            if (not template_data.theme is None
+                or not template_data.path.startswith('/')):
+                raise exceptions.InternalError(
+                    "invalid template data, was expecting absolute URL")
+            static_dir = os.path.dirname(template_data.path)
+            file_path = os.path.join(static_dir, filename)
+            if os.path.exists(file_path):
+                return TemplateData(site=None, theme=None, path=file_path)
+            else:
+                return None
+
+        sites_and_themes = TemplateLoader.get_sites_and_themes(template_data.site,
+                                                            template_data.theme,
+                                                            settings)
+        for site, theme in sites_and_themes:
+            site_root_dir = self.sites_paths[site]
+            relative_path = os.path.join(C.TEMPLATE_STATIC_DIR, filename)
+            absolute_path = os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR,
+                                         theme, relative_path)
+            if os.path.exists(absolute_path):
+                return TemplateData(site=site, theme=theme, path=relative_path)
+
+        return None
+
+    def _append_css_paths(
+            self,
+            template_data: TemplateData,
+            css_files: list,
+            css_files_noscript: list,
+            name_root: str,
+            settings: dict
+
+        ) -> None:
+        """Append found css to css_files and css_files_noscript
+
+        @param css_files: list to fill of relative path to found css file
+        @param css_files_noscript: list to fill of relative path to found css file
+            with "_noscript" suffix
+        """
+        name = name_root + ".css"
+        css_path = self.get_static_path(template_data, name, settings)
+        if css_path is not None:
+            css_files.append(self.get_front_url(css_path))
+            noscript_name = name_root + "_noscript.css"
+            noscript_path = self.get_static_path(
+                template_data, noscript_name, settings)
+            if noscript_path is not None:
+                css_files_noscript.append(self.get_front_url(noscript_path))
+
+    def get_css_files(self, template_data):
+        """Retrieve CSS files to use according template_data
+
+        For each element of the path, a .css file is looked for in /static, and returned
+        if it exists.
+        Previous element are kept by replacing '/' with '_'.
+        styles_extra.css, styles.css, highlight.css and fonts.css are always used if they
+            exist.
+        For each found file, if a file with the same name and "_noscript" suffix exists,
+        it will be returned is second part of resulting tuple.
+        For instance, if template_data is (some_site, some_theme, blog/articles.html),
+        following files are returned, each time trying [some_site root] first,
+        then default site (i.e. sat_templates) root:
+            - some_theme/static/styles.css is returned if it exists
+              else default/static/styles.css
+            - some_theme/static/blog.css is returned if it exists
+              else default/static/blog.css (if it exists too)
+            - some_theme/static/blog_articles.css is returned if it exists
+              else default/static/blog_articles.css (if it exists too)
+        and for each found files, if same file with _noscript suffix exists, it is put
+        in noscript list (for instance (some_theme/static/styles_noscript.css)).
+        The behaviour may be changed with theme settings: if "fallback" is set, specified
+        themes will be checked instead of default. The theme will be checked in given
+        order, and "fallback" may be None or empty list to not check anything.
+        @param template_data(TemplateData): data of the current template
+        @return (tuple[list[unicode], list[unicode]]): a tuple with:
+            - front URLs of CSS files to use
+            - front URLs of CSS files to use when scripts are not enabled
+        """
+        # TODO: some caching would be nice
+        css_files = []
+        css_files_noscript = []
+        path_elems = template_data.path.split('/')
+        path_elems[-1] = os.path.splitext(path_elems[-1])[0]
+        site = template_data.site
+        if site is None:
+            # absolute path
+            settings = {}
+        else:
+            settings = self.sites_themes[site][template_data.theme]['settings']
+
+        css_path = self.get_static_path(template_data, 'fonts.css', settings)
+        if css_path is not None:
+            css_files.append(self.get_front_url(css_path))
+
+        for name_root in ('styles', 'styles_extra', 'highlight'):
+            self._append_css_paths(
+                template_data, css_files, css_files_noscript, name_root, settings)
+
+        for idx in range(len(path_elems)):
+            name_root = "_".join(path_elems[:idx+1])
+            self._append_css_paths(
+                template_data, css_files, css_files_noscript, name_root, settings)
+
+        return css_files, css_files_noscript
+
+    ## custom filters ##
+
+    @contextfilter
+    def _front_url(self, ctx, relative_url):
+        """Get front URL (URL seen by end-user) from a relative URL
+
+        This default method return absolute full path
+        """
+        template_data = ctx['template_data']
+        if template_data.site is None:
+            assert template_data.theme is None
+            assert template_data.path.startswith("/")
+            return os.path.join(os.path.dirname(template_data.path, relative_url))
+
+        site_root_dir = self.sites_paths[template_data.site]
+        return os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR, template_data.theme,
+                            relative_url)
+
+    @contextfilter
+    def _next_gidx(self, ctx, value):
+        """Use next current global index as suffix"""
+        next_ = ctx["gidx"].next(value)
+        return value if next_ == 0 else "{}_{}".format(value, next_)
+
+    @contextfilter
+    def _cur_gidx(self, ctx, value):
+        """Use current current global index as suffix"""
+        current = ctx["gidx"].current(value)
+        return value if not current else "{}_{}".format(value, current)
+
+    def _date_fmt(
+        self,
+        timestamp: Union[int, float],
+        fmt: str = "short",
+        date_only: bool = False,
+        auto_limit: int = 7,
+        auto_old_fmt: str = "short",
+        auto_new_fmt: str = "relative",
+        tz_name: Optional[str] = None
+    ) -> str:
+        if is_undefined(fmt):
+            fmt = "short"
+
+        try:
+            return date_utils.date_fmt(
+                timestamp, fmt, date_only, auto_limit, auto_old_fmt,
+                auto_new_fmt, locale_str = self._locale_str,
+                tz_info=tz_name or date_utils.TZ_UTC
+            )
+        except Exception as e:
+            log.warning(_("Can't parse date: {msg}").format(msg=e))
+            return str(timestamp)
+
+    def _timestamp_to_hour(self, timestamp: float) -> int:
+        """Get hour of day corresponding to a timestamp"""
+        dt = datetime.fromtimestamp(timestamp)
+        return dt.hour
+
+    def attr_escape(self, text):
+        """escape a text to a value usable as an attribute
+
+        remove spaces, and put in lower case
+        """
+        return RE_ATTR_ESCAPE.sub("_", text.strip().lower())[:50]
+
+    def _xmlui_class(self, xmlui_item, fields):
+        """return classes computed from XMLUI fields name
+
+        will return a string with a series of escaped {name}_{value} separated by spaces.
+        @param xmlui_item(xmlui.XMLUIPanel): XMLUI containing the widgets to use
+        @param fields(iterable(unicode)): names of the widgets to use
+        @return (unicode, None): computer string to use as class attribute value
+            None if no field was specified
+        """
+        classes = []
+        for name in fields:
+            escaped_name = self.attr_escape(name)
+            try:
+                for value in xmlui_item.widgets[name].values:
+                    classes.append(escaped_name + "_" + self.attr_escape(value))
+            except KeyError:
+                log.debug(
+                    _('ignoring field "{name}": it doesn\'t exists').format(name=name)
+                )
+                continue
+        return " ".join(classes) or None
+
+    @contextfilter
+    def _item_filter(self, ctx, item, filters):
+        """return item's value, filtered if suitable
+
+        @param item(object): item to filter
+            value must have name and value attributes,
+            mostly used for XMLUI items
+        @param filters(dict[unicode, (callable, dict, None)]): map of name => filter
+            if filter is None, return the value unchanged
+            if filter is a callable, apply it
+            if filter is a dict, it can have following keys:
+                - filters: iterable of filters to apply
+                - filters_args: kwargs of filters in the same order as filters (use empty
+                                dict if needed)
+                - template: template to format where {value} is the filtered value
+        """
+        value = item.value
+        filter_ = filters.get(item.name, None)
+        if filter_ is None:
+            return value
+        elif isinstance(filter_, dict):
+            filters_args = filter_.get("filters_args")
+            for idx, f_name in enumerate(filter_.get("filters", [])):
+                kwargs = filters_args[idx] if filters_args is not None else {}
+                filter_func = self.env.filters[f_name]
+                try:
+                    eval_context_filter = filter_func.evalcontextfilter
+                except AttributeError:
+                    eval_context_filter = False
+
+                if eval_context_filter:
+                    value = filter_func(ctx.eval_ctx, value, **kwargs)
+                else:
+                    value = filter_func(value, **kwargs)
+            template = filter_.get("template")
+            if template:
+                # format will return a string, so we need to check first
+                # if the value is safe or not, and re-mark it after formatting
+                is_safe = isinstance(value, safe)
+                value = template.format(value=value)
+                if is_safe:
+                    value = safe(value)
+            return value
+
+    def _adv_format(self, value, template, **kwargs):
+        """Advancer formatter
+
+        like format() method, but take care or special values like None
+        @param value(unicode): value to format
+        @param template(None, unicode): template to use with format() method.
+            It will be formatted using value=value and **kwargs
+            None to return value unchanged
+        @return (unicode): formatted value
+        """
+        if template is None:
+            return value
+        #  jinja use string when no special char is used, so we have to convert to unicode
+        return str(template).format(value=value, **kwargs)
+
+    def _dict_ext(self, source_dict, extra_dict, key=None):
+        """extend source_dict with extra dict and return the result
+
+        @param source_dict(dict): dictionary to extend
+        @param extra_dict(dict, None): dictionary to use to extend first one
+            None to return source_dict unmodified
+        @param key(unicode, None): if specified extra_dict[key] will be used
+            if it doesn't exists, a copy of unmodified source_dict is returned
+        @return (dict): resulting dictionary
+        """
+        if extra_dict is None:
+            return source_dict
+        if key is not None:
+            extra_dict = extra_dict.get(key, {})
+        ret = source_dict.copy()
+        ret.update(extra_dict)
+        return ret
+
+    def highlight(self, code, lexer_name=None, lexer_opts=None, html_fmt_opts=None):
+        """Do syntax highlighting on code
+
+        Under the hood, pygments is used, check its documentation for options possible
+        values.
+        @param code(unicode): code or markup to highlight
+        @param lexer_name(unicode, None): name of the lexer to use
+            None to autodetect it
+        @param html_fmt_opts(dict, None): kword arguments to use for HtmlFormatter
+        @return (unicode): HTML markup with highlight classes
+        """
+        if lexer_opts is None:
+            lexer_opts = {}
+        if html_fmt_opts is None:
+            html_fmt_opts = {}
+        if lexer_name is None:
+            lexer = lexers.guess_lexer(code, **lexer_opts)
+        else:
+            lexer = lexers.get_lexer_by_name(lexer_name, **lexer_opts)
+        formatter = formatters.HtmlFormatter(**html_fmt_opts)
+        return safe(pygments.highlight(code, lexer, formatter))
+
+    ## custom tests ##
+
+    def _in_the_past(self, timestamp):
+        """check if a date is in the past
+
+        @param timestamp(unicode, int): unix time
+        @return (bool): True if date is in the past
+        """
+        return time.time() > int(timestamp)
+
+    ## template methods ##
+
+    def _icon_defs(self, *names):
+        """Define svg icons which will be used in the template.
+
+        Their name is used as id
+        """
+        svg_elt = etree.Element(
+            "svg",
+            nsmap={None: "http://www.w3.org/2000/svg"},
+            width="0",
+            height="0",
+            style="display: block",
+        )
+        defs_elt = etree.SubElement(svg_elt, "defs")
+        for name in names:
+            path = os.path.join(self.icons_path, name + ".svg")
+            icon_svg_elt = etree.parse(path).getroot()
+            # we use icon name as id, so we can retrieve them easily
+            icon_svg_elt.set("id", name)
+            if not icon_svg_elt.tag == "{http://www.w3.org/2000/svg}svg":
+                raise exceptions.DataError("invalid SVG element")
+            defs_elt.append(icon_svg_elt)
+        return safe(etree.tostring(svg_elt, encoding="unicode"))
+
+    def _icon_use(self, name, cls=""):
+        return safe('<svg class="svg-icon{cls}" xmlns="http://www.w3.org/2000/svg" '
+                    'viewBox="0 0 100 100">\n'
+                    '    <use href="#{name}"/>'
+                    '</svg>\n'.format(name=name, cls=(" " + cls) if cls else ""))
+
+    def _icon_from_client(self, client):
+        """Get icon name to represent a disco client"""
+        if client is None:
+            return 'desktop'
+        elif 'pc' in client:
+            return 'desktop'
+        elif 'phone' in client:
+            return 'mobile'
+        elif 'web' in client:
+            return 'globe'
+        elif 'console' in client:
+            return 'terminal'
+        else:
+            return 'desktop'
+
+    def render(self, template, site=None, theme=None, locale=C.DEFAULT_LOCALE,
+               media_path="", css_files=None, css_inline=False, **kwargs):
+        """Render a template
+
+        @param template(unicode): template to render (e.g. blog/articles.html)
+        @param site(unicode): site name
+            None or empty string for defaut site (i.e. SàT templates)
+        @param theme(unicode): template theme
+        @param media_path(unicode): prefix of the SàT media path/URL to use for
+            template root. Must end with a u'/'
+        @param css_files(list[unicode],None): CSS files to use
+            CSS files must be in static dir of the template
+            use None for automatic selection of CSS files based on template category
+            None is recommended. General static/style.css and theme file name will be
+            used.
+        @param css_inline(bool): if True, CSS will be embedded in the HTML page
+        @param **kwargs: variable to transmit to the template
+        """
+        if not template:
+            raise ValueError("template can't be empty")
+        if site is not None or theme is not None:
+            # user wants to set site and/or theme, so we add it to the template path
+            if site is None:
+                site = ''
+            if theme is None:
+                theme = C.TEMPLATE_THEME_DEFAULT
+            if template[0] == "(":
+                raise ValueError(
+                    "you can't specify site or theme in template path and in argument "
+                    "at the same time"
+                )
+
+            template_data = TemplateData(site, theme, template)
+            template = "({site}/{theme}){template}".format(
+                site=site, theme=theme, template=template)
+        else:
+            template_data = self.env.loader.parse_template(template)
+
+        # we need to save template_data in environment, to load right templates when they
+        # are referenced from other templates (e.g. import)
+        # FIXME: this trick will not work anymore if we use async templates (it works
+        #        here because we know that the rendering will be blocking until we unset
+        #        _template_data)
+        self.env._template_data = template_data
+
+        template_source = self.env.get_template(template)
+
+        if css_files is None:
+            css_files, css_files_noscript = self.get_css_files(template_data)
+        else:
+            css_files_noscript = []
+
+        kwargs["icon_defs"] = self._icon_defs
+        kwargs["icon"] = self._icon_use
+        kwargs["icon_from_client"] = self._icon_from_client
+
+        if css_inline:
+            css_contents = []
+            for files, suffix in ((css_files, ""),
+                                  (css_files_noscript, "_noscript")):
+                site_root_dir = self.sites_paths[template_data.site]
+                for css_file in files:
+                    css_file_path = os.path.join(site_root_dir, css_file)
+                    with open(css_file_path) as f:
+                        css_contents.append(f.read())
+                if css_contents:
+                    kwargs["css_content" + suffix] = "\n".join(css_contents)
+
+        scripts_handler = ScriptsHandler(self, template_data)
+        self.set_locale(locale)
+
+        # XXX: theme used in template arguments is the requested theme, which may differ
+        #      from actual theme if the template doesn't exist in the requested theme.
+        rendered = template_source.render(
+            template_data=template_data,
+            media_path=media_path,
+            css_files=css_files,
+            css_files_noscript=css_files_noscript,
+            locale=self._locale,
+            locales=self.locales,
+            gidx=Indexer(),
+            script=scripts_handler,
+            **kwargs
+        )
+        self.env._template_data = None
+        return rendered
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/template_xmlui.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" template XMLUI parsing
+
+XMLUI classes from this modules can then be iterated to create the template
+"""
+
+from functools import partial
+from libervia.backend.core.log import getLogger
+from sat_frontends.tools import xmlui
+from sat_frontends.tools import jid
+try:
+    from jinja2 import Markup as safe
+except ImportError:
+    # Safe marks XHTML values as usable as it.
+    # If jinja2 is not there, we can use a simple lamba
+    safe = lambda x: x
+
+
+log = getLogger(__name__)
+
+
+## Widgets ##
+
+
+class Widget(object):
+    category = "widget"
+    type = None
+    enabled = True
+    read_only = True
+
+    def __init__(self, xmlui_parent):
+        self.xmlui_parent = xmlui_parent
+
+    @property
+    def name(self):
+        return self._xmlui_name
+
+
+class ValueWidget(Widget):
+    def __init__(self, xmlui_parent, value):
+        super(ValueWidget, self).__init__(xmlui_parent)
+        self.value = value
+
+    @property
+    def values(self):
+        return [self.value]
+
+    @property
+    def labels(self):
+        #  helper property, there is not label for ValueWidget
+        # but using labels make rendering more easy (one single method to call)
+        #  values are actually returned
+        return [self.value]
+
+
+class InputWidget(ValueWidget):
+    def __init__(self, xmlui_parent, value, read_only=False):
+        super(InputWidget, self).__init__(xmlui_parent, value)
+        self.read_only = read_only
+
+
+class OptionsWidget(Widget):
+    def __init__(self, xmlui_parent, options, selected, style):
+        super(OptionsWidget, self).__init__(xmlui_parent)
+        self.options = options
+        self.selected = selected
+        self.style = style
+
+    @property
+    def values(self):
+        for value, label in self.items:
+            yield value
+
+    @property
+    def value(self):
+        if self.multi or self.no_select or len(self.selected) != 1:
+            raise ValueError(
+                "Can't get value for list with multiple selections or nothing selected"
+            )
+        return self.selected[0]
+
+    @property
+    def labels(self):
+        """return only labels from self.items"""
+        for value, label in self.items:
+            yield label
+
+    @property
+    def items(self):
+        """return suitable items, according to style"""
+        no_select = self.no_select
+        for value, label in self.options:
+            if no_select or value in self.selected:
+                yield value, label
+
+    @property
+    def inline(self):
+        return "inline" in self.style
+
+    @property
+    def no_select(self):
+        return "noselect" in self.style
+
+    @property
+    def multi(self):
+        return "multi" in self.style
+
+
+class EmptyWidget(xmlui.EmptyWidget, Widget):
+    def __init__(self, _xmlui_parent):
+        Widget.__init__(self)
+
+
+class TextWidget(xmlui.TextWidget, ValueWidget):
+    type = "text"
+
+
+class JidWidget(xmlui.JidWidget, ValueWidget):
+    type = "jid"
+
+    def __init__(self, xmlui_parent, value):
+        self.value = jid.JID(value)
+
+
+class LabelWidget(xmlui.LabelWidget, ValueWidget):
+    type = "label"
+
+    @property
+    def for_name(self):
+        try:
+            return self._xmlui_for_name
+        except AttributeError:
+            return None
+
+
+class StringWidget(xmlui.StringWidget, InputWidget):
+    type = "string"
+
+
+class JidInputWidget(xmlui.JidInputWidget, StringWidget):
+    type = "jid"
+
+class TextBoxWidget(xmlui.TextWidget, InputWidget):
+    type = "textbox"
+
+
+class XHTMLBoxWidget(xmlui.XHTMLBoxWidget, InputWidget):
+    type = "xhtmlbox"
+
+    def __init__(self, xmlui_parent, value, read_only=False):
+        # XXX: XHTMLBoxWidget value must be cleaned (harmful XHTML must be removed)
+        #      This is normally done in the backend, the frontends should not need to
+        #      worry about it.
+        super(XHTMLBoxWidget, self).__init__(
+            xmlui_parent=xmlui_parent, value=safe(value), read_only=read_only)
+
+
+class ListWidget(xmlui.ListWidget, OptionsWidget):
+    type = "list"
+
+
+## Containers ##
+
+
+class Container(object):
+    category = "container"
+    type = None
+
+    def __init__(self, xmlui_parent):
+        self.xmlui_parent = xmlui_parent
+        self.children = []
+
+    def __iter__(self):
+        return iter(self.children)
+
+    def _xmlui_append(self, widget):
+        self.children.append(widget)
+
+    def _xmlui_remove(self, widget):
+        self.children.remove(widget)
+
+
+class VerticalContainer(xmlui.VerticalContainer, Container):
+    type = "vertical"
+
+
+class PairsContainer(xmlui.PairsContainer, Container):
+    type = "pairs"
+
+
+class LabelContainer(xmlui.PairsContainer, Container):
+    type = "label"
+
+
+## Factory ##
+
+
+class WidgetFactory(object):
+
+    def __getattr__(self, attr):
+        if attr.startswith("create"):
+            cls = globals()[attr[6:]]
+            return cls
+
+
+## Core ##
+
+
+class XMLUIPanel(xmlui.XMLUIPanel):
+    widget_factory = WidgetFactory()
+
+    def show(self, *args, **kwargs):
+        raise NotImplementedError
+
+
+class XMLUIDialog(xmlui.XMLUIDialog):
+    dialog_factory = WidgetFactory()
+
+    def __init__(*args, **kwargs):
+        raise NotImplementedError
+
+
+create = partial(xmlui.create, class_map={
+    xmlui.CLASS_PANEL: XMLUIPanel,
+    xmlui.CLASS_DIALOG: XMLUIDialog})
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/tls.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,140 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""TLS handling with twisted"""
+
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.tools import config as tools_config
+
+
+try:
+    import OpenSSL
+    from twisted.internet import ssl
+except ImportError:
+    ssl = None
+
+
+log = getLogger(__name__)
+
+
+def get_options_from_config(config, section=""):
+    options = {}
+    for option in ('tls_certificate', 'tls_private_key', 'tls_chain'):
+        options[option] = tools_config.config_get(config, section, option)
+    return options
+
+
+def tls_options_check(options):
+    """Check options coherence if TLS is activated, and update missing values
+
+    Must be called only if TLS is activated
+    """
+    if not options["tls_certificate"]:
+        raise exceptions.ConfigError(
+            "a TLS certificate is needed to activate HTTPS connection")
+    if not options["tls_private_key"]:
+        options["tls_private_key"] = options["tls_certificate"]
+
+
+def load_certificates(f):
+    """Read a .pem file with a list of certificates
+
+    @param f (file): file obj (opened .pem file)
+    @return (list[OpenSSL.crypto.X509]): list of certificates
+    @raise OpenSSL.crypto.Error: error while parsing the file
+    """
+    # XXX: didn't found any method to load a .pem file with several certificates
+    #      so the certificates split is done here
+    certificates = []
+    buf = []
+    while True:
+        line = f.readline()
+        buf.append(line)
+        if "-----END CERTIFICATE-----" in line:
+            certificates.append(
+                OpenSSL.crypto.load_certificate(
+                    OpenSSL.crypto.FILETYPE_PEM, "".join(buf)
+                )
+            )
+            buf = []
+        elif not line:
+            log.debug(f"{len(certificates)} certificate(s) found")
+            return certificates
+
+
+def load_p_key(f):
+    """Read a private key from a .pem file
+
+    @param f (file): file obj (opened .pem file)
+    @return (list[OpenSSL.crypto.PKey]): private key object
+    @raise OpenSSL.crypto.Error: error while parsing the file
+    """
+    return OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, f.read())
+
+
+def load_certificate(f):
+    """Read a public certificate from a .pem file
+
+    @param f (file): file obj (opened .pem file)
+    @return (list[OpenSSL.crypto.X509]): public certificate
+    @raise OpenSSL.crypto.Error: error while parsing the file
+    """
+    return OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, f.read())
+
+
+def get_tls_context_factory(options):
+    """Load TLS certificate and build the context factory needed for listenSSL"""
+    if ssl is None:
+        raise ImportError("Python module pyOpenSSL is not installed!")
+
+    cert_options = {}
+
+    for name, option, method in [
+        ("privateKey", "tls_private_key", load_p_key),
+        ("certificate", "tls_certificate", load_certificate),
+        ("extraCertChain", "tls_chain", load_certificates),
+    ]:
+        path = options[option]
+        if not path:
+            assert option == "tls_chain"
+            continue
+        log.debug(f"loading {option} from {path}")
+        try:
+            with open(path) as f:
+                cert_options[name] = method(f)
+        except IOError as e:
+            raise exceptions.DataError(
+                f"Error while reading file {path} for option {option}: {e}"
+            )
+        except OpenSSL.crypto.Error:
+            raise exceptions.DataError(
+                f"Error while parsing file {path} for option {option}, are you sure "
+                f"it is a valid .pem file?"
+            )
+            if (
+                option == "tls_private_key"
+                and options["tls_certificate"] == path
+            ):
+                raise exceptions.ConfigError(
+                    f"You are using the same file for private key and public "
+                    f"certificate, make sure that both a in {path} or use "
+                    f"--tls_private_key option"
+                )
+
+    return ssl.CertificateOptions(**cert_options)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/uri.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" XMPP uri parsing tools """
+
+from typing import Optional
+import sys
+import urllib.parse
+import urllib.request, urllib.parse, urllib.error
+
+# FIXME: basic implementation, need to follow RFC 5122
+
+
+def parse_xmpp_uri(uri):
+    """Parse an XMPP uri and return a dict with various information
+
+    @param uri(unicode): uri to parse
+    @return dict(unicode, unicode): data depending of the URI where key can be:
+        type: one of ("pubsub", TODO)
+            type is always present
+        sub_type: can be:
+            - microblog
+            only used for pubsub for now
+        path: XMPP path (jid of the service or entity)
+        node: node used
+        id: id of the element (item for pubsub)
+    @raise ValueError: the scheme is not xmpp
+    """
+    uri_split = urllib.parse.urlsplit(uri)
+    if uri_split.scheme != "xmpp":
+        raise ValueError("this is not a XMPP URI")
+
+    # XXX: we don't use jid.JID for path as it can be used both in backend and frontend
+    # which may use different JID classes
+    data = {"path": urllib.parse.unquote(uri_split.path)}
+
+    query_end = uri_split.query.find(";")
+    if query_end == -1:
+        # we just have a JID
+        query_type = None
+    else:
+        query_type = uri_split.query[:query_end]
+        if "=" in query_type:
+            raise ValueError("no query type, invalid XMPP URI")
+
+    if sys.version_info >= (3, 9):
+        # parse_qs behaviour has been modified in Python 3.9, ";" is not understood as a
+        # parameter separator anymore but the "separator" argument has been added to
+        # change it.
+        pairs = urllib.parse.parse_qs(uri_split.geturl(), separator=";")
+    else:
+        pairs = urllib.parse.parse_qs(uri_split.geturl())
+    for k, v in list(pairs.items()):
+        if len(v) != 1:
+            raise NotImplementedError("multiple values not managed")
+        if k in ("path", "type", "sub_type"):
+            raise NotImplementedError("reserved key used in URI, this is not supported")
+        data[k] = urllib.parse.unquote(v[0])
+
+    if query_type:
+        data["type"] = query_type
+    elif "node" in data:
+        data["type"] = "pubsub"
+    else:
+        data["type"] = ""
+
+    if "node" in data:
+        if data["node"].startswith("urn:xmpp:microblog:"):
+            data["sub_type"] = "microblog"
+
+    return data
+
+
+def add_pairs(uri, pairs):
+    for k, v in pairs.items():
+        uri.append(
+            ";"
+            + urllib.parse.quote_plus(k.encode("utf-8"))
+            + "="
+            + urllib.parse.quote_plus(v.encode("utf-8"))
+        )
+
+
+def build_xmpp_uri(type_: Optional[str] = None, **kwargs: str) -> str:
+    uri = ["xmpp:"]
+    subtype = kwargs.pop("subtype", None)
+    path = kwargs.pop("path")
+    uri.append(urllib.parse.quote_plus(path.encode("utf-8")).replace("%40", "@"))
+
+    if type_ is None:
+        # we have an URI to a JID
+        if kwargs:
+            raise NotImplementedError(
+                "keyword arguments are not supported for URI without type"
+            )
+    elif type_ == "pubsub":
+        if subtype == "microblog" and not kwargs.get("node"):
+            kwargs["node"] = "urn:xmpp:microblog:0"
+        if kwargs:
+            uri.append("?")
+            add_pairs(uri, kwargs)
+    else:
+        raise NotImplementedError("{type_} URI are not handled yet".format(type_=type_))
+
+    return "".join(uri)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/common/utils.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,159 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Misc utils for both backend and frontends"""
+
+import collections.abc
+size_units = {
+    "b": 1,
+    "kb": 1000,
+    "mb": 1000**2,
+    "gb": 1000**3,
+    "tb": 1000**4,
+    "pb": 1000**5,
+    "eb": 1000**6,
+    "zb": 1000**7,
+    "yb": 1000**8,
+    "o": 1,
+    "ko": 1000,
+    "mo": 1000**2,
+    "go": 1000**3,
+    "to": 1000**4,
+    "po": 1000**5,
+    "eo": 1000**6,
+    "zo": 1000**7,
+    "yo": 1000**8,
+    "kib": 1024,
+    "mib": 1024**2,
+    "gib": 1024**3,
+    "tib": 1024**4,
+    "pib": 1024**5,
+    "eib": 1024**6,
+    "zib": 1024**7,
+    "yib": 1024**8,
+    "kio": 1024,
+    "mio": 1024**2,
+    "gio": 1024**3,
+    "tio": 1024**4,
+    "pio": 1024**5,
+    "eio": 1024**6,
+    "zio": 1024**7,
+    "yio": 1024**8,
+}
+
+
+def per_luminance(red, green, blue):
+    """Caculate the perceived luminance of a RGB color
+
+    @param red(int): 0-1 normalized value of red
+    @param green(int): 0-1 normalized value of green
+    @param blue(int): 0-1 normalized value of blue
+    @return (float): 0-1 value of luminance (<0.5 is dark, else it's light)
+    """
+    # cf. https://stackoverflow.com/a/1855903, thanks Gacek
+
+    return 0.299 * red + 0.587 * green + 0.114 * blue
+
+
+def recursive_update(ori: dict, update: dict):
+    """Recursively update a dictionary"""
+    # cf. https://stackoverflow.com/a/3233356, thanks Alex Martelli
+    for k, v in update.items():
+        if isinstance(v, collections.abc.Mapping):
+            ori[k] = recursive_update(ori.get(k, {}), v)
+        else:
+            ori[k] = v
+    return ori
+
+class OrderedSet(collections.abc.MutableSet):
+    """A mutable sequence which doesn't keep duplicates"""
+    # TODO: complete missing set methods
+
+    def __init__(self, values=None):
+        self._dict = {}
+        if values is not None:
+            self.update(values)
+
+    def __len__(self):
+        return len(self._dict)
+
+    def __iter__(self):
+        return iter(self._dict.keys())
+
+    def __contains__(self, item):
+        return item in self._dict
+
+    def add(self, item):
+        self._dict[item] = None
+
+    def discard(self, item):
+        try:
+            del self._dict[item]
+        except KeyError:
+            pass
+
+    def update(self, items):
+        self._dict.update({i: None for i in items})
+
+
+def parse_size(size):
+    """Parse a file size with optional multiple symbole"""
+    try:
+        return int(size)
+    except ValueError:
+        number = []
+        symbol = []
+        try:
+            for c in size:
+                if c == " ":
+                    continue
+                if c.isdigit():
+                    number.append(c)
+                elif c.isalpha():
+                    symbol.append(c)
+                else:
+                    raise ValueError("unexpected char in size: {c!r} (size: {size!r})")
+            number = int("".join(number))
+            symbol = "".join(symbol)
+            if symbol:
+                try:
+                    multiplier = size_units[symbol.lower()]
+                except KeyError:
+                    raise ValueError(
+                        "unknown size multiplier symbole: {symbol!r} (size: {size!r})")
+                else:
+                    return number * multiplier
+            return number
+        except Exception as e:
+            raise ValueError(f"invalid size: {e}")
+
+
+def get_size_multiplier(size, suffix="o"):
+    """Get multiplier of a file size"""
+    size = int(size)
+    #  cf. https://stackoverflow.com/a/1094933 (thanks)
+    for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]:
+        if abs(size) < 1024.0:
+            return size, f"{unit}{suffix}"
+        size /= 1024.0
+    return size, f"Yi{suffix}"
+
+
+def get_human_size(size, suffix="o", sep=" "):
+    size, symbol = get_size_multiplier(size, suffix)
+    return f"{size:.2f}{sep}{symbol}"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/config.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" Configuration related useful methods """
+
+import os
+import csv
+import json
+from typing import Any
+from configparser import ConfigParser, DEFAULTSECT, NoOptionError, NoSectionError
+from xdg import BaseDirectory
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+
+log = getLogger(__name__)
+
+
+def fix_config_option(section, option, value, silent=True):
+    """Force a configuration option value
+
+    the option will be written in the first found user config file, a new user
+    config will be created if none is found.
+
+    @param section (str): the config section
+    @param option (str): the config option
+    @param value (str): the new value
+    @param silent (boolean): toggle logging output (must be True when called from sat.sh)
+    """
+    config = ConfigParser()
+    target_file = None
+    for file_ in C.CONFIG_FILES[::-1]:
+        # we will eventually update the existing file with the highest priority,
+        # if it's a user personal file...
+        if not silent:
+            log.debug(_("Testing file %s") % file_)
+        if os.path.isfile(file_):
+            if file_.startswith(os.path.expanduser("~")):
+                config.read([file_])
+                target_file = file_
+            break
+    if not target_file:
+        # ... otherwise we create a new config file for that user
+        target_file = (
+            f"{BaseDirectory.save_config_path(C.APP_NAME_FILE)}/{C.APP_NAME_FILE}.conf"
+        )
+    if section and section.upper() != DEFAULTSECT and not config.has_section(section):
+        config.add_section(section)
+    config.set(section, option, value)
+    with open(target_file, "wb") as configfile:
+        config.write(configfile)  # for the next time that user launches sat
+    if not silent:
+        if option in ("passphrase",):  # list here the options storing a password
+            value = "******"
+        log.warning(_("Config auto-update: {option} set to {value} in the file "
+                      "{config_file}.").format(option=option, value=value,
+                                                config_file=target_file))
+
+
+def parse_main_conf(log_filenames=False):
+    """Look for main .ini configuration file, and parse it
+
+    @param log_filenames(bool): if True, log filenames of read config files
+    """
+    config = ConfigParser(defaults=C.DEFAULT_CONFIG)
+    try:
+        filenames = config.read(C.CONFIG_FILES)
+    except Exception as e:
+        log.error(_("Can't read main config: {msg}").format(msg=e), exc_info=True)
+    else:
+        if log_filenames:
+            if filenames:
+                log.info(
+                    _("Configuration was read from: {filenames}").format(
+                        filenames=', '.join(filenames)))
+            else:
+                log.warning(
+                    _("No configuration file found, using default settings")
+                )
+
+    return config
+
+
+def config_get(config, section, name, default=None):
+    """Get a configuration option
+
+    @param config (ConfigParser): the configuration instance
+    @param section (str): section of the config file (None or '' for DEFAULT)
+    @param name (str): name of the option
+    @param default: value to use if not found, or Exception to raise an exception
+    @return (unicode, list, dict): parsed value
+    @raise: NoOptionError if option is not present and default is Exception
+            NoSectionError if section doesn't exists and default is Exception
+            exceptions.ParsingError error while parsing value
+    """
+    if not section:
+        section = DEFAULTSECT
+
+    try:
+        value = config.get(section, name)
+    except (NoOptionError, NoSectionError) as e:
+        if default is Exception:
+            raise e
+        return default
+
+    if name.endswith("_path") or name.endswith("_dir"):
+        value = os.path.expanduser(value)
+    # thx to Brian (http://stackoverflow.com/questions/186857/splitting-a-semicolon-separated-string-to-a-dictionary-in-python/186873#186873)
+    elif name.endswith("_list"):
+        value = next(csv.reader(
+            [value], delimiter=",", quotechar='"', skipinitialspace=True
+        ))
+    elif name.endswith("_dict"):
+        try:
+            value = json.loads(value)
+        except ValueError as e:
+            raise exceptions.ParsingError("Error while parsing data: {}".format(e))
+        if not isinstance(value, dict):
+            raise exceptions.ParsingError(
+                "{name} value is not a dict: {value}".format(name=name, value=value)
+            )
+    elif name.endswith("_json"):
+        try:
+            value = json.loads(value)
+        except ValueError as e:
+            raise exceptions.ParsingError("Error while parsing data: {}".format(e))
+    return value
+
+
+def get_conf(
+    conf: ConfigParser,
+    prefix: str,
+    section: str,
+    name: str,
+    default: Any
+) -> Any:
+    """Get configuration value from environment or config file
+
+    @param str: prefix to use for the varilable name (see `name` below)
+    @param section: config section to use
+    @param name: unsuffixed name.
+        For environment variable, `LIBERVIA_<prefix>_` will be prefixed (and name
+        will be set to uppercase).
+        For config file, `<prefix>_` will be prefixed (and DEFAULT section will be
+        used).
+        Environment variable has priority over config values. If Environment variable
+        is set but empty string, config value will be used.
+    @param default: default value to use if varilable is set neither in environment,
+    nor in config
+    """
+    # XXX: This is a temporary method until parameters are refactored
+    value = os.getenv(f"LIBERVIA_{prefix}_{name}".upper())
+    return value or config_get(conf, section, f"{prefix}_{name}", default)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/image.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,226 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Methods to manipulate images"""
+
+import tempfile
+import mimetypes
+from PIL import Image, ImageOps
+from pathlib import Path
+from twisted.internet import threads
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+try:
+    import cairosvg
+except Exception as e:
+    log.warning(_("SVG support not available, please install cairosvg: {e}").format(
+        e=e))
+    cairosvg = None
+
+
+def check(host, path, max_size=None):
+    """Analyze image and return a report
+
+    report will indicate if image is too large, and the recommended new size if this is
+    the case
+    @param host: SàT instance
+    @param path(str, pathlib.Path): image to open
+    @param max_size(tuple[int, int]): maximum accepted size of image
+        None to use value set in config
+    @return dict: report on image, with following keys:
+        - too_large: true if image is oversized
+        - recommended_size: if too_large is True, recommended size to use
+    """
+    report = {}
+    image = Image.open(path)
+    if max_size is None:
+        max_size = tuple(host.memory.config_get(None, "image_max", (1200, 720)))
+    if image.size > max_size:
+        report['too_large'] = True
+        if image.size[0] > max_size[0]:
+            factor = max_size[0] / image.size[0]
+            if image.size[1] * factor > max_size[1]:
+                factor = max_size[1] / image.size[1]
+        else:
+            factor = max_size[1] / image.size[1]
+        report['recommended_size'] = [int(image.width*factor), int(image.height*factor)]
+    else:
+        report['too_large'] = False
+
+    return report
+
+
+def _resize_blocking(image_path, new_size, dest, fix_orientation):
+    im_path = Path(image_path)
+    im = Image.open(im_path)
+    resized = im.resize(new_size, Image.LANCZOS)
+    if fix_orientation:
+        resized = ImageOps.exif_transpose(resized)
+
+    if dest is None:
+        dest = tempfile.NamedTemporaryFile(suffix=im_path.suffix, delete=False)
+    elif isinstance(dest, Path):
+        dest = dest.open('wb')
+
+    with dest as f:
+        resized.save(f, format=im.format)
+
+    return Path(f.name)
+
+
+def resize(image_path, new_size, dest=None, fix_orientation=True):
+    """Resize an image to a new file, and return its path
+
+    @param image_path(str, Path): path of the original image
+    @param new_size(tuple[int, int]): size to use for new image
+    @param dest(None, Path, file): where the resized image must be stored, can be:
+        - None: use a temporary file
+            file will be converted to PNG
+        - Path: path to the file to create/overwrite
+        - file: a file object which must be opened for writing in binary mode
+    @param fix_orientation: if True, use EXIF data to set orientation
+    @return (Path): path of the resized file.
+        The image at this path should be deleted after use
+    """
+    return threads.deferToThread(
+        _resize_blocking, image_path, new_size, dest, fix_orientation)
+
+
+def _convert_blocking(image_path, dest, extra):
+    media_type = mimetypes.guess_type(str(image_path), strict=False)[0]
+
+    if dest is None:
+        dest = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
+        filepath = Path(dest.name)
+    elif isinstance(dest, Path):
+        filepath = dest
+    else:
+        # we should have a file-like object
+        try:
+            name = dest.name
+        except AttributeError:
+            name = None
+        if name:
+            try:
+                filepath = Path(name)
+            except TypeError:
+                filepath = Path('noname.png')
+        else:
+            filepath = Path('noname.png')
+
+    if media_type == "image/svg+xml":
+        if cairosvg is None:
+            raise exceptions.MissingModule(
+                f"Can't convert SVG image at {image_path} due to missing CairoSVG module")
+        width, height = extra.get('width'), extra.get('height')
+        cairosvg.svg2png(
+            url=str(image_path), write_to=dest,
+            output_width=width, output_height=height
+        )
+    else:
+        suffix = filepath.suffix
+        if not suffix:
+            raise ValueError(
+                "A suffix is missing for destination, it is needed to determine file "
+                "format")
+        if not suffix in Image.EXTENSION:
+            Image.init()
+        try:
+            im_format = Image.EXTENSION[suffix]
+        except KeyError:
+            raise ValueError(
+                "Dest image format can't be determined, {suffix!r} suffix is unknown"
+            )
+        im = Image.open(image_path)
+        im.save(dest, format=im_format)
+
+    log.debug(f"image {image_path} has been converted to {filepath}")
+    return filepath
+
+
+def convert(image_path, dest=None, extra=None):
+    """Convert an image to a new file, and return its path
+
+    @param image_path(str, Path): path of the image to convert
+    @param dest(None, Path, file): where the converted image must be stored, can be:
+        - None: use a temporary file
+        - Path: path to the file to create/overwrite
+        - file: a file object which must be opened for writing in binary mode
+    @param extra(None, dict): conversion options
+        if image_path link to a SVG file, following options can be used:
+                - width: destination width
+                - height: destination height
+    @return (Path): path of the converted file.
+        a generic name is used if dest is an unnamed file like object
+    """
+    image_path = Path(image_path)
+    if not image_path.is_file():
+        raise ValueError(f"There is no file at {image_path}!")
+    if extra is None:
+        extra = {}
+    return threads.deferToThread(_convert_blocking, image_path, dest, extra)
+
+
+def __fix_orientation_blocking(image_path):
+    im = Image.open(image_path)
+    im_format = im.format
+    exif = im.getexif()
+    orientation = exif.get(0x0112)
+    if orientation is None or orientation<2:
+        # nothing to do
+        return False
+    im = ImageOps.exif_transpose(im)
+    im.save(image_path, im_format)
+    log.debug(f"image {image_path} orientation has been fixed")
+    return True
+
+
+def fix_orientation(image_path: Path) -> bool:
+    """Apply orientation found in EXIF data if any
+
+    @param image_path: image location, image will be modified in place
+    @return True if image has been modified
+    """
+    return threads.deferToThread(__fix_orientation_blocking, image_path)
+
+
+def guess_type(source):
+    """Guess image media type
+
+    @param source(str, Path, file): image to guess type
+    @return (str, None): media type, or None if we can't guess
+    """
+    if isinstance(source, str):
+        source = Path(source)
+
+    if isinstance(source, Path):
+        # we first try to guess from file name
+        media_type = mimetypes.guess_type(source, strict=False)[0]
+        if media_type is not None:
+            return media_type
+
+    # file name is not enough, we try to open it
+    img = Image.open(source)
+    try:
+        return Image.MIME[img.format]
+    except KeyError:
+        return None
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/sat_defer.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,269 @@
+#!/usr/bin/env python3
+
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""tools related to deferred"""
+
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+from libervia.backend.core import exceptions
+from twisted.internet import defer
+from twisted.internet import error as internet_error
+from twisted.internet import reactor
+from twisted.words.protocols.jabber import error as jabber_error
+from twisted.python import failure
+from libervia.backend.core.constants import Const as C
+from libervia.backend.memory import memory
+
+KEY_DEFERREDS = "deferreds"
+KEY_NEXT = "next_defer"
+
+
+def stanza_2_not_found(failure_):
+    """Convert item-not-found StanzaError to exceptions.NotFound"""
+    failure_.trap(jabber_error.StanzaError)
+    if failure_.value.condition == 'item-not-found':
+        raise exceptions.NotFound(failure_.value.text or failure_.value.condition)
+    return failure_
+
+
+class DelayedDeferred(object):
+    """A Deferred-like which is launched after a delay"""
+
+    def __init__(self, delay, result):
+        """
+        @param delay(float): delay before launching the callback, in seconds
+        @param result: result used with the callback
+        """
+        self._deferred = defer.Deferred()
+        self._timer = reactor.callLater(delay, self._deferred.callback, result)
+
+    def cancel(self):
+        try:
+            self._timer.cancel()
+        except internet_error.AlreadyCalled:
+            pass
+        self._deferred.cancel()
+
+    def addCallbacks(self, *args, **kwargs):
+        self._deferred.addCallbacks(*args, **kwargs)
+
+    def addCallback(self, *args, **kwargs):
+        self._deferred.addCallback(*args, **kwargs)
+
+    def addErrback(self, *args, **kwargs):
+        self._deferred.addErrback(*args, **kwargs)
+
+    def addBoth(self, *args, **kwargs):
+        self._deferred.addBoth(*args, **kwargs)
+
+    def chainDeferred(self, *args, **kwargs):
+        self._deferred.chainDeferred(*args, **kwargs)
+
+    def pause(self):
+        self._deferred.pause()
+
+    def unpause(self):
+        self._deferred.unpause()
+
+
+class RTDeferredSessions(memory.Sessions):
+    """Real Time Deferred Sessions"""
+
+    def __init__(self, timeout=120):
+        """Manage list of Deferreds in real-time, allowing to get intermediate results
+
+        @param timeout (int): nb of seconds before deferreds cancellation
+        """
+        super(RTDeferredSessions, self).__init__(
+            timeout=timeout, resettable_timeout=False
+        )
+
+    def new_session(self, deferreds, profile):
+        """Launch a new session with a list of deferreds
+
+        @param deferreds(list[defer.Deferred]): list of deferred to call
+        @param profile: %(doc_profile)s
+        @param return (tupe[str, defer.Deferred]): tuple with session id and a deferred wich fire *WITHOUT RESULT* when all results are received
+        """
+        data = {KEY_NEXT: defer.Deferred()}
+        session_id, session_data = super(RTDeferredSessions, self).new_session(
+            data, profile=profile
+        )
+        if isinstance(deferreds, dict):
+            session_data[KEY_DEFERREDS] = list(deferreds.values())
+            iterator = iter(deferreds.items())
+        else:
+            session_data[KEY_DEFERREDS] = deferreds
+            iterator = enumerate(deferreds)
+
+        for idx, d in iterator:
+            d._RTDeferred_index = idx
+            d._RTDeferred_return = None
+            d.addCallback(self._callback, d, session_id, profile)
+            d.addErrback(self._errback, d, session_id, profile)
+        return session_id
+
+    def _purge_session(
+        self, session_id, reason="timeout", no_warning=False, got_result=False
+    ):
+        """Purge the session
+
+        @param session_id(str): id of the session to purge
+        @param reason (unicode): human readable reason why the session is purged
+        @param no_warning(bool): if True, no warning will be put in logs
+        @param got_result(bool): True if the session is purged after normal ending (i.e.: all the results have been gotten).
+            reason and no_warning are ignored if got_result is True.
+        @raise KeyError: session doesn't exists (anymore ?)
+        """
+        if not got_result:
+            try:
+                timer, session_data, profile = self._sessions[session_id]
+            except ValueError:
+                raise exceptions.InternalError(
+                    "was expecting timer, session_data and profile; is profile set ?"
+                )
+
+            # next_defer must be called before deferreds,
+            # else its callback will be called by _gotResult
+            next_defer = session_data[KEY_NEXT]
+            if not next_defer.called:
+                next_defer.errback(failure.Failure(defer.CancelledError(reason)))
+
+            deferreds = session_data[KEY_DEFERREDS]
+            for d in deferreds:
+                d.cancel()
+
+            if not no_warning:
+                log.warning(
+                    "RTDeferredList cancelled: {} (profile {})".format(reason, profile)
+                )
+
+        super(RTDeferredSessions, self)._purge_session(session_id)
+
+    def _gotResult(self, session_id, profile):
+        """Method called after each callback or errback
+
+        manage the next_defer deferred
+        """
+        session_data = self.profile_get(session_id, profile)
+        defer_next = session_data[KEY_NEXT]
+        if not defer_next.called:
+            defer_next.callback(None)
+
+    def _callback(self, result, deferred, session_id, profile):
+        deferred._RTDeferred_return = (True, result)
+        self._gotResult(session_id, profile)
+
+    def _errback(self, failure, deferred, session_id, profile):
+        deferred._RTDeferred_return = (False, failure)
+        self._gotResult(session_id, profile)
+
+    def cancel(self, session_id, reason="timeout", no_log=False):
+        """Stop this RTDeferredList
+
+        Cancel all remaining deferred, and call self.final_defer.errback
+        @param reason (unicode): reason of the cancellation
+        @param no_log(bool): if True, don't log the cancellation
+        """
+        self._purge_session(session_id, reason=reason, no_warning=no_log)
+
+    def get_results(
+        self, session_id, on_success=None, on_error=None, profile=C.PROF_KEY_NONE
+    ):
+        """Get current results of a real-time deferred session
+
+        result already gotten are deleted
+        @param session_id(str): session id
+        @param on_success: can be:
+            - None: add success normaly to results
+            - callable: replace result by the return value of on_success(result) (may be deferred)
+        @param on_error: can be:
+            - None: add error normaly to results
+            - C.IGNORE: don't put errors in results
+            - callable: replace failure by the return value of on_error(failure) (may be deferred)
+        @param profile=%(doc_profile)s
+        @param result(tuple): tuple(remaining, results) where:
+            - remaining[int] is the number of remaining deferred
+                (deferreds from which we don't have result yet)
+            - results is a dict where:
+                - key is the index of the deferred if deferred is a list, or its key if it's a dict
+                - value = (success, result) where:
+                    - success is True if the deferred was successful
+                    - result is the result in case of success, else the failure
+            If remaining == 0, the session is ended
+        @raise KeyError: the session is already finished or doesn't exists at all
+        """
+        if profile == C.PROF_KEY_NONE:
+            raise exceptions.ProfileNotSetError
+        session_data = self.profile_get(session_id, profile)
+
+        @defer.inlineCallbacks
+        def next_cb(__):
+            # we got one or several results
+            results = {}
+            filtered_data = []  # used to keep deferreds without results
+            deferreds = session_data[KEY_DEFERREDS]
+
+            for d in deferreds:
+                if (
+                    d._RTDeferred_return
+                ):  # we don't use d.called as called is True before the full callbacks chain has been called
+                    # we have a result
+                    idx = d._RTDeferred_index
+                    success, result = d._RTDeferred_return
+                    if success:
+                        if on_success is not None:
+                            if callable(on_success):
+                                result = yield on_success(result)
+                            else:
+                                raise exceptions.InternalError(
+                                    "Unknown value of on_success: {}".format(on_success)
+                                )
+
+                    else:
+                        if on_error is not None:
+                            if on_error == C.IGNORE:
+                                continue
+                            elif callable(on_error):
+                                result = yield on_error(result)
+                            else:
+                                raise exceptions.InternalError(
+                                    "Unknown value of on_error: {}".format(on_error)
+                                )
+                    results[idx] = (success, result)
+                else:
+                    filtered_data.append(d)
+
+            # we change the deferred with the filtered list
+            # in other terms, we don't want anymore deferred from which we have got the result
+            session_data[KEY_DEFERREDS] = filtered_data
+
+            if filtered_data:
+                # we create a new next_defer only if we are still waiting for results
+                session_data[KEY_NEXT] = defer.Deferred()
+            else:
+                # no more data to get, the result have been gotten,
+                # we can cleanly finish the session
+                self._purge_session(session_id, got_result=True)
+
+            defer.returnValue((len(filtered_data), results))
+
+        # we wait for a result
+        return session_data[KEY_NEXT].addCallback(next_cb)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/stream.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,262 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" interfaces """
+
+from argparse import OPTIONAL
+from pathlib import Path
+from typing import Callable, Optional, Union
+import uuid
+import os
+from zope import interface
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.core_types import SatXMPPEntity
+from libervia.backend.core.log import getLogger
+from twisted.protocols import basic
+from twisted.internet import interfaces
+
+from libervia.backend.core.sat_main import SAT
+
+log = getLogger(__name__)
+
+
+class IStreamProducer(interface.Interface):
+    def start_stream(consumer):
+        """start producing the stream
+
+        @return (D): deferred fired when stream is finished
+        """
+        pass
+
+
+class SatFile:
+    """A file-like object to have high level files manipulation"""
+
+    # TODO: manage "with" statement
+
+    def __init__(
+        self,
+        host: SAT,
+        client: SatXMPPEntity,
+        path: Union[str, Path],
+        mode: str = "rb",
+        uid: Optional[str] = None,
+        size: Optional[int] = None,
+        data_cb: Optional[Callable] = None,
+        auto_end_signals: bool = True,
+        check_size_with_read: bool = False,
+        pre_close_cb: Optional[Callable]=None
+    ) -> None:
+        """
+        @param host: %(doc_host)s
+        @param path(Path, str): path to the file to get or write to
+        @param mode(str): same as for built-in "open" function
+        @param uid(unicode, None): unique id identifing this progressing element
+            This uid will be used with self.host.progress_get
+            will be automaticaly generated if None
+        @param size(None, int): size of the file (when known in advance)
+        @param data_cb(None, callable): method to call on each data read/write
+            can be used to do processing like calculating hash.
+            if data_cb return a non None value, it will be used instead of the
+            data read/to write
+        @param auto_end_signals(bool): if True, progress_finished and progress_error signals
+            are automatically sent.
+            if False, you'll have to call self.progress_finished and self.progress_error
+            yourself.
+            progress_started signal is always sent automatically
+        @param check_size_with_read(bool): if True, size will be checked using number of
+            bytes read or written. This is useful when data_cb modifiy len of file.
+        @param pre_close_cb:
+        """
+        self.host = host
+        self.profile = client.profile
+        self.uid = uid or str(uuid.uuid4())
+        self._file = open(path, mode)
+        self.size = size
+        self.data_cb = data_cb
+        self.auto_end_signals = auto_end_signals
+        self.pre_close_cb = pre_close_cb
+        metadata = self.get_progress_metadata()
+        self.host.register_progress_cb(
+            self.uid, self.get_progress, metadata, profile=client.profile
+        )
+        self.host.bridge.progress_started(self.uid, metadata, client.profile)
+
+        self._transfer_count = 0 if check_size_with_read else None
+
+    @property
+    def check_size_with_read(self):
+        return self._transfer_count is not None
+
+    @check_size_with_read.setter
+    def check_size_with_read(self, value):
+        if value and self._transfer_count is None:
+            self._transfer_count = 0
+        else:
+            self._transfer_count = None
+
+    def check_size(self):
+        """Check that current size correspond to given size
+
+        must be used when the transfer is supposed to be finished
+        @return (bool): True if the position is the same as given size
+        @raise exceptions.NotFound: size has not be specified
+        """
+        if self.check_size_with_read:
+            position = self._transfer_count
+        else:
+            position = self._file.tell()
+        if self.size is None:
+            raise exceptions.NotFound
+        return position == self.size
+
+    def close(self, progress_metadata=None, error=None):
+        """Close the current file
+
+        @param progress_metadata(None, dict): metadata to send with _onProgressFinished
+            message
+        @param error(None, unicode): set to an error message if progress was not
+            successful
+            mutually exclusive with progress_metadata
+            error can happen even if error is None, if current size differ from given size
+        """
+        if self._file.closed:
+            return  # avoid double close (which is allowed) error
+        if self.pre_close_cb is not None:
+            self.pre_close_cb()
+        if error is None:
+            try:
+                size_ok = self.check_size()
+            except exceptions.NotFound:
+                size_ok = True
+            if not size_ok:
+                error = "declared and actual size mismatch"
+                log.warning(error)
+                progress_metadata = None
+
+        self._file.close()
+
+        if self.auto_end_signals:
+            if error is None:
+                self.progress_finished(progress_metadata)
+            else:
+                assert progress_metadata is None
+                self.progress_error(error)
+
+        self.host.remove_progress_cb(self.uid, self.profile)
+        if error is not None:
+            log.error(f"file {self._file} closed with an error: {error}")
+
+    @property
+    def closed(self):
+        return self._file.closed
+
+    def progress_finished(self, metadata=None):
+        if metadata is None:
+            metadata = {}
+        self.host.bridge.progress_finished(self.uid, metadata, self.profile)
+
+    def progress_error(self, error):
+        self.host.bridge.progress_error(self.uid, error, self.profile)
+
+    def flush(self):
+        self._file.flush()
+
+    def write(self, buf):
+        if self.data_cb is not None:
+            ret = self.data_cb(buf)
+            if ret is not None:
+                buf = ret
+        if self._transfer_count is not None:
+            self._transfer_count += len(buf)
+        self._file.write(buf)
+
+    def read(self, size=-1):
+        read = self._file.read(size)
+        if self.data_cb is not None:
+            ret = self.data_cb(read)
+            if ret is not None:
+                read = ret
+        if self._transfer_count is not None:
+            self._transfer_count += len(read)
+        return read
+
+    def seek(self, offset, whence=os.SEEK_SET):
+        self._file.seek(offset, whence)
+
+    def tell(self):
+        return self._file.tell()
+
+    @property
+    def mode(self):
+        return self._file.mode
+
+    def get_progress_metadata(self):
+        """Return progression metadata as given to progress_started
+
+        @return (dict): metadata (check bridge for documentation)
+        """
+        metadata = {"type": C.META_TYPE_FILE}
+
+        mode = self._file.mode
+        if "+" in mode:
+            pass  # we have no direction in read/write modes
+        elif mode in ("r", "rb"):
+            metadata["direction"] = "out"
+        elif mode in ("w", "wb"):
+            metadata["direction"] = "in"
+        elif "U" in mode:
+            metadata["direction"] = "out"
+        else:
+            raise exceptions.InternalError
+
+        metadata["name"] = self._file.name
+
+        return metadata
+
+    def get_progress(self, progress_id, profile):
+        ret = {"position": self._file.tell()}
+        if self.size:
+            ret["size"] = self.size
+        return ret
+
+
+@interface.implementer(IStreamProducer)
+@interface.implementer(interfaces.IConsumer)
+class FileStreamObject(basic.FileSender):
+    def __init__(self, host, client, path, **kwargs):
+        """
+
+        A SatFile will be created and put in self.file_obj
+        @param path(unicode): path to the file
+        @param **kwargs: kw arguments to pass to SatFile
+        """
+        self.file_obj = SatFile(host, client, path, **kwargs)
+
+    def registerProducer(self, producer, streaming):
+        pass
+
+    def start_stream(self, consumer):
+        return self.beginFileTransfer(self.file_obj, consumer)
+
+    def write(self, data):
+        self.file_obj.write(data)
+
+    def close(self, *args, **kwargs):
+        self.file_obj.close(*args, **kwargs)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/trigger.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,134 @@
+#!/usr/bin/env python3
+
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Misc usefull classes"""
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+class TriggerException(Exception):
+    pass
+
+
+class SkipOtherTriggers(Exception):
+    """ Exception to raise if normal behaviour must be followed instead of following triggers list """
+
+    pass
+
+
+class TriggerManager(object):
+    """This class manage triggers: code which interact to change the behaviour of SàT"""
+
+    try:  # FIXME: to be removed when a better solution is found
+        MIN_PRIORITY = float("-inf")
+        MAX_PRIORITY = float("+inf")
+    except:  # XXX: Pyjamas will bug if you specify ValueError here
+        # Pyjamas uses the JS Float class
+        MIN_PRIORITY = Number.NEGATIVE_INFINITY
+        MAX_PRIORITY = Number.POSITIVE_INFINITY
+
+    def __init__(self):
+        self.__triggers = {}
+
+    def add(self, point_name, callback, priority=0):
+        """Add a trigger to a point
+
+        @param point_name: name of the point when the trigger should be run
+        @param callback: method to call at the trigger point
+        @param priority: callback will be called in priority order, biggest
+        first
+        """
+        if point_name not in self.__triggers:
+            self.__triggers[point_name] = []
+        if priority != 0 and priority in [
+            trigger_tuple[0] for trigger_tuple in self.__triggers[point_name]
+        ]:
+            if priority in (self.MIN_PRIORITY, self.MAX_PRIORITY):
+                log.warning(_("There is already a bound priority [%s]") % point_name)
+            else:
+                log.debug(
+                    _("There is already a trigger with the same priority [%s]")
+                    % point_name
+                )
+        self.__triggers[point_name].append((priority, callback))
+        self.__triggers[point_name].sort(
+            key=lambda trigger_tuple: trigger_tuple[0], reverse=True
+        )
+
+    def remove(self, point_name, callback):
+        """Remove a trigger from a point
+
+        @param point_name: name of the point when the trigger should be run
+        @param callback: method to remove, must exists in the trigger point
+        """
+        for trigger_tuple in self.__triggers[point_name]:
+            if trigger_tuple[1] == callback:
+                self.__triggers[point_name].remove(trigger_tuple)
+                return
+        raise TriggerException("Trying to remove an unexisting trigger")
+
+    def point(self, point_name, *args, **kwargs):
+        """This put a trigger point
+
+        All the triggers for that point will be run
+        @param point_name: name of the trigger point
+        @param *args: args to transmit to trigger
+        @param *kwargs: kwargs to transmit to trigger
+            if "triggers_no_cancel" is present, it will be popup out
+                when set to True, this argument don't let triggers stop
+                the workflow
+        @return: True if the action must be continued, False else
+        """
+        if point_name not in self.__triggers:
+            return True
+
+        can_cancel = not kwargs.pop('triggers_no_cancel', False)
+
+        for priority, trigger in self.__triggers[point_name]:
+            try:
+                if not trigger(*args, **kwargs) and can_cancel:
+                    return False
+            except SkipOtherTriggers:
+                break
+        return True
+
+    def return_point(self, point_name, *args, **kwargs):
+        """Like point but trigger must return (continue, return_value)
+
+        All triggers for that point must return a tuple with 2 values:
+            - continue, same as for point, if False action must be finished
+            - return_value: value to return ONLY IF CONTINUE IS FALSE
+        @param point_name: name of the trigger point
+        @return: True if the action must be continued, False else
+        """
+
+        if point_name not in self.__triggers:
+            return True
+
+        for priority, trigger in self.__triggers[point_name]:
+            try:
+                cont, ret_value = trigger(*args, **kwargs)
+                if not cont:
+                    return False, ret_value
+            except SkipOtherTriggers:
+                break
+        return True, None
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/utils.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,362 @@
+#!/usr/bin/env python3
+
+# SaT: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+""" various useful methods """
+
+from typing import Optional, Union
+import unicodedata
+import os.path
+import datetime
+import subprocess
+import time
+import sys
+import random
+import inspect
+import textwrap
+import functools
+import asyncio
+from twisted.python import procutils, failure
+from twisted.internet import defer
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import xmpp_datetime
+
+log = getLogger(__name__)
+
+
+NO_REPOS_DATA = "repository data unknown"
+repos_cache_dict = None
+repos_cache = None
+
+
+def clean_ustr(ustr):
+    """Clean unicode string
+
+    remove special characters from unicode string
+    """
+
+    def valid_chars(unicode_source):
+        for char in unicode_source:
+            if unicodedata.category(char) == "Cc" and char != "\n":
+                continue
+            yield char
+
+    return "".join(valid_chars(ustr))
+
+
+def logError(failure_):
+    """Genertic errback which log the error as a warning, and re-raise it"""
+    log.warning(failure_.value)
+    raise failure_
+
+
+def partial(func, *fixed_args, **fixed_kwargs):
+    # FIXME: temporary hack to workaround the fact that inspect.getargspec is not working with functools.partial
+    #        making partial unusable with current D-bus module (in add_method).
+    #        Should not be needed anywore once moved to Python 3
+
+    ori_args = inspect.getargspec(func).args
+    func = functools.partial(func, *fixed_args, **fixed_kwargs)
+    if ori_args[0] == "self":
+        del ori_args[0]
+    ori_args = ori_args[len(fixed_args) :]
+    for kw in fixed_kwargs:
+        ori_args.remove(kw)
+
+    exec(
+        textwrap.dedent(
+            """\
+    def method({args}):
+        return func({kw_args})
+    """
+        ).format(
+            args=", ".join(ori_args), kw_args=", ".join([a + "=" + a for a in ori_args])
+        ),
+        locals(),
+    )
+
+    return method
+
+
+def as_deferred(func, *args, **kwargs):
+    """Call a method and return a Deferred
+
+    the method can be a simple callable, a Deferred or a coroutine.
+    It is similar to defer.maybeDeferred, but also handles coroutines
+    """
+    try:
+        ret = func(*args, **kwargs)
+    except Exception as e:
+        return defer.fail(failure.Failure(e))
+    else:
+        if asyncio.iscoroutine(ret):
+            return defer.ensureDeferred(ret)
+        elif isinstance(ret, defer.Deferred):
+            return ret
+        elif isinstance(ret, failure.Failure):
+            return defer.fail(ret)
+        else:
+            return defer.succeed(ret)
+
+
+def aio(func):
+    """Decorator to return a Deferred from asyncio coroutine
+
+    Functions with this decorator are run in asyncio context
+    """
+    def wrapper(*args, **kwargs):
+        return defer.Deferred.fromFuture(asyncio.ensure_future(func(*args, **kwargs)))
+    return wrapper
+
+
+def as_future(d):
+    return d.asFuture(asyncio.get_event_loop())
+
+
+def ensure_deferred(func):
+    """Decorator to apply ensureDeferred to a function
+
+    to be used when the function is called by third party library (e.g. wokkel)
+    Otherwise, it's better to use ensureDeferred as early as possible.
+    """
+    def wrapper(*args, **kwargs):
+        return defer.ensureDeferred(func(*args, **kwargs))
+    return wrapper
+
+
+def xmpp_date(
+    timestamp: Optional[Union[float, int]] = None,
+    with_time: bool = True
+) -> str:
+    """Return date according to XEP-0082 specification
+
+    to avoid reveling the timezone, we always return UTC dates
+    the string returned by this method is valid with RFC 3339
+    this function redirects to the functions in the :mod:`sat.tools.datetime` module
+    @param timestamp(None, float): posix timestamp. If None current time will be used
+    @param with_time(bool): if True include the time
+    @return(unicode): XEP-0082 formatted date and time
+    """
+    dtime = datetime.datetime.fromtimestamp(
+        time.time() if timestamp is None else timestamp,
+        datetime.timezone.utc
+    )
+
+    return (
+        xmpp_datetime.format_datetime(dtime) if with_time
+        else xmpp_datetime.format_date(dtime.date())
+    )
+
+
+def parse_xmpp_date(
+    xmpp_date_str: str,
+    with_time: bool = True
+) -> float:
+    """Get timestamp from XEP-0082 datetime
+
+    @param xmpp_date_str: XEP-0082 formatted datetime or time
+    @param with_time: if True, ``xmpp_date_str`` must be a datetime, otherwise if must be
+    a time profile.
+    @return: datetime converted to unix time
+    @raise ValueError: the format is invalid
+    """
+    if with_time:
+        dt = xmpp_datetime.parse_datetime(xmpp_date_str)
+    else:
+        d = xmpp_datetime.parse_date(xmpp_date_str)
+        dt = datetime.datetime.combine(d, datetime.datetime.min.time())
+
+    return dt.timestamp()
+
+
+def generate_password(vocabulary=None, size=20):
+    """Generate a password with random characters.
+
+    @param vocabulary(iterable): characters to use to create password
+    @param size(int): number of characters in the password to generate
+    @return (unicode): generated password
+    """
+    random.seed()
+    if vocabulary is None:
+        vocabulary = [
+            chr(i) for i in list(range(0x30, 0x3A)) + list(range(0x41, 0x5B)) + list(range(0x61, 0x7B))
+        ]
+    return "".join([random.choice(vocabulary) for i in range(15)])
+
+
+def get_repository_data(module, as_string=True, is_path=False):
+    """Retrieve info on current mecurial repository
+
+    Data is gotten by using the following methods, in order:
+        - using "hg" executable
+        - looking for a .hg/dirstate in parent directory of module (or in module/.hg if
+            is_path is True), and parse dirstate file to get revision
+        - checking package version, which should have repository data when we are on a dev version
+    @param module(unicode): module to look for (e.g. sat, libervia)
+        module can be a path if is_path is True (see below)
+    @param as_string(bool): if True return a string, else return a dictionary
+    @param is_path(bool): if True "module" is not handled as a module name, but as an
+        absolute path to the parent of a ".hg" directory
+    @return (unicode, dictionary): retrieved info in a nice string,
+        or a dictionary with retrieved data (key is not present if data is not found),
+        key can be:
+            - node: full revision number (40 bits)
+            - branch: branch name
+            - date: ISO 8601 format date
+            - tag: latest tag used in hierarchie
+            - distance: number of commits since the last tag
+    """
+    global repos_cache_dict
+    if as_string:
+        global repos_cache
+        if repos_cache is not None:
+            return repos_cache
+    else:
+        if repos_cache_dict is not None:
+            return repos_cache_dict
+
+    if sys.platform == "android":
+        #  FIXME: workaround to avoid trouble on android, need to be fixed properly
+        repos_cache = "Cagou android build"
+        return repos_cache
+
+    KEYS = ("node", "node_short", "branch", "date", "tag", "distance")
+    ori_cwd = os.getcwd()
+
+    if is_path:
+        repos_root = os.path.abspath(module)
+    else:
+        repos_root = os.path.abspath(os.path.dirname(module.__file__))
+
+    try:
+        hg_path = procutils.which("hg")[0]
+    except IndexError:
+        log.warning("Can't find hg executable")
+        hg_path = None
+        hg_data = {}
+
+    if hg_path is not None:
+        os.chdir(repos_root)
+        try:
+            hg_data_raw = subprocess.check_output(
+                [
+                    "python3",
+                    hg_path,
+                    "log",
+                    "-r",
+                    "-1",
+                    "--template",
+                    "{node}\n"
+                    "{node|short}\n"
+                    "{branch}\n"
+                    "{date|isodate}\n"
+                    "{latesttag}\n"
+                    "{latesttagdistance}",
+                ],
+                text=True
+            )
+        except subprocess.CalledProcessError as e:
+            log.error(f"Can't get repository data: {e}")
+            hg_data = {}
+        except Exception as e:
+            log.error(f"Unexpected error, can't get repository data : [{type(e)}] {e}")
+            hg_data = {}
+        else:
+            hg_data = dict(list(zip(KEYS, hg_data_raw.split("\n"))))
+            try:
+                hg_data["modified"] = "+" in subprocess.check_output(["python3", hg_path, "id", "-i"], text=True)
+            except subprocess.CalledProcessError:
+                pass
+    else:
+        hg_data = {}
+
+    if not hg_data:
+        # .hg/dirstate method
+        log.debug("trying dirstate method")
+        if is_path:
+            os.chdir(repos_root)
+        else:
+            os.chdir(os.path.abspath(os.path.dirname(repos_root)))
+        try:
+            with open(".hg/dirstate", 'rb') as hg_dirstate:
+                hg_data["node"] = hg_dirstate.read(20).hex()
+                hg_data["node_short"] = hg_data["node"][:12]
+        except IOError:
+            log.debug("Can't access repository data")
+
+    # we restore original working dir
+    os.chdir(ori_cwd)
+
+    if not hg_data:
+        log.debug("Mercurial not available or working, trying package version")
+        try:
+            import pkg_resources
+        except ImportError:
+            log.warning("pkg_resources not available, can't get package data")
+        else:
+            try:
+                pkg_version = pkg_resources.get_distribution(C.APP_NAME_FILE).version
+                version, local_id = pkg_version.split("+", 1)
+            except pkg_resources.DistributionNotFound:
+                log.warning("can't retrieve package data")
+            except ValueError:
+                log.info(
+                    "no local version id in package: {pkg_version}".format(
+                        pkg_version=pkg_version
+                    )
+                )
+            else:
+                version = version.replace(".dev0", "D")
+                if version != C.APP_VERSION:
+                    log.warning(
+                        "Incompatible version ({version}) and pkg_version ({pkg_version})"
+                        .format(
+                            version=C.APP_VERSION, pkg_version=pkg_version
+                        )
+                    )
+                else:
+                    try:
+                        hg_node, hg_distance = local_id.split(".")
+                    except ValueError:
+                        log.warning("Version doesn't specify repository data")
+                    hg_data = {"node_short": hg_node, "distance": hg_distance}
+
+    repos_cache_dict = hg_data
+
+    if as_string:
+        if not hg_data:
+            repos_cache = NO_REPOS_DATA
+        else:
+            strings = ["rev", hg_data["node_short"]]
+            try:
+                if hg_data["modified"]:
+                    strings.append("[M]")
+            except KeyError:
+                pass
+            try:
+                strings.extend(["({branch} {date})".format(**hg_data)])
+            except KeyError:
+                pass
+            try:
+                strings.extend(["+{distance}".format(**hg_data)])
+            except KeyError:
+                pass
+            repos_cache = " ".join(strings)
+        return repos_cache
+    else:
+        return hg_data
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/video.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Methods to manipulate videos"""
+from typing import Union
+from pathlib import Path
+from twisted.python.procutils import which
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+from .common import async_process
+
+
+log = getLogger(__name__)
+
+
+
+
+
+try:
+    ffmpeg_path = which('ffmpeg')[0]
+except IndexError:
+    log.warning(_(
+        "ffmpeg executable not found, video thumbnails won't be available"))
+    ffmpeg_path = None
+
+
+async def get_thumbnail(video_path: Union[Path, str], dest_path: Path) -> Path:
+    """Extract thumbnail from video
+
+    @param video_path: source of the video
+    @param dest_path: path where the file must be saved
+    @return: path of the generated thumbnail
+        image is created in temporary directory but is not delete automatically
+        it should be deleted after use.
+        Image will be in JPEG format.
+    @raise exceptions.NotFound: ffmpeg is missing
+    """
+    if ffmpeg_path is None:
+        raise exceptions.NotFound(
+            _("ffmpeg executable is not available, can't generate video thumbnail"))
+
+    await async_process.run(
+        ffmpeg_path, "-i", str(video_path), "-ss", "10", "-frames:v", "1", str(dest_path)
+    )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/web.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+
+# Libervia: an XMPP client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from typing import Optional, Union
+from pathlib import Path
+from io import BufferedIOBase
+
+from OpenSSL import SSL
+import treq
+from treq.client import HTTPClient
+from twisted.internet import reactor, ssl
+from twisted.internet.interfaces import IOpenSSLClientConnectionCreator
+from twisted.web import iweb
+from twisted.web import client as http_client
+from zope.interface import implementer
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+
+
+log = getLogger(__name__)
+
+
+SSLError = SSL.Error
+
+
+@implementer(IOpenSSLClientConnectionCreator)
+class NoCheckConnectionCreator(object):
+    def __init__(self, hostname, ctx):
+        self._ctx = ctx
+
+    def clientConnectionForTLS(self, tlsProtocol):
+        context = self._ctx
+        connection = SSL.Connection(context, None)
+        connection.set_app_data(tlsProtocol)
+        return connection
+
+
+@implementer(iweb.IPolicyForHTTPS)
+class NoCheckContextFactory:
+    """Context factory which doesn't do TLS certificate check
+
+    /!\\ it's obvisously a security flaw to use this class,
+    and it should be used only with explicit agreement from the end used
+    """
+
+    def creatorForNetloc(self, hostname, port):
+        log.warning(
+            "TLS check disabled for {host} on port {port}".format(
+                host=hostname, port=port
+            )
+        )
+        certificateOptions = ssl.CertificateOptions(trustRoot=None)
+        return NoCheckConnectionCreator(hostname, certificateOptions.getContext())
+
+
+#: following treq doesn't check TLS, obviously it is unsecure and should not be used
+#: without explicit warning
+treq_client_no_ssl = HTTPClient(http_client.Agent(reactor, NoCheckContextFactory()))
+
+
+async def download_file(
+    url: str,
+    dest: Union[str, Path, BufferedIOBase],
+    max_size: Optional[int] = None
+) -> None:
+    """Helper method to download a file
+
+    This is for internal download, for high level download with progression, use
+    ``plugin_misc_download``.
+
+    Inspired from
+    https://treq.readthedocs.io/en/latest/howto.html#handling-streaming-responses
+
+    @param dest: destination filename or file-like object
+        of it's a file-like object, you'll have to close it yourself
+    @param max_size: if set, an exceptions.DataError will be raised if the downloaded file
+        is bigger that given value (in bytes).
+    """
+    if isinstance(dest, BufferedIOBase):
+        f = dest
+        must_close = False
+    else:
+        dest = Path(dest)
+        f = dest.open("wb")
+        must_close = True
+    d = treq.get(url, unbuffered=True)
+    written = 0
+
+    def write(data: bytes):
+        if max_size is not None:
+            nonlocal written
+            written += len(data)
+            if written > max_size:
+                raise exceptions.DataError(
+                    "downloaded file is bigger than expected ({max_size})"
+                )
+        f.write(data)
+
+    d.addCallback(treq.collect, f.write)
+    try:
+        await d
+    except exceptions.DataError as e:
+        log.warning("download cancelled due to file oversized")
+        raise e
+    except Exception as e:
+        log.error(f"Can't write file {dest}: {e}")
+        raise e
+    finally:
+        if must_close:
+            f.close()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/xml_tools.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,2093 @@
+#!/usr/bin/env python3
+
+# SAT: a jabber client
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from collections import OrderedDict
+import html.entities
+import re
+from typing import Dict, Optional, Tuple, Union, Literal, overload, Iterable
+from xml.dom import NotFoundErr, minidom
+import xml.etree.ElementTree as ET
+from lxml import etree
+
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from twisted.words.xish import domish
+from wokkel import data_form
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+
+log = getLogger(__name__)
+
+"""This library help manage XML used in SàT (parameters, registration, etc)"""
+
+SAT_FORM_PREFIX = "SAT_FORM_"
+SAT_PARAM_SEPARATOR = "_XMLUI_PARAM_"  # used to have unique elements names
+html_entity_re = re.compile(r"&([a-zA-Z]+?);")
+XML_ENTITIES = ("quot", "amp", "apos", "lt", "gt")
+
+# method to clean XHTML, receive raw unsecure XML or HTML, must return cleaned raw XHTML
+# this method must be set during runtime
+clean_xhtml = None
+
+# TODO: move XMLUI stuff in a separate module
+# TODO: rewrite this with lxml or ElementTree or domish.Element: it's complicated and difficult to maintain with current minidom implementation
+
+# Helper functions
+
+
+def _data_form_field_2_xmlui_data(field, read_only=False):
+    """Get data needed to create an XMLUI's Widget from Wokkel's data_form's Field.
+
+    The attribute field can be modified (if it's fixed and it has no value).
+    @param field (data_form.Field): a field with attributes "value", "fieldType",
+                                    "label" and "var"
+    @param read_only (bool): if True and it makes sense, create a read only input widget
+    @return: a tuple (widget_type, widget_args, widget_kwargs)
+    """
+    widget_args = field.values or [None]
+    widget_kwargs = {}
+    if field.fieldType is None and field.ext_type is not None:
+        # we have an extended field
+        if field.ext_type == "xml":
+            element = field.value
+            if element.uri == C.NS_XHTML:
+                widget_type = "xhtmlbox"
+                widget_args[0] = element.toXml()
+                widget_kwargs["read_only"] = read_only
+            else:
+                log.warning("unknown XML element, falling back to textbox")
+                widget_type = "textbox"
+                widget_args[0] = element.toXml()
+                widget_kwargs["read_only"] = read_only
+        else:
+
+            raise exceptions.DataError("unknown extended type {ext_type}".format(
+                ext_type = field.ext_type))
+
+    elif field.fieldType == "fixed" or field.fieldType is None:
+        widget_type = "text"
+        if field.value is None:
+            if field.label is None:
+                log.warning(_("Fixed field has neither value nor label, ignoring it"))
+                field.value = ""
+            else:
+                field.value = field.label
+                field.label = None
+            widget_args = [field.value]
+    elif field.fieldType == "text-single":
+        widget_type = "string"
+        widget_kwargs["read_only"] = read_only
+    elif field.fieldType == "jid-single":
+        widget_type = "jid_input"
+        widget_kwargs["read_only"] = read_only
+    elif field.fieldType == "text-multi":
+        widget_type = "textbox"
+        widget_args = ["\n".join(field.values)]
+        widget_kwargs["read_only"] = read_only
+    elif field.fieldType == "hidden":
+        widget_type = "hidden"
+    elif field.fieldType == "text-private":
+        widget_type = "password"
+        widget_kwargs["read_only"] = read_only
+    elif field.fieldType == "boolean":
+        widget_type = "bool"
+        if widget_args[0] is None:
+            widget_args = ["false"]
+        widget_kwargs["read_only"] = read_only
+    elif field.fieldType == "integer":
+        widget_type = "integer"
+        widget_kwargs["read_only"] = read_only
+    elif field.fieldType == "list-single":
+        widget_type = "list"
+        widget_kwargs["options"] = [
+            (option.value, option.label or option.value) for option in field.options
+        ]
+        widget_kwargs["selected"] = widget_args
+        widget_args = []
+    elif field.fieldType == "list-multi":
+        widget_type = "list"
+        widget_kwargs["options"] = [
+            (option.value, option.label or option.value) for option in field.options
+        ]
+        widget_kwargs["selected"] = widget_args
+        widget_kwargs["styles"] =  ["multi"]
+        widget_args = []
+    else:
+        log.error(
+            "FIXME FIXME FIXME: Type [%s] is not managed yet by SàT" % field.fieldType
+        )
+        widget_type = "string"
+        widget_kwargs["read_only"] = read_only
+
+    if field.var:
+        widget_kwargs["name"] = field.var
+
+    return widget_type, widget_args, widget_kwargs
+
+def data_form_2_widgets(form_ui, form, read_only=False, prepend=None, filters=None):
+    """Complete an existing XMLUI with widget converted from XEP-0004 data forms.
+
+    @param form_ui (XMLUI): XMLUI instance
+    @param form (data_form.Form): Wokkel's implementation of data form
+    @param read_only (bool): if True and it makes sense, create a read only input widget
+    @param prepend(iterable, None): widgets to prepend to main LabelContainer
+        if not None, must be an iterable of *args for add_widget. Those widgets will
+        be added first to the container.
+    @param filters(dict, None): if not None, a dictionary of callable:
+        key is the name of the widget to filter
+        the value is a callable, it will get form's XMLUI, widget's type, args and kwargs
+            and must return widget's type, args and kwargs (which can be modified)
+        This is especially useful to modify well known fields
+    @return: the completed XMLUI instance
+    """
+    if filters is None:
+        filters = {}
+    if form.instructions:
+        form_ui.addText("\n".join(form.instructions), "instructions")
+
+    form_ui.change_container("label")
+
+    if prepend is not None:
+        for widget_args in prepend:
+            form_ui.add_widget(*widget_args)
+
+    for field in form.fieldList:
+        widget_type, widget_args, widget_kwargs = _data_form_field_2_xmlui_data(
+            field, read_only
+        )
+        try:
+            widget_filter = filters[widget_kwargs["name"]]
+        except KeyError:
+            pass
+        else:
+            widget_type, widget_args, widget_kwargs = widget_filter(
+                form_ui, widget_type, widget_args, widget_kwargs
+            )
+        if widget_type != "hidden":
+            label = field.label or field.var
+            if label:
+                form_ui.addLabel(label)
+            else:
+                form_ui.addEmpty()
+
+        form_ui.add_widget(widget_type, *widget_args, **widget_kwargs)
+
+    return form_ui
+
+
+def data_form_2_xmlui(form, submit_id, session_id=None, read_only=False):
+    """Take a data form (Wokkel's XEP-0004 implementation) and convert it to a SàT XMLUI.
+
+    @param form (data_form.Form): a Form instance
+    @param submit_id (unicode): callback id to call when submitting form
+    @param session_id (unicode): session id to return with the data
+    @param read_only (bool): if True and it makes sense, create a read only input widget
+    @return: XMLUI instance
+    """
+    form_ui = XMLUI("form", "vertical", submit_id=submit_id, session_id=session_id)
+    return data_form_2_widgets(form_ui, form, read_only=read_only)
+
+
+def data_form_2_data_dict(form: data_form.Form) -> dict:
+    """Convert data form to a simple dict, easily serialisable
+
+    see data_dict_2_data_form for a description of the format
+    """
+    fields = []
+    data_dict = {
+        "fields": fields
+    }
+    if form.formNamespace:
+        data_dict["namespace"] = form.formNamespace
+    for form_field in form.fieldList:
+        field = {"type": form_field.fieldType}
+        fields.append(field)
+        for src_name, dest_name in (
+            ('var', 'name'),
+            ('label', 'label'),
+            ('value', 'value'),
+            # FIXME: we probably should have only "values"
+            ('values', 'values')
+        ):
+            value = getattr(form_field, src_name, None)
+            if value:
+                field[dest_name] = value
+        if form_field.options:
+            options = field["options"] = []
+            for form_opt in form_field.options:
+                opt = {"value": form_opt.value}
+                if form_opt.label:
+                    opt["label"] = form_opt.label
+                options.append(opt)
+
+        if form_field.fieldType is None and form_field.ext_type == "xml":
+            if isinstance(form_field.value, domish.Element):
+                if ((form_field.value.uri == C.NS_XHTML
+                     and form_field.value.name == "div")):
+                    field["type"] = "xhtml"
+                    if form_field.value.children:
+                        log.warning(
+                            "children are not managed for XHTML fields: "
+                            f"{form_field.value.toXml()}"
+                        )
+    return data_dict
+
+
+def data_dict_2_data_form(data_dict):
+    """Convert serialisable dict of data to a data form
+
+    The format of the dict is as follow:
+        - an optional "namespace" key with form namespace
+        - a mandatory "fields" key with list of fields as follow:
+            - "type" is mostly the same as data_form.Field.fieldType
+            - "name" is used to set the "var" attribute of data_form.Field
+            - "label", and "value" follow same attribude in data_form.Field
+            - "xhtml" is used for "xml" fields with child in the C.NS_XHTML namespace
+            - "options" are list of dict with optional "label" and mandatory "value"
+              following suitable attributes from data_form.Option
+            - "required" is the same as data_form.Field.required
+    """
+    # TODO: describe format
+    fields = []
+    for field_data in data_dict["fields"]:
+        field_type = field_data.get('type', 'text-single')
+        kwargs = {
+            "fieldType": field_type,
+            "var": field_data["name"],
+            "label": field_data.get('label'),
+            "value": field_data.get("value"),
+            "required": field_data.get("required")
+        }
+        if field_type == "xhtml":
+            kwargs.update({
+                "fieldType": None,
+                "ext_type": "xml",
+            })
+            if kwargs["value"] is None:
+                kwargs["value"] = domish.Element((C.NS_XHTML, "div"))
+        elif "options" in field_data:
+            kwargs["options"] = [
+                data_form.Option(o["value"], o.get("label"))
+                for o in field_data["options"]
+            ]
+        field = data_form.Field(**kwargs)
+        fields.append(field)
+    return data_form.Form(
+        "form",
+        formNamespace=data_dict.get("namespace"),
+        fields=fields
+    )
+
+
+def data_form_elt_result_2_xmlui_data(form_xml):
+    """Parse a data form result (not parsed by Wokkel's XEP-0004 implementation).
+
+    The raw data form is used because Wokkel doesn't manage result items parsing yet.
+    @param form_xml (domish.Element): element of the data form
+    @return: a couple (headers, result_list):
+        - headers (dict{unicode: unicode}): form headers (field labels and types)
+        - xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs)
+    """
+    headers = OrderedDict()
+    try:
+        reported_elt = next(form_xml.elements("jabber:x:data", "reported"))
+    except StopIteration:
+        raise exceptions.DataError(
+            "Couldn't find expected <reported> tag in %s" % form_xml.toXml()
+        )
+
+    for elt in reported_elt.elements():
+        if elt.name != "field":
+            raise exceptions.DataError("Unexpected tag")
+        name = elt["var"]
+        label = elt.attributes.get("label", "")
+        type_ = elt.attributes.get("type")
+        headers[name] = (label, type_)
+
+    if not headers:
+        raise exceptions.DataError("No reported fields (see XEP-0004 §3.4)")
+
+    xmlui_data = []
+    item_elts = form_xml.elements("jabber:x:data", "item")
+
+    for item_elt in item_elts:
+        for elt in item_elt.elements():
+            if elt.name != "field":
+                log.warning("Unexpected tag (%s)" % elt.name)
+                continue
+            field = data_form.Field.fromElement(elt)
+
+            xmlui_data.append(_data_form_field_2_xmlui_data(field))
+
+    return headers, xmlui_data
+
+
+def xmlui_data_2_advanced_list(xmlui, headers, xmlui_data):
+    """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list.
+
+    The raw data form is used because Wokkel doesn't manage result items parsing yet.
+    @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added
+    @param headers (dict{unicode: unicode}): form headers (field labels and types)
+    @param xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs)
+    @return: the completed XMLUI instance
+    """
+    adv_list = AdvancedListContainer(
+        xmlui, headers=headers, columns=len(headers), parent=xmlui.current_container
+    )
+    xmlui.change_container(adv_list)
+
+    for widget_type, widget_args, widget_kwargs in xmlui_data:
+        xmlui.add_widget(widget_type, *widget_args, **widget_kwargs)
+
+    return xmlui
+
+
+def data_form_result_2_advanced_list(xmlui, form_xml):
+    """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list.
+
+    The raw data form is used because Wokkel doesn't manage result items parsing yet.
+    @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added
+    @param form_xml (domish.Element): element of the data form
+    @return: the completed XMLUI instance
+    """
+    headers, xmlui_data = data_form_elt_result_2_xmlui_data(form_xml)
+    xmlui_data_2_advanced_list(xmlui, headers, xmlui_data)
+
+
+def data_form_elt_result_2_xmlui(form_elt, session_id=None):
+    """Take a raw data form (not parsed by XEP-0004) and convert it to a SàT XMLUI.
+
+    The raw data form is used because Wokkel doesn't manage result items parsing yet.
+    @param form_elt (domish.Element): element of the data form
+    @param session_id (unicode): session id to return with the data
+    @return: XMLUI instance
+    """
+    xml_ui = XMLUI("window", "vertical", session_id=session_id)
+    try:
+        data_form_result_2_advanced_list(xml_ui, form_elt)
+    except exceptions.DataError:
+        parsed_form = data_form.Form.fromElement(form_elt)
+        data_form_2_widgets(xml_ui, parsed_form, read_only=True)
+    return xml_ui
+
+
+def data_form_result_2_xmlui(result_form, base_form, session_id=None, prepend=None,
+                         filters=None, read_only=True):
+    """Convert data form result to SàT XMLUI.
+
+    @param result_form (data_form.Form): result form to convert
+    @param base_form (data_form.Form): initial form (i.e. of form type "form")
+        this one is necessary to reconstruct options when needed (e.g. list elements)
+    @param session_id (unicode): session id to return with the data
+    @param prepend: same as for [data_form_2_widgets]
+    @param filters: same as for [data_form_2_widgets]
+    @param read_only: same as for [data_form_2_widgets]
+    @return: XMLUI instance
+    """
+    # we deepcopy the form because _data_form_field_2_xmlui_data can modify the value
+    # FIXME: check if it's really important, the only modified value seems to be
+    #        the replacement of None by "" on fixed fields
+    # form = deepcopy(result_form)
+    form = result_form
+    for name, field in form.fields.items():
+        try:
+            base_field = base_form.fields[name]
+        except KeyError:
+            continue
+        field.options = base_field.options[:]
+    xml_ui = XMLUI("window", "vertical", session_id=session_id)
+    data_form_2_widgets(xml_ui, form, read_only=read_only, prepend=prepend, filters=filters)
+    return xml_ui
+
+
+def _clean_value(value):
+    """Workaround method to avoid DBus types with D-Bus bridge.
+
+    @param value: value to clean
+    @return: value in a non DBus type (only clean string yet)
+    """
+    # XXX: must be removed when DBus types will no cause problems anymore
+    # FIXME: should be cleaned inside D-Bus bridge itself
+    if isinstance(value, str):
+        return str(value)
+    return value
+
+
+def xmlui_result_2_data_form_result(xmlui_data):
+    """ Extract form data from a XMLUI return.
+
+    @param xmlui_data (dict): data returned by frontends for XMLUI form
+    @return: dict of data usable by Wokkel's data form
+    """
+    ret = {}
+    for key, value in xmlui_data.items():
+        if not key.startswith(SAT_FORM_PREFIX):
+            continue
+        if isinstance(value, str):
+            if "\n" in value:
+                # data form expects multi-lines text to be in separated values
+                value = value.split('\n')
+            elif "\t" in value:
+                # FIXME: workaround to handle multiple values. Proper serialisation must
+                #   be done in XMLUI
+                value = value.split("\t")
+        ret[key[len(SAT_FORM_PREFIX) :]] = _clean_value(value)
+    return ret
+
+
+def form_escape(name):
+    """Return escaped name for forms.
+
+    @param name (unicode): form name
+    @return: unicode
+    """
+    return "%s%s" % (SAT_FORM_PREFIX, name)
+
+
+def is_xmlui_cancelled(raw_xmlui):
+    """Tell if an XMLUI has been cancelled by checking raw XML"""
+    return C.bool(raw_xmlui.get('cancelled', C.BOOL_FALSE))
+
+
+def xmlui_result_to_elt(xmlui_data):
+    """Construct result domish.Element from XMLUI result.
+
+    @param xmlui_data (dict): data returned by frontends for XMLUI form
+    @return: domish.Element
+    """
+    form = data_form.Form("submit")
+    form.makeFields(xmlui_result_2_data_form_result(xmlui_data))
+    return form.toElement()
+
+
+def tuple_list_2_data_form(values):
+    """Convert a list of tuples (name, value) to a wokkel submit data form.
+
+    @param values (list): list of tuples
+    @return: data_form.Form
+    """
+    form = data_form.Form("submit")
+    for value in values:
+        field = data_form.Field(var=value[0], value=value[1])
+        form.addField(field)
+
+    return form
+
+
+def params_xml_2_xmlui(xml):
+    """Convert the XML for parameter to a SàT XML User Interface.
+
+    @param xml (unicode)
+    @return: XMLUI
+    """
+    # TODO: refactor params and use Twisted directly to parse XML
+    params_doc = minidom.parseString(xml.encode("utf-8"))
+    top = params_doc.documentElement
+    if top.nodeName != "params":
+        raise exceptions.DataError(_("INTERNAL ERROR: parameters xml not valid"))
+
+    param_ui = XMLUI("param", "tabs")
+    tabs_cont = param_ui.current_container
+
+    for category in top.getElementsByTagName("category"):
+        category_name = category.getAttribute("name")
+        label = category.getAttribute("label")
+        if not category_name:
+            raise exceptions.DataError(
+                _("INTERNAL ERROR: params categories must have a name")
+            )
+        tabs_cont.add_tab(category_name, label=label, container=LabelContainer)
+        for param in category.getElementsByTagName("param"):
+            widget_kwargs = {}
+
+            param_name = param.getAttribute("name")
+            param_label = param.getAttribute("label")
+            type_ = param.getAttribute("type")
+            if not param_name and type_ != "text":
+                raise exceptions.DataError(_("INTERNAL ERROR: params must have a name"))
+
+            value = param.getAttribute("value") or None
+            callback_id = param.getAttribute("callback_id") or None
+
+            if type_ == "list":
+                options, selected = _params_get_list_options(param)
+                widget_kwargs["options"] = options
+                widget_kwargs["selected"] = selected
+                widget_kwargs["styles"] = ["extensible"]
+            elif type_ == "jids_list":
+                widget_kwargs["jids"] = _params_get_list_jids(param)
+
+            if type_ in ("button", "text"):
+                param_ui.addEmpty()
+                value = param_label
+            else:
+                param_ui.addLabel(param_label or param_name)
+
+            if value:
+                widget_kwargs["value"] = value
+
+            if callback_id:
+                widget_kwargs["callback_id"] = callback_id
+                others = [
+                    "%s%s%s"
+                    % (category_name, SAT_PARAM_SEPARATOR, other.getAttribute("name"))
+                    for other in category.getElementsByTagName("param")
+                    if other.getAttribute("type") != "button"
+                ]
+                widget_kwargs["fields_back"] = others
+
+            widget_kwargs["name"] = "%s%s%s" % (
+                category_name,
+                SAT_PARAM_SEPARATOR,
+                param_name,
+            )
+
+            param_ui.add_widget(type_, **widget_kwargs)
+
+    return param_ui.toXml()
+
+
+def _params_get_list_options(param):
+    """Retrieve the options for list element.
+
+    The <option/> tags must be direct children of <param/>.
+    @param param (domish.Element): element
+    @return: a tuple (options, selected_value)
+    """
+    if len(param.getElementsByTagName("options")) > 0:
+        raise exceptions.DataError(
+            _("The 'options' tag is not allowed in parameter of type 'list'!")
+        )
+    elems = param.getElementsByTagName("option")
+    if len(elems) == 0:
+        return []
+    options = []
+    for elem in elems:
+        value = elem.getAttribute("value")
+        if not value:
+            raise exceptions.InternalError("list option must have a value")
+        label = elem.getAttribute("label")
+        if label:
+            options.append((value, label))
+        else:
+            options.append(value)
+    selected = [
+        elem.getAttribute("value")
+        for elem in elems
+        if elem.getAttribute("selected") == "true"
+    ]
+    return (options, selected)
+
+
+def _params_get_list_jids(param):
+    """Retrive jids from a jids_list element.
+
+    the <jid/> tags must be direct children of <param/>
+    @param param (domish.Element): element
+    @return: a list of jids
+    """
+    elems = param.getElementsByTagName("jid")
+    jids = [
+        elem.firstChild.data
+        for elem in elems
+        if elem.firstChild is not None and elem.firstChild.nodeType == elem.TEXT_NODE
+    ]
+    return jids
+
+
+### XMLUI Elements ###
+
+
+class Element(object):
+    """ Base XMLUI element """
+
+    type = None
+
+    def __init__(self, xmlui, parent=None):
+        """Create a container element
+
+        @param xmlui: XMLUI instance
+        @parent: parent element
+        """
+        assert self.type is not None
+        self.children = []
+        if not hasattr(self, "elem"):
+            self.elem = parent.xmlui.doc.createElement(self.type)
+        self.xmlui = xmlui
+        if parent is not None:
+            parent.append(self)
+        self.parent = parent
+
+    def append(self, child):
+        """Append a child to this element.
+
+        @param child (Element): child element
+        @return: the added child Element
+        """
+        self.elem.appendChild(child.elem)
+        child.parent = self
+        self.children.append(child)
+        return child
+
+
+class TopElement(Element):
+    """ Main XML Element """
+
+    type = "top"
+
+    def __init__(self, xmlui):
+        self.elem = xmlui.doc.documentElement
+        super(TopElement, self).__init__(xmlui)
+
+
+class TabElement(Element):
+    """ Used by TabsContainer to give name and label to tabs."""
+
+    type = "tab"
+
+    def __init__(self, parent, name, label, selected=False):
+        """
+
+        @param parent (TabsContainer): parent container
+        @param name (unicode): tab name
+        @param label (unicode): tab label
+        @param selected (bool): set to True to select this tab
+        """
+        if not isinstance(parent, TabsContainer):
+            raise exceptions.DataError(_("TabElement must be a child of TabsContainer"))
+        super(TabElement, self).__init__(parent.xmlui, parent)
+        self.elem.setAttribute("name", name)
+        self.elem.setAttribute("label", label)
+        if selected:
+            self.set_selected(selected)
+
+    def set_selected(self, selected=False):
+        """Set the tab selected.
+
+        @param selected (bool): set to True to select this tab
+        """
+        self.elem.setAttribute("selected", "true" if selected else "false")
+
+
+class FieldBackElement(Element):
+    """ Used by ButtonWidget to indicate which field have to be sent back """
+
+    type = "field_back"
+
+    def __init__(self, parent, name):
+        assert isinstance(parent, ButtonWidget)
+        super(FieldBackElement, self).__init__(parent.xmlui, parent)
+        self.elem.setAttribute("name", name)
+
+
+class InternalFieldElement(Element):
+    """ Used by internal callbacks to indicate which fields are manipulated """
+
+    type = "internal_field"
+
+    def __init__(self, parent, name):
+        super(InternalFieldElement, self).__init__(parent.xmlui, parent)
+        self.elem.setAttribute("name", name)
+
+
+class InternalDataElement(Element):
+    """ Used by internal callbacks to retrieve extra data """
+
+    type = "internal_data"
+
+    def __init__(self, parent, children):
+        super(InternalDataElement, self).__init__(parent.xmlui, parent)
+        assert isinstance(children, list)
+        for child in children:
+            self.elem.childNodes.append(child)
+
+
+class OptionElement(Element):
+    """" Used by ListWidget to specify options """
+
+    type = "option"
+
+    def __init__(self, parent, option, selected=False):
+        """
+
+        @param parent
+        @param option (string, tuple)
+        @param selected (boolean)
+        """
+        assert isinstance(parent, ListWidget)
+        super(OptionElement, self).__init__(parent.xmlui, parent)
+        if isinstance(option, str):
+            value, label = option, option
+        elif isinstance(option, tuple):
+            value, label = option
+        else:
+            raise NotImplementedError
+        self.elem.setAttribute("value", value)
+        self.elem.setAttribute("label", label)
+        if selected:
+            self.elem.setAttribute("selected", "true")
+
+
+class JidElement(Element):
+    """" Used by JidsListWidget to specify jids"""
+
+    type = "jid"
+
+    def __init__(self, parent, jid_):
+        """
+        @param jid_(jid.JID, unicode): jid to append
+        """
+        assert isinstance(parent, JidsListWidget)
+        super(JidElement, self).__init__(parent.xmlui, parent)
+        if isinstance(jid_, jid.JID):
+            value = jid_.full()
+        elif isinstance(jid_, str):
+            value = str(jid_)
+        else:
+            raise NotImplementedError
+        jid_txt = self.xmlui.doc.createTextNode(value)
+        self.elem.appendChild(jid_txt)
+
+
+class RowElement(Element):
+    """" Used by AdvancedListContainer """
+
+    type = "row"
+
+    def __init__(self, parent):
+        assert isinstance(parent, AdvancedListContainer)
+        super(RowElement, self).__init__(parent.xmlui, parent)
+        if parent.next_row_idx is not None:
+            if parent.auto_index:
+                raise exceptions.DataError(_("Can't set row index if auto_index is True"))
+            self.elem.setAttribute("index", parent.next_row_idx)
+            parent.next_row_idx = None
+
+
+class HeaderElement(Element):
+    """" Used by AdvancedListContainer """
+
+    type = "header"
+
+    def __init__(self, parent, name=None, label=None, description=None):
+        """
+        @param parent: AdvancedListContainer instance
+        @param name: name of the container
+        @param label: label to be displayed in columns
+        @param description: long descriptive text
+        """
+        assert isinstance(parent, AdvancedListContainer)
+        super(HeaderElement, self).__init__(parent.xmlui, parent)
+        if name:
+            self.elem.setAttribute("name", name)
+        if label:
+            self.elem.setAttribute("label", label)
+        if description:
+            self.elem.setAttribute("description", description)
+
+
+## Containers ##
+
+
+class Container(Element):
+    """ And Element which contains other ones and has a layout """
+
+    type = None
+
+    def __init__(self, xmlui, parent=None):
+        """Create a container element
+
+        @param xmlui: XMLUI instance
+        @parent: parent element or None
+        """
+        self.elem = xmlui.doc.createElement("container")
+        super(Container, self).__init__(xmlui, parent)
+        self.elem.setAttribute("type", self.type)
+
+    def get_parent_container(self):
+        """ Return first parent container
+
+        @return: parent container or None
+        """
+        current = self.parent
+        while not isinstance(current, (Container)) and current is not None:
+            current = current.parent
+        return current
+
+
+class VerticalContainer(Container):
+    type = "vertical"
+
+
+class HorizontalContainer(Container):
+    type = "horizontal"
+
+
+class PairsContainer(Container):
+    """Container with series of 2 elements"""
+    type = "pairs"
+
+
+class LabelContainer(Container):
+    """Like PairsContainer, but first element can only be a label"""
+    type = "label"
+
+
+class TabsContainer(Container):
+    type = "tabs"
+
+    def add_tab(self, name, label=None, selected=None, container=VerticalContainer):
+        """Add a tab.
+
+        @param name (unicode): tab name
+        @param label (unicode): tab label
+        @param selected (bool): set to True to select this tab
+        @param container (class): container class, inheriting from Container
+        @return: the container for the new tab
+        """
+        if not label:
+            label = name
+        tab_elt = TabElement(self, name, label, selected)
+        new_container = container(self.xmlui, tab_elt)
+        return self.xmlui.change_container(new_container)
+
+    def end(self):
+        """ Called when we have finished tabs
+
+        change current container to first container parent
+        """
+        parent_container = self.get_parent_container()
+        self.xmlui.change_container(parent_container)
+
+
+class AdvancedListContainer(Container):
+    """A list which can contain other widgets, headers, etc"""
+
+    type = "advanced_list"
+
+    def __init__(
+        self,
+        xmlui,
+        callback_id=None,
+        name=None,
+        headers=None,
+        items=None,
+        columns=None,
+        selectable="no",
+        auto_index=False,
+        parent=None,
+    ):
+        """Create an advanced list
+
+        @param headers: optional headers information
+        @param callback_id: id of the method to call when selection is done
+        @param items: list of widgets to add (just the first row)
+        @param columns: number of columns in this table, or None to autodetect
+        @param selectable: one of:
+            'no': nothing is done
+            'single': one row can be selected
+        @param auto_index: if True, indexes will be generated by frontends,
+                           starting from 0
+        @return: created element
+        """
+        assert selectable in ("no", "single")
+        if not items and columns is None:
+            raise exceptions.DataError(_("either items or columns need do be filled"))
+        if headers is None:
+            headers = []
+        if items is None:
+            items = []
+        super(AdvancedListContainer, self).__init__(xmlui, parent)
+        if columns is None:
+            columns = len(items[0])
+        self._columns = columns
+        self._item_idx = 0
+        self.current_row = None
+        if headers:
+            if len(headers) != self._columns:
+                raise exceptions.DataError(
+                    _("Headers lenght doesn't correspond to columns")
+                )
+            self.add_headers(headers)
+        if items:
+            self.add_items(items)
+        self.elem.setAttribute("columns", str(self._columns))
+        if callback_id is not None:
+            self.elem.setAttribute("callback", callback_id)
+        self.elem.setAttribute("selectable", selectable)
+        self.auto_index = auto_index
+        if auto_index:
+            self.elem.setAttribute("auto_index", "true")
+        self.next_row_idx = None
+
+    def add_headers(self, headers):
+        for header in headers:
+            self.addHeader(header)
+
+    def addHeader(self, header):
+        pass  # TODO
+
+    def add_items(self, items):
+        for item in items:
+            self.append(item)
+
+    def set_row_index(self, idx):
+        """ Set index for next row
+
+        index are returned when a row is selected, in data's "index" key
+        @param idx: string index to associate to the next row
+        """
+        self.next_row_idx = idx
+
+    def append(self, child):
+        if isinstance(child, RowElement):
+            return super(AdvancedListContainer, self).append(child)
+        if self._item_idx % self._columns == 0:
+            self.current_row = RowElement(self)
+        self.current_row.append(child)
+        self._item_idx += 1
+
+    def end(self):
+        """ Called when we have finished list
+
+        change current container to first container parent
+        """
+        if self._item_idx % self._columns != 0:
+            raise exceptions.DataError(_("Incorrect number of items in list"))
+        parent_container = self.get_parent_container()
+        self.xmlui.change_container(parent_container)
+
+
+## Widgets ##
+
+
+class Widget(Element):
+    type = None
+
+    def __init__(self, xmlui, name=None, parent=None):
+        """Create an element
+
+        @param xmlui: XMLUI instance
+        @param name: name of the element or None
+        @param parent: parent element or None
+        """
+        self.elem = xmlui.doc.createElement("widget")
+        super(Widget, self).__init__(xmlui, parent)
+        if name:
+            self.elem.setAttribute("name", name)
+            if name in xmlui.named_widgets:
+                raise exceptions.ConflictError(
+                    _('A widget with the name "{name}" already exists.').format(
+                        name=name
+                    )
+                )
+            xmlui.named_widgets[name] = self
+        self.elem.setAttribute("type", self.type)
+
+    def set_internal_callback(self, callback, fields, data_elts=None):
+        """Set an internal UI callback when the widget value is changed.
+
+        The internal callbacks are NO callback ids, they are strings from
+        a predefined set of actions that are running in the scope of XMLUI.
+        @param callback (string): a value from:
+            - 'copy': process the widgets given in 'fields' two by two, by
+                copying the values of one widget to the other. Target widgets
+                of type List do not accept the empty value.
+            - 'move': same than copy but moves the values if the source widget
+                is not a List.
+            - 'groups_of_contact': process the widgets two by two, assume A is
+                is a list of JID and B a list of groups, select in B the groups
+                to which the JID selected in A belongs.
+            - more operation to be added when necessary...
+        @param fields (list): a list of widget names (string)
+        @param data_elts (list[Element]): extra data elements
+        """
+        self.elem.setAttribute("internal_callback", callback)
+        if fields:
+            for field in fields:
+                InternalFieldElement(self, field)
+        if data_elts:
+            InternalDataElement(self, data_elts)
+
+
+class EmptyWidget(Widget):
+    """Place holder widget"""
+
+    type = "empty"
+
+
+class TextWidget(Widget):
+    """Used for blob of text"""
+
+    type = "text"
+
+    def __init__(self, xmlui, value, name=None, parent=None):
+        super(TextWidget, self).__init__(xmlui, name, parent)
+        value_elt = self.xmlui.doc.createElement("value")
+        text = self.xmlui.doc.createTextNode(value)
+        value_elt.appendChild(text)
+        self.elem.appendChild(value_elt)
+
+    @property
+    def value(self):
+        return self.elem.firstChild.firstChild.wholeText
+
+
+class LabelWidget(Widget):
+    """One line blob of text
+
+    used most of time to display the desciption or name of the next widget
+    """
+    type = "label"
+
+    def __init__(self, xmlui, label, name=None, parent=None):
+        super(LabelWidget, self).__init__(xmlui, name, parent)
+        self.elem.setAttribute("value", label)
+
+
+class HiddenWidget(Widget):
+    """Not displayed widget, frontends will just copy the value(s)"""
+    type = "hidden"
+
+    def __init__(self, xmlui, value, name, parent=None):
+        super(HiddenWidget, self).__init__(xmlui, name, parent)
+        value_elt = self.xmlui.doc.createElement("value")
+        text = self.xmlui.doc.createTextNode(value)
+        value_elt.appendChild(text)
+        self.elem.appendChild(value_elt)
+
+    @property
+    def value(self):
+        return self.elem.firstChild.firstChild.wholeText
+
+
+class JidWidget(Widget):
+    """Used to display a Jabber ID, some specific methods can be added"""
+
+    type = "jid"
+
+    def __init__(self, xmlui, jid, name=None, parent=None):
+        super(JidWidget, self).__init__(xmlui, name, parent)
+        try:
+            self.elem.setAttribute("value", jid.full())
+        except AttributeError:
+            self.elem.setAttribute("value", str(jid))
+
+    @property
+    def value(self):
+        return self.elem.getAttribute("value")
+
+
+class DividerWidget(Widget):
+    type = "divider"
+
+    def __init__(self, xmlui, style="line", name=None, parent=None):
+        """ Create a divider
+
+        @param xmlui: XMLUI instance
+        @param style: one of:
+            - line: a simple line
+            - dot: a line of dots
+            - dash: a line of dashes
+            - plain: a full thick line
+            - blank: a blank line/space
+        @param name: name of the widget
+        @param parent: parent container
+
+        """
+        super(DividerWidget, self).__init__(xmlui, name, parent)
+        self.elem.setAttribute("style", style)
+
+
+### Inputs ###
+
+
+class InputWidget(Widget):
+    """Widget which can accept user inputs
+
+    used mainly in forms
+    """
+
+    def __init__(self, xmlui, name=None, parent=None, read_only=False):
+        super(InputWidget, self).__init__(xmlui, name, parent)
+        if read_only:
+            self.elem.setAttribute("read_only", "true")
+
+
+class StringWidget(InputWidget):
+    type = "string"
+
+    def __init__(self, xmlui, value=None, name=None, parent=None, read_only=False):
+        super(StringWidget, self).__init__(xmlui, name, parent, read_only=read_only)
+        if value:
+            value_elt = self.xmlui.doc.createElement("value")
+            text = self.xmlui.doc.createTextNode(value)
+            value_elt.appendChild(text)
+            self.elem.appendChild(value_elt)
+
+    @property
+    def value(self):
+        return self.elem.firstChild.firstChild.wholeText
+
+
+class PasswordWidget(StringWidget):
+    type = "password"
+
+
+class TextBoxWidget(StringWidget):
+    type = "textbox"
+
+
+class XHTMLBoxWidget(StringWidget):
+    """Specialized textbox to manipulate XHTML"""
+    type = "xhtmlbox"
+
+    def __init__(self, xmlui, value, name=None, parent=None, read_only=False, clean=True):
+        """
+        @param clean(bool): if True, the XHTML is considered insecure and will be cleaned
+            Only set to False if you are absolutely sure that the XHTML is safe (in other
+            word, set to False only if you made the XHTML yourself)
+        """
+        if clean:
+            if clean_xhtml is None:
+                raise exceptions.NotFound(
+                    "No cleaning method set, can't clean the XHTML")
+            value = clean_xhtml(value)
+
+        super(XHTMLBoxWidget, self).__init__(
+            xmlui, value=value, name=name, parent=parent, read_only=read_only)
+
+
+class JidInputWidget(StringWidget):
+    type = "jid_input"
+
+
+# TODO handle min and max values
+class IntWidget(StringWidget):
+    type = "int"
+
+    def __init__(self, xmlui, value=0, name=None, parent=None, read_only=False):
+        try:
+            int(value)
+        except ValueError:
+            raise exceptions.DataError(_("Value must be an integer"))
+        super(IntWidget, self).__init__(xmlui, value, name, parent, read_only=read_only)
+
+
+class BoolWidget(InputWidget):
+    type = "bool"
+
+    def __init__(self, xmlui, value="false", name=None, parent=None, read_only=False):
+        if isinstance(value, bool):
+            value = "true" if value else "false"
+        elif value == "0":
+            value = "false"
+        elif value == "1":
+            value = "true"
+        if value not in ("true", "false"):
+            raise exceptions.DataError(_("Value must be 0, 1, false or true"))
+        super(BoolWidget, self).__init__(xmlui, name, parent, read_only=read_only)
+        self.elem.setAttribute("value", value)
+
+
+class ButtonWidget(Widget):
+    type = "button"
+
+    def __init__(
+        self, xmlui, callback_id, value=None, fields_back=None, name=None, parent=None
+    ):
+        """Add a button
+
+        @param callback_id: callback which will be called if button is pressed
+        @param value: label of the button
+        @param fields_back: list of names of field to give back when pushing the button
+        @param name: name
+        @param parent: parent container
+        """
+        if fields_back is None:
+            fields_back = []
+        super(ButtonWidget, self).__init__(xmlui, name, parent)
+        self.elem.setAttribute("callback", callback_id)
+        if value:
+            self.elem.setAttribute("value", value)
+        for field in fields_back:
+            FieldBackElement(self, field)
+
+
+class ListWidget(InputWidget):
+    type = "list"
+    STYLES = ("multi", "noselect", "extensible", "reducible", "inline")
+
+    def __init__(
+        self, xmlui, options, selected=None, styles=None, name=None, parent=None
+    ):
+        """
+
+        @param xmlui
+        @param options (list[option]): each option can be given as:
+            - a single string if the label and the value are the same
+            - a tuple with a couple of string (value,label) if the label and the
+              value differ
+        @param selected (list[string]): list of the selected values
+        @param styles (iterable[string]): flags to set the behaviour of the list
+            can be:
+                - multi: multiple selection is allowed
+                - noselect: no selection is allowed
+                    useful when only the list itself is needed
+                - extensible: can be extended by user (i.e. new options can be added)
+                - reducible: can be reduced by user (i.e. options can be removed)
+                - inline: hint that this list should be displayed on a single line
+                          (e.g. list of labels)
+        @param name (string)
+        @param parent
+        """
+        styles = set() if styles is None else set(styles)
+        if styles is None:
+            styles = set()
+        else:
+            styles = set(styles)
+        if "noselect" in styles and ("multi" in styles or selected):
+            raise exceptions.DataError(
+                _(
+                    '"multi" flag and "selected" option are not compatible with '
+                    '"noselect" flag'
+                )
+            )
+        if not options:
+            # we can have no options if we get a submitted data form
+            # but we can't use submitted values directly,
+            # because we would not have the labels
+            log.warning(_('empty "options" list'))
+        super(ListWidget, self).__init__(xmlui, name, parent)
+        self.add_options(options, selected)
+        self.set_styles(styles)
+
+    def add_options(self, options, selected=None):
+        """Add options to a multi-values element (e.g. list) """
+        if selected:
+            if isinstance(selected, str):
+                selected = [selected]
+        else:
+            selected = []
+        for option in options:
+            assert isinstance(option, str) or isinstance(option, tuple)
+            value = option if isinstance(option, str) else option[0]
+            OptionElement(self, option, value in selected)
+
+    def set_styles(self, styles):
+        if not styles.issubset(self.STYLES):
+            raise exceptions.DataError(_("invalid styles"))
+        for style in styles:
+            self.elem.setAttribute(style, "yes")
+        # TODO: check flags incompatibily (noselect and multi) like in __init__
+
+    def setStyle(self, style):
+        self.set_styles([style])
+
+    @property
+    def value(self):
+        """Return the value of first selected option"""
+        for child in self.elem.childNodes:
+            if child.tagName == "option" and child.getAttribute("selected") == "true":
+                return child.getAttribute("value")
+        return ""
+
+
+class JidsListWidget(InputWidget):
+    """A list of text or jids where elements can be added/removed or modified"""
+
+    type = "jids_list"
+
+    def __init__(self, xmlui, jids, styles=None, name=None, parent=None):
+        """
+
+        @param xmlui
+        @param jids (list[jid.JID]): base jids
+        @param styles (iterable[string]): flags to set the behaviour of the list
+        @param name (string)
+        @param parent
+        """
+        super(JidsListWidget, self).__init__(xmlui, name, parent)
+        styles = set() if styles is None else set(styles)
+        if not styles.issubset([]):  # TODO
+            raise exceptions.DataError(_("invalid styles"))
+        for style in styles:
+            self.elem.setAttribute(style, "yes")
+        if not jids:
+            log.debug("empty jids list")
+        else:
+            self.add_jids(jids)
+
+    def add_jids(self, jids):
+        for jid_ in jids:
+            JidElement(self, jid_)
+
+
+## Dialog Elements ##
+
+
+class DialogElement(Element):
+    """Main dialog element """
+
+    type = "dialog"
+
+    def __init__(self, parent, type_, level=None):
+        if not isinstance(parent, TopElement):
+            raise exceptions.DataError(
+                _("DialogElement must be a direct child of TopElement")
+            )
+        super(DialogElement, self).__init__(parent.xmlui, parent)
+        self.elem.setAttribute(C.XMLUI_DATA_TYPE, type_)
+        self.elem.setAttribute(C.XMLUI_DATA_LVL, level or C.XMLUI_DATA_LVL_DEFAULT)
+
+
+class MessageElement(Element):
+    """Element with the instruction message"""
+
+    type = C.XMLUI_DATA_MESS
+
+    def __init__(self, parent, message):
+        if not isinstance(parent, DialogElement):
+            raise exceptions.DataError(
+                _("MessageElement must be a direct child of DialogElement")
+            )
+        super(MessageElement, self).__init__(parent.xmlui, parent)
+        message_txt = self.xmlui.doc.createTextNode(message)
+        self.elem.appendChild(message_txt)
+
+
+class ButtonsElement(Element):
+    """Buttons element which indicate which set to use"""
+
+    type = "buttons"
+
+    def __init__(self, parent, set_):
+        if not isinstance(parent, DialogElement):
+            raise exceptions.DataError(
+                _("ButtonsElement must be a direct child of DialogElement")
+            )
+        super(ButtonsElement, self).__init__(parent.xmlui, parent)
+        self.elem.setAttribute("set", set_)
+
+
+class FileElement(Element):
+    """File element used for FileDialog"""
+
+    type = "file"
+
+    def __init__(self, parent, type_):
+        if not isinstance(parent, DialogElement):
+            raise exceptions.DataError(
+                _("FileElement must be a direct child of DialogElement")
+            )
+        super(FileElement, self).__init__(parent.xmlui, parent)
+        self.elem.setAttribute("type", type_)
+
+
+## XMLUI main class
+
+
+class XMLUI(object):
+    """This class is used to create a user interface (form/window/parameters/etc) using SàT XML"""
+
+    def __init__(self, panel_type="window", container="vertical", dialog_opt=None,
+        title=None, submit_id=None, session_id=None):
+        """Init SàT XML Panel
+
+        @param panel_type: one of
+            - C.XMLUI_WINDOW (new window)
+            - C.XMLUI_POPUP
+            - C.XMLUI_FORM (form, depend of the frontend, usually a panel with
+              cancel/submit buttons)
+            - C.XMLUI_PARAM (parameters, presentation depend of the frontend)
+            - C.XMLUI_DIALOG (one common dialog, presentation depend of frontend)
+        @param container: disposition of elements, one of:
+            - vertical: elements are disposed up to bottom
+            - horizontal: elements are disposed left to right
+            - pairs: elements come on two aligned columns
+              (usually one for a label, the next for the element)
+            - label: associations of one LabelWidget or EmptyWidget with an other widget
+                similar to pairs but specialized in LabelWidget,
+                and not necessarily arranged in 2 columns
+            - tabs: elemens are in categories with tabs (notebook)
+        @param dialog_opt: only used if panel_type == C.XMLUI_DIALOG.
+            Dictionnary (string/string) where key can be:
+            - C.XMLUI_DATA_TYPE: type of dialog, value can be:
+                - C.XMLUI_DIALOG_MESSAGE (default): an information/error message.
+                  Action of user is necessary to close the dialog.
+                  Usually the frontend display a classic popup.
+                - C.XMLUI_DIALOG_NOTE: like a C.XMLUI_DIALOG_MESSAGE, but action of user
+                  is not necessary to close, at frontend choice (it can be closed after
+                  a timeout). Usually the frontend display as a timed out notification
+                - C.XMLUI_DIALOG_CONFIRM: dialog with 2 choices (usualy "Ok"/"Cancel").
+                    returned data can contain:
+                        - "answer": "true" if answer is "ok", "yes" or equivalent,
+                                    "false" else
+                - C.XLMUI_DIALOG_FILE: a file selection dialog
+                    returned data can contain:
+                        - "cancelled": "true" if dialog has been cancelled, not present
+                                       or "false" else
+                        - "path": path of the choosed file/dir
+            - C.XMLUI_DATA_MESS: message shown in dialog
+            - C.XMLUI_DATA_LVL: one of:
+                - C.XMLUI_DATA_LVL_INFO (default): normal message
+                - C.XMLUI_DATA_LVL_WARNING: attention of user is important
+                - C.XMLUI_DATA_LVL_ERROR: something went wrong
+            - C.XMLUI_DATA_BTNS_SET: one of:
+                - C.XMLUI_DATA_BTNS_SET_OKCANCEL (default): classical "OK" and "Cancel"
+                  set
+                - C.XMLUI_DATA_BTNS_SET_YESNO: a translated "yes" for OK, and "no" for
+                  Cancel
+            - C.XMLUI_DATA_FILETYPE: only used for file dialogs, one of:
+                - C.XMLUI_DATA_FILETYPE_FILE: a file path is requested
+                - C.XMLUI_DATA_FILETYPE_DIR: a dir path is requested
+                - C.XMLUI_DATA_FILETYPE_DEFAULT: same as C.XMLUI_DATA_FILETYPE_FILE
+
+        @param title: title or default if None
+        @param submit_id: callback id to call for panel_type we can submit (form, param,
+                          dialog)
+        @param session_id: use to keep a session attached to the dialog, must be
+                           returned by frontends
+        @attribute named_widgets(dict): map from name to widget
+        """
+        if panel_type not in [
+            C.XMLUI_WINDOW,
+            C.XMLUI_FORM,
+            C.XMLUI_PARAM,
+            C.XMLUI_POPUP,
+            C.XMLUI_DIALOG,
+        ]:
+            raise exceptions.DataError(_("Unknown panel type [%s]") % panel_type)
+        if panel_type == C.XMLUI_FORM and submit_id is None:
+            raise exceptions.DataError(_("form XMLUI need a submit_id"))
+        if not isinstance(container, str):
+            raise exceptions.DataError(_("container argument must be a string"))
+        if dialog_opt is not None and panel_type != C.XMLUI_DIALOG:
+            raise exceptions.DataError(
+                _("dialog_opt can only be used with dialog panels")
+            )
+        self.type = panel_type
+        impl = minidom.getDOMImplementation()
+
+        self.doc = impl.createDocument(None, "sat_xmlui", None)
+        top_element = self.doc.documentElement
+        top_element.setAttribute("type", panel_type)
+        if title:
+            top_element.setAttribute("title", title)
+        self.submit_id = submit_id
+        self.session_id = session_id
+        if panel_type == C.XMLUI_DIALOG:
+            if dialog_opt is None:
+                dialog_opt = {}
+            self._create_dialog(dialog_opt)
+            return
+        self.main_container = self._create_container(container, TopElement(self))
+        self.current_container = self.main_container
+        self.named_widgets = {}
+
+    @staticmethod
+    def creator_wrapper(widget_cls, is_input):
+        # TODO: once moved to Python 3, use functools.partialmethod and
+        #       remove the creator_wrapper
+        def create_widget(self, *args, **kwargs):
+            if self.type == C.XMLUI_DIALOG:
+                raise exceptions.InternalError(_(
+                    "create_widget can't be used with dialogs"))
+            if "parent" not in kwargs:
+                kwargs["parent"] = self.current_container
+            if "name" not in kwargs and is_input:
+                # name can be given as first argument or in keyword
+                # arguments for InputWidgets
+                args = list(args)
+                kwargs["name"] = args.pop(0)
+            return widget_cls(self, *args, **kwargs)
+        return create_widget
+
+    @classmethod
+    def _introspect(cls):
+        """ Introspect module to find Widgets and Containers, and create addXXX methods"""
+        # FIXME: we can't log anything because this file is used
+        #        in bin/sat script then evaluated
+        #        bin/sat should be refactored
+        # log.debug(u'introspecting XMLUI widgets and containers')
+        cls._containers = {}
+        cls._widgets = {}
+        for obj in list(globals().values()):
+            try:
+                if issubclass(obj, Widget):
+                    if obj.__name__ == "Widget":
+                        continue
+                    cls._widgets[obj.type] = obj
+                    creator_name = "add" + obj.__name__
+                    if creator_name.endswith('Widget'):
+                        creator_name = creator_name[:-6]
+                    is_input = issubclass(obj, InputWidget)
+                    # FIXME: cf. above comment
+                    # log.debug(u"Adding {creator_name} creator (is_input={is_input}))"
+                    #     .format(creator_name=creator_name, is_input=is_input))
+
+                    assert not hasattr(cls, creator_name)
+                    # XXX: we need to use creator_wrapper because we are in a loop
+                    #      and Python 2 doesn't support default values in kwargs
+                    #      when using *args, **kwargs
+                    setattr(cls, creator_name, cls.creator_wrapper(obj, is_input))
+
+                elif issubclass(obj, Container):
+                    if obj.__name__ == "Container":
+                        continue
+                    cls._containers[obj.type] = obj
+            except TypeError:
+                pass
+
+    def __del__(self):
+        self.doc.unlink()
+
+    @property
+    def submit_id(self):
+        top_element = self.doc.documentElement
+        if not top_element.hasAttribute("submit"):
+            # getAttribute never return None (it return empty string it attribute doesn't exists)
+            # so we have to manage None here
+            return None
+        value = top_element.getAttribute("submit")
+        return value
+
+    @submit_id.setter
+    def submit_id(self, value):
+        top_element = self.doc.documentElement
+        if value is None:
+            try:
+                top_element.removeAttribute("submit")
+            except NotFoundErr:
+                pass
+        else:  # submit_id can be the empty string to bypass form restriction
+            top_element.setAttribute("submit", value)
+
+    @property
+    def session_id(self):
+        top_element = self.doc.documentElement
+        value = top_element.getAttribute("session_id")
+        return value or None
+
+    @session_id.setter
+    def session_id(self, value):
+        top_element = self.doc.documentElement
+        if value is None:
+            try:
+                top_element.removeAttribute("session_id")
+            except NotFoundErr:
+                pass
+        elif value:
+            top_element.setAttribute("session_id", value)
+        else:
+            raise exceptions.DataError("session_id can't be empty")
+
+    def _create_dialog(self, dialog_opt):
+        dialog_type = dialog_opt.setdefault(C.XMLUI_DATA_TYPE, C.XMLUI_DIALOG_MESSAGE)
+        if (
+            dialog_type in [C.XMLUI_DIALOG_CONFIRM, C.XMLUI_DIALOG_FILE]
+            and self.submit_id is None
+        ):
+            raise exceptions.InternalError(
+                _("Submit ID must be filled for this kind of dialog")
+            )
+        top_element = TopElement(self)
+        level = dialog_opt.get(C.XMLUI_DATA_LVL)
+        dialog_elt = DialogElement(top_element, dialog_type, level)
+
+        try:
+            MessageElement(dialog_elt, dialog_opt[C.XMLUI_DATA_MESS])
+        except KeyError:
+            pass
+
+        try:
+            ButtonsElement(dialog_elt, dialog_opt[C.XMLUI_DATA_BTNS_SET])
+        except KeyError:
+            pass
+
+        try:
+            FileElement(dialog_elt, dialog_opt[C.XMLUI_DATA_FILETYPE])
+        except KeyError:
+            pass
+
+    def _create_container(self, container, parent=None, **kwargs):
+        """Create a container element
+
+        @param type: container type (cf init doc)
+        @parent: parent element or None
+        """
+        if container not in self._containers:
+            raise exceptions.DataError(_("Unknown container type [%s]") % container)
+        cls = self._containers[container]
+        new_container = cls(self, parent=parent, **kwargs)
+        return new_container
+
+    def change_container(self, container, **kwargs):
+        """Change the current container
+
+        @param container: either container type (container it then created),
+                          or an Container instance"""
+        if isinstance(container, str):
+            self.current_container = self._create_container(
+                container,
+                self.current_container.get_parent_container() or self.main_container,
+                **kwargs
+            )
+        else:
+            self.current_container = (
+                self.main_container if container is None else container
+            )
+        assert isinstance(self.current_container, Container)
+        return self.current_container
+
+    def add_widget(self, type_, *args, **kwargs):
+        """Convenience method to add an element"""
+        if "parent" not in kwargs:
+            kwargs["parent"] = self.current_container
+        try:
+            cls = self._widgets[type_]
+        except KeyError:
+            raise exceptions.DataError(_("Invalid type [{type_}]").format(type_=type_))
+        return cls(self, *args, **kwargs)
+
+    def toXml(self):
+        """return the XML representation of the panel"""
+        return self.doc.toxml()
+
+
+# we call this to have automatic discovery of containers and widgets
+XMLUI._introspect()
+
+
+# Some sugar for XMLUI dialogs
+
+
+def note(message, title="", level=C.XMLUI_DATA_LVL_INFO):
+    """sugar to easily create a Note Dialog
+
+    @param message(unicode): body of the note
+    @param title(unicode): title of the note
+    @param level(unicode): one of C.XMLUI_DATA_LVL_*
+    @return(XMLUI): instance of XMLUI
+    """
+    note_xmlui = XMLUI(
+        C.XMLUI_DIALOG,
+        dialog_opt={
+            C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE,
+            C.XMLUI_DATA_MESS: message,
+            C.XMLUI_DATA_LVL: level,
+        },
+        title=title,
+    )
+    return note_xmlui
+
+
+def quick_note(host, client, message, title="", level=C.XMLUI_DATA_LVL_INFO):
+    """more sugar to do the whole note process"""
+    note_ui = note(message, title, level)
+    host.action_new({"xmlui": note_ui.toXml()}, profile=client.profile)
+
+
+def deferred_ui(host, xmlui, chained=False):
+    """create a deferred linked to XMLUI
+
+    @param xmlui(XMLUI): instance of the XMLUI
+        Must be an XMLUI that you can submit, with submit_id set to ''
+    @param chained(bool): True if the Deferred result must be returned to the frontend
+        useful when backend is in a series of dialogs with an ui
+    @return (D(data)): a deferred which fire the data
+    """
+    assert xmlui.submit_id == ""
+    xmlui_d = defer.Deferred()
+
+    def on_submit(data, profile):
+        xmlui_d.callback(data)
+        return xmlui_d if chained else {}
+
+    xmlui.submit_id = host.register_callback(on_submit, with_data=True, one_shot=True)
+    return xmlui_d
+
+
+def defer_xmlui(host, xmlui, action_extra=None, security_limit=C.NO_SECURITY_LIMIT,
+    chained=False, profile=C.PROF_KEY_NONE):
+    """Create a deferred linked to XMLUI
+
+    @param xmlui(XMLUI): instance of the XMLUI
+        Must be an XMLUI that you can submit, with submit_id set to ''
+    @param profile: %(doc_profile)s
+    @param action_extra(None, dict): extra action to merge with xmlui
+        mainly used to add meta informations (see action_new doc)
+    @param security_limit: %(doc_security_limit)s
+    @param chained(bool): True if the Deferred result must be returned to the frontend
+        useful when backend is in a series of dialogs with an ui
+    @return (data): a deferred which fire the data
+    """
+    xmlui_d = deferred_ui(host, xmlui, chained)
+    action_data = {"xmlui": xmlui.toXml()}
+    if action_extra is not None:
+        action_data.update(action_extra)
+    host.action_new(
+        action_data,
+        security_limit=security_limit,
+        keep_id=xmlui.submit_id,
+        profile=profile,
+    )
+    return xmlui_d
+
+
+def defer_dialog(
+    host,
+    message: str,
+    title: str = "Please confirm",
+    type_: str = C.XMLUI_DIALOG_CONFIRM,
+    options: Optional[dict] = None,
+    action_extra: Optional[dict] = None,
+    security_limit: int = C.NO_SECURITY_LIMIT,
+    chained: bool = False,
+    profile: str = C.PROF_KEY_NONE
+) -> defer.Deferred:
+    """Create a submitable dialog and manage it with a deferred
+
+    @param message: message to display
+    @param title: title of the dialog
+    @param type: dialog type (C.XMLUI_DIALOG_* or plugin specific string)
+    @param options: if not None, will be used to update (extend) dialog_opt arguments of
+        XMLUI
+    @param action_extra: extra action to merge with xmlui
+        mainly used to add meta informations (see action_new doc)
+    @param security_limit: %(doc_security_limit)s
+    @param chained: True if the Deferred result must be returned to the frontend
+        useful when backend is in a series of dialogs with an ui
+    @param profile: %(doc_profile)s
+    @return: answer dict
+    """
+    assert profile is not None
+    dialog_opt = {"type": type_, "message": message}
+    if options is not None:
+        dialog_opt.update(options)
+    dialog = XMLUI(C.XMLUI_DIALOG, title=title, dialog_opt=dialog_opt, submit_id="")
+    return defer_xmlui(host, dialog, action_extra, security_limit, chained, profile)
+
+
+def defer_confirm(*args, **kwargs):
+    """call defer_dialog and return a boolean instead of the whole data dict"""
+    d = defer_dialog(*args, **kwargs)
+    d.addCallback(lambda data: C.bool(data["answer"]))
+    return d
+
+
+# Misc other funtions
+
+def element_copy(
+    element: domish.Element,
+    with_parent: bool = True,
+    with_children: bool = True
+) -> domish.Element:
+    """Make a copy of a domish.Element
+
+    The copy will have its own children list, so other elements
+    can be added as direct children without modifying orignal one.
+    Children are not deeply copied, so if an element is added to a child or grandchild,
+    it will also affect original element.
+    @param element: Element to clone
+    """
+    new_elt = domish.Element(
+        (element.uri, element.name),
+        defaultUri = element.defaultUri,
+        attribs = element.attributes,
+        localPrefixes = element.localPrefixes)
+    if with_parent:
+        new_elt.parent = element.parent
+    if with_children:
+        new_elt.children = element.children[:]
+    return new_elt
+
+
+def is_xhtml_field(field):
+    """Check if a data_form.Field is an XHTML one"""
+    return (field.fieldType is None and field.ext_type == "xml" and
+            field.value.uri == C.NS_XHTML)
+
+
+class ElementParser:
+    """Callable class to parse XML string into Element"""
+
+    # XXX: Found at http://stackoverflow.com/questions/2093400/how-to-create-twisted-words-xish-domish-element-entirely-from-raw-xml/2095942#2095942
+
+    def _escape_html(self, matchobj):
+        entity = matchobj.group(1)
+        if entity in XML_ENTITIES:
+            # we don't escape XML entities
+            return matchobj.group(0)
+        else:
+            try:
+                return chr(html.entities.name2codepoint[entity])
+            except KeyError:
+                log.warning("removing unknown entity {}".format(entity))
+                return ""
+
+    def __call__(self, raw_xml, force_spaces=False, namespace=None):
+        """
+        @param raw_xml(unicode): the raw XML
+        @param force_spaces (bool): if True, replace occurrences of '\n' and '\t'
+                                    with ' '.
+        @param namespace(unicode, None): if set, use this namespace for the wrapping
+                                         element
+        """
+        # we need to wrap element in case
+        # there is not a unique one on the top
+        if namespace is not None:
+            raw_xml = "<div xmlns='{}'>{}</div>".format(namespace, raw_xml)
+        else:
+            raw_xml = "<div>{}</div>".format(raw_xml)
+
+        # avoid ParserError on HTML escaped chars
+        raw_xml = html_entity_re.sub(self._escape_html, raw_xml)
+
+        self.result = None
+
+        def on_start(elem):
+            self.result = elem
+
+        def on_end():
+            pass
+
+        def onElement(elem):
+            self.result.addChild(elem)
+
+        parser = domish.elementStream()
+        parser.DocumentStartEvent = on_start
+        parser.ElementEvent = onElement
+        parser.DocumentEndEvent = on_end
+        tmp = domish.Element((None, "s"))
+        if force_spaces:
+            raw_xml = raw_xml.replace("\n", " ").replace("\t", " ")
+        tmp.addRawXml(raw_xml)
+        parser.parse(tmp.toXml().encode("utf-8"))
+        top_elt = self.result.firstChildElement()
+        # we now can check if there was a unique element on the top
+        # and remove our wrapping <div/> is this is the case
+        top_elt_children = list(top_elt.elements())
+        if len(top_elt_children) == 1:
+            top_elt = top_elt_children[0]
+        return top_elt
+
+
+parse = ElementParser()
+
+
+# FIXME: this method is duplicated from frontends.tools.xmlui.get_text
+def get_text(node):
+    """Get child text nodes of a domish.Element.
+
+    @param node (domish.Element)
+    @return: joined unicode text of all nodes
+    """
+    data = []
+    for child in node.childNodes:
+        if child.nodeType == child.TEXT_NODE:
+            data.append(child.wholeText)
+    return "".join(data)
+
+
+def find_all(elt, namespaces=None, names=None):
+    """Find child element at any depth matching criteria
+
+    @param elt(domish.Element): top parent of the elements to find
+    @param names(iterable[unicode], basestring, None): names to match
+        None to accept every names
+    @param namespace(iterable[unicode], basestring, None): URIs to match
+        None to accept every namespaces
+    @return ((G)domish.Element): found elements
+    """
+    if isinstance(namespaces, str):
+        namespaces = tuple((namespaces,))
+    if isinstance(names, str):
+        names = tuple((names,))
+
+    for child in elt.elements():
+        if (
+            domish.IElement.providedBy(child)
+            and (not names or child.name in names)
+            and (not namespaces or child.uri in namespaces)
+        ):
+            yield child
+        for found in find_all(child, namespaces, names):
+            yield found
+
+
+def find_ancestor(
+    elt,
+    name: str,
+    namespace: Optional[Union[str, Iterable[str]]] = None
+    ) -> domish.Element:
+    """Retrieve ancestor of an element
+
+    @param elt: starting element, its parent will be checked recursively until the
+        required one if found
+    @param name: name of the element to find
+    @param namespace: namespace of the element to find
+        - None to find any element with that name
+        - a simple string to find the namespace
+        - several namespaces can be specified in an iterable, if an element with any of
+          this namespace and given name is found, it will match
+
+    """
+    if isinstance(namespace, str):
+        namespace = [namespace]
+    current = elt.parent
+    while True:
+        if current is None:
+            raise exceptions.NotFound(
+                f"Can't find any ancestor {name!r} (xmlns: {namespace!r})"
+            )
+        if current.name == name and (namespace is None or current.uri in namespace):
+            return current
+        current = current.parent
+
+
+def p_fmt_elt(elt, indent=0, defaultUri=""):
+    """Pretty format a domish.Element"""
+    strings = []
+    for child in elt.children:
+        if domish.IElement.providedBy(child):
+            strings.append(p_fmt_elt(child, indent+2, defaultUri=elt.defaultUri))
+        else:
+            strings.append(f"{(indent+2)*' '}{child!s}")
+    if elt.children:
+        nochild_elt = domish.Element(
+            (elt.uri, elt.name), elt.defaultUri, elt.attributes, elt.localPrefixes
+        )
+        strings.insert(0, f"{indent*' '}{nochild_elt.toXml(defaultUri=defaultUri)[:-2]}>")
+        strings.append(f"{indent*' '}</{nochild_elt.name}>")
+    else:
+        strings.append(f"{indent*' '}{elt.toXml(defaultUri=defaultUri)}")
+    return '\n'.join(strings)
+
+
+def pp_elt(elt):
+    """Pretty print a domish.Element"""
+    print(p_fmt_elt(elt))
+
+
+# ElementTree
+
+def et_get_namespace_and_name(et_elt: ET.Element) -> Tuple[Optional[str], str]:
+    """Retrieve element namespace and name from ElementTree element
+
+    @param et_elt: ElementTree element
+    @return: namespace and name of the element
+        if not namespace if specified, None is returned
+    """
+    name = et_elt.tag
+    if not name:
+        raise ValueError("no name set in ET element")
+    elif name[0] != "{":
+        return None, name
+    end_idx = name.find("}")
+    if end_idx == -1:
+        raise ValueError("Invalid ET name")
+    return name[1:end_idx], name[end_idx+1:]
+
+
+def et_elt_2_domish_elt(et_elt: Union[ET.Element, etree.Element]) -> domish.Element:
+    """Convert ElementTree element to Twisted's domish.Element
+
+    Note: this is a naive implementation, adapted to XMPP, and some content are ignored
+        (attributes namespaces, tail)
+    """
+    namespace, name = et_get_namespace_and_name(et_elt)
+    elt = domish.Element((namespace, name), attribs=et_elt.attrib)
+    if et_elt.text:
+        elt.addContent(et_elt.text)
+    for child in et_elt:
+        elt.addChild(et_elt_2_domish_elt(child))
+    return elt
+
+
+@overload
+def domish_elt_2_et_elt(elt: domish.Element, lxml: Literal[False]) -> ET.Element:
+    ...
+
+@overload
+def domish_elt_2_et_elt(elt: domish.Element, lxml: Literal[True]) -> etree.Element:
+    ...
+
+@overload
+def domish_elt_2_et_elt(
+    elt: domish.Element, lxml: bool
+) -> Union[ET.Element, etree.Element]:
+    ...
+
+def domish_elt_2_et_elt(elt, lxml = False):
+    """Convert Twisted's domish.Element to ElementTree equivalent
+
+    Note: this is a naive implementation, adapted to XMPP, and some text content may be
+        missing (content put after a tag, i.e. what would go to the "tail" attribute of ET
+        Element)
+    """
+    tag = f"{{{elt.uri}}}{elt.name}" if elt.uri else elt.name
+    if lxml:
+        et_elt = etree.Element(tag, attr=elt.attributes)
+    else:
+        et_elt = ET.Element(tag, attrib=elt.attributes)
+    content = str(elt)
+    if content:
+        et_elt.text = str(elt)
+    for child in elt.elements():
+        et_elt.append(domish_elt_2_et_elt(child, lxml=lxml))
+    return et_elt
+
+
+def domish_elt_2_et_elt2(element: domish.Element) -> ET.Element:
+    """
+    WIP, originally from the OMEMO plugin
+    """
+
+    element_name = element.name
+    if element.uri is not None:
+        element_name = "{" + element.uri + "}" + element_name
+
+    attrib: Dict[str, str] = {}
+    for qname, value in element.attributes.items():
+        attribute_name = qname[1] if isinstance(qname, tuple) else qname
+        attribute_namespace = qname[0] if isinstance(qname, tuple) else None
+        if attribute_namespace is not None:
+            attribute_name = "{" + attribute_namespace + "}" + attribute_name
+
+        attrib[attribute_name] = value
+
+    result = ET.Element(element_name, attrib)
+
+    last_child: Optional[ET.Element] = None
+    for child in element.children:
+        if isinstance(child, str):
+            if last_child is None:
+                result.text = child
+            else:
+                last_child.tail = child
+        else:
+            last_child = domish_elt_2_et_elt2(child)
+            result.append(last_child)
+
+    return result
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/tools/xmpp_datetime.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+
+# Libervia: XMPP Date and Time profiles as per XEP-0082
+# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from datetime import date, datetime, time, timezone
+import re
+from typing import Optional, Tuple
+
+from libervia.backend.core import exceptions
+
+
+__all__ = [  # pylint: disable=unused-variable
+    "format_date",
+    "parse_date",
+    "format_datetime",
+    "parse_datetime",
+    "format_time",
+    "parse_time"
+]
+
+
+def __parse_fraction_of_a_second(value: str) -> Tuple[str, Optional[int]]:
+    """
+    datetime's strptime only supports up to six digits of the fraction of a seconds, while
+    the XEP-0082 specification allows for any number of digits. This function parses and
+    removes the optional fraction of a second from the input string.
+
+    @param value: The input string, containing a section of the format [.sss].
+    @return: The input string with the fraction of a second removed, and the fraction of a
+        second parsed with microsecond resolution. Returns the unaltered input string and
+        ``None`` if no fraction of a second was found in the input string.
+    """
+
+    #  The following regex matches the optional fraction of a seconds for manual
+    # processing.
+    match = re.search(r"\.(\d*)", value)
+    microsecond: Optional[int] = None
+    if match is not None:
+        # Remove the fraction of a second from the input string
+        value = value[:match.start()] + value[match.end():]
+
+        # datetime supports microsecond resolution for the fraction of a second, thus
+        # limit/pad the parsed fraction of a second to six digits
+        microsecond = int(match.group(1)[:6].ljust(6, '0'))
+
+    return value, microsecond
+
+
+def format_date(value: Optional[date] = None) -> str:
+    """
+    @param value: The date for format. Defaults to the current date in the UTC timezone.
+    @return: The date formatted according to the Date profile specified in XEP-0082.
+
+    @warning: Formatting of the current date in the local timezone may leak geographical
+        information of the sender. Thus, it is advised to only format the current date in
+        UTC.
+    """
+    # CCYY-MM-DD
+
+    # The Date profile of XEP-0082 is equal to the ISO 8601 format.
+    return (datetime.now(timezone.utc).date() if value is None else value).isoformat()
+
+
+def parse_date(value: str) -> date:
+    """
+    @param value: A string containing date information formatted according to the Date
+        profile specified in XEP-0082.
+    @return: The date parsed from the input string.
+    @raise exceptions.ParsingError: if the input string is not correctly formatted.
+    """
+    # CCYY-MM-DD
+
+    # The Date profile of XEP-0082 is equal to the ISO 8601 format.
+    try:
+        return date.fromisoformat(value)
+    except ValueError as e:
+        raise exceptions.ParsingError() from e
+
+
+def format_datetime(
+    value: Optional[datetime] = None,
+    include_microsecond: bool = False
+) -> str:
+    """
+    @param value: The datetime to format. Defaults to the current datetime.
+        must be an aware datetime object (timezone must be specified)
+    @param include_microsecond: Include the microsecond of the datetime in the output.
+    @return: The datetime formatted according to the DateTime profile specified in
+        XEP-0082. The datetime is always converted to UTC before formatting to avoid
+        leaking geographical information of the sender.
+    """
+    # CCYY-MM-DDThh:mm:ss[.sss]TZD
+
+    # We format the time in UTC, since the %z formatter of strftime doesn't include colons
+    # to separate hours and minutes which is required by XEP-0082. UTC allows us to put a
+    # simple letter 'Z' as the time zone definition.
+    if value is not None:
+        if value.tzinfo is None:
+            raise exceptions.InternalError(
+                "an aware datetime object must be used, but a naive one has been provided"
+            )
+        value = value.astimezone(timezone.utc)  # pylint: disable=no-member
+    else:
+        value = datetime.now(timezone.utc)
+
+    if include_microsecond:
+        return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
+
+    return value.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def parse_datetime(value: str) -> datetime:
+    """
+    @param value: A string containing datetime information formatted according to the
+        DateTime profile specified in XEP-0082.
+    @return: The datetime parsed from the input string.
+    @raise exceptions.ParsingError: if the input string is not correctly formatted.
+    """
+    # CCYY-MM-DDThh:mm:ss[.sss]TZD
+
+    value, microsecond = __parse_fraction_of_a_second(value)
+
+    try:
+        result = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z")
+    except ValueError as e:
+        raise exceptions.ParsingError() from e
+
+    if microsecond is not None:
+        result = result.replace(microsecond=microsecond)
+
+    return result
+
+
+def format_time(value: Optional[time] = None, include_microsecond: bool = False) -> str:
+    """
+    @param value: The time to format. Defaults to the current time in the UTC timezone.
+    @param include_microsecond: Include the microsecond of the time in the output.
+    @return: The time formatted according to the Time profile specified in XEP-0082.
+
+    @warning: Since accurate timezone conversion requires the date to be known, this
+        function cannot convert input times to UTC before formatting. This means that
+        geographical information of the sender may be leaked if a time in local timezone
+        is formatted. Thus, when passing a time to format, it is advised to pass the time
+        in UTC if possible.
+    """
+    # hh:mm:ss[.sss][TZD]
+
+    if value is None:
+        # There is no time.now() method as one might expect, but the current time can be
+        # extracted from a datetime object including time zone information.
+        value = datetime.now(timezone.utc).timetz()
+
+    # The format created by time.isoformat complies with the XEP-0082 Time profile.
+    return value.isoformat("auto" if include_microsecond else "seconds")
+
+
+def parse_time(value: str) -> time:
+    """
+    @param value: A string containing time information formatted according to the Time
+        profile specified in XEP-0082.
+    @return: The time parsed from the input string.
+    @raise exceptions.ParsingError: if the input string is not correctly formatted.
+    """
+    # hh:mm:ss[.sss][TZD]
+
+    value, microsecond = __parse_fraction_of_a_second(value)
+
+    # The format parsed by time.fromisoformat mostly complies with the XEP-0082 Time
+    # profile, except that it doesn't handle the letter Z as time zone information for
+    # UTC. This can be fixed with a simple string replacement of 'Z' with "+00:00", which
+    # is another way to represent UTC.
+    try:
+        result = time.fromisoformat(value.replace('Z', "+00:00"))
+    except ValueError as e:
+        raise exceptions.ParsingError() from e
+
+    if microsecond is not None:
+        result = result.replace(microsecond=microsecond)
+
+    return result
--- a/sat/VERSION	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-0.9.0D
--- a/sat/__init__.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,26 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-import os.path
-from sat_tmp import wokkel
-
-version_file = os.path.join(os.path.dirname(__file__), "VERSION")
-with open(version_file) as f:
-    __version__ = f.read().strip()
-
-if not wokkel.installed:
-    wokkel.install()
--- a/sat/bridge/bridge_constructor/base_constructor.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,364 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""base constructor class"""
-
-from sat.bridge.bridge_constructor.constants import Const as C
-from configparser import NoOptionError
-import sys
-import os
-import os.path
-import re
-from importlib import import_module
-
-
-class ParseError(Exception):
-    # Used when the signature parsing is going wrong (invalid signature ?)
-    pass
-
-
-class Constructor(object):
-    NAME = None  # used in arguments parsing, filename will be used if not set
-    # following attribute are used by default generation method
-    # they can be set to dict of strings using python formatting syntax
-    # dict keys will be used to select part to replace (e.g. "signals" key will
-    # replace ##SIGNALS_PART## in template), while the value is the format
-    # keys starting with "signal" will be used for signals, while ones starting with
-    # "method" will be used for methods
-    #  check D-Bus constructor for an example
-    CORE_FORMATS = None
-    CORE_TEMPLATE = None
-    CORE_DEST = None
-    FRONTEND_FORMATS = None
-    FRONTEND_TEMPLATE = None
-    FRONTEND_DEST = None
-
-    # set to False if your bridge needs only core
-    FRONTEND_ACTIVATE = True
-
-    def __init__(self, bridge_template, options):
-        self.bridge_template = bridge_template
-        self.args = options
-
-    @property
-    def constructor_dir(self):
-        constructor_mod = import_module(self.__module__)
-        return os.path.dirname(constructor_mod.__file__)
-
-    def getValues(self, name):
-        """Return values of a function in a dict
-        @param name: Name of the function to get
-        @return: dict, each key has the config value or None if the value is not set"""
-        function = {}
-        for option in ["type", "category", "sig_in", "sig_out", "doc"]:
-            try:
-                value = self.bridge_template.get(name, option)
-            except NoOptionError:
-                value = None
-            function[option] = value
-        return function
-
-    def get_default(self, name):
-        """Return default values of a function in a dict
-        @param name: Name of the function to get
-        @return: dict, each key is the integer param number (no key if no default value)"""
-        default_dict = {}
-        def_re = re.compile(r"param_(\d+)_default")
-
-        for option in self.bridge_template.options(name):
-            match = def_re.match(option)
-            if match:
-                try:
-                    idx = int(match.group(1))
-                except ValueError:
-                    raise ParseError(
-                        "Invalid value [%s] for parameter number" % match.group(1)
-                    )
-                default_dict[idx] = self.bridge_template.get(name, option)
-
-        return default_dict
-
-    def getFlags(self, name):
-        """Return list of flags set for this function
-
-        @param name: Name of the function to get
-        @return: List of flags (string)
-        """
-        flags = []
-        for option in self.bridge_template.options(name):
-            if option in C.DECLARATION_FLAGS:
-                flags.append(option)
-        return flags
-
-    def get_arguments_doc(self, name):
-        """Return documentation of arguments
-        @param name: Name of the function to get
-        @return: dict, each key is the integer param number (no key if no argument doc), value is a tuple (name, doc)"""
-        doc_dict = {}
-        option_re = re.compile(r"doc_param_(\d+)")
-        value_re = re.compile(r"^(\w+): (.*)$", re.MULTILINE | re.DOTALL)
-        for option in self.bridge_template.options(name):
-            if option == "doc_return":
-                doc_dict["return"] = self.bridge_template.get(name, option)
-                continue
-            match = option_re.match(option)
-            if match:
-                try:
-                    idx = int(match.group(1))
-                except ValueError:
-                    raise ParseError(
-                        "Invalid value [%s] for parameter number" % match.group(1)
-                    )
-                value_match = value_re.match(self.bridge_template.get(name, option))
-                if not value_match:
-                    raise ParseError("Invalid value for parameter doc [%i]" % idx)
-                doc_dict[idx] = (value_match.group(1), value_match.group(2))
-        return doc_dict
-
-    def get_doc(self, name):
-        """Return documentation of the method
-        @param name: Name of the function to get
-        @return: string documentation, or None"""
-        if self.bridge_template.has_option(name, "doc"):
-            return self.bridge_template.get(name, "doc")
-        return None
-
-    def arguments_parser(self, signature):
-        """Generator which return individual arguments signatures from a global signature"""
-        start = 0
-        i = 0
-
-        while i < len(signature):
-            if signature[i] not in ["b", "y", "n", "i", "x", "q", "u", "t", "d", "s",
-                                    "a"]:
-                raise ParseError("Unmanaged attribute type [%c]" % signature[i])
-
-            if signature[i] == "a":
-                i += 1
-                if (
-                    signature[i] != "{" and signature[i] != "("
-                ):  # FIXME: must manage tuples out of arrays
-                    i += 1
-                    yield signature[start:i]
-                    start = i
-                    continue  # we have a simple type for the array
-                opening_car = signature[i]
-                assert opening_car in ["{", "("]
-                closing_car = "}" if opening_car == "{" else ")"
-                opening_count = 1
-                while True:  # we have a dict or a list of tuples
-                    i += 1
-                    if i >= len(signature):
-                        raise ParseError("missing }")
-                    if signature[i] == opening_car:
-                        opening_count += 1
-                    if signature[i] == closing_car:
-                        opening_count -= 1
-                        if opening_count == 0:
-                            break
-            i += 1
-            yield signature[start:i]
-            start = i
-
-    def get_arguments(self, signature, name=None, default=None, unicode_protect=False):
-        """Return arguments to user given a signature
-
-        @param signature: signature in the short form (using s,a,i,b etc)
-        @param name: dictionary of arguments name like given by get_arguments_doc
-        @param default: dictionary of default values, like given by get_default
-        @param unicode_protect: activate unicode protection on strings (return strings as unicode(str))
-        @return (str): arguments that correspond to a signature (e.g.: "sss" return "arg1, arg2, arg3")
-        """
-        idx = 0
-        attr_string = []
-
-        for arg in self.arguments_parser(signature):
-            attr_string.append(
-                (
-                    "str(%(name)s)%(default)s"
-                    if (unicode_protect and arg == "s")
-                    else "%(name)s%(default)s"
-                )
-                % {
-                    "name": name[idx][0] if (name and idx in name) else "arg_%i" % idx,
-                    "default": "=" + default[idx] if (default and idx in default) else "",
-                }
-            )
-            # give arg_1, arg2, etc or name1, name2=default, etc.
-            # give unicode(arg_1), unicode(arg_2), etc. if unicode_protect is set and arg is a string
-            idx += 1
-
-        return ", ".join(attr_string)
-
-    def get_template_path(self, template_file):
-        """return template path corresponding to file name
-
-        @param template_file(str): name of template file
-        """
-        return os.path.join(self.constructor_dir, template_file)
-
-    def core_completion_method(self, completion, function, default, arg_doc, async_):
-        """override this method to extend completion"""
-        pass
-
-    def core_completion_signal(self, completion, function, default, arg_doc, async_):
-        """override this method to extend completion"""
-        pass
-
-    def frontend_completion_method(self, completion, function, default, arg_doc, async_):
-        """override this method to extend completion"""
-        pass
-
-    def frontend_completion_signal(self, completion, function, default, arg_doc, async_):
-        """override this method to extend completion"""
-        pass
-
-    def generate(self, side):
-        """generate bridge
-
-        call generate_core_side or generateFrontendSide if they exists
-        else call generic self._generate method
-        """
-        try:
-            if side == "core":
-                method = self.generate_core_side
-            elif side == "frontend":
-                if not self.FRONTEND_ACTIVATE:
-                    print("This constructor only handle core, please use core side")
-                    sys.exit(1)
-                method = self.generateFrontendSide
-        except AttributeError:
-            self._generate(side)
-        else:
-            method()
-
-    def _generate(self, side):
-        """generate the backend
-
-        this is a generic method which will use formats found in self.CORE_SIGNAL_FORMAT
-        and self.CORE_METHOD_FORMAT (standard format method will be used)
-        @param side(str): core or frontend
-        """
-        side_vars = []
-        for var in ("FORMATS", "TEMPLATE", "DEST"):
-            attr = "{}_{}".format(side.upper(), var)
-            value = getattr(self, attr)
-            if value is None:
-                raise NotImplementedError
-            side_vars.append(value)
-
-        FORMATS, TEMPLATE, DEST = side_vars
-        del side_vars
-
-        parts = {part.upper(): [] for part in FORMATS}
-        sections = self.bridge_template.sections()
-        sections.sort()
-        for section in sections:
-            function = self.getValues(section)
-            print(("Adding %s %s" % (section, function["type"])))
-            default = self.get_default(section)
-            arg_doc = self.get_arguments_doc(section)
-            async_ = "async" in self.getFlags(section)
-            completion = {
-                "sig_in": function["sig_in"] or "",
-                "sig_out": function["sig_out"] or "",
-                "category": "plugin" if function["category"] == "plugin" else "core",
-                "name": section,
-                # arguments with default values
-                "args": self.get_arguments(
-                    function["sig_in"], name=arg_doc, default=default
-                ),
-                "args_no_default": self.get_arguments(function["sig_in"], name=arg_doc),
-            }
-
-            extend_method = getattr(
-                self, "{}_completion_{}".format(side, function["type"])
-            )
-            extend_method(completion, function, default, arg_doc, async_)
-
-            for part, fmt in FORMATS.items():
-                if (part.startswith(function["type"])
-                    or part.startswith(f"async_{function['type']}")):
-                    parts[part.upper()].append(fmt.format(**completion))
-
-        # at this point, signals_part, methods_part and direct_calls should be filled,
-        # we just have to place them in the right part of the template
-        bridge = []
-        const_override = {
-            env[len(C.ENV_OVERRIDE) :]: v
-            for env, v in os.environ.items()
-            if env.startswith(C.ENV_OVERRIDE)
-        }
-        template_path = self.get_template_path(TEMPLATE)
-        try:
-            with open(template_path) as template:
-                for line in template:
-
-                    for part, extend_list in parts.items():
-                        if line.startswith("##{}_PART##".format(part)):
-                            bridge.extend(extend_list)
-                            break
-                    else:
-                        # the line is not a magic part replacement
-                        if line.startswith("const_"):
-                            const_name = line[len("const_") : line.find(" = ")].strip()
-                            if const_name in const_override:
-                                print(("const {} overriden".format(const_name)))
-                                bridge.append(
-                                    "const_{} = {}".format(
-                                        const_name, const_override[const_name]
-                                    )
-                                )
-                                continue
-                        bridge.append(line.replace("\n", ""))
-        except IOError:
-            print(("can't open template file [{}]".format(template_path)))
-            sys.exit(1)
-
-        # now we write to final file
-        self.final_write(DEST, bridge)
-
-    def final_write(self, filename, file_buf):
-        """Write the final generated file in [dest dir]/filename
-
-        @param filename: name of the file to generate
-        @param file_buf: list of lines (stings) of the file
-        """
-        if os.path.exists(self.args.dest_dir) and not os.path.isdir(self.args.dest_dir):
-            print(
-                "The destination dir [%s] can't be created: a file with this name already exists !"
-            )
-            sys.exit(1)
-        try:
-            if not os.path.exists(self.args.dest_dir):
-                os.mkdir(self.args.dest_dir)
-            full_path = os.path.join(self.args.dest_dir, filename)
-            if os.path.exists(full_path) and not self.args.force:
-                print((
-                    "The destination file [%s] already exists ! Use --force to overwrite it"
-                    % full_path
-                ))
-            try:
-                with open(full_path, "w") as dest_file:
-                    dest_file.write("\n".join(file_buf))
-            except IOError:
-                print(("Can't open destination file [%s]" % full_path))
-        except OSError:
-            print("It's not possible to generate the file, check your permissions")
-            exit(1)
--- a/sat/bridge/bridge_constructor/bridge_constructor.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,137 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-from sat.bridge import bridge_constructor
-from sat.bridge.bridge_constructor.constants import Const as C
-from sat.bridge.bridge_constructor import constructors, base_constructor
-import argparse
-from configparser import ConfigParser as Parser
-from importlib import import_module
-import os
-import os.path
-
-# consts
-__version__ = C.APP_VERSION
-
-
-class BridgeConstructor(object):
-    def import_constructors(self):
-        constructors_dir = os.path.dirname(constructors.__file__)
-        self.protocoles = {}
-        for dir_ in os.listdir(constructors_dir):
-            init_path = os.path.join(constructors_dir, dir_, "__init__.py")
-            constructor_path = os.path.join(constructors_dir, dir_, "constructor.py")
-            module_path = "sat.bridge.bridge_constructor.constructors.{}.constructor".format(
-                dir_
-            )
-            if os.path.isfile(init_path) and os.path.isfile(constructor_path):
-                mod = import_module(module_path)
-                for attr in dir(mod):
-                    obj = getattr(mod, attr)
-                    if not isinstance(obj, type):
-                        continue
-                    if issubclass(obj, base_constructor.Constructor):
-                        name = obj.NAME or dir_
-                        self.protocoles[name] = obj
-                        break
-        if not self.protocoles:
-            raise ValueError("no protocole constructor found")
-
-    def parse_args(self):
-        """Check command line options"""
-        parser = argparse.ArgumentParser(
-            description=C.DESCRIPTION,
-            formatter_class=argparse.RawDescriptionHelpFormatter,
-        )
-
-        parser.add_argument("--version", action="version", version=__version__)
-        default_protocole = (
-            C.DEFAULT_PROTOCOLE
-            if C.DEFAULT_PROTOCOLE in self.protocoles
-            else self.protocoles[0]
-        )
-        parser.add_argument(
-            "-p",
-            "--protocole",
-            choices=sorted(self.protocoles),
-            default=default_protocole,
-            help="generate bridge using PROTOCOLE (default: %(default)s)",
-        )  # (default: %s, possible values: [%s])" % (DEFAULT_PROTOCOLE, ", ".join(MANAGED_PROTOCOLES)))
-        parser.add_argument(
-            "-s",
-            "--side",
-            choices=("core", "frontend"),
-            default="core",
-            help="which side of the bridge do you want to make ?",
-        )  # (default: %default, possible values: [core, frontend])")
-        default_template = os.path.join(
-            os.path.dirname(bridge_constructor.__file__), "bridge_template.ini"
-        )
-        parser.add_argument(
-            "-t",
-            "--template",
-            type=argparse.FileType(),
-            default=default_template,
-            help="use TEMPLATE to generate bridge (default: %(default)s)",
-        )
-        parser.add_argument(
-            "-f",
-            "--force",
-            action="store_true",
-            help=("force overwritting of existing files"),
-        )
-        parser.add_argument(
-            "-d", "--debug", action="store_true", help=("add debug information printing")
-        )
-        parser.add_argument(
-            "--no-unicode",
-            action="store_false",
-            dest="unicode",
-            help=("remove unicode type protection from string results"),
-        )
-        parser.add_argument(
-            "--flags", nargs="+", default=[], help=("constructors' specific flags")
-        )
-        parser.add_argument(
-            "--dest-dir",
-            default=C.DEST_DIR_DEFAULT,
-            help=(
-                "directory when the generated files will be written (default: %(default)s)"
-            ),
-        )
-
-        return parser.parse_args()
-
-    def go(self):
-        self.import_constructors()
-        args = self.parse_args()
-        template_parser = Parser()
-        try:
-            template_parser.read_file(args.template)
-        except IOError:
-            print("The template file doesn't exist or is not accessible")
-            exit(1)
-        constructor = self.protocoles[args.protocole](template_parser, args)
-        constructor.generate(args.side)
-
-
-if __name__ == "__main__":
-    bc = BridgeConstructor()
-    bc.go()
--- a/sat/bridge/bridge_constructor/bridge_template.ini	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1026 +0,0 @@
-[DEFAULT]
-doc_profile=profile: Name of the profile.
-doc_profile_key=profile_key: Profile key which can be either a magic (eg: @DEFAULT@) or the name of an existing profile.
-doc_security_limit=security_limit: -1 means no security, 0 is the maximum security then the higher the less secure
-
-;signals
-
-[connected]
-type=signal
-category=core
-sig_in=ss
-doc=Connection is done
-doc_param_0=jid_s: the JID that we were assigned by the server, as the resource might differ from the JID we asked for.
-doc_param_1=%(doc_profile)s
-
-[disconnected]
-type=signal
-category=core
-sig_in=s
-doc=Connection is finished or lost
-doc_param_0=%(doc_profile)s
-
-[contact_new]
-type=signal
-category=core
-sig_in=sa{ss}ass
-doc=New contact received in roster
-doc_param_0=contact_jid: JID which has just been added
-doc_param_1=attributes: Dictionary of attributes where keys are:
- - name: name of the contact
- - to: "True" if the contact give its presence information to us
- - from: "True" if contact is registred to our presence information
- - ask: "True" is subscription is pending
-doc_param_2=groups: Roster's groups where the contact is
-doc_param_3=%(doc_profile)s
-
-[message_new]
-type=signal
-category=core
-sig_in=sdssa{ss}a{ss}sss
-doc=A message has been received
-doc_param_0=uid: unique ID of the message (id specific to SàT, this it *NOT* an XMPP id)
-doc_param_1=timestamp: when the message was sent (or declared sent for delayed messages)
-doc_param_2=from_jid: JID where the message is comming from
-doc_param_3=to_jid: JID where the message must be sent
-doc_param_4=message: message itself, can be in several languages (key is language code or '' for default)
-doc_param_5=subject: subject of the message, can be in several languages (key is language code or '' for default)
-doc_param_6=mess_type: Type of the message (cf RFC 6121 §5.2.2) + C.MESS_TYPE_INFO (system info)
-doc_param_7=extra: extra message information, can have data added by plugins and/or:
-  - thread: id of the thread
-  - thread_parent: id of the parent of the current thread
-  - received_timestamp: date of receiption for delayed messages
-  - delay_sender: entity which has originally sent or which has delayed the message
-  - info_type: subtype for info messages
-doc_param_8=%(doc_profile)s
-
-[message_encryption_started]
-type=signal
-category=core
-sig_in=sss
-doc=A message encryption session has been started
-doc_param_0=to_jid: JID of the recipient (bare jid if it's encrypted for all devices)
-doc_param_1=encryption_data: (JSON_OBJ) data of the encryption algorithm used, encoded as a json object.
-    it always has the following keys:
-        - name: human readable name of the algorithm
-        - namespace: namespace of the encryption plugin
-    following keys are present if suitable:
-        - directed_devices: list or resource where session is encrypted
-doc_param_2=%(doc_profile_key)s
-
-[message_encryption_stopped]
-type=signal
-category=core
-sig_in=sa{ss}s
-doc=A message encryption session has been stopped
-doc_param_0=to_jid: JID of the recipient (full jid if it's only stopped for one device)
-doc_param_1=encryption_data: data of the encryption algorithm stopped, has a least following keys:
-    - name: human readable name of the algorithm
-    - namespace: namespace of the encryption plugin
-doc_param_2=%(doc_profile_key)s
-
-[presence_update]
-type=signal
-category=core
-sig_in=ssia{ss}s
-doc=Somebody changed his presence information.
-doc_param_0=entity_jid: JID from which we have presence informatios
-doc_param_1=show: availability status (see RFC 6121 §4.7.2.1)
-doc_param_2=priority: Priority level of the ressource (see RFC 6121 §4.7.2.3)
-doc_param_3=statuses: Natural language description of the availability status (see RFC 6121 §4.7.2.2)
-doc_param_4=%(doc_profile)s
-
-[subscribe]
-type=signal
-category=core
-sig_in=sss
-doc=Somebody wants to be added in roster
-doc_param_0=sub_type: Subscription states (see RFC 6121 §3)
-doc_param_1=entity_jid: JID from which the subscription is coming
-doc_param_2=%(doc_profile)s
-
-[param_update]
-type=signal
-category=core
-sig_in=ssss
-doc=A parameter has been changed
-doc_param_0=name: Name of the updated parameter
-doc_param_1=value: New value of the parameter
-doc_param_2=category: Category of the updated parameter
-doc_param_3=%(doc_profile)s
-
-[contact_deleted]
-type=signal
-category=core
-sig_in=ss
-doc=A contact has been supressed from roster
-doc_param_0=entity_jid: JID of the contact removed from roster
-doc_param_1=%(doc_profile)s
-
-[action_new]
-type=signal
-category=core
-sig_in=ssis
-doc=A frontend action is requested
-doc_param_0=action_data: a serialised dict where key can be:
-    - xmlui: a XMLUI describing the action
-    - progress: a progress id
-    - meta_*: meta information on the action, used to make automation more easy,
-        some are defined below
-    - meta_from_jid: origin of the request
-    - meta_type: type of the request, can be one of:
-        - C.META_TYPE_FILE: a file transfer request validation
-        - C.META_TYPE_OVERWRITE: a file overwriting confirmation
-    - meta_progress_id: progress id linked to this action
-doc_param_1=id: action id
-    This id can be used later by frontends to announce to other ones that the action is managed and can now be ignored.
-doc_param_2=%(doc_security_limit)s
-doc_param_3=%(doc_profile)s
-
-[entity_data_updated]
-type=signal
-category=core
-sig_in=ssss
-doc=An entity's data has been updated
-doc_param_0=jid: entity's bare jid
-doc_param_1=name: Name of the updated value
-doc_param_2=value: New value
-doc_param_3=%(doc_profile)s
-
-[progress_started]
-type=signal
-category=core
-sig_in=sa{ss}s
-doc=A progressing operation has just started
-doc_param_0=id: id of the progression operation
-doc_param_1=metadata: dict of progress metadata, key can be:
-    - name: name of the progression, full path for a file
-    - direction: "in" for incoming data, "out" else
-    - type: type of the progression:
-        C.META_TYPE_FILE: file transfer
-doc_param_2=%(doc_profile)s
-
-[progress_finished]
-type=signal
-category=core
-sig_in=sa{ss}s
-doc=A progressing operation is finished
-doc_param_0=id: id of the progression operation
-doc_param_1=metadata: dict of progress status metadata, key can be:
-    - hash: value of the computed hash
-    - hash_algo: alrorithm used to compute hash
-    - hash_verified: C.BOOL_TRUE if hash is verified and OK
-        C.BOOL_FALSE if hash was not received ([progress_error] will be used if there is a mismatch)
-    - url: url linked to the progression (e.g. download url after a file upload)
-doc_param_2=%(doc_profile)s
-
-[progress_error]
-type=signal
-category=core
-sig_in=sss
-doc=There was an error during progressing operation
-doc_param_0=id: id of the progression operation
-doc_param_1=error: error message
-doc_param_2=%(doc_profile)s
-
-[_debug]
-type=signal
-category=core
-sig_in=sa{ss}s
-doc=Debug method, useful for developers
-doc_param_0=action: action to do
-doc_param_1=params: action parameters
-doc_param_2=%(doc_profile)s
-
-;methods
-
-[ready_get]
-async=
-type=method
-category=core
-sig_in=
-sig_out=
-doc=Return when backend is initialised
-
-[version_get]
-type=method
-category=core
-sig_in=
-sig_out=s
-doc=Get "Salut à Toi" full version
-
-[features_get]
-type=method
-category=core
-sig_in=s
-sig_out=a{sa{ss}}
-doc=Get available features and plugins
- features can changes for differents profiles, e.g. because of differents server capabilities
-doc_param_0=%(doc_profile_key)s
-doc_return=dictionary of available features:
- plugin import name is used as key, data is an other dict managed by the plugin
-async=
-
-[profile_name_get]
-type=method
-category=core
-sig_in=s
-sig_out=s
-param_0_default="@DEFAULT@"
-doc=Get real profile name from profile key
-doc_param_0=%(doc_profile_key)s
-doc_return=Real profile name
-
-[profiles_list_get]
-type=method
-category=core
-sig_in=bb
-sig_out=as
-param_0_default=True
-param_1_default=False
-doc_param_0=clients: get clients profiles
-doc_param_1=components: get components profiles
-doc=Get list of profiles
-
-[profile_set_default]
-type=method
-category=core
-sig_in=s
-sig_out=
-doc_param_0=%(doc_profile)s
-doc=Set default profile
-
-[entity_data_get]
-type=method
-category=core
-sig_in=sass
-sig_out=a{ss}
-doc=Get data in cache for an entity
-doc_param_0=jid: entity's bare jid
-doc_param_1=keys: list of keys to get
-doc_param_2=%(doc_profile)s
-doc_return=dictionary of asked key,
- if key doesn't exist, the resulting dictionary will not have the key
-
-[entities_data_get]
-type=method
-category=core
-sig_in=asass
-sig_out=a{sa{ss}}
-doc=Get data in cache for several entities at once
-doc_param_0=jids: list of entities bare jid, or empty list to have all jids in cache
-doc_param_1=keys: list of keys to get
-doc_param_2=%(doc_profile)s
-doc_return=dictionary with jids as keys and dictionary of asked key as values
- values are serialised
- if key doesn't exist for a jid, the resulting dictionary will not have it
-
-[profile_create]
-async=
-type=method
-category=core
-sig_in=sss
-sig_out=
-param_1_default=''
-param_2_default=''
-doc=Create a new profile
-doc_param_0=%(doc_profile)s
-doc_param_1=password: password of the profile
-doc_param_2=component: set to component entry point if it is a component, else use empty string
-doc_return=callback is called when profile actually exists in database and memory
-errback is called with error constant as parameter:
- - ConflictError: the profile name already exists
- - CancelError: profile creation canceled
- - NotFound: component entry point is not available
-
-[profile_delete_async]
-async=
-type=method
-category=core
-sig_in=s
-sig_out=
-doc=Delete a profile
-doc_param_0=%(doc_profile)s
-doc_return=callback is called when profile has been deleted from database and memory
-errback is called with error constant as parameter:
- - ProfileUnknownError: the profile name is unknown
- - ConnectedProfileError: a connected profile would not be deleted
-
-[connect]
-async=
-type=method
-category=core
-sig_in=ssa{ss}
-sig_out=b
-param_0_default="@DEFAULT@"
-param_1_default=''
-param_2_default={}
-doc=Connect a profile
-doc_param_0=%(doc_profile_key)s
-doc_param_1=password: the SàT profile password
-doc_param_2=options: connection options
-doc_return=a deferred boolean or failure:
-    - boolean if the profile authentication succeed:
-        - True if the XMPP connection was already established
-        - False if the XMPP connection has been initiated (it may still fail)
-    - failure if the profile authentication failed
-
-[profile_start_session]
-async=
-type=method
-category=core
-sig_in=ss
-sig_out=b
-param_0_default=''
-param_1_default="@DEFAULT@"
-doc=Start a profile session without connecting it (if it's not already the case)
-doc_param_0=password: the SàT profile password
-doc_param_1=%(doc_profile_key)s
-doc_return=D(bool):
-        - True if the profile session was already started
-        - False else
-
-[profile_is_session_started]
-type=method
-category=core
-sig_in=s
-sig_out=b
-param_0_default="@DEFAULT@"
-doc=Tell if a profile session is loaded
-doc_param_0=%(doc_profile_key)s
-
-[disconnect]
-async=
-type=method
-category=core
-sig_in=s
-sig_out=
-param_0_default="@DEFAULT@"
-doc=Disconnect a profile
-doc_param_0=%(doc_profile_key)s
-
-[is_connected]
-type=method
-category=core
-sig_in=s
-sig_out=b
-param_0_default="@DEFAULT@"
-doc=Tell if a profile is connected
-doc_param_0=%(doc_profile_key)s
-
-[contact_get]
-async=
-type=method
-category=core
-sig_in=ss
-sig_out=(a{ss}as)
-param_1_default="@DEFAULT@"
-doc=Return informations in roster about a contact
-doc_param_1=%(doc_profile_key)s
-doc_return=tuple with the following values:
- - list of attributes as in [contact_new]
- - groups where the contact is
-
-[contacts_get]
-async=
-type=method
-category=core
-sig_in=s
-sig_out=a(sa{ss}as)
-param_0_default="@DEFAULT@"
-doc=Return information about all contacts (the roster)
-doc_param_0=%(doc_profile_key)s
-doc_return=array of tuples with the following values:
- - JID of the contact
- - list of attributes as in [contact_new]
- - groups where the contact is
-
-[contacts_get_from_group]
-type=method
-category=core
-sig_in=ss
-sig_out=as
-param_1_default="@DEFAULT@"
-doc=Return information about all contacts
-doc_param_0=group: name of the group to check
-doc_param_1=%(doc_profile_key)s
-doc_return=array of jids
-
-[main_resource_get]
-type=method
-category=core
-sig_in=ss
-sig_out=s
-param_1_default="@DEFAULT@"
-doc=Return the last resource connected for a contact
-doc_param_0=contact_jid: jid of the contact
-doc_param_1=%(doc_profile_key)s
-doc_return=the resource connected of the contact with highest priority, or ""
-
-[presence_statuses_get]
-type=method
-category=core
-sig_in=s
-sig_out=a{sa{s(sia{ss})}}
-param_0_default="@DEFAULT@"
-doc=Return presence information of all contacts
-doc_param_0=%(doc_profile_key)s
-doc_return=Dict of presence with bare JID of contact as key, and value as follow:
- A dict where key is the resource and the value is a tuple with (show, priority, statuses) as for [presence_update]
-
-[sub_waiting_get]
-type=method
-category=core
-sig_in=s
-sig_out=a{ss}
-param_0_default="@DEFAULT@"
-doc=Get subscription requests in queue
-doc_param_0=%(doc_profile_key)s
-doc_return=Dict where contact JID is the key, and value is the subscription type
-
-[message_send]
-async=
-type=method
-category=core
-sig_in=sa{ss}a{ss}sss
-sig_out=
-param_2_default={}
-param_3_default="auto"
-param_4_default={}
-param_5_default="@NONE@"
-doc=Send a message
-doc_param_0=to_jid: JID of the recipient
-doc_param_1=message: body of the message:
-    key is the language of the body, use '' when unknown
-doc_param_2=subject: Subject of the message
-    key is the language of the subject, use '' when unknown
-doc_param_3=mess_type: Type of the message (cf RFC 6121 §5.2.2) or "auto" for automatic type detection
-doc_param_4=extra: (serialised) optional data that can be used by a plugin to build more specific messages 
-doc_param_5=%(doc_profile_key)s
-
-[message_encryption_start]
-async=
-type=method
-category=core
-sig_in=ssbs
-sig_out=
-param_1_default=''
-param_2_default=False
-param_3_default="@NONE@"
-doc=Start an encryption session
-doc_param_0=to_jid: JID of the recipient (bare jid if it must be encrypted for all devices)
-doc_param_1=namespace: namespace of the encryption algorithm to use
-doc_param_2=replace: if True and an encryption session already exists, it will be replaced by this one
-    else a ConflictError will be raised
-doc_param_3=%(doc_profile_key)s
-
-[message_encryption_stop]
-async=
-type=method
-category=core
-sig_in=ss
-sig_out=
-doc=Stop an encryption session
-doc_param_0=to_jid: JID of the recipient (full jid if encryption must be stopped for one device only)
-doc_param_1=%(doc_profile_key)s
-
-[message_encryption_get]
-type=method
-category=core
-sig_in=ss
-sig_out=s
-doc=Retrieve encryption data for a given entity
-doc_param_0=to_jid: bare JID of the recipient
-doc_param_1=%(doc_profile_key)s
-doc_return=(JSON_OBJ) empty string if session is unencrypted, else a json encoded objects.
-    In case of dict, following keys are always present:
-        - name: human readable name of the encryption algorithm
-        - namespace: namespace of the plugin
-    following key can be present if suitable:
-        - directed_devices: list or resource where session is encrypted
-
-[encryption_namespace_get]
-type=method
-category=core
-sig_in=s
-sig_out=s
-doc=Get algorithm namespace from its name
-
-[encryption_plugins_get]
-type=method
-category=core
-sig_in=
-sig_out=s
-doc=Retrieve registered plugins for encryption
-
-[encryption_trust_ui_get]
-async=
-type=method
-category=core
-sig_in=sss
-sig_out=s
-doc=Get XMLUI to manage trust for given encryption algorithm
-doc_param_0=to_jid: bare JID of entity to manage
-doc_param_1=namespace: namespace of the algorithm to manage
-doc_param_2=%(doc_profile_key)s
-doc_return=(XMLUI) UI of the trust management
-
-[presence_set]
-type=method
-category=core
-sig_in=ssa{ss}s
-sig_out=
-param_0_default=''
-param_1_default=''
-param_2_default={}
-param_3_default="@DEFAULT@"
-doc=Set presence information for the profile
-doc_param_0=to_jid: the JID to who we send the presence data (emtpy string for broadcast)
-doc_param_1=show: as for [presence_update]
-doc_param_2=statuses: as for [presence_update]
-doc_param_3=%(doc_profile_key)s
-
-[subscription]
-type=method
-category=core
-sig_in=sss
-sig_out=
-param_2_default="@DEFAULT@"
-doc=Send subscription request/answer to a contact
-doc_param_0=sub_type: as for [subscribe]
-doc_param_1=entity: as for [subscribe]
-doc_param_2=%(doc_profile_key)s
-
-[config_get]
-type=method
-category=core
-sig_in=ss
-sig_out=s
-doc=get main configuration option
-doc_param_0=section: section of the configuration file (empty string for DEFAULT)
-doc_param_1=name: name of the option
-
-[param_set]
-type=method
-category=core
-sig_in=sssis
-sig_out=
-param_3_default=-1
-param_4_default="@DEFAULT@"
-doc=Change a parameter
-doc_param_0=name: Name of the parameter to change
-doc_param_1=value: New Value of the parameter
-doc_param_2=category: Category of the parameter to change
-doc_param_3=%(doc_security_limit)s
-doc_param_4=%(doc_profile_key)s
-
-[param_get_a]
-type=method
-category=core
-sig_in=ssss
-sig_out=s
-param_2_default="value"
-param_3_default="@DEFAULT@"
-doc=Helper method to get a parameter's attribute *when profile is connected*
-doc_param_0=name: as for [param_set]
-doc_param_1=category: as for [param_set]
-doc_param_2=attribute: Name of the attribute
-doc_param_3=%(doc_profile_key)s
-
-[private_data_get]
-async=
-type=method
-category=core
-sig_in=sss
-sig_out=s
-doc=Retrieve private data
-doc_param_0=namespace: unique namespace to use
-doc_param_1=key: key of the data to set
-doc_param_2=%(doc_profile_key)s
-doc_return=serialised data
-
-[private_data_set]
-async=
-type=method
-category=core
-sig_in=ssss
-sig_out=
-doc=Store private data
-doc_param_0=namespace: unique namespace to use
-doc_param_1=key: key of the data to set
-doc_param_2=data: serialised data
-doc_param_3=%(doc_profile_key)s
-
-[private_data_delete]
-async=
-type=method
-category=core
-sig_in=sss
-sig_out=
-doc=Delete private data
-doc_param_0=namespace: unique namespace to use
-doc_param_1=key: key of the data to delete
-doc_param_3=%(doc_profile_key)s
-
-[param_get_a_async]
-async=
-type=method
-category=core
-sig_in=sssis
-sig_out=s
-param_2_default="value"
-param_3_default=-1
-param_4_default="@DEFAULT@"
-doc=Helper method to get a parameter's attribute
-doc_param_0=name: as for [param_set]
-doc_param_1=category: as for [param_set]
-doc_param_2=attribute: Name of the attribute
-doc_param_3=%(doc_security_limit)s
-doc_param_4=%(doc_profile_key)s
-
-[params_values_from_category_get_async]
-async=
-type=method
-category=code
-sig_in=sisss
-sig_out=a{ss}
-param_1_default=-1
-param_2_default=""
-param_3_default=""
-param_4_default="@DEFAULT@"
-doc=Get "attribute" for all params of a category
-doc_param_0=category: as for [param_set]
-doc_param_1=%(doc_security_limit)s
-doc_param_2=app: name of the frontend requesting the parameters, or '' to get all parameters
-doc_param_3=extra: extra options/filters
-doc_param_4=%(doc_profile_key)s
-
-[param_ui_get]
-async=
-type=method
-category=core
-sig_in=isss
-sig_out=s
-param_0_default=-1
-param_1_default=''
-param_2_default=''
-param_3_default="@DEFAULT@"
-doc=Return a SàT XMLUI for parameters, eventually restrict the result to the parameters concerning a given frontend
-doc_param_0=%(doc_security_limit)s
-doc_param_1=app: name of the frontend requesting the parameters, or '' to get all parameters
-doc_param_2=extra: extra options/filters
-doc_param_3=%(doc_profile_key)s
-
-[params_categories_get]
-type=method
-category=core
-sig_in=
-sig_out=as
-doc=Get all categories currently existing in parameters
-doc_return=list of categories
-
-[params_register_app]
-type=method
-category=core
-sig_in=sis
-sig_out=
-param_1_default=-1
-param_2_default=''
-doc=Register frontend's specific parameters
-doc_param_0=xml: XML definition of the parameters to be added
-doc_param_1=%(doc_security_limit)s
-doc_param_2=app: name of the frontend registering the parameters
-
-[history_get]
-async=
-type=method
-category=core
-sig_in=ssiba{ss}s
-sig_out=a(sdssa{ss}a{ss}ss)
-param_3_default=True
-param_4_default=''
-param_5_default="@NONE@"
-doc=Get history of a communication between two entities
-doc_param_0=from_jid: source JID (bare jid for catch all, full jid else)
-doc_param_1=to_jid: dest JID (bare jid for catch all, full jid else)
-doc_param_2=limit: max number of history elements to get (0 for the whole history)
-doc_param_3=between: True if we want history between the two jids (in both direction), False if we only want messages from from_jid to to_jid
-doc_param_4=filters: patterns to filter the history results, can be:
-    - body: pattern must be in message body
-    - search: pattern must be in message body or source resource
-    - types: type must be one of those, values are separated by spaces
-    - not_types: type must not be one of those, values are separated by spaces
-    - before_uid: check only message received before message with given uid
-doc_param_5=%(doc_profile)s
-doc_return=Ordered list (by timestamp) of data as in [message_new] (without final profile)
-
-[contact_add]
-type=method
-category=core
-sig_in=ss
-sig_out=
-param_1_default="@DEFAULT@"
-doc=Add a contact to profile's roster
-doc_param_0=entity_jid: JID to add to roster
-doc_param_1=%(doc_profile_key)s
-
-[contact_update]
-type=method
-category=core
-sig_in=ssass
-sig_out=
-param_3_default="@DEFAULT@"
-doc=update a contact in profile's roster
-doc_param_0=entity_jid: JID update in roster
-doc_param_1=name: roster's name for the entity
-doc_param_2=groups: list of group where the entity is
-doc_param_3=%(doc_profile_key)s
-
-[contact_del]
-async=
-type=method
-category=core
-sig_in=ss
-sig_out=
-param_1_default="@DEFAULT@"
-doc=Remove a contact from profile's roster
-doc_param_0=entity_jid: JID to remove from roster
-doc_param_1=%(doc_profile_key)s
-
-[roster_resync]
-async=
-type=method
-category=core
-sig_in=s
-sig_out=
-param_0_default="@DEFAULT@"
-doc=Do a full resynchronisation of roster with server
-doc_param_0=%(doc_profile_key)s
-
-[action_launch]
-async=
-type=method
-category=core
-sig_in=sss
-sig_out=s
-param_2_default="@DEFAULT@"
-doc=Launch a registred action
-doc_param_0=callback_id: id of the registred callback
-doc_param_1=data: optional data
-doc_param_2=%(doc_profile_key)s
-doc_return=dict where key can be:
-    - xmlui: a XMLUI need to be displayed
-
-[actions_get]
-type=method
-category=core
-sig_in=s
-sig_out=a(ssi)
-param_0_default="@DEFAULT@"
-doc=Get all not yet answered actions
-doc_param_0=%(doc_profile_key)s
-doc_return=list of data as for [action_new] (without the profile)
-
-[progress_get]
-type=method
-category=core
-sig_in=ss
-sig_out=a{ss}
-doc=Get progress information for an action
-doc_param_0=id: id of the progression status
-doc_param_1=%(doc_profile)s
-doc_return=dict with progress informations:
- - position: current position
- - size: end position (optional if not known)
- other metadata may be present
-
-[progress_get_all_metadata]
-type=method
-category=core
-sig_in=s
-sig_out=a{sa{sa{ss}}}
-doc=Get all active progress informations
-doc_param_0=%(doc_profile)s or C.PROF_KEY_ALL for all profiles
-doc_return= a dict which map profile to progress_dict
-    progress_dict map progress_id to progress_metadata
-    progress_metadata is the same dict as sent by [progress_started]
-
-[progress_get_all]
-type=method
-category=core
-sig_in=s
-sig_out=a{sa{sa{ss}}}
-doc=Get all active progress informations
-doc_param_0=%(doc_profile)s or C.PROF_KEY_ALL for all profiles
-doc_return= a dict which map profile to progress_dict
-    progress_dict map progress_id to progress_data
-    progress_data is the same dict as returned by [progress_get]
-
-[menus_get]
-type=method
-category=core
-sig_in=si
-sig_out=a(ssasasa{ss})
-doc=Get all additional menus
-doc_param_0=language: language in which the menu should be translated (empty string for default)
-doc_param_1=security_limit: %(doc_security_limit)s
-doc_return=list of tuple with the following value:
- - menu_id: menu id (same as callback id)
- - menu_type: Type which can be:
-    * NORMAL: Classical application menu
- - menu_path: raw path of the menu
- - menu_path_i18n: translated path of the menu
- - extra: extra data, like icon name
-
-[menu_launch]
-async=
-type=method
-category=core
-sig_in=sasa{ss}is
-sig_out=a{ss}
-doc=Launch a registred menu
-doc_param_0=menu_type: type of the menu (C.MENU_*)
-doc_param_1=path: canonical (untranslated) path of the menu
-doc_param_2=data: optional data
-doc_param_3=%(doc_security_limit)s
-doc_param_4=%(doc_profile_key)s
-doc_return=dict where key can be:
-    - xmlui: a XMLUI need to be displayed
-
-[menu_help_get]
-type=method
-category=core
-sig_in=ss
-sig_out=s
-param_2="NORMAL"
-doc=Get help information for a menu
-doc_param_0=menu_id: id of the menu (same as callback_id)
-doc_param_1=language: language in which the menu should be translated (empty string for default)
-doc_return=Translated help string
-
-[disco_infos]
-async=
-type=method
-category=core
-sig_in=ssbs
-sig_out=(asa(sss)a{sa(a{ss}as)})
-param_1_default=u''
-param_2_default=True
-param_3_default="@DEFAULT@"
-doc=Discover infos on an entity
-doc_param_0=entity_jid: JID to discover
-doc_param_1=node: node to use
-doc_param_2=use_cache: use cached data if available
-doc_param_3=%(doc_profile_key)s
-doc_return=discovery data:
- - list of features
- - list of identities (category, type, name)
- - dictionary of extensions (FORM_TYPE as key), with value of:
-    - list of field which are:
-        - dictionary key/value where key can be:
-            * var
-            * label
-            * type
-            * desc
-        - list of values
-
-[disco_items]
-async=
-type=method
-category=core
-sig_in=ssbs
-sig_out=a(sss)
-param_1_default=u''
-param_2_default=True
-param_3_default="@DEFAULT@"
-doc=Discover items of an entity
-doc_param_0=entity_jid: JID to discover
-doc_param_1=node: node to use
-doc_param_2=use_cache: use cached data if available
-doc_param_3=%(doc_profile_key)s
-doc_return=array of tuple (entity, node identifier, name)
-
-[disco_find_by_features]
-async=
-type=method
-category=core
-sig_in=asa(ss)bbbbbs
-sig_out=(a{sa(sss)}a{sa(sss)}a{sa(sss)})
-param_2_default=False
-param_3_default=True
-param_4_default=True
-param_5_default=True
-param_6_default=False
-param_7_default="@DEFAULT@"
-doc=Discover items of an entity
-doc_param_0=namespaces: namespaces of the features to check
-doc_param_1=identities: identities to filter
-doc_param_2=bare_jid: if True only retrieve bare jids
-    if False, retrieve full jids of connected resources
-doc_param_3=service: True to check server's services
-doc_param_4=roster: True to check connected devices from people in roster
-doc_param_5=own_jid: True to check profile's jid
-doc_param_6=local_device: True to check device on which the backend is running
-doc_param_7=%(doc_profile_key)s
-doc_return=tuple of maps of found entities full jids to their identities. Maps are in this order:
- - services entities
- - own entities (i.e. entities linked to profile's jid)
- - roster entities
-
-[params_template_save]
-type=method
-category=core
-sig_in=s
-sig_out=b
-doc=Save parameters template to xml file
-doc_param_0=filename: output filename
-doc_return=boolean (True in case of success)
-
-[params_template_load]
-type=method
-category=core
-sig_in=s
-sig_out=b
-doc=Load parameters template from xml file
-doc_param_0=filename: input filename
-doc_return=boolean (True in case of success)
-
-[session_infos_get]
-async=
-type=method
-category=core
-sig_in=s
-sig_out=a{ss}
-doc=Get various informations on current profile session
-doc_param_0=%(doc_profile_key)s
-doc_return=session informations, with at least the following keys:
-    jid: current full jid
-    started: date of creation of the session (Epoch time)
-
-[devices_infos_get]
-async=
-type=method
-category=core
-sig_in=ss
-sig_out=s
-doc=Get various informations on an entity devices
-doc_param_0=bare_jid: get data on known devices from this entity
-    empty string to get devices of the profile
-doc_param_1=%(doc_profile_key)s
-doc_return=list of known devices, where each item is a dict with a least following keys:
-    resource: device resource
-
-[namespaces_get]
-type=method
-category=core
-sig_in=
-sig_out=a{ss}
-doc=Get a dict to short name => whole namespaces
-doc_return=namespaces mapping
-
-[image_check]
-type=method
-category=core
-sig_in=s
-sig_out=s
-doc=Analyze an image a return a report
-doc_return=serialized report
-
-[image_resize]
-async=
-type=method
-category=core
-sig_in=sii
-sig_out=s
-doc=Create a new image with desired size
-doc_param_0=image_path: path of the image to resize
-doc_param_1=width: width of the new image
-doc_param_2=height: height of the new image
-doc_return=path of the new image with desired size
-    the image must be deleted once not needed anymore
-
-[image_generate_preview]
-async=
-type=method
-category=core
-sig_in=ss
-sig_out=s
-doc=Generate a preview of an image in cache
-doc_param_0=image_path: path of the original image
-doc_param_1=%(doc_profile_key)s
-doc_return=path to the preview in cache
-
-[image_convert]
-async=
-type=method
-category=core
-sig_in=ssss
-sig_out=s
-doc=Convert an image to an other format
-doc_param_0=source: path of the image to convert
-doc_param_1=dest: path to the location where the new image must be stored.
-    Empty string to generate a file in cache, unique to the source
-doc_param_3=extra: serialised extra
-doc_param_4=profile_key: either profile_key or empty string to use common cache
-    this parameter is used only when dest is empty
-doc_return=path to the new converted image
--- a/sat/bridge/bridge_constructor/constants.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,43 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core import constants
-
-
-class Const(constants.Const):
-
-    NAME = "bridge_constructor"
-    DEST_DIR_DEFAULT = "generated"
-    DESCRIPTION = """{name} Copyright (C) 2009-2021 Jérôme Poisson (aka Goffi)
-
-    This script construct a SàT bridge using the given protocol
-
-    This program comes with ABSOLUTELY NO WARRANTY;
-    This is free software, and you are welcome to redistribute it
-    under certain conditions.
-    """.format(
-        name=NAME, version=constants.Const.APP_VERSION
-    )
-    #  TODO: move protocoles in separate files (plugins?)
-    DEFAULT_PROTOCOLE = "dbus"
-
-    # flags used method/signal declaration (not to be confused with constructor flags)
-    DECLARATION_FLAGS = ["deprecated", "async"]
-
-    ENV_OVERRIDE = "SAT_BRIDGE_CONST_"  # Prefix used to override a constant
--- a/sat/bridge/bridge_constructor/constructors/dbus-xml/constructor.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,102 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.bridge.bridge_constructor import base_constructor
-from xml.dom import minidom
-import sys
-
-
-class DbusXmlConstructor(base_constructor.Constructor):
-    """Constructor for DBus XML syntaxt (used by Qt frontend)"""
-
-    def __init__(self, bridge_template, options):
-        base_constructor.Constructor.__init__(self, bridge_template, options)
-
-        self.template = "dbus_xml_template.xml"
-        self.core_dest = "org.libervia.sat.xml"
-        self.default_annotation = {
-            "a{ss}": "StringDict",
-            "a(sa{ss}as)": "QList<Contact>",
-            "a{i(ss)}": "HistoryT",
-            "a(sss)": "QList<MenuT>",
-            "a{sa{s(sia{ss})}}": "PresenceStatusT",
-        }
-
-    def generate_core_side(self):
-        try:
-            doc = minidom.parse(self.get_template_path(self.template))
-            interface_elt = doc.getElementsByTagName("interface")[0]
-        except IOError:
-            print("Can't access template")
-            sys.exit(1)
-        except IndexError:
-            print("Template error")
-            sys.exit(1)
-
-        sections = self.bridge_template.sections()
-        sections.sort()
-        for section in sections:
-            function = self.getValues(section)
-            print(("Adding %s %s" % (section, function["type"])))
-            new_elt = doc.createElement(
-                "method" if function["type"] == "method" else "signal"
-            )
-            new_elt.setAttribute("name", section)
-
-            idx = 0
-            args_doc = self.get_arguments_doc(section)
-            for arg in self.arguments_parser(function["sig_in"] or ""):
-                arg_elt = doc.createElement("arg")
-                arg_elt.setAttribute(
-                    "name", args_doc[idx][0] if idx in args_doc else "arg_%i" % idx
-                )
-                arg_elt.setAttribute("type", arg)
-                _direction = "in" if function["type"] == "method" else "out"
-                arg_elt.setAttribute("direction", _direction)
-                new_elt.appendChild(arg_elt)
-                if "annotation" in self.args.flags:
-                    if arg in self.default_annotation:
-                        annot_elt = doc.createElement("annotation")
-                        annot_elt.setAttribute(
-                            "name", "com.trolltech.QtDBus.QtTypeName.In%d" % idx
-                        )
-                        annot_elt.setAttribute("value", self.default_annotation[arg])
-                        new_elt.appendChild(annot_elt)
-                idx += 1
-
-            if function["sig_out"]:
-                arg_elt = doc.createElement("arg")
-                arg_elt.setAttribute("type", function["sig_out"])
-                arg_elt.setAttribute("direction", "out")
-                new_elt.appendChild(arg_elt)
-                if "annotation" in self.args.flags:
-                    if function["sig_out"] in self.default_annotation:
-                        annot_elt = doc.createElement("annotation")
-                        annot_elt.setAttribute(
-                            "name", "com.trolltech.QtDBus.QtTypeName.Out0"
-                        )
-                        annot_elt.setAttribute(
-                            "value", self.default_annotation[function["sig_out"]]
-                        )
-                        new_elt.appendChild(annot_elt)
-
-            interface_elt.appendChild(new_elt)
-
-        # now we write to final file
-        self.final_write(self.core_dest, [doc.toprettyxml()])
--- a/sat/bridge/bridge_constructor/constructors/dbus-xml/dbus_xml_template.xml	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-<node>
-  <interface name="org.libervia.Libervia.core">
-  </interface>
-</node>
--- a/sat/bridge/bridge_constructor/constructors/dbus/constructor.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,118 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.bridge.bridge_constructor import base_constructor
-
-
-class DbusConstructor(base_constructor.Constructor):
-    NAME = "dbus"
-    CORE_TEMPLATE = "dbus_core_template.py"
-    CORE_DEST = "dbus_bridge.py"
-    CORE_FORMATS = {
-        "methods_declarations": """\
-        Method('{name}', arguments='{sig_in}', returns='{sig_out}'),""",
-
-        "methods": """\
-    def dbus_{name}(self, {args}):
-        {debug}return self._callback("{name}", {args_no_default})\n""",
-
-        "signals_declarations": """\
-        Signal('{name}', '{sig_in}'),""",
-
-        "signals": """\
-    def {name}(self, {args}):
-        self._obj.emitSignal("{name}", {args})\n""",
-    }
-
-    FRONTEND_TEMPLATE = "dbus_frontend_template.py"
-    FRONTEND_DEST = CORE_DEST
-    FRONTEND_FORMATS = {
-        "methods": """\
-    def {name}(self, {args}{async_comma}{async_args}):
-        {error_handler}{blocking_call}{debug}return {result}\n""",
-        "async_methods": """\
-    def {name}(self{async_comma}{args}):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        reply_handler = lambda ret=None: loop.call_soon_threadsafe(fut.set_result, ret)
-        error_handler = lambda err: loop.call_soon_threadsafe(fut.set_exception, dbus_to_bridge_exception(err))
-        self.db_{category}_iface.{name}({args_result}{async_comma}timeout=const_TIMEOUT, reply_handler=reply_handler, error_handler=error_handler)
-        {debug}return fut\n""",
-    }
-
-    def core_completion_signal(self, completion, function, default, arg_doc, async_):
-        completion["category"] = completion["category"].upper()
-        completion["body"] = (
-            "pass"
-            if not self.args.debug
-            else 'log.debug ("{}")'.format(completion["name"])
-        )
-
-    def core_completion_method(self, completion, function, default, arg_doc, async_):
-        completion.update(
-            {
-                "debug": (
-                    "" if not self.args.debug
-                    else f'log.debug ("{completion["name"]}")\n{8 * " "}'
-                )
-            }
-        )
-
-    def frontend_completion_method(self, completion, function, default, arg_doc, async_):
-        completion.update(
-            {
-                # XXX: we can manage blocking call in the same way as async one: if callback is None the call will be blocking
-                "debug": ""
-                if not self.args.debug
-                else 'log.debug ("%s")\n%s' % (completion["name"], 8 * " "),
-                "args_result": self.get_arguments(function["sig_in"], name=arg_doc),
-                "async_args": "callback=None, errback=None",
-                "async_comma": ", " if function["sig_in"] else "",
-                "error_handler": """if callback is None:
-            error_handler = None
-        else:
-            if errback is None:
-                errback = log.error
-            error_handler = lambda err:errback(dbus_to_bridge_exception(err))
-        """,
-            }
-        )
-        if async_:
-            completion["blocking_call"] = ""
-            completion[
-                "async_args_result"
-            ] = "timeout=const_TIMEOUT, reply_handler=callback, error_handler=error_handler"
-        else:
-            # XXX: To have a blocking call, we must have not reply_handler, so we test if callback exists, and add reply_handler only in this case
-            completion[
-                "blocking_call"
-            ] = """kwargs={}
-        if callback is not None:
-            kwargs['timeout'] = const_TIMEOUT
-            kwargs['reply_handler'] = callback
-            kwargs['error_handler'] = error_handler
-        """
-            completion["async_args_result"] = "**kwargs"
-        result = (
-            "self.db_%(category)s_iface.%(name)s(%(args_result)s%(async_comma)s%(async_args_result)s)"
-            % completion
-        )
-        completion["result"] = (
-            "str(%s)" if self.args.unicode and function["sig_out"] == "s" else "%s"
-        ) % result
--- a/sat/bridge/bridge_constructor/constructors/dbus/dbus_core_template.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,171 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia communication bridge
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from types import MethodType
-from functools import partialmethod
-from twisted.internet import defer, reactor
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.exceptions import BridgeInitError
-from sat.tools import config
-from txdbus import client, objects, error
-from txdbus.interface import DBusInterface, Method, Signal
-
-
-log = getLogger(__name__)
-
-# Interface prefix
-const_INT_PREFIX = config.config_get(
-    config.parse_main_conf(),
-    "",
-    "bridge_dbus_int_prefix",
-    "org.libervia.Libervia")
-const_ERROR_PREFIX = const_INT_PREFIX + ".error"
-const_OBJ_PATH = "/org/libervia/Libervia/bridge"
-const_CORE_SUFFIX = ".core"
-const_PLUGIN_SUFFIX = ".plugin"
-
-
-class ParseError(Exception):
-    pass
-
-
-class DBusException(Exception):
-    pass
-
-
-class MethodNotRegistered(DBusException):
-    dbusErrorName = const_ERROR_PREFIX + ".MethodNotRegistered"
-
-
-class GenericException(DBusException):
-    def __init__(self, twisted_error):
-        """
-
-        @param twisted_error (Failure): instance of twisted Failure
-        error message is used to store a repr of message and condition in a tuple,
-        so it can be evaluated by the frontend bridge.
-        """
-        try:
-            # twisted_error.value is a class
-            class_ = twisted_error.value().__class__
-        except TypeError:
-            # twisted_error.value is an instance
-            class_ = twisted_error.value.__class__
-            data = twisted_error.getErrorMessage()
-            try:
-                data = (data, twisted_error.value.condition)
-            except AttributeError:
-                data = (data,)
-        else:
-            data = (str(twisted_error),)
-        self.dbusErrorName = ".".join(
-            (const_ERROR_PREFIX, class_.__module__, class_.__name__)
-        )
-        super(GenericException, self).__init__(repr(data))
-
-    @classmethod
-    def create_and_raise(cls, exc):
-        raise cls(exc)
-
-
-class DBusObject(objects.DBusObject):
-
-    core_iface = DBusInterface(
-        const_INT_PREFIX + const_CORE_SUFFIX,
-##METHODS_DECLARATIONS_PART##
-##SIGNALS_DECLARATIONS_PART##
-    )
-    plugin_iface = DBusInterface(
-        const_INT_PREFIX + const_PLUGIN_SUFFIX
-    )
-
-    dbusInterfaces = [core_iface, plugin_iface]
-
-    def __init__(self, path):
-        super().__init__(path)
-        log.debug("Init DBusObject...")
-        self.cb = {}
-
-    def register_method(self, name, cb):
-        self.cb[name] = cb
-
-    def _callback(self, name, *args, **kwargs):
-        """Call the callback if it exists, raise an exception else"""
-        try:
-            cb = self.cb[name]
-        except KeyError:
-            raise MethodNotRegistered
-        else:
-            d = defer.maybeDeferred(cb, *args, **kwargs)
-            d.addErrback(GenericException.create_and_raise)
-            return d
-
-##METHODS_PART##
-
-class bridge:
-
-    def __init__(self):
-        log.info("Init DBus...")
-        self._obj = DBusObject(const_OBJ_PATH)
-
-    async def post_init(self):
-        try:
-            conn = await client.connect(reactor)
-        except error.DBusException as e:
-            if e.errName == "org.freedesktop.DBus.Error.NotSupported":
-                log.error(
-                    _(
-                        "D-Bus is not launched, please see README to see instructions on "
-                        "how to launch it"
-                    )
-                )
-            raise BridgeInitError(str(e))
-
-        conn.exportObject(self._obj)
-        await conn.requestBusName(const_INT_PREFIX)
-
-##SIGNALS_PART##
-    def register_method(self, name, callback):
-        log.debug(f"registering DBus bridge method [{name}]")
-        self._obj.register_method(name, callback)
-
-    def emit_signal(self, name, *args):
-        self._obj.emitSignal(name, *args)
-
-    def add_method(
-            self, name, int_suffix, in_sign, out_sign, method, async_=False, doc={}
-    ):
-        """Dynamically add a method to D-Bus bridge"""
-        # FIXME: doc parameter is kept only temporary, the time to remove it from calls
-        log.debug(f"Adding method {name!r} to D-Bus bridge")
-        self._obj.plugin_iface.addMethod(
-            Method(name, arguments=in_sign, returns=out_sign)
-        )
-        # we have to create a method here instead of using partialmethod, because txdbus
-        # uses __func__ which doesn't work with partialmethod
-        def caller(self_, *args, **kwargs):
-            return self_._callback(name, *args, **kwargs)
-        setattr(self._obj, f"dbus_{name}", MethodType(caller, self._obj))
-        self.register_method(name, method)
-
-    def add_signal(self, name, int_suffix, signature, doc={}):
-        """Dynamically add a signal to D-Bus bridge"""
-        log.debug(f"Adding signal {name!r} to D-Bus bridge")
-        self._obj.plugin_iface.addSignal(Signal(name, signature))
-        setattr(bridge, name, partialmethod(bridge.emit_signal, name))
--- a/sat/bridge/bridge_constructor/constructors/dbus/dbus_frontend_template.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,223 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT communication bridge
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import asyncio
-import dbus
-import ast
-from sat.core.i18n import _
-from sat.tools import config
-from sat.core.log import getLogger
-from sat.core.exceptions import BridgeExceptionNoService, BridgeInitError
-from dbus.mainloop.glib import DBusGMainLoop
-from .bridge_frontend import BridgeException
-
-
-DBusGMainLoop(set_as_default=True)
-log = getLogger(__name__)
-
-
-# Interface prefix
-const_INT_PREFIX = config.config_get(
-    config.parse_main_conf(),
-    "",
-    "bridge_dbus_int_prefix",
-    "org.libervia.Libervia")
-const_ERROR_PREFIX = const_INT_PREFIX + ".error"
-const_OBJ_PATH = '/org/libervia/Libervia/bridge'
-const_CORE_SUFFIX = ".core"
-const_PLUGIN_SUFFIX = ".plugin"
-const_TIMEOUT = 120
-
-
-def dbus_to_bridge_exception(dbus_e):
-    """Convert a DBusException to a BridgeException.
-
-    @param dbus_e (DBusException)
-    @return: BridgeException
-    """
-    full_name = dbus_e.get_dbus_name()
-    if full_name.startswith(const_ERROR_PREFIX):
-        name = dbus_e.get_dbus_name()[len(const_ERROR_PREFIX) + 1:]
-    else:
-        name = full_name
-    # XXX: dbus_e.args doesn't contain the original DBusException args, but we
-    # receive its serialized form in dbus_e.args[0]. From that we can rebuild
-    # the original arguments list thanks to ast.literal_eval (secure eval).
-    message = dbus_e.get_dbus_message()  # similar to dbus_e.args[0]
-    try:
-        message, condition = ast.literal_eval(message)
-    except (SyntaxError, ValueError, TypeError):
-        condition = ''
-    return BridgeException(name, message, condition)
-
-
-class bridge:
-
-    def bridge_connect(self, callback, errback):
-        try:
-            self.sessions_bus = dbus.SessionBus()
-            self.db_object = self.sessions_bus.get_object(const_INT_PREFIX,
-                                                          const_OBJ_PATH)
-            self.db_core_iface = dbus.Interface(self.db_object,
-                                                dbus_interface=const_INT_PREFIX + const_CORE_SUFFIX)
-            self.db_plugin_iface = dbus.Interface(self.db_object,
-                                                  dbus_interface=const_INT_PREFIX + const_PLUGIN_SUFFIX)
-        except dbus.exceptions.DBusException as e:
-            if e._dbus_error_name in ('org.freedesktop.DBus.Error.ServiceUnknown',
-                                      'org.freedesktop.DBus.Error.Spawn.ExecFailed'):
-                errback(BridgeExceptionNoService())
-            elif e._dbus_error_name == 'org.freedesktop.DBus.Error.NotSupported':
-                log.error(_("D-Bus is not launched, please see README to see instructions on how to launch it"))
-                errback(BridgeInitError)
-            else:
-                errback(e)
-        else:
-            callback()
-        #props = self.db_core_iface.getProperties()
-
-    def register_signal(self, functionName, handler, iface="core"):
-        if iface == "core":
-            self.db_core_iface.connect_to_signal(functionName, handler)
-        elif iface == "plugin":
-            self.db_plugin_iface.connect_to_signal(functionName, handler)
-        else:
-            log.error(_('Unknown interface'))
-
-    def __getattribute__(self, name):
-        """ usual __getattribute__ if the method exists, else try to find a plugin method """
-        try:
-            return object.__getattribute__(self, name)
-        except AttributeError:
-            # The attribute is not found, we try the plugin proxy to find the requested method
-
-            def get_plugin_method(*args, **kwargs):
-                # We first check if we have an async call. We detect this in two ways:
-                #   - if we have the 'callback' and 'errback' keyword arguments
-                #   - or if the last two arguments are callable
-
-                async_ = False
-                args = list(args)
-
-                if kwargs:
-                    if 'callback' in kwargs:
-                        async_ = True
-                        _callback = kwargs.pop('callback')
-                        _errback = kwargs.pop('errback', lambda failure: log.error(str(failure)))
-                    try:
-                        args.append(kwargs.pop('profile'))
-                    except KeyError:
-                        try:
-                            args.append(kwargs.pop('profile_key'))
-                        except KeyError:
-                            pass
-                    # at this point, kwargs should be empty
-                    if kwargs:
-                        log.warning("unexpected keyword arguments, they will be ignored: {}".format(kwargs))
-                elif len(args) >= 2 and callable(args[-1]) and callable(args[-2]):
-                    async_ = True
-                    _errback = args.pop()
-                    _callback = args.pop()
-
-                method = getattr(self.db_plugin_iface, name)
-
-                if async_:
-                    kwargs['timeout'] = const_TIMEOUT
-                    kwargs['reply_handler'] = _callback
-                    kwargs['error_handler'] = lambda err: _errback(dbus_to_bridge_exception(err))
-
-                try:
-                    return method(*args, **kwargs)
-                except ValueError as e:
-                    if e.args[0].startswith("Unable to guess signature"):
-                        # XXX: if frontend is started too soon after backend, the
-                        #   inspection misses methods (notably plugin dynamically added
-                        #   methods). The following hack works around that by redoing the
-                        #   cache of introspected methods signatures.
-                        log.debug("using hack to work around inspection issue")
-                        proxy = self.db_plugin_iface.proxy_object
-                        IN_PROGRESS = proxy.INTROSPECT_STATE_INTROSPECT_IN_PROGRESS
-                        proxy._introspect_state = IN_PROGRESS
-                        proxy._Introspect()
-                        return self.db_plugin_iface.get_dbus_method(name)(*args, **kwargs)
-                    raise e
-
-            return get_plugin_method
-
-##METHODS_PART##
-
-class AIOBridge(bridge):
-
-    def register_signal(self, functionName, handler, iface="core"):
-        loop = asyncio.get_running_loop()
-        async_handler = lambda *args: asyncio.run_coroutine_threadsafe(handler(*args), loop)
-        return super().register_signal(functionName, async_handler, iface)
-
-    def __getattribute__(self, name):
-        """ usual __getattribute__ if the method exists, else try to find a plugin method """
-        try:
-            return object.__getattribute__(self, name)
-        except AttributeError:
-            # The attribute is not found, we try the plugin proxy to find the requested method
-            def get_plugin_method(*args, **kwargs):
-                loop = asyncio.get_running_loop()
-                fut = loop.create_future()
-                method = getattr(self.db_plugin_iface, name)
-                reply_handler = lambda ret=None: loop.call_soon_threadsafe(
-                    fut.set_result, ret)
-                error_handler = lambda err: loop.call_soon_threadsafe(
-                    fut.set_exception, dbus_to_bridge_exception(err))
-                try:
-                    method(
-                        *args,
-                        **kwargs,
-                        timeout=const_TIMEOUT,
-                        reply_handler=reply_handler,
-                        error_handler=error_handler
-                    )
-                except ValueError as e:
-                    if e.args[0].startswith("Unable to guess signature"):
-                        # same hack as for bridge.__getattribute__
-                        log.warning("using hack to work around inspection issue")
-                        proxy = self.db_plugin_iface.proxy_object
-                        IN_PROGRESS = proxy.INTROSPECT_STATE_INTROSPECT_IN_PROGRESS
-                        proxy._introspect_state = IN_PROGRESS
-                        proxy._Introspect()
-                        self.db_plugin_iface.get_dbus_method(name)(
-                            *args,
-                            **kwargs,
-                            timeout=const_TIMEOUT,
-                            reply_handler=reply_handler,
-                            error_handler=error_handler
-                        )
-
-                    else:
-                        raise e
-                return fut
-
-            return get_plugin_method
-
-    def bridge_connect(self):
-        loop = asyncio.get_running_loop()
-        fut = loop.create_future()
-        super().bridge_connect(
-            callback=lambda: loop.call_soon_threadsafe(fut.set_result, None),
-            errback=lambda e: loop.call_soon_threadsafe(fut.set_exception, e)
-        )
-        return fut
-
-##ASYNC_METHODS_PART##
--- a/sat/bridge/bridge_constructor/constructors/embedded/constructor.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,100 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.bridge.bridge_constructor import base_constructor
-
-#  from textwraps import dedent
-
-
-class EmbeddedConstructor(base_constructor.Constructor):
-    NAME = "embedded"
-    CORE_TEMPLATE = "embedded_template.py"
-    CORE_DEST = "embedded.py"
-    CORE_FORMATS = {
-        "methods": """\
-    def {name}(self, {args}{args_comma}callback=None, errback=None):
-{ret_routine}
-""",
-        "signals": """\
-    def {name}(self, {args}):
-        try:
-            cb = self._signals_cbs["{category}"]["{name}"]
-        except KeyError:
-            log.warning(u"ignoring signal {name}: no callback registered")
-        else:
-            cb({args_result})
-""",
-    }
-    FRONTEND_TEMPLATE = "embedded_frontend_template.py"
-    FRONTEND_DEST = CORE_DEST
-    FRONTEND_FORMATS = {}
-
-    def core_completion_method(self, completion, function, default, arg_doc, async_):
-        completion.update(
-            {
-                "debug": ""
-                if not self.args.debug
-                else 'log.debug ("%s")\n%s' % (completion["name"], 8 * " "),
-                "args_result": self.get_arguments(function["sig_in"], name=arg_doc),
-                "args_comma": ", " if function["sig_in"] else "",
-            }
-        )
-
-        if async_:
-            completion["cb_or_lambda"] = (
-                "callback" if function["sig_out"] else "lambda __: callback()"
-            )
-            completion[
-                "ret_routine"
-            ] = """\
-        d = self._methods_cbs["{name}"]({args_result})
-        if callback is not None:
-            d.addCallback({cb_or_lambda})
-        if errback is None:
-            d.addErrback(lambda failure_: log.error(failure_))
-        else:
-            d.addErrback(errback)
-        return d
-        """.format(
-                **completion
-            )
-        else:
-            completion["ret_or_nothing"] = "ret" if function["sig_out"] else ""
-            completion[
-                "ret_routine"
-            ] = """\
-        try:
-            ret = self._methods_cbs["{name}"]({args_result})
-        except Exception as e:
-            if errback is not None:
-                errback(e)
-            else:
-                raise e
-        else:
-            if callback is None:
-                return ret
-            else:
-                callback({ret_or_nothing})""".format(
-                **completion
-            )
-
-    def core_completion_signal(self, completion, function, default, arg_doc, async_):
-        completion.update(
-            {"args_result": self.get_arguments(function["sig_in"], name=arg_doc)}
-        )
--- a/sat/bridge/bridge_constructor/constructors/embedded/embedded_frontend_template.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.bridge.embedded import bridge
--- a/sat/bridge/bridge_constructor/constructors/embedded/embedded_template.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,123 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core import exceptions
-
-
-class _Bridge(object):
-    def __init__(self):
-        log.debug("Init embedded bridge...")
-        self._methods_cbs = {}
-        self._signals_cbs = {"core": {}, "plugin": {}}
-
-    def bridge_connect(self, callback, errback):
-        callback()
-
-    def register_method(self, name, callback):
-        log.debug("registering embedded bridge method [{}]".format(name))
-        if name in self._methods_cbs:
-            raise exceptions.ConflictError("method {} is already regitered".format(name))
-        self._methods_cbs[name] = callback
-
-    def register_signal(self, functionName, handler, iface="core"):
-        iface_dict = self._signals_cbs[iface]
-        if functionName in iface_dict:
-            raise exceptions.ConflictError(
-                "signal {name} is already regitered for interface {iface}".format(
-                    name=functionName, iface=iface
-                )
-            )
-        iface_dict[functionName] = handler
-
-    def call_method(self, name, out_sign, async_, args, kwargs):
-        callback = kwargs.pop("callback", None)
-        errback = kwargs.pop("errback", None)
-        if async_:
-            d = self._methods_cbs[name](*args, **kwargs)
-            if callback is not None:
-                d.addCallback(callback if out_sign else lambda __: callback())
-            if errback is None:
-                d.addErrback(lambda failure_: log.error(failure_))
-            else:
-                d.addErrback(errback)
-            return d
-        else:
-            try:
-                ret = self._methods_cbs[name](*args, **kwargs)
-            except Exception as e:
-                if errback is not None:
-                    errback(e)
-                else:
-                    raise e
-            else:
-                if callback is None:
-                    return ret
-                else:
-                    if out_sign:
-                        callback(ret)
-                    else:
-                        callback()
-
-    def send_signal(self, name, args, kwargs):
-        try:
-            cb = self._signals_cbs["plugin"][name]
-        except KeyError:
-            log.debug("ignoring signal {}: no callback registered".format(name))
-        else:
-            cb(*args, **kwargs)
-
-    def add_method(self, name, int_suffix, in_sign, out_sign, method, async_=False, doc={}):
-        # FIXME: doc parameter is kept only temporary, the time to remove it from calls
-        log.debug("Adding method [{}] to embedded bridge".format(name))
-        self.register_method(name, method)
-        setattr(
-            self.__class__,
-            name,
-            lambda self_, *args, **kwargs: self.call_method(
-                name, out_sign, async_, args, kwargs
-            ),
-        )
-
-    def add_signal(self, name, int_suffix, signature, doc={}):
-        setattr(
-            self.__class__,
-            name,
-            lambda self_, *args, **kwargs: self.send_signal(name, args, kwargs),
-        )
-
-    ## signals ##
-
-
-##SIGNALS_PART##
-## methods ##
-
-##METHODS_PART##
-
-# we want the same instance for both core and frontend
-bridge = None
-
-
-def bridge():
-    global bridge
-    if bridge is None:
-        bridge = _Bridge()
-    return bridge
--- a/sat/bridge/bridge_constructor/constructors/mediawiki/constructor.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,168 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.bridge.bridge_constructor import base_constructor
-import sys
-from datetime import datetime
-import re
-
-
-class MediawikiConstructor(base_constructor.Constructor):
-    def __init__(self, bridge_template, options):
-        base_constructor.Constructor.__init__(self, bridge_template, options)
-        self.core_template = "mediawiki_template.tpl"
-        self.core_dest = "mediawiki.wiki"
-
-    def _add_text_decorations(self, text):
-        """Add text decorations like coloration or shortcuts"""
-
-        def anchor_link(match):
-            link = match.group(1)
-            # we add anchor_link for [method_name] syntax:
-            if link in self.bridge_template.sections():
-                return "[[#%s|%s]]" % (link, link)
-            print("WARNING: found an anchor link to an unknown method")
-            return link
-
-        return re.sub(r"\[(\w+)\]", anchor_link, text)
-
-    def _wiki_parameter(self, name, sig_in):
-        """Format parameters with the wiki syntax
-        @param name: name of the function
-        @param sig_in: signature in
-        @return: string of the formated parameters"""
-        arg_doc = self.get_arguments_doc(name)
-        arg_default = self.get_default(name)
-        args_str = self.get_arguments(sig_in)
-        args = args_str.split(", ") if args_str else []  # ugly but it works :)
-        wiki = []
-        for i in range(len(args)):
-            if i in arg_doc:
-                name, doc = arg_doc[i]
-                doc = "\n:".join(doc.rstrip("\n").split("\n"))
-                wiki.append("; %s: %s" % (name, self._add_text_decorations(doc)))
-            else:
-                wiki.append("; arg_%d: " % i)
-            if i in arg_default:
-                wiki.append(":''DEFAULT: %s''" % arg_default[i])
-        return "\n".join(wiki)
-
-    def _wiki_return(self, name):
-        """Format return doc with the wiki syntax
-        @param name: name of the function
-        """
-        arg_doc = self.get_arguments_doc(name)
-        wiki = []
-        if "return" in arg_doc:
-            wiki.append("\n|-\n! scope=row | return value\n|")
-            wiki.append(
-                "<br />\n".join(
-                    self._add_text_decorations(arg_doc["return"]).rstrip("\n").split("\n")
-                )
-            )
-        return "\n".join(wiki)
-
-    def generate_core_side(self):
-        signals_part = []
-        methods_part = []
-        sections = self.bridge_template.sections()
-        sections.sort()
-        for section in sections:
-            function = self.getValues(section)
-            print(("Adding %s %s" % (section, function["type"])))
-            async_msg = """<br />'''This method is asynchronous'''"""
-            deprecated_msg = """<br />'''<font color="#FF0000">/!\ WARNING /!\ : This method is deprecated, please don't use it !</font>'''"""
-            signature_signal = (
-                """\
-! scope=row | signature
-| %s
-|-\
-"""
-                % function["sig_in"]
-            )
-            signature_method = """\
-! scope=row | signature in
-| %s
-|-
-! scope=row | signature out
-| %s
-|-\
-""" % (
-                function["sig_in"],
-                function["sig_out"],
-            )
-            completion = {
-                "signature": signature_signal
-                if function["type"] == "signal"
-                else signature_method,
-                "sig_out": function["sig_out"] or "",
-                "category": function["category"],
-                "name": section,
-                "doc": self.get_doc(section) or "FIXME: No description available",
-                "async": async_msg if "async" in self.getFlags(section) else "",
-                "deprecated": deprecated_msg
-                if "deprecated" in self.getFlags(section)
-                else "",
-                "parameters": self._wiki_parameter(section, function["sig_in"]),
-                "return": self._wiki_return(section)
-                if function["type"] == "method"
-                else "",
-            }
-
-            dest = signals_part if function["type"] == "signal" else methods_part
-            dest.append(
-                """\
-== %(name)s ==
-''%(doc)s''
-%(deprecated)s
-%(async)s
-{| class="wikitable" style="text-align:left; width:80%%;"
-! scope=row | category
-| %(category)s
-|-
-%(signature)s
-! scope=row | parameters
-|
-%(parameters)s%(return)s
-|}
-"""
-                % completion
-            )
-
-        # at this point, signals_part, and methods_part should be filled,
-        # we just have to place them in the right part of the template
-        core_bridge = []
-        template_path = self.get_template_path(self.core_template)
-        try:
-            with open(template_path) as core_template:
-                for line in core_template:
-                    if line.startswith("##SIGNALS_PART##"):
-                        core_bridge.extend(signals_part)
-                    elif line.startswith("##METHODS_PART##"):
-                        core_bridge.extend(methods_part)
-                    elif line.startswith("##TIMESTAMP##"):
-                        core_bridge.append("Generated on %s" % datetime.now())
-                    else:
-                        core_bridge.append(line.replace("\n", ""))
-        except IOError:
-            print(("Can't open template file [%s]" % template_path))
-            sys.exit(1)
-
-        # now we write to final file
-        self.final_write(self.core_dest, core_bridge)
--- a/sat/bridge/bridge_constructor/constructors/mediawiki/mediawiki_template.tpl	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,11 +0,0 @@
-[[Catégorie:Salut à Toi]]
-[[Catégorie:documentation développeur]]
-
-= Overview =
-This is an autogenerated doc for SàT bridge's API
-= Signals =
-##SIGNALS_PART##
-= Methods =
-##METHODS_PART##
-----
-##TIMESTAMP##
--- a/sat/bridge/bridge_constructor/constructors/pb/constructor.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,71 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.bridge.bridge_constructor import base_constructor
-
-
-class pbConstructor(base_constructor.Constructor):
-    NAME = "pb"
-    CORE_TEMPLATE = "pb_core_template.py"
-    CORE_DEST = "pb.py"
-    CORE_FORMATS = {
-        "signals": """\
-    def {name}(self, {args}):
-        {debug}self.send_signal("{name}", {args_no_def})\n"""
-    }
-
-    FRONTEND_TEMPLATE = "pb_frontend_template.py"
-    FRONTEND_DEST = CORE_DEST
-    FRONTEND_FORMATS = {
-        "methods": """\
-    def {name}(self{args_comma}{args}, callback=None, errback=None):
-        {debug}d = self.root.callRemote("{name}"{args_comma}{args_no_def})
-        if callback is not None:
-            d.addCallback({callback})
-        if errback is None:
-            d.addErrback(self._generic_errback)
-        else:
-            d.addErrback(self._errback, ori_errback=errback)\n""",
-        "async_methods": """\
-    def {name}(self{args_comma}{args}):
-        {debug}d = self.root.callRemote("{name}"{args_comma}{args_no_def})
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())\n""",
-    }
-
-    def core_completion_signal(self, completion, function, default, arg_doc, async_):
-        completion["args_no_def"] = self.get_arguments(function["sig_in"], name=arg_doc)
-        completion["debug"] = (
-            ""
-            if not self.args.debug
-            else 'log.debug ("%s")\n%s' % (completion["name"], 8 * " ")
-        )
-
-    def frontend_completion_method(self, completion, function, default, arg_doc, async_):
-        completion.update(
-            {
-                "args_comma": ", " if function["sig_in"] else "",
-                "args_no_def": self.get_arguments(function["sig_in"], name=arg_doc),
-                "callback": "callback"
-                if function["sig_out"]
-                else "lambda __: callback()",
-                "debug": ""
-                if not self.args.debug
-                else 'log.debug ("%s")\n%s' % (completion["name"], 8 * " "),
-            }
-        )
--- a/sat/bridge/bridge_constructor/constructors/pb/pb_core_template.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,166 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import dataclasses
-from functools import partial
-from pathlib import Path
-from twisted.spread import jelly, pb
-from twisted.internet import reactor
-from sat.core.log import getLogger
-from sat.tools import config
-
-log = getLogger(__name__)
-
-
-## jelly hack
-# we monkey patch jelly to handle namedtuple
-ori_jelly = jelly._Jellier.jelly
-
-
-def fixed_jelly(self, obj):
-    """this method fix handling of namedtuple"""
-    if isinstance(obj, tuple) and not obj is tuple:
-        obj = tuple(obj)
-    return ori_jelly(self, obj)
-
-
-jelly._Jellier.jelly = fixed_jelly
-
-
-@dataclasses.dataclass(eq=False)
-class HandlerWrapper:
-    # we use a wrapper to keep signals handlers because RemoteReference doesn't support
-    # comparison (other than equality), making it unusable with a list
-    handler: pb.RemoteReference
-
-
-class PBRoot(pb.Root):
-    def __init__(self):
-        self.signals_handlers = []
-
-    def remote_init_bridge(self, signals_handler):
-        self.signals_handlers.append(HandlerWrapper(signals_handler))
-        log.info("registered signal handler")
-
-    def send_signal_eb(self, failure_, signal_name):
-        if not failure_.check(pb.PBConnectionLost):
-            log.error(
-                f"Error while sending signal {signal_name}: {failure_}",
-            )
-
-    def send_signal(self, name, args, kwargs):
-        to_remove = []
-        for wrapper in self.signals_handlers:
-            handler = wrapper.handler
-            try:
-                d = handler.callRemote(name, *args, **kwargs)
-            except pb.DeadReferenceError:
-                to_remove.append(wrapper)
-            else:
-                d.addErrback(self.send_signal_eb, name)
-        if to_remove:
-            for wrapper in to_remove:
-                log.debug("Removing signal handler for dead frontend")
-                self.signals_handlers.remove(wrapper)
-
-    def _bridge_deactivate_signals(self):
-        if hasattr(self, "signals_paused"):
-            log.warning("bridge signals already deactivated")
-            if self.signals_handler:
-                self.signals_paused.extend(self.signals_handler)
-        else:
-            self.signals_paused = self.signals_handlers
-        self.signals_handlers = []
-        log.debug("bridge signals have been deactivated")
-
-    def _bridge_reactivate_signals(self):
-        try:
-            self.signals_handlers = self.signals_paused
-        except AttributeError:
-            log.debug("signals were already activated")
-        else:
-            del self.signals_paused
-            log.debug("bridge signals have been reactivated")
-
-##METHODS_PART##
-
-
-class bridge(object):
-    def __init__(self):
-        log.info("Init Perspective Broker...")
-        self.root = PBRoot()
-        conf = config.parse_main_conf()
-        get_conf = partial(config.get_conf, conf, "bridge_pb", "")
-        conn_type = get_conf("connection_type", "unix_socket")
-        if conn_type == "unix_socket":
-            local_dir = Path(config.config_get(conf, "", "local_dir")).resolve()
-            socket_path = local_dir / "bridge_pb"
-            log.info(f"using UNIX Socket at {socket_path}")
-            reactor.listenUNIX(
-                str(socket_path), pb.PBServerFactory(self.root), mode=0o600
-            )
-        elif conn_type == "socket":
-            port = int(get_conf("port", 8789))
-            log.info(f"using TCP Socket at port {port}")
-            reactor.listenTCP(port, pb.PBServerFactory(self.root))
-        else:
-            raise ValueError(f"Unknown pb connection type: {conn_type!r}")
-
-    def send_signal(self, name, *args, **kwargs):
-        self.root.send_signal(name, args, kwargs)
-
-    def remote_init_bridge(self, signals_handler):
-        self.signals_handlers.append(signals_handler)
-        log.info("registered signal handler")
-
-    def register_method(self, name, callback):
-        log.debug("registering PB bridge method [%s]" % name)
-        setattr(self.root, "remote_" + name, callback)
-        #  self.root.register_method(name, callback)
-
-    def add_method(
-            self, name, int_suffix, in_sign, out_sign, method, async_=False, doc={}
-    ):
-        """Dynamically add a method to PB bridge"""
-        # FIXME: doc parameter is kept only temporary, the time to remove it from calls
-        log.debug("Adding method {name} to PB bridge".format(name=name))
-        self.register_method(name, method)
-
-    def add_signal(self, name, int_suffix, signature, doc={}):
-        log.debug("Adding signal {name} to PB bridge".format(name=name))
-        setattr(
-            self, name, lambda *args, **kwargs: self.send_signal(name, *args, **kwargs)
-        )
-
-    def bridge_deactivate_signals(self):
-        """Stop sending signals to bridge
-
-        Mainly used for mobile frontends, when the frontend is paused
-        """
-        self.root._bridge_deactivate_signals()
-
-    def bridge_reactivate_signals(self):
-        """Send again signals to bridge
-
-        Should only be used after bridge_deactivate_signals has been called
-        """
-        self.root._bridge_reactivate_signals()
-
-##SIGNALS_PART##
--- a/sat/bridge/bridge_constructor/constructors/pb/pb_frontend_template.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,199 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT communication bridge
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import asyncio
-from logging import getLogger
-from functools import partial
-from pathlib import Path
-from twisted.spread import pb
-from twisted.internet import reactor, defer
-from twisted.internet.error import ConnectionRefusedError, ConnectError
-from sat.core import exceptions
-from sat.tools import config
-from sat_frontends.bridge.bridge_frontend import BridgeException
-
-log = getLogger(__name__)
-
-
-class SignalsHandler(pb.Referenceable):
-    def __getattr__(self, name):
-        if name.startswith("remote_"):
-            log.debug("calling an unregistered signal: {name}".format(name=name[7:]))
-            return lambda *args, **kwargs: None
-
-        else:
-            raise AttributeError(name)
-
-    def register_signal(self, name, handler, iface="core"):
-        log.debug("registering signal {name}".format(name=name))
-        method_name = "remote_" + name
-        try:
-            self.__getattribute__(method_name)
-        except AttributeError:
-            pass
-        else:
-            raise exceptions.InternalError(
-                "{name} signal handler has been registered twice".format(
-                    name=method_name
-                )
-            )
-        setattr(self, method_name, handler)
-
-
-class bridge(object):
-
-    def __init__(self):
-        self.signals_handler = SignalsHandler()
-
-    def __getattr__(self, name):
-        return partial(self.call, name)
-
-    def _generic_errback(self, err):
-        log.error(f"bridge error: {err}")
-
-    def _errback(self, failure_, ori_errback):
-        """Convert Failure to BridgeException"""
-        ori_errback(
-            BridgeException(
-                name=failure_.type.decode('utf-8'),
-                message=str(failure_.value)
-            )
-        )
-
-    def remote_callback(self, result, callback):
-        """call callback with argument or None
-
-        if result is not None not argument is used,
-        else result is used as argument
-        @param result: remote call result
-        @param callback(callable): method to call on result
-        """
-        if result is None:
-            callback()
-        else:
-            callback(result)
-
-    def call(self, name, *args, **kwargs):
-        """call a remote method
-
-        @param name(str): name of the bridge method
-        @param args(list): arguments
-            may contain callback and errback as last 2 items
-        @param kwargs(dict): keyword arguments
-            may contain callback and errback
-        """
-        callback = errback = None
-        if kwargs:
-            try:
-                callback = kwargs.pop("callback")
-            except KeyError:
-                pass
-            try:
-                errback = kwargs.pop("errback")
-            except KeyError:
-                pass
-        elif len(args) >= 2 and callable(args[-1]) and callable(args[-2]):
-            errback = args.pop()
-            callback = args.pop()
-        d = self.root.callRemote(name, *args, **kwargs)
-        if callback is not None:
-            d.addCallback(self.remote_callback, callback)
-        if errback is not None:
-            d.addErrback(errback)
-
-    def _init_bridge_eb(self, failure_):
-        log.error("Can't init bridge: {msg}".format(msg=failure_))
-        return failure_
-
-    def _set_root(self, root):
-        """set remote root object
-
-        bridge will then be initialised
-        """
-        self.root = root
-        d = root.callRemote("initBridge", self.signals_handler)
-        d.addErrback(self._init_bridge_eb)
-        return d
-
-    def get_root_object_eb(self, failure_):
-        """Call errback with appropriate bridge error"""
-        if failure_.check(ConnectionRefusedError, ConnectError):
-            raise exceptions.BridgeExceptionNoService
-        else:
-            raise failure_
-
-    def bridge_connect(self, callback, errback):
-        factory = pb.PBClientFactory()
-        conf = config.parse_main_conf()
-        get_conf = partial(config.get_conf, conf, "bridge_pb", "")
-        conn_type = get_conf("connection_type", "unix_socket")
-        if conn_type == "unix_socket":
-            local_dir = Path(config.config_get(conf, "", "local_dir")).resolve()
-            socket_path = local_dir / "bridge_pb"
-            reactor.connectUNIX(str(socket_path), factory)
-        elif conn_type == "socket":
-            host = get_conf("host", "localhost")
-            port = int(get_conf("port", 8789))
-            reactor.connectTCP(host, port, factory)
-        else:
-            raise ValueError(f"Unknown pb connection type: {conn_type!r}")
-        d = factory.getRootObject()
-        d.addCallback(self._set_root)
-        if callback is not None:
-            d.addCallback(lambda __: callback())
-        d.addErrback(self.get_root_object_eb)
-        if errback is not None:
-            d.addErrback(lambda failure_: errback(failure_.value))
-        return d
-
-    def register_signal(self, functionName, handler, iface="core"):
-        self.signals_handler.register_signal(functionName, handler, iface)
-
-
-##METHODS_PART##
-
-class AIOSignalsHandler(SignalsHandler):
-
-    def register_signal(self, name, handler, iface="core"):
-        async_handler = lambda *args, **kwargs: defer.Deferred.fromFuture(
-            asyncio.ensure_future(handler(*args, **kwargs)))
-        return super().register_signal(name, async_handler, iface)
-
-
-class AIOBridge(bridge):
-
-    def __init__(self):
-        self.signals_handler = AIOSignalsHandler()
-
-    def _errback(self, failure_):
-        """Convert Failure to BridgeException"""
-        raise BridgeException(
-            name=failure_.type.decode('utf-8'),
-            message=str(failure_.value)
-            )
-
-    def call(self, name, *args, **kwargs):
-        d = self.root.callRemote(name, *args, *kwargs)
-        d.addErrback(self._errback)
-        return d.asFuture(asyncio.get_event_loop())
-
-    async def bridge_connect(self):
-        d = super().bridge_connect(callback=None, errback=None)
-        return await d.asFuture(asyncio.get_event_loop())
-
-##ASYNC_METHODS_PART##
--- a/sat/bridge/dbus_bridge.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,495 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia communication bridge
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from types import MethodType
-from functools import partialmethod
-from twisted.internet import defer, reactor
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.exceptions import BridgeInitError
-from sat.tools import config
-from txdbus import client, objects, error
-from txdbus.interface import DBusInterface, Method, Signal
-
-
-log = getLogger(__name__)
-
-# Interface prefix
-const_INT_PREFIX = config.config_get(
-    config.parse_main_conf(),
-    "",
-    "bridge_dbus_int_prefix",
-    "org.libervia.Libervia")
-const_ERROR_PREFIX = const_INT_PREFIX + ".error"
-const_OBJ_PATH = "/org/libervia/Libervia/bridge"
-const_CORE_SUFFIX = ".core"
-const_PLUGIN_SUFFIX = ".plugin"
-
-
-class ParseError(Exception):
-    pass
-
-
-class DBusException(Exception):
-    pass
-
-
-class MethodNotRegistered(DBusException):
-    dbusErrorName = const_ERROR_PREFIX + ".MethodNotRegistered"
-
-
-class GenericException(DBusException):
-    def __init__(self, twisted_error):
-        """
-
-        @param twisted_error (Failure): instance of twisted Failure
-        error message is used to store a repr of message and condition in a tuple,
-        so it can be evaluated by the frontend bridge.
-        """
-        try:
-            # twisted_error.value is a class
-            class_ = twisted_error.value().__class__
-        except TypeError:
-            # twisted_error.value is an instance
-            class_ = twisted_error.value.__class__
-            data = twisted_error.getErrorMessage()
-            try:
-                data = (data, twisted_error.value.condition)
-            except AttributeError:
-                data = (data,)
-        else:
-            data = (str(twisted_error),)
-        self.dbusErrorName = ".".join(
-            (const_ERROR_PREFIX, class_.__module__, class_.__name__)
-        )
-        super(GenericException, self).__init__(repr(data))
-
-    @classmethod
-    def create_and_raise(cls, exc):
-        raise cls(exc)
-
-
-class DBusObject(objects.DBusObject):
-
-    core_iface = DBusInterface(
-        const_INT_PREFIX + const_CORE_SUFFIX,
-        Method('action_launch', arguments='sss', returns='s'),
-        Method('actions_get', arguments='s', returns='a(ssi)'),
-        Method('config_get', arguments='ss', returns='s'),
-        Method('connect', arguments='ssa{ss}', returns='b'),
-        Method('contact_add', arguments='ss', returns=''),
-        Method('contact_del', arguments='ss', returns=''),
-        Method('contact_get', arguments='ss', returns='(a{ss}as)'),
-        Method('contact_update', arguments='ssass', returns=''),
-        Method('contacts_get', arguments='s', returns='a(sa{ss}as)'),
-        Method('contacts_get_from_group', arguments='ss', returns='as'),
-        Method('devices_infos_get', arguments='ss', returns='s'),
-        Method('disco_find_by_features', arguments='asa(ss)bbbbbs', returns='(a{sa(sss)}a{sa(sss)}a{sa(sss)})'),
-        Method('disco_infos', arguments='ssbs', returns='(asa(sss)a{sa(a{ss}as)})'),
-        Method('disco_items', arguments='ssbs', returns='a(sss)'),
-        Method('disconnect', arguments='s', returns=''),
-        Method('encryption_namespace_get', arguments='s', returns='s'),
-        Method('encryption_plugins_get', arguments='', returns='s'),
-        Method('encryption_trust_ui_get', arguments='sss', returns='s'),
-        Method('entities_data_get', arguments='asass', returns='a{sa{ss}}'),
-        Method('entity_data_get', arguments='sass', returns='a{ss}'),
-        Method('features_get', arguments='s', returns='a{sa{ss}}'),
-        Method('history_get', arguments='ssiba{ss}s', returns='a(sdssa{ss}a{ss}ss)'),
-        Method('image_check', arguments='s', returns='s'),
-        Method('image_convert', arguments='ssss', returns='s'),
-        Method('image_generate_preview', arguments='ss', returns='s'),
-        Method('image_resize', arguments='sii', returns='s'),
-        Method('is_connected', arguments='s', returns='b'),
-        Method('main_resource_get', arguments='ss', returns='s'),
-        Method('menu_help_get', arguments='ss', returns='s'),
-        Method('menu_launch', arguments='sasa{ss}is', returns='a{ss}'),
-        Method('menus_get', arguments='si', returns='a(ssasasa{ss})'),
-        Method('message_encryption_get', arguments='ss', returns='s'),
-        Method('message_encryption_start', arguments='ssbs', returns=''),
-        Method('message_encryption_stop', arguments='ss', returns=''),
-        Method('message_send', arguments='sa{ss}a{ss}sss', returns=''),
-        Method('namespaces_get', arguments='', returns='a{ss}'),
-        Method('param_get_a', arguments='ssss', returns='s'),
-        Method('param_get_a_async', arguments='sssis', returns='s'),
-        Method('param_set', arguments='sssis', returns=''),
-        Method('param_ui_get', arguments='isss', returns='s'),
-        Method('params_categories_get', arguments='', returns='as'),
-        Method('params_register_app', arguments='sis', returns=''),
-        Method('params_template_load', arguments='s', returns='b'),
-        Method('params_template_save', arguments='s', returns='b'),
-        Method('params_values_from_category_get_async', arguments='sisss', returns='a{ss}'),
-        Method('presence_set', arguments='ssa{ss}s', returns=''),
-        Method('presence_statuses_get', arguments='s', returns='a{sa{s(sia{ss})}}'),
-        Method('private_data_delete', arguments='sss', returns=''),
-        Method('private_data_get', arguments='sss', returns='s'),
-        Method('private_data_set', arguments='ssss', returns=''),
-        Method('profile_create', arguments='sss', returns=''),
-        Method('profile_delete_async', arguments='s', returns=''),
-        Method('profile_is_session_started', arguments='s', returns='b'),
-        Method('profile_name_get', arguments='s', returns='s'),
-        Method('profile_set_default', arguments='s', returns=''),
-        Method('profile_start_session', arguments='ss', returns='b'),
-        Method('profiles_list_get', arguments='bb', returns='as'),
-        Method('progress_get', arguments='ss', returns='a{ss}'),
-        Method('progress_get_all', arguments='s', returns='a{sa{sa{ss}}}'),
-        Method('progress_get_all_metadata', arguments='s', returns='a{sa{sa{ss}}}'),
-        Method('ready_get', arguments='', returns=''),
-        Method('roster_resync', arguments='s', returns=''),
-        Method('session_infos_get', arguments='s', returns='a{ss}'),
-        Method('sub_waiting_get', arguments='s', returns='a{ss}'),
-        Method('subscription', arguments='sss', returns=''),
-        Method('version_get', arguments='', returns='s'),
-        Signal('_debug', 'sa{ss}s'),
-        Signal('action_new', 'ssis'),
-        Signal('connected', 'ss'),
-        Signal('contact_deleted', 'ss'),
-        Signal('contact_new', 'sa{ss}ass'),
-        Signal('disconnected', 's'),
-        Signal('entity_data_updated', 'ssss'),
-        Signal('message_encryption_started', 'sss'),
-        Signal('message_encryption_stopped', 'sa{ss}s'),
-        Signal('message_new', 'sdssa{ss}a{ss}sss'),
-        Signal('param_update', 'ssss'),
-        Signal('presence_update', 'ssia{ss}s'),
-        Signal('progress_error', 'sss'),
-        Signal('progress_finished', 'sa{ss}s'),
-        Signal('progress_started', 'sa{ss}s'),
-        Signal('subscribe', 'sss'),
-    )
-    plugin_iface = DBusInterface(
-        const_INT_PREFIX + const_PLUGIN_SUFFIX
-    )
-
-    dbusInterfaces = [core_iface, plugin_iface]
-
-    def __init__(self, path):
-        super().__init__(path)
-        log.debug("Init DBusObject...")
-        self.cb = {}
-
-    def register_method(self, name, cb):
-        self.cb[name] = cb
-
-    def _callback(self, name, *args, **kwargs):
-        """Call the callback if it exists, raise an exception else"""
-        try:
-            cb = self.cb[name]
-        except KeyError:
-            raise MethodNotRegistered
-        else:
-            d = defer.maybeDeferred(cb, *args, **kwargs)
-            d.addErrback(GenericException.create_and_raise)
-            return d
-
-    def dbus_action_launch(self, callback_id, data, profile_key="@DEFAULT@"):
-        return self._callback("action_launch", callback_id, data, profile_key)
-
-    def dbus_actions_get(self, profile_key="@DEFAULT@"):
-        return self._callback("actions_get", profile_key)
-
-    def dbus_config_get(self, section, name):
-        return self._callback("config_get", section, name)
-
-    def dbus_connect(self, profile_key="@DEFAULT@", password='', options={}):
-        return self._callback("connect", profile_key, password, options)
-
-    def dbus_contact_add(self, entity_jid, profile_key="@DEFAULT@"):
-        return self._callback("contact_add", entity_jid, profile_key)
-
-    def dbus_contact_del(self, entity_jid, profile_key="@DEFAULT@"):
-        return self._callback("contact_del", entity_jid, profile_key)
-
-    def dbus_contact_get(self, arg_0, profile_key="@DEFAULT@"):
-        return self._callback("contact_get", arg_0, profile_key)
-
-    def dbus_contact_update(self, entity_jid, name, groups, profile_key="@DEFAULT@"):
-        return self._callback("contact_update", entity_jid, name, groups, profile_key)
-
-    def dbus_contacts_get(self, profile_key="@DEFAULT@"):
-        return self._callback("contacts_get", profile_key)
-
-    def dbus_contacts_get_from_group(self, group, profile_key="@DEFAULT@"):
-        return self._callback("contacts_get_from_group", group, profile_key)
-
-    def dbus_devices_infos_get(self, bare_jid, profile_key):
-        return self._callback("devices_infos_get", bare_jid, profile_key)
-
-    def dbus_disco_find_by_features(self, namespaces, identities, bare_jid=False, service=True, roster=True, own_jid=True, local_device=False, profile_key="@DEFAULT@"):
-        return self._callback("disco_find_by_features", namespaces, identities, bare_jid, service, roster, own_jid, local_device, profile_key)
-
-    def dbus_disco_infos(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@"):
-        return self._callback("disco_infos", entity_jid, node, use_cache, profile_key)
-
-    def dbus_disco_items(self, entity_jid, node=u'', use_cache=True, profile_key="@DEFAULT@"):
-        return self._callback("disco_items", entity_jid, node, use_cache, profile_key)
-
-    def dbus_disconnect(self, profile_key="@DEFAULT@"):
-        return self._callback("disconnect", profile_key)
-
-    def dbus_encryption_namespace_get(self, arg_0):
-        return self._callback("encryption_namespace_get", arg_0)
-
-    def dbus_encryption_plugins_get(self, ):
-        return self._callback("encryption_plugins_get", )
-
-    def dbus_encryption_trust_ui_get(self, to_jid, namespace, profile_key):
-        return self._callback("encryption_trust_ui_get", to_jid, namespace, profile_key)
-
-    def dbus_entities_data_get(self, jids, keys, profile):
-        return self._callback("entities_data_get", jids, keys, profile)
-
-    def dbus_entity_data_get(self, jid, keys, profile):
-        return self._callback("entity_data_get", jid, keys, profile)
-
-    def dbus_features_get(self, profile_key):
-        return self._callback("features_get", profile_key)
-
-    def dbus_history_get(self, from_jid, to_jid, limit, between=True, filters='', profile="@NONE@"):
-        return self._callback("history_get", from_jid, to_jid, limit, between, filters, profile)
-
-    def dbus_image_check(self, arg_0):
-        return self._callback("image_check", arg_0)
-
-    def dbus_image_convert(self, source, dest, arg_2, extra):
-        return self._callback("image_convert", source, dest, arg_2, extra)
-
-    def dbus_image_generate_preview(self, image_path, profile_key):
-        return self._callback("image_generate_preview", image_path, profile_key)
-
-    def dbus_image_resize(self, image_path, width, height):
-        return self._callback("image_resize", image_path, width, height)
-
-    def dbus_is_connected(self, profile_key="@DEFAULT@"):
-        return self._callback("is_connected", profile_key)
-
-    def dbus_main_resource_get(self, contact_jid, profile_key="@DEFAULT@"):
-        return self._callback("main_resource_get", contact_jid, profile_key)
-
-    def dbus_menu_help_get(self, menu_id, language):
-        return self._callback("menu_help_get", menu_id, language)
-
-    def dbus_menu_launch(self, menu_type, path, data, security_limit, profile_key):
-        return self._callback("menu_launch", menu_type, path, data, security_limit, profile_key)
-
-    def dbus_menus_get(self, language, security_limit):
-        return self._callback("menus_get", language, security_limit)
-
-    def dbus_message_encryption_get(self, to_jid, profile_key):
-        return self._callback("message_encryption_get", to_jid, profile_key)
-
-    def dbus_message_encryption_start(self, to_jid, namespace='', replace=False, profile_key="@NONE@"):
-        return self._callback("message_encryption_start", to_jid, namespace, replace, profile_key)
-
-    def dbus_message_encryption_stop(self, to_jid, profile_key):
-        return self._callback("message_encryption_stop", to_jid, profile_key)
-
-    def dbus_message_send(self, to_jid, message, subject={}, mess_type="auto", extra={}, profile_key="@NONE@"):
-        return self._callback("message_send", to_jid, message, subject, mess_type, extra, profile_key)
-
-    def dbus_namespaces_get(self, ):
-        return self._callback("namespaces_get", )
-
-    def dbus_param_get_a(self, name, category, attribute="value", profile_key="@DEFAULT@"):
-        return self._callback("param_get_a", name, category, attribute, profile_key)
-
-    def dbus_param_get_a_async(self, name, category, attribute="value", security_limit=-1, profile_key="@DEFAULT@"):
-        return self._callback("param_get_a_async", name, category, attribute, security_limit, profile_key)
-
-    def dbus_param_set(self, name, value, category, security_limit=-1, profile_key="@DEFAULT@"):
-        return self._callback("param_set", name, value, category, security_limit, profile_key)
-
-    def dbus_param_ui_get(self, security_limit=-1, app='', extra='', profile_key="@DEFAULT@"):
-        return self._callback("param_ui_get", security_limit, app, extra, profile_key)
-
-    def dbus_params_categories_get(self, ):
-        return self._callback("params_categories_get", )
-
-    def dbus_params_register_app(self, xml, security_limit=-1, app=''):
-        return self._callback("params_register_app", xml, security_limit, app)
-
-    def dbus_params_template_load(self, filename):
-        return self._callback("params_template_load", filename)
-
-    def dbus_params_template_save(self, filename):
-        return self._callback("params_template_save", filename)
-
-    def dbus_params_values_from_category_get_async(self, category, security_limit=-1, app="", extra="", profile_key="@DEFAULT@"):
-        return self._callback("params_values_from_category_get_async", category, security_limit, app, extra, profile_key)
-
-    def dbus_presence_set(self, to_jid='', show='', statuses={}, profile_key="@DEFAULT@"):
-        return self._callback("presence_set", to_jid, show, statuses, profile_key)
-
-    def dbus_presence_statuses_get(self, profile_key="@DEFAULT@"):
-        return self._callback("presence_statuses_get", profile_key)
-
-    def dbus_private_data_delete(self, namespace, key, arg_2):
-        return self._callback("private_data_delete", namespace, key, arg_2)
-
-    def dbus_private_data_get(self, namespace, key, profile_key):
-        return self._callback("private_data_get", namespace, key, profile_key)
-
-    def dbus_private_data_set(self, namespace, key, data, profile_key):
-        return self._callback("private_data_set", namespace, key, data, profile_key)
-
-    def dbus_profile_create(self, profile, password='', component=''):
-        return self._callback("profile_create", profile, password, component)
-
-    def dbus_profile_delete_async(self, profile):
-        return self._callback("profile_delete_async", profile)
-
-    def dbus_profile_is_session_started(self, profile_key="@DEFAULT@"):
-        return self._callback("profile_is_session_started", profile_key)
-
-    def dbus_profile_name_get(self, profile_key="@DEFAULT@"):
-        return self._callback("profile_name_get", profile_key)
-
-    def dbus_profile_set_default(self, profile):
-        return self._callback("profile_set_default", profile)
-
-    def dbus_profile_start_session(self, password='', profile_key="@DEFAULT@"):
-        return self._callback("profile_start_session", password, profile_key)
-
-    def dbus_profiles_list_get(self, clients=True, components=False):
-        return self._callback("profiles_list_get", clients, components)
-
-    def dbus_progress_get(self, id, profile):
-        return self._callback("progress_get", id, profile)
-
-    def dbus_progress_get_all(self, profile):
-        return self._callback("progress_get_all", profile)
-
-    def dbus_progress_get_all_metadata(self, profile):
-        return self._callback("progress_get_all_metadata", profile)
-
-    def dbus_ready_get(self, ):
-        return self._callback("ready_get", )
-
-    def dbus_roster_resync(self, profile_key="@DEFAULT@"):
-        return self._callback("roster_resync", profile_key)
-
-    def dbus_session_infos_get(self, profile_key):
-        return self._callback("session_infos_get", profile_key)
-
-    def dbus_sub_waiting_get(self, profile_key="@DEFAULT@"):
-        return self._callback("sub_waiting_get", profile_key)
-
-    def dbus_subscription(self, sub_type, entity, profile_key="@DEFAULT@"):
-        return self._callback("subscription", sub_type, entity, profile_key)
-
-    def dbus_version_get(self, ):
-        return self._callback("version_get", )
-
-
-class bridge:
-
-    def __init__(self):
-        log.info("Init DBus...")
-        self._obj = DBusObject(const_OBJ_PATH)
-
-    async def post_init(self):
-        try:
-            conn = await client.connect(reactor)
-        except error.DBusException as e:
-            if e.errName == "org.freedesktop.DBus.Error.NotSupported":
-                log.error(
-                    _(
-                        "D-Bus is not launched, please see README to see instructions on "
-                        "how to launch it"
-                    )
-                )
-            raise BridgeInitError(str(e))
-
-        conn.exportObject(self._obj)
-        await conn.requestBusName(const_INT_PREFIX)
-
-    def _debug(self, action, params, profile):
-        self._obj.emitSignal("_debug", action, params, profile)
-
-    def action_new(self, action_data, id, security_limit, profile):
-        self._obj.emitSignal("action_new", action_data, id, security_limit, profile)
-
-    def connected(self, jid_s, profile):
-        self._obj.emitSignal("connected", jid_s, profile)
-
-    def contact_deleted(self, entity_jid, profile):
-        self._obj.emitSignal("contact_deleted", entity_jid, profile)
-
-    def contact_new(self, contact_jid, attributes, groups, profile):
-        self._obj.emitSignal("contact_new", contact_jid, attributes, groups, profile)
-
-    def disconnected(self, profile):
-        self._obj.emitSignal("disconnected", profile)
-
-    def entity_data_updated(self, jid, name, value, profile):
-        self._obj.emitSignal("entity_data_updated", jid, name, value, profile)
-
-    def message_encryption_started(self, to_jid, encryption_data, profile_key):
-        self._obj.emitSignal("message_encryption_started", to_jid, encryption_data, profile_key)
-
-    def message_encryption_stopped(self, to_jid, encryption_data, profile_key):
-        self._obj.emitSignal("message_encryption_stopped", to_jid, encryption_data, profile_key)
-
-    def message_new(self, uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile):
-        self._obj.emitSignal("message_new", uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile)
-
-    def param_update(self, name, value, category, profile):
-        self._obj.emitSignal("param_update", name, value, category, profile)
-
-    def presence_update(self, entity_jid, show, priority, statuses, profile):
-        self._obj.emitSignal("presence_update", entity_jid, show, priority, statuses, profile)
-
-    def progress_error(self, id, error, profile):
-        self._obj.emitSignal("progress_error", id, error, profile)
-
-    def progress_finished(self, id, metadata, profile):
-        self._obj.emitSignal("progress_finished", id, metadata, profile)
-
-    def progress_started(self, id, metadata, profile):
-        self._obj.emitSignal("progress_started", id, metadata, profile)
-
-    def subscribe(self, sub_type, entity_jid, profile):
-        self._obj.emitSignal("subscribe", sub_type, entity_jid, profile)
-
-    def register_method(self, name, callback):
-        log.debug(f"registering DBus bridge method [{name}]")
-        self._obj.register_method(name, callback)
-
-    def emit_signal(self, name, *args):
-        self._obj.emitSignal(name, *args)
-
-    def add_method(
-            self, name, int_suffix, in_sign, out_sign, method, async_=False, doc={}
-    ):
-        """Dynamically add a method to D-Bus bridge"""
-        # FIXME: doc parameter is kept only temporary, the time to remove it from calls
-        log.debug(f"Adding method {name!r} to D-Bus bridge")
-        self._obj.plugin_iface.addMethod(
-            Method(name, arguments=in_sign, returns=out_sign)
-        )
-        # we have to create a method here instead of using partialmethod, because txdbus
-        # uses __func__ which doesn't work with partialmethod
-        def caller(self_, *args, **kwargs):
-            return self_._callback(name, *args, **kwargs)
-        setattr(self._obj, f"dbus_{name}", MethodType(caller, self._obj))
-        self.register_method(name, method)
-
-    def add_signal(self, name, int_suffix, signature, doc={}):
-        """Dynamically add a signal to D-Bus bridge"""
-        log.debug(f"Adding signal {name!r} to D-Bus bridge")
-        self._obj.plugin_iface.addSignal(Signal(name, signature))
-        setattr(bridge, name, partialmethod(bridge.emit_signal, name))
\ No newline at end of file
--- a/sat/bridge/pb.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,212 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import dataclasses
-from functools import partial
-from pathlib import Path
-from twisted.spread import jelly, pb
-from twisted.internet import reactor
-from sat.core.log import getLogger
-from sat.tools import config
-
-log = getLogger(__name__)
-
-
-## jelly hack
-# we monkey patch jelly to handle namedtuple
-ori_jelly = jelly._Jellier.jelly
-
-
-def fixed_jelly(self, obj):
-    """this method fix handling of namedtuple"""
-    if isinstance(obj, tuple) and not obj is tuple:
-        obj = tuple(obj)
-    return ori_jelly(self, obj)
-
-
-jelly._Jellier.jelly = fixed_jelly
-
-
-@dataclasses.dataclass(eq=False)
-class HandlerWrapper:
-    # we use a wrapper to keep signals handlers because RemoteReference doesn't support
-    # comparison (other than equality), making it unusable with a list
-    handler: pb.RemoteReference
-
-
-class PBRoot(pb.Root):
-    def __init__(self):
-        self.signals_handlers = []
-
-    def remote_init_bridge(self, signals_handler):
-        self.signals_handlers.append(HandlerWrapper(signals_handler))
-        log.info("registered signal handler")
-
-    def send_signal_eb(self, failure_, signal_name):
-        if not failure_.check(pb.PBConnectionLost):
-            log.error(
-                f"Error while sending signal {signal_name}: {failure_}",
-            )
-
-    def send_signal(self, name, args, kwargs):
-        to_remove = []
-        for wrapper in self.signals_handlers:
-            handler = wrapper.handler
-            try:
-                d = handler.callRemote(name, *args, **kwargs)
-            except pb.DeadReferenceError:
-                to_remove.append(wrapper)
-            else:
-                d.addErrback(self.send_signal_eb, name)
-        if to_remove:
-            for wrapper in to_remove:
-                log.debug("Removing signal handler for dead frontend")
-                self.signals_handlers.remove(wrapper)
-
-    def _bridge_deactivate_signals(self):
-        if hasattr(self, "signals_paused"):
-            log.warning("bridge signals already deactivated")
-            if self.signals_handler:
-                self.signals_paused.extend(self.signals_handler)
-        else:
-            self.signals_paused = self.signals_handlers
-        self.signals_handlers = []
-        log.debug("bridge signals have been deactivated")
-
-    def _bridge_reactivate_signals(self):
-        try:
-            self.signals_handlers = self.signals_paused
-        except AttributeError:
-            log.debug("signals were already activated")
-        else:
-            del self.signals_paused
-            log.debug("bridge signals have been reactivated")
-
-##METHODS_PART##
-
-
-class bridge(object):
-    def __init__(self):
-        log.info("Init Perspective Broker...")
-        self.root = PBRoot()
-        conf = config.parse_main_conf()
-        get_conf = partial(config.get_conf, conf, "bridge_pb", "")
-        conn_type = get_conf("connection_type", "unix_socket")
-        if conn_type == "unix_socket":
-            local_dir = Path(config.config_get(conf, "", "local_dir")).resolve()
-            socket_path = local_dir / "bridge_pb"
-            log.info(f"using UNIX Socket at {socket_path}")
-            reactor.listenUNIX(
-                str(socket_path), pb.PBServerFactory(self.root), mode=0o600
-            )
-        elif conn_type == "socket":
-            port = int(get_conf("port", 8789))
-            log.info(f"using TCP Socket at port {port}")
-            reactor.listenTCP(port, pb.PBServerFactory(self.root))
-        else:
-            raise ValueError(f"Unknown pb connection type: {conn_type!r}")
-
-    def send_signal(self, name, *args, **kwargs):
-        self.root.send_signal(name, args, kwargs)
-
-    def remote_init_bridge(self, signals_handler):
-        self.signals_handlers.append(signals_handler)
-        log.info("registered signal handler")
-
-    def register_method(self, name, callback):
-        log.debug("registering PB bridge method [%s]" % name)
-        setattr(self.root, "remote_" + name, callback)
-        #  self.root.register_method(name, callback)
-
-    def add_method(
-            self, name, int_suffix, in_sign, out_sign, method, async_=False, doc={}
-    ):
-        """Dynamically add a method to PB bridge"""
-        # FIXME: doc parameter is kept only temporary, the time to remove it from calls
-        log.debug("Adding method {name} to PB bridge".format(name=name))
-        self.register_method(name, method)
-
-    def add_signal(self, name, int_suffix, signature, doc={}):
-        log.debug("Adding signal {name} to PB bridge".format(name=name))
-        setattr(
-            self, name, lambda *args, **kwargs: self.send_signal(name, *args, **kwargs)
-        )
-
-    def bridge_deactivate_signals(self):
-        """Stop sending signals to bridge
-
-        Mainly used for mobile frontends, when the frontend is paused
-        """
-        self.root._bridge_deactivate_signals()
-
-    def bridge_reactivate_signals(self):
-        """Send again signals to bridge
-
-        Should only be used after bridge_deactivate_signals has been called
-        """
-        self.root._bridge_reactivate_signals()
-
-    def _debug(self, action, params, profile):
-        self.send_signal("_debug", action, params, profile)
-
-    def action_new(self, action_data, id, security_limit, profile):
-        self.send_signal("action_new", action_data, id, security_limit, profile)
-
-    def connected(self, jid_s, profile):
-        self.send_signal("connected", jid_s, profile)
-
-    def contact_deleted(self, entity_jid, profile):
-        self.send_signal("contact_deleted", entity_jid, profile)
-
-    def contact_new(self, contact_jid, attributes, groups, profile):
-        self.send_signal("contact_new", contact_jid, attributes, groups, profile)
-
-    def disconnected(self, profile):
-        self.send_signal("disconnected", profile)
-
-    def entity_data_updated(self, jid, name, value, profile):
-        self.send_signal("entity_data_updated", jid, name, value, profile)
-
-    def message_encryption_started(self, to_jid, encryption_data, profile_key):
-        self.send_signal("message_encryption_started", to_jid, encryption_data, profile_key)
-
-    def message_encryption_stopped(self, to_jid, encryption_data, profile_key):
-        self.send_signal("message_encryption_stopped", to_jid, encryption_data, profile_key)
-
-    def message_new(self, uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile):
-        self.send_signal("message_new", uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra, profile)
-
-    def param_update(self, name, value, category, profile):
-        self.send_signal("param_update", name, value, category, profile)
-
-    def presence_update(self, entity_jid, show, priority, statuses, profile):
-        self.send_signal("presence_update", entity_jid, show, priority, statuses, profile)
-
-    def progress_error(self, id, error, profile):
-        self.send_signal("progress_error", id, error, profile)
-
-    def progress_finished(self, id, metadata, profile):
-        self.send_signal("progress_finished", id, metadata, profile)
-
-    def progress_started(self, id, metadata, profile):
-        self.send_signal("progress_started", id, metadata, profile)
-
-    def subscribe(self, sub_type, entity_jid, profile):
-        self.send_signal("subscribe", sub_type, entity_jid, profile)
--- a/sat/core/constants.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,534 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-try:
-    from xdg import BaseDirectory
-    from os.path import expanduser, realpath
-except ImportError:
-    BaseDirectory = None
-from os.path import dirname
-from typing import Final
-import sat
-
-
-class Const(object):
-
-    ## Application ##
-    APP_NAME = "Libervia"
-    APP_COMPONENT = "backend"
-    APP_NAME_ALT = "Libervia"
-    APP_NAME_FILE = "libervia"
-    APP_NAME_FULL = f"{APP_NAME} ({APP_COMPONENT})"
-    APP_VERSION = (
-        sat.__version__
-    )  # Please add 'D' at the end of version in sat/VERSION for dev versions
-    APP_RELEASE_NAME = "La Ruche"
-    APP_URL = "https://libervia.org"
-
-    ## Runtime ##
-    PLUGIN_EXT = "py"
-    HISTORY_SKIP = "skip"
-
-    ## Main config ##
-    DEFAULT_BRIDGE = "dbus"
-
-    ## Protocol ##
-    XMPP_C2S_PORT = 5222
-    XMPP_MAX_RETRIES = None
-    # default port used on Prosody, may differ on other servers
-    XMPP_COMPONENT_PORT = 5347
-
-    ## Parameters ##
-    NO_SECURITY_LIMIT = -1  #  FIXME: to rename
-    SECURITY_LIMIT_MAX = 0
-    INDIVIDUAL = "individual"
-    GENERAL = "general"
-    # General parameters
-    HISTORY_LIMIT = "History"
-    SHOW_OFFLINE_CONTACTS = "Offline contacts"
-    SHOW_EMPTY_GROUPS = "Empty groups"
-    # Parameters related to connection
-    FORCE_SERVER_PARAM = "Force server"
-    FORCE_PORT_PARAM = "Force port"
-    # Parameters related to encryption
-    PROFILE_PASS_PATH = ("General", "Password")
-    MEMORY_CRYPTO_NAMESPACE = "crypto"  # for the private persistent binary dict
-    MEMORY_CRYPTO_KEY = "personal_key"
-    # Parameters for static blog pages
-    # FIXME: blog constants should not be in core constants
-    STATIC_BLOG_KEY = "Blog page"
-    STATIC_BLOG_PARAM_TITLE = "Title"
-    STATIC_BLOG_PARAM_BANNER = "Banner"
-    STATIC_BLOG_PARAM_KEYWORDS = "Keywords"
-    STATIC_BLOG_PARAM_DESCRIPTION = "Description"
-
-    ## Menus ##
-    MENU_GLOBAL = "GLOBAL"
-    MENU_ROOM = "ROOM"
-    MENU_SINGLE = "SINGLE"
-    MENU_JID_CONTEXT = "JID_CONTEXT"
-    MENU_ROSTER_JID_CONTEXT = "ROSTER_JID_CONTEXT"
-    MENU_ROSTER_GROUP_CONTEXT = "MENU_ROSTER_GROUP_CONTEXT"
-    MENU_ROOM_OCCUPANT_CONTEXT = "MENU_ROOM_OCCUPANT_CONTEXT"
-
-    ## Profile and entities ##
-    PROF_KEY_NONE = "@NONE@"
-    PROF_KEY_DEFAULT = "@DEFAULT@"
-    PROF_KEY_ALL = "@ALL@"
-    ENTITY_ALL = "@ALL@"
-    ENTITY_ALL_RESOURCES = "@ALL_RESOURCES@"
-    ENTITY_MAIN_RESOURCE = "@MAIN_RESOURCE@"
-    ENTITY_CAP_HASH = "CAP_HASH"
-    ENTITY_TYPE = "type"
-    ENTITY_TYPE_MUC = "MUC"
-
-    ## Roster jids selection ##
-    PUBLIC = "PUBLIC"
-    ALL = (
-        "ALL"
-    )  # ALL means all known contacts, while PUBLIC means everybody, known or not
-    GROUP = "GROUP"
-    JID = "JID"
-
-    ## Messages ##
-    MESS_TYPE_INFO = "info"
-    MESS_TYPE_CHAT = "chat"
-    MESS_TYPE_ERROR = "error"
-    MESS_TYPE_GROUPCHAT = "groupchat"
-    MESS_TYPE_HEADLINE = "headline"
-    MESS_TYPE_NORMAL = "normal"
-    MESS_TYPE_AUTO = "auto"  # magic value to let the backend guess the type
-    MESS_TYPE_STANDARD = (
-        MESS_TYPE_CHAT,
-        MESS_TYPE_ERROR,
-        MESS_TYPE_GROUPCHAT,
-        MESS_TYPE_HEADLINE,
-        MESS_TYPE_NORMAL,
-    )
-    MESS_TYPE_ALL = MESS_TYPE_STANDARD + (MESS_TYPE_INFO, MESS_TYPE_AUTO)
-
-    MESS_EXTRA_INFO = "info_type"
-    EXTRA_INFO_DECR_ERR = "DECRYPTION_ERROR"
-    EXTRA_INFO_ENCR_ERR = "ENCRYPTION_ERROR"
-
-    # encryption is a key for plugins
-    MESS_KEY_ENCRYPTION: Final = "ENCRYPTION"
-    # encrypted is a key for frontends
-    MESS_KEY_ENCRYPTED = "encrypted"
-    MESS_KEY_TRUSTED = "trusted"
-
-    # File encryption algorithms
-    ENC_AES_GCM = "AES-GCM"
-
-    ## Chat ##
-    CHAT_ONE2ONE = "one2one"
-    CHAT_GROUP = "group"
-
-    ## Presence ##
-    PRESENCE_UNAVAILABLE = "unavailable"
-    PRESENCE_SHOW_AWAY = "away"
-    PRESENCE_SHOW_CHAT = "chat"
-    PRESENCE_SHOW_DND = "dnd"
-    PRESENCE_SHOW_XA = "xa"
-    PRESENCE_SHOW = "show"
-    PRESENCE_STATUSES = "statuses"
-    PRESENCE_STATUSES_DEFAULT = "default"
-    PRESENCE_PRIORITY = "priority"
-
-    ## Common namespaces ##
-    NS_XML = "http://www.w3.org/XML/1998/namespace"
-    NS_CLIENT = "jabber:client"
-    NS_COMPONENT = "jabber:component:accept"
-    NS_STREAM = (NS_CLIENT, NS_COMPONENT)
-    NS_FORWARD = "urn:xmpp:forward:0"
-    NS_DELAY = "urn:xmpp:delay"
-    NS_XHTML = "http://www.w3.org/1999/xhtml"
-
-    ## Common XPath ##
-
-    IQ_GET = '/iq[@type="get"]'
-    IQ_SET = '/iq[@type="set"]'
-
-    ## Directories ##
-
-    # directory for components specific data
-    COMPONENTS_DIR = "components"
-    CACHE_DIR = "cache"
-    # files in file dir are stored for long term
-    # files dir is global, i.e. for all profiles
-    FILES_DIR = "files"
-    # FILES_LINKS_DIR is a directory where files owned by a specific profile
-    # are linked to the global files directory. This way the directory can be
-    #  shared per profiles while keeping global directory where identical files
-    # shared between different profiles are not duplicated.
-    FILES_LINKS_DIR = "files_links"
-    # FILES_TMP_DIR is where profile's partially transfered files are put.
-    # Once transfer is completed, they are moved to FILES_DIR
-    FILES_TMP_DIR = "files_tmp"
-
-    ## Templates ##
-    TEMPLATE_TPL_DIR = "templates"
-    TEMPLATE_THEME_DEFAULT = "default"
-    TEMPLATE_STATIC_DIR = "static"
-    # templates i18n
-    KEY_LANG = "lang"
-    KEY_THEME = "theme"
-
-    ## Plugins ##
-
-    # PLUGIN_INFO keys
-    # XXX: we use PI instead of PLUG_INFO which would normally be used
-    #      to make the header more readable
-    PI_NAME = "name"
-    PI_IMPORT_NAME = "import_name"
-    PI_MAIN = "main"
-    PI_HANDLER = "handler"
-    PI_TYPE = (
-        "type"
-    )  #  FIXME: should be types, and should handle single unicode type or tuple of types (e.g. "blog" and "import")
-    PI_MODES = "modes"
-    PI_PROTOCOLS = "protocols"
-    PI_DEPENDENCIES = "dependencies"
-    PI_RECOMMENDATIONS = "recommendations"
-    PI_DESCRIPTION = "description"
-    PI_USAGE = "usage"
-
-    # Types
-    PLUG_TYPE_XEP = "XEP"
-    PLUG_TYPE_MISC = "MISC"
-    PLUG_TYPE_EXP = "EXP"
-    PLUG_TYPE_SEC = "SEC"
-    PLUG_TYPE_SYNTAXE = "SYNTAXE"
-    PLUG_TYPE_PUBSUB = "PUBSUB"
-    PLUG_TYPE_BLOG = "BLOG"
-    PLUG_TYPE_IMPORT = "IMPORT"
-    PLUG_TYPE_ENTRY_POINT = "ENTRY_POINT"
-
-    # Modes
-    PLUG_MODE_CLIENT = "client"
-    PLUG_MODE_COMPONENT = "component"
-    PLUG_MODE_DEFAULT = (PLUG_MODE_CLIENT,)
-    PLUG_MODE_BOTH = (PLUG_MODE_CLIENT, PLUG_MODE_COMPONENT)
-
-    # names of widely used plugins
-    TEXT_CMDS = "TEXT-COMMANDS"
-
-    # PubSub event categories
-    PS_PEP = "PEP"
-    PS_MICROBLOG = "MICROBLOG"
-
-    # PubSub
-    PS_PUBLISH = "publish"
-    PS_RETRACT = "retract"  # used for items
-    PS_DELETE = "delete"  # used for nodes
-    PS_PURGE = "purge"  # used for nodes
-    PS_ITEM = "item"
-    PS_ITEMS = "items"  # Can contain publish and retract items
-    PS_EVENTS = (PS_ITEMS, PS_DELETE, PS_PURGE)
-
-    ## MESSAGE/NOTIFICATION LEVELS ##
-
-    LVL_INFO = "info"
-    LVL_WARNING = "warning"
-    LVL_ERROR = "error"
-
-    ## XMLUI ##
-    XMLUI_WINDOW = "window"
-    XMLUI_POPUP = "popup"
-    XMLUI_FORM = "form"
-    XMLUI_PARAM = "param"
-    XMLUI_DIALOG = "dialog"
-    XMLUI_DIALOG_CONFIRM = "confirm"
-    XMLUI_DIALOG_MESSAGE = "message"
-    XMLUI_DIALOG_NOTE = "note"
-    XMLUI_DIALOG_FILE = "file"
-    XMLUI_DATA_ANSWER = "answer"
-    XMLUI_DATA_CANCELLED = "cancelled"
-    XMLUI_DATA_TYPE = "type"
-    XMLUI_DATA_MESS = "message"
-    XMLUI_DATA_LVL = "level"
-    XMLUI_DATA_LVL_INFO = LVL_INFO
-    XMLUI_DATA_LVL_WARNING = LVL_WARNING
-    XMLUI_DATA_LVL_ERROR = LVL_ERROR
-    XMLUI_DATA_LVL_DEFAULT = XMLUI_DATA_LVL_INFO
-    XMLUI_DATA_LVLS = (XMLUI_DATA_LVL_INFO, XMLUI_DATA_LVL_WARNING, XMLUI_DATA_LVL_ERROR)
-    XMLUI_DATA_BTNS_SET = "buttons_set"
-    XMLUI_DATA_BTNS_SET_OKCANCEL = "ok/cancel"
-    XMLUI_DATA_BTNS_SET_YESNO = "yes/no"
-    XMLUI_DATA_BTNS_SET_DEFAULT = XMLUI_DATA_BTNS_SET_OKCANCEL
-    XMLUI_DATA_FILETYPE = "filetype"
-    XMLUI_DATA_FILETYPE_FILE = "file"
-    XMLUI_DATA_FILETYPE_DIR = "dir"
-    XMLUI_DATA_FILETYPE_DEFAULT = XMLUI_DATA_FILETYPE_FILE
-
-    ## Logging ##
-    LOG_LVL_DEBUG = "DEBUG"
-    LOG_LVL_INFO = "INFO"
-    LOG_LVL_WARNING = "WARNING"
-    LOG_LVL_ERROR = "ERROR"
-    LOG_LVL_CRITICAL = "CRITICAL"
-    LOG_LEVELS = (
-        LOG_LVL_DEBUG,
-        LOG_LVL_INFO,
-        LOG_LVL_WARNING,
-        LOG_LVL_ERROR,
-        LOG_LVL_CRITICAL,
-    )
-    LOG_BACKEND_STANDARD = "standard"
-    LOG_BACKEND_TWISTED = "twisted"
-    LOG_BACKEND_BASIC = "basic"
-    LOG_BACKEND_CUSTOM = "custom"
-    LOG_BASE_LOGGER = "root"
-    LOG_TWISTED_LOGGER = "twisted"
-    LOG_OPT_SECTION = "DEFAULT"  # section of sat.conf where log options should be
-    LOG_OPT_PREFIX = "log_"
-    # (option_name, default_value) tuples
-    LOG_OPT_COLORS = (
-        "colors",
-        "true",
-    )  # true for auto colors, force to have colors even if stdout is not a tty, false for no color
-    LOG_OPT_TAINTS_DICT = (
-        "levels_taints_dict",
-        {
-            LOG_LVL_DEBUG: ("cyan",),
-            LOG_LVL_INFO: (),
-            LOG_LVL_WARNING: ("yellow",),
-            LOG_LVL_ERROR: ("red", "blink", r"/!\ ", "blink_off"),
-            LOG_LVL_CRITICAL: ("bold", "red", "Guru Meditation ", "normal_weight"),
-        },
-    )
-    LOG_OPT_LEVEL = ("level", "info")
-    LOG_OPT_FORMAT = ("fmt", "%(message)s")  # similar to logging format.
-    LOG_OPT_LOGGER = ("logger", "")  # regex to filter logger name
-    LOG_OPT_OUTPUT_SEP = "//"
-    LOG_OPT_OUTPUT_DEFAULT = "default"
-    LOG_OPT_OUTPUT_MEMORY = "memory"
-    LOG_OPT_OUTPUT_MEMORY_LIMIT = 300
-    LOG_OPT_OUTPUT_FILE = "file"  # file is implicit if only output
-    LOG_OPT_OUTPUT = (
-        "output",
-        LOG_OPT_OUTPUT_SEP + LOG_OPT_OUTPUT_DEFAULT,
-    )  # //default = normal output (stderr or a file with twistd), path/to/file for a file (must be the first if used), //memory for memory (options can be put in parenthesis, e.g.: //memory(500) for a 500 lines memory)
-
-    ## action constants ##
-    META_TYPE_FILE = "file"
-    META_TYPE_CALL = "call"
-    META_TYPE_OVERWRITE = "overwrite"
-    META_TYPE_NOT_IN_ROSTER_LEAK = "not_in_roster_leak"
-    META_SUBTYPE_CALL_AUDIO = "audio"
-    META_SUBTYPE_CALL_VIDEO = "video"
-
-    ## HARD-CODED ACTIONS IDS (generated with uuid.uuid4) ##
-    AUTHENTICATE_PROFILE_ID = "b03bbfa8-a4ae-4734-a248-06ce6c7cf562"
-    CHANGE_XMPP_PASSWD_ID = "878b9387-de2b-413b-950f-e424a147bcd0"
-
-    ## Text values ##
-    BOOL_TRUE = "true"
-    BOOL_FALSE = "false"
-
-    ## Special values used in bridge methods calls ##
-    HISTORY_LIMIT_DEFAULT = -1
-    HISTORY_LIMIT_NONE = -2
-
-    ## Progress error special values ##
-    PROGRESS_ERROR_DECLINED = "declined"  #  session has been declined by peer user
-    PROGRESS_ERROR_FAILED = "failed"  #  something went wrong with the session
-
-    ## Files ##
-    FILE_TYPE_DIRECTORY = "directory"
-    FILE_TYPE_FILE = "file"
-    # when filename can't be found automatically, this one will be used
-    FILE_DEFAULT_NAME = "unnamed"
-
-    ## Permissions management ##
-    ACCESS_PERM_READ = "read"
-    ACCESS_PERM_WRITE = "write"
-    ACCESS_PERMS = {ACCESS_PERM_READ, ACCESS_PERM_WRITE}
-    ACCESS_TYPE_PUBLIC = "public"
-    ACCESS_TYPE_WHITELIST = "whitelist"
-    ACCESS_TYPES = (ACCESS_TYPE_PUBLIC, ACCESS_TYPE_WHITELIST)
-
-    ## Common data keys ##
-    KEY_THUMBNAILS = "thumbnails"
-    KEY_PROGRESS_ID = "progress_id"
-    KEY_ATTACHMENTS = "attachments"
-    KEY_ATTACHMENTS_MEDIA_TYPE = "media_type"
-    KEY_ATTACHMENTS_PREVIEW = "preview"
-    KEY_ATTACHMENTS_RESIZE = "resize"
-
-
-    ## Common extra keys/values ##
-    KEY_ORDER_BY = "order_by"
-    KEY_USE_CACHE = "use_cache"
-    KEY_DECRYPT = "decrypt"
-
-    ORDER_BY_CREATION = 'creation'
-    ORDER_BY_MODIFICATION = 'modification'
-
-    # internationalisation
-    DEFAULT_LOCALE = "en_GB"
-
-    ## Command Line ##
-
-    # Exit codes used by CLI applications
-    EXIT_OK = 0
-    EXIT_ERROR = 1  # generic error, when nothing else match
-    EXIT_BAD_ARG = 2  # arguments given by user are bad
-    EXIT_BRIDGE_ERROR = 3  # can't connect to bridge
-    EXIT_BRIDGE_ERRBACK = 4  # something went wrong when calling a bridge method
-    EXIT_BACKEND_NOT_FOUND = 5  # can't find backend with this bride
-    EXIT_NOT_FOUND = 16  # an item required by a command was not found
-    EXIT_DATA_ERROR = 17  # data needed for a command is invalid
-    EXIT_MISSING_FEATURE = 18  # a needed plugin or feature is not available
-    EXIT_CONFLICT = 19  # an item already exists
-    EXIT_USER_CANCELLED = 20  # user cancelled action
-    EXIT_INTERNAL_ERROR = 111  # unexpected error
-    EXIT_FILE_NOT_EXE = (
-        126
-    )  # a file to be executed was found, but it was not an executable utility (cf. man 1 exit)
-    EXIT_CMD_NOT_FOUND = 127  # a utility to be executed was not found (cf. man 1 exit)
-    EXIT_CMD_ERROR = 127  # a utility to be executed returned an error exit code
-    EXIT_SIGNAL_INT = 128  # a command was interrupted by a signal (cf. man 1 exit)
-
-    ## Misc ##
-    SAVEFILE_DATABASE = APP_NAME_FILE + ".db"
-    IQ_SET = '/iq[@type="set"]'
-    ENV_PREFIX = "SAT_"  # Prefix used for environment variables
-    IGNORE = "ignore"
-    NO_LIMIT = -1  # used in bridge when a integer value is expected
-    DEFAULT_MAX_AGE = 1209600  # default max age of cached files, in seconds
-    STANZA_NAMES = ("iq", "message", "presence")
-
-    # Stream Hooks
-    STREAM_HOOK_SEND = "send"
-    STREAM_HOOK_RECEIVE = "receive"
-
-    @classmethod
-    def LOG_OPTIONS(cls):
-        """Return options checked for logs"""
-        # XXX: we use a classmethod so we can use Const inheritance to change default options
-        return (
-            cls.LOG_OPT_COLORS,
-            cls.LOG_OPT_TAINTS_DICT,
-            cls.LOG_OPT_LEVEL,
-            cls.LOG_OPT_FORMAT,
-            cls.LOG_OPT_LOGGER,
-            cls.LOG_OPT_OUTPUT,
-        )
-
-    @classmethod
-    def bool(cls, value: str) -> bool:
-        """@return (bool): bool value for associated constant"""
-        assert isinstance(value, str)
-        return value.lower() in (cls.BOOL_TRUE, "1", "yes", "on")
-
-    @classmethod
-    def bool_const(cls, value: bool) -> str:
-        """@return (str): constant associated to bool value"""
-        assert isinstance(value, bool)
-        return cls.BOOL_TRUE if value else cls.BOOL_FALSE
-
-
-
-## Configuration ##
-if (
-    BaseDirectory
-):  # skipped when xdg module is not available (should not happen in backend)
-    if "org.libervia.cagou" in BaseDirectory.__file__:
-        # FIXME: hack to make config read from the right location on Android
-        # TODO: fix it in a more proper way
-
-        # we need to use Android API to get downloads directory
-        import os.path
-        from jnius import autoclass
-
-        # we don't want the very verbose jnius log when we are in DEBUG level
-        import logging
-        logging.getLogger('jnius').setLevel(logging.WARNING)
-        logging.getLogger('jnius.reflect').setLevel(logging.WARNING)
-
-        Environment = autoclass("android.os.Environment")
-
-        BaseDirectory = None
-        Const.DEFAULT_CONFIG = {
-            "local_dir": "/data/data/org.libervia.cagou/app",
-            "media_dir": "/data/data/org.libervia.cagou/files/app/media",
-            # FIXME: temporary location for downloads, need to call API properly
-            "downloads_dir": os.path.join(
-                Environment.getExternalStoragePublicDirectory(
-                    Environment.DIRECTORY_DOWNLOADS
-                ).getAbsolutePath(),
-                Const.APP_NAME_FILE,
-            ),
-            "pid_dir": "%(local_dir)s",
-            "log_dir": "%(local_dir)s",
-        }
-        Const.CONFIG_FILES = [
-            "/data/data/org.libervia.cagou/files/app/android/"
-            + Const.APP_NAME_FILE
-            + ".conf"
-        ]
-    else:
-        import os
-        # we use parent of "sat" module dir as last config path, this is useful for
-        # per instance configurations (e.g. a dev instance and a main instance)
-        root_dir = dirname(dirname(sat.__file__)) + '/'
-        Const.CONFIG_PATHS = (
-            # /etc/_sat.conf is used for system-related settings (e.g. when media_dir
-            # is set by the distribution and has not reason to change, or in a Docker
-            # image)
-            ["/etc/_", "/etc/", "~/", "~/."]
-            + [
-                "{}/".format(path)
-                for path in list(BaseDirectory.load_config_paths(Const.APP_NAME_FILE))
-            ]
-            # this is to handle legacy sat.conf
-            + [
-                "{}/".format(path)
-                for path in list(BaseDirectory.load_config_paths("sat"))
-            ]
-            + [root_dir]
-        )
-
-        # on recent versions of Flatpak, FLATPAK_ID is set at run time
-        # it seems that this is not the case on older versions,
-        # but FLATPAK_SANDBOX_DIR seems set then
-        if os.getenv('FLATPAK_ID') or os.getenv('FLATPAK_SANDBOX_DIR'):
-            # for Flatpak, the conf can't be set in /etc or $HOME, so we have
-            # to add /app
-            Const.CONFIG_PATHS.append('/app/')
-
-        ## Configuration ##
-        Const.DEFAULT_CONFIG = {
-            "media_dir": "/usr/share/" + Const.APP_NAME_FILE + "/media",
-            "local_dir": BaseDirectory.save_data_path(Const.APP_NAME_FILE),
-            "downloads_dir": "~/Downloads/" + Const.APP_NAME_FILE,
-            "pid_dir": "%(local_dir)s",
-            "log_dir": "%(local_dir)s",
-        }
-
-        # List of the configuration filenames sorted by ascending priority
-        Const.CONFIG_FILES = [
-            realpath(expanduser(path) + Const.APP_NAME_FILE + ".conf")
-            for path in Const.CONFIG_PATHS
-        ] + [
-            # legacy sat.conf
-            realpath(expanduser(path) + "sat.conf")
-            for path in Const.CONFIG_PATHS
-        ]
-
--- a/sat/core/core_types.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,66 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia types
-# Copyright (C) 2011  Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from collections import namedtuple
-from typing import Dict, Callable, Optional
-from typing_extensions import TypedDict
-
-from twisted.words.protocols.jabber import jid as t_jid
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.xish import domish
-
-
-class SatXMPPEntity:
-
-    profile: str
-    jid: t_jid.JID
-    is_component: bool
-    server_jid: t_jid.JID
-    IQ: Callable[[Optional[str], Optional[int]], xmlstream.IQ]
-
-EncryptionPlugin = namedtuple("EncryptionPlugin", ("instance",
-                                                   "name",
-                                                   "namespace",
-                                                   "priority",
-                                                   "directed"))
-
-
-class EncryptionSession(TypedDict):
-    plugin: EncryptionPlugin
-
-
-# Incomplete types built through observation rather than code inspection.
-MessageDataExtra = TypedDict(
-    "MessageDataExtra",
-    { "encrypted": bool, "origin_id": str },
-    total=False
-)
-
-
-MessageData = TypedDict("MessageData", {
-    "from": t_jid.JID,
-    "to": t_jid.JID,
-    "uid": str,
-    "message": Dict[str, str],
-    "subject": Dict[str, str],
-    "type": str,
-    "timestamp": float,
-    "extra": MessageDataExtra,
-    "ENCRYPTION": EncryptionSession,
-    "xml": domish.Element
-}, total=False)
--- a/sat/core/exceptions.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,158 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT Exceptions
-# Copyright (C) 2011  Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-class ProfileUnknownError(Exception):
-    pass
-
-
-class ProfileNotInCacheError(Exception):
-    pass
-
-
-class ProfileNotSetError(Exception):
-    """This error raises when no profile has been set (value @NONE@ is found, but it should have been replaced)"""
-
-
-class ProfileConnected(Exception):
-    """This error is raised when trying to delete a connected profile."""
-
-
-class ProfileNotConnected(Exception):
-    pass
-
-
-class ProfileKeyUnknown(Exception):
-    pass
-
-
-class ClientTypeError(Exception):
-    """This code is not allowed for this type of client (i.e. component or not)"""
-
-
-class UnknownEntityError(Exception):
-    pass
-
-
-class UnknownGroupError(Exception):
-    pass
-
-
-class MissingModule(Exception):
-    # Used to indicate when a plugin dependence is not found
-    # it's nice to indicate when to find the dependence in argument string
-    pass
-
-
-class MissingPlugin(Exception):
-    """A SàT plugin needed for a feature/method is missing"""
-    pass
-
-
-class NotFound(Exception):
-    pass
-
-
-class ConfigError(Exception):
-    pass
-
-
-class DataError(Exception):
-    pass
-
-
-class ExternalRequestError(Exception):
-    """Request to third party server failed"""
-
-
-class ConflictError(Exception):
-    pass
-
-
-class TimeOutError(Exception):
-    pass
-
-
-class CancelError(Exception):
-    pass
-
-
-class InternalError(Exception):
-    pass
-
-
-class FeatureNotFound(
-    Exception
-):  # a disco feature/identity which is needed is not present
-    pass
-
-
-class BridgeInitError(Exception):
-    pass
-
-
-class BridgeExceptionNoService(Exception):
-    pass
-
-
-class DatabaseError(Exception):
-    pass
-
-
-class PasswordError(Exception):
-    pass
-
-
-class PermissionError(Exception):
-    pass
-
-
-class ParsingError(ValueError):
-    pass
-
-
-class EncryptionError(Exception):
-    """Invalid encryption"""
-    pass
-
-
-# Something which need to be done is not available yet
-class NotReady(Exception):
-    pass
-
-
-class NetworkError(Exception):
-    """Something is wrong with a request (e.g. HTTP(S))"""
-
-
-class InvalidCertificate(Exception):
-    """A TLS certificate is not valid"""
-    pass
-
-
-class CommandException(RuntimeError):
-    """An external command failed
-
-    stdout and stderr will be attached to the Exception
-    """
-
-    def __init__(self, msg, stdout, stderr):
-        super(CommandException, self).__init__(msg)
-        self.stdout = stdout
-        self.stderr = stderr
--- a/sat/core/i18n.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,50 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Callable, cast
-
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-try:
-
-    import gettext
-
-    _ = gettext.translation("sat", "i18n", fallback=True).gettext
-    _translators = {None: gettext.NullTranslations()}
-
-    def language_switch(lang=None):
-        if not lang in _translators:
-            _translators[lang] = gettext.translation(
-                "sat", languages=[lang], fallback=True
-            )
-        _translators[lang].install()
-
-
-except ImportError:
-
-    log.warning("gettext support disabled")
-    _ = cast(Callable[[str], str], lambda msg: msg)  # Libervia doesn't support gettext
-
-    def language_switch(lang=None):
-        pass
-
-
-D_ = cast(Callable[[str], str], lambda msg: msg)  # used for deferred translations
--- a/sat/core/launcher.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,247 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""Script launching SàT backend"""
-
-import sys
-import os
-import argparse
-from pathlib import Path
-from configparser import ConfigParser
-from twisted.application import app
-from twisted.python import usage
-from sat.core.constants import Const as C
-
-
-class SatLogger(app.AppLogger):
-
-    def start(self, application):
-        # logging is initialised by sat.core.log_config via the Twisted plugin, nothing
-        # to do here
-        self._initialLog()
-
-    def stop(self):
-        pass
-
-
-class Launcher:
-    APP_NAME=C.APP_NAME
-    APP_NAME_FILE=C.APP_NAME_FILE
-
-    @property
-    def NOT_RUNNING_MSG(self):
-        return f"{self.APP_NAME} is *NOT* running"
-
-    def cmd_no_subparser(self, args):
-        """Command launched by default"""
-        args.extra_args = []
-        self.cmd_background(args)
-
-    def cmd_background(self, args):
-        self.run_twistd(args)
-
-    def cmd_foreground(self, args):
-        self.run_twistd(args, twistd_opts=['--nodaemon'])
-
-    def cmd_debug(self, args):
-        self.run_twistd(args, twistd_opts=['--debug'])
-
-    def cmd_stop(self, args):
-        import signal
-        import time
-        config = self.get_config()
-        pid_file = self.get_pid_file(config)
-        if not pid_file.is_file():
-            print(self.NOT_RUNNING_MSG)
-            sys.exit(0)
-        try:
-            pid = int(pid_file.read_text())
-        except Exception as e:
-            print(f"Can't read PID file at {pid_file}: {e}")
-            # we use the same exit code as DATA_ERROR in jp
-            sys.exit(17)
-        print(f"Terminating {self.APP_NAME}…")
-        os.kill(pid, signal.SIGTERM)
-        kill_started = time.time()
-        state = "init"
-        import errno
-        while True:
-            try:
-                os.kill(pid, 0)
-            except OSError as e:
-                if e.errno == errno.ESRCH:
-                    break
-                elif e.errno == errno.EPERM:
-                    print(f"Can't kill {self.APP_NAME}, the process is owned by an other user", file=sys.stderr)
-                    sys.exit(18)
-                else:
-                    raise e
-            time.sleep(0.2)
-            now = time.time()
-            if state == 'init' and now - kill_started > 5:
-                if state == 'init':
-                    state = 'waiting'
-                    print(f"Still waiting for {self.APP_NAME} to be terminated…")
-            elif state == 'waiting' and now - kill_started > 10:
-                state == 'killing'
-                print("Waiting for too long, we kill the process")
-                os.kill(pid, signal.SIGKILL)
-                sys.exit(1)
-
-        sys.exit(0)
-
-    def cmd_status(self, args):
-        config = self.get_config()
-        pid_file = self.get_pid_file(config)
-        if pid_file.is_file():
-            import errno
-            try:
-                pid = int(pid_file.read_text())
-            except Exception as e:
-                print(f"Can't read PID file at {pid_file}: {e}")
-                # we use the same exit code as DATA_ERROR in jp
-                sys.exit(17)
-            # we check if there is a process
-            # inspired by https://stackoverflow.com/a/568285 and https://stackoverflow.com/a/6940314
-            try:
-                os.kill(pid, 0)
-            except OSError as e:
-                if e.errno == errno.ESRCH:
-                    running = False
-                elif e.errno == errno.EPERM:
-                    print("Process {pid} is run by an other user")
-                    running = True
-            else:
-                running = True
-
-            if running:
-                print(f"{self.APP_NAME} is running (pid: {pid})")
-                sys.exit(0)
-            else:
-                print(f"{self.NOT_RUNNING_MSG}, but a pid file is present (bad exit ?): {pid_file}")
-                sys.exit(2)
-        else:
-            print(self.NOT_RUNNING_MSG)
-            sys.exit(1)
-
-    def parse_args(self):
-        parser = argparse.ArgumentParser(description=f"Launch {self.APP_NAME} backend")
-        parser.set_defaults(cmd=self.cmd_no_subparser)
-        subparsers = parser.add_subparsers()
-        extra_help = f"arguments to pass to {self.APP_NAME} service"
-
-        bg_parser = subparsers.add_parser(
-            'background',
-            aliases=['bg'],
-            help=f"run {self.APP_NAME} backend in background (as a daemon)")
-        bg_parser.add_argument('extra_args', nargs=argparse.REMAINDER, help=extra_help)
-        bg_parser.set_defaults(cmd=self.cmd_background)
-
-        fg_parser = subparsers.add_parser(
-            'foreground',
-            aliases=['fg'],
-            help=f"run {self.APP_NAME} backend in foreground")
-        fg_parser.add_argument('extra_args', nargs=argparse.REMAINDER, help=extra_help)
-        fg_parser.set_defaults(cmd=self.cmd_foreground)
-
-        dbg_parser = subparsers.add_parser(
-            'debug',
-            aliases=['dbg'],
-            help=f"run {self.APP_NAME} backend in debug mode")
-        dbg_parser.add_argument('extra_args', nargs=argparse.REMAINDER, help=extra_help)
-        dbg_parser.set_defaults(cmd=self.cmd_debug)
-
-        stop_parser = subparsers.add_parser(
-            'stop',
-            help=f"stop running {self.APP_NAME} backend")
-        stop_parser.set_defaults(cmd=self.cmd_stop)
-
-        status_parser = subparsers.add_parser(
-            'status',
-            help=f"indicate if {self.APP_NAME} backend is running")
-        status_parser.set_defaults(cmd=self.cmd_status)
-
-        return parser.parse_args()
-
-    def get_config(self):
-        config = ConfigParser(defaults=C.DEFAULT_CONFIG)
-        try:
-            config.read(C.CONFIG_FILES)
-        except Exception as e:
-            print (rf"/!\ Can't read main config! {e}")
-            sys.exit(1)
-        return config
-
-    def get_pid_file(self, config):
-        pid_dir = Path(config.get('DEFAULT', 'pid_dir')).expanduser()
-        return pid_dir / f"{self.APP_NAME_FILE}.pid"
-
-    def run_twistd(self, args, twistd_opts=None):
-        """Run twistd settings options with args"""
-        from twisted.python.runtime import platformType
-        if platformType == "win32":
-            from twisted.scripts._twistw import (ServerOptions,
-                                                 WindowsApplicationRunner as app_runner)
-        else:
-            from twisted.scripts._twistd_unix import (ServerOptions,
-                                                      UnixApplicationRunner as app_runner)
-
-        app_runner.loggerFactory = SatLogger
-        server_options = ServerOptions()
-        config = self.get_config()
-        pid_file = self.get_pid_file(config)
-        log_dir = Path(config.get('DEFAULT', 'log_dir')).expanduser()
-        log_file = log_dir / f"{self.APP_NAME_FILE}.log"
-        server_opts = [
-            '--no_save',
-            '--pidfile', str(pid_file),
-            '--logfile', str(log_file),
-            ]
-        if twistd_opts is not None:
-            server_opts.extend(twistd_opts)
-        server_opts.append(self.APP_NAME_FILE)
-        if args.extra_args:
-            try:
-                args.extra_args.remove('--')
-            except ValueError:
-                pass
-            server_opts.extend(args.extra_args)
-        try:
-            server_options.parseOptions(server_opts)
-        except usage.error as ue:
-            print(server_options)
-            print("%s: %s" % (sys.argv[0], ue))
-            sys.exit(1)
-        else:
-            runner = app_runner(server_options)
-            runner.run()
-            if runner._exitSignal is not None:
-                app._exitWithSignal(runner._exitSignal)
-            try:
-                sys.exit(app._exitCode)
-            except AttributeError:
-                pass
-
-    @classmethod
-    def run(cls):
-        args = cls().parse_args()
-        args.cmd(args)
-
-
-if __name__ == '__main__':
-    Launcher.run()
--- a/sat/core/log.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,426 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""High level logging functions"""
-# XXX: this module use standard logging module when possible, but as SàT can work in different cases where logging is not the best choice (twisted, pyjamas, etc), it is necessary to have a dedicated module. Additional feature like environment variables and colors are also managed.
-# TODO: change formatting from "%s" style to "{}" when moved to Python 3
-
-from typing import TYPE_CHECKING, Any, Optional, Dict
-
-if TYPE_CHECKING:
-    from logging import _ExcInfoType
-else:
-    _ExcInfoType = Any
-
-from sat.core.constants import Const as C
-from sat.tools.common.ansi import ANSI as A
-from sat.core import exceptions
-import traceback
-
-backend = None
-_loggers: Dict[str, "Logger"] = {}
-handlers = {}
-COLOR_START = '%(color_start)s'
-COLOR_END = '%(color_end)s'
-
-
-class Filtered(Exception):
-    pass
-
-
-class Logger:
-    """High level logging class"""
-    fmt = None # format option as given by user (e.g. SAT_LOG_LOGGER)
-    filter_name = None # filter to call
-    post_treat = None
-
-    def __init__(self, name):
-        if isinstance(name, Logger):
-            self.copy(name)
-        else:
-            self._name = name
-
-    def copy(self, other):
-        """Copy values from other Logger"""
-        self.fmt = other.fmt
-        self.Filter_name = other.fmt
-        self.post_treat = other.post_treat
-        self._name = other._name
-
-    def add_traceback(self, message):
-        tb = traceback.format_exc()
-        return message + "\n==== traceback ====\n" + tb
-
-    def out(
-        self,
-        message: object,
-        level: Optional[str] = None,
-        exc_info: _ExcInfoType = False,
-        **kwargs
-    ) -> None:
-        """Actually log the message
-
-        @param message: formatted message
-        """
-        if exc_info:
-            message = self.add_traceback(message)
-        print(message)
-
-    def log(
-        self,
-        level: str,
-        message: object,
-        exc_info: _ExcInfoType = False,
-        **kwargs
-    ) -> None:
-        """Print message
-
-        @param level: one of C.LOG_LEVELS
-        @param message: message to format and print
-        """
-        if exc_info:
-            message = self.add_traceback(message)
-        try:
-            formatted = self.format(level, message)
-            if self.post_treat is None:
-                self.out(formatted, level, **kwargs)
-            else:
-                self.out(self.post_treat(level, formatted), level, **kwargs)
-        except Filtered:
-            pass
-
-    def format(self, level: str, message: object) -> object:
-        """Format message according to Logger.fmt
-
-        @param level: one of C.LOG_LEVELS
-        @param message: message to format
-        @return: formatted message
-
-        @raise: Filtered when the message must not be logged
-        """
-        if self.fmt is None and self.filter_name is None:
-            return message
-        record = {'name': self._name,
-                  'message': message,
-                  'levelname': level,
-                 }
-        try:
-            if not self.filter_name.dict_filter(record):
-                raise Filtered
-        except (AttributeError, TypeError): # XXX: TypeError is here because of a pyjamas bug which need to be fixed (TypeError is raised instead of AttributeError)
-            if self.filter_name is not None:
-                raise ValueError("Bad filter: filters must have a .filter method")
-        try:
-            return self.fmt % record
-        except TypeError:
-            return message
-        except KeyError as e:
-            if e.args[0] == 'profile':
-                # XXX: %(profile)s use some magic with introspection, for debugging purpose only *DO NOT* use in production
-                record['profile'] = configure_cls[backend].get_profile()
-                return self.fmt % record
-            else:
-                raise e
-
-    def debug(self, msg: object, **kwargs) -> None:
-        self.log(C.LOG_LVL_DEBUG, msg, **kwargs)
-
-    def info(self, msg: object, **kwargs) -> None:
-        self.log(C.LOG_LVL_INFO, msg, **kwargs)
-
-    def warning(self, msg: object, **kwargs) -> None:
-        self.log(C.LOG_LVL_WARNING, msg, **kwargs)
-
-    def error(self, msg: object, **kwargs) -> None:
-        self.log(C.LOG_LVL_ERROR, msg, **kwargs)
-
-    def critical(self, msg: object, **kwargs) -> None:
-        self.log(C.LOG_LVL_CRITICAL, msg, **kwargs)
-
-    def exception(self, msg: object, exc_info=True, **kwargs) -> None:
-        self.log(C.LOG_LVL_ERROR, msg, exc_info=exc_info, **kwargs)
-
-
-class FilterName(object):
-    """Filter on logger name according to a regex"""
-
-    def __init__(self, name_re):
-        """Initialise name filter
-
-        @param name_re: regular expression used to filter names (using search and not match)
-        """
-        assert name_re
-        import re
-        self.name_re = re.compile(name_re)
-
-    def filter(self, record):
-        if self.name_re.search(record.name) is not None:
-            return 1
-        return 0
-
-    def dict_filter(self, dict_record):
-        """Filter using a dictionary record
-
-        @param dict_record: dictionary with at list a key "name" with logger name
-        @return: True if message should be logged
-        """
-        class LogRecord(object):
-            pass
-        log_record = LogRecord()
-        log_record.name = dict_record['name']
-        return self.filter(log_record) == 1
-
-
-class ConfigureBase:
-    LOGGER_CLASS = Logger
-    # True if color location is specified in fmt (with COLOR_START)
-    _color_location = False
-
-    def __init__(self, level=None, fmt=None, output=None, logger=None, colors=False,
-                 levels_taints_dict=None, force_colors=False, backend_data=None):
-        """Configure a backend
-
-        @param level: one of C.LOG_LEVELS
-        @param fmt: format string, pretty much as in std logging.
-            Accept the following keywords (maybe more depending on backend):
-            - "message"
-            - "levelname"
-            - "name" (logger name)
-        @param logger: if set, use it as a regular expression to filter on logger name.
-            Use search to match expression, so ^ or $ can be necessary.
-        @param colors: if True use ANSI colors to show log levels
-        @param force_colors: if True ANSI colors are used even if stdout is not a tty
-        """
-        self.backend_data = backend_data
-        self.pre_treatment()
-        self.configure_level(level)
-        self.configure_format(fmt)
-        self.configure_output(output)
-        self.configure_logger(logger)
-        self.configure_colors(colors, force_colors, levels_taints_dict)
-        self.post_treatment()
-        self.update_current_logger()
-
-    def update_current_logger(self):
-        """update existing logger to the class needed for this backend"""
-        if self.LOGGER_CLASS is None:
-            return
-        for name, logger in list(_loggers.items()):
-            _loggers[name] = self.LOGGER_CLASS(logger)
-
-    def pre_treatment(self):
-        pass
-
-    def configure_level(self, level):
-        if level is not None:
-            # we deactivate methods below level
-            level_idx = C.LOG_LEVELS.index(level)
-            def dev_null(self, msg):
-                pass
-            for _level in C.LOG_LEVELS[:level_idx]:
-                setattr(Logger, _level.lower(), dev_null)
-
-    def configure_format(self, fmt):
-        if fmt is not None:
-            if fmt != '%(message)s': # %(message)s is the same as None
-                Logger.fmt = fmt
-            if COLOR_START in fmt:
-                ConfigureBase._color_location = True
-                if fmt.find(COLOR_END,fmt.rfind(COLOR_START))<0:
-                   # color_start not followed by an end, we add it
-                    Logger.fmt += COLOR_END
-
-    def configure_output(self, output):
-        if output is not None:
-            if output != C.LOG_OPT_OUTPUT_SEP + C.LOG_OPT_OUTPUT_DEFAULT:
-                # TODO: manage other outputs
-                raise NotImplementedError("Basic backend only manage default output yet")
-
-    def configure_logger(self, logger):
-        if logger:
-            Logger.filter_name = FilterName(logger)
-
-    def configure_colors(self, colors, force_colors, levels_taints_dict):
-        if colors:
-            # if color are used, we need to handle levels_taints_dict
-            for level in list(levels_taints_dict.keys()):
-                # we wants levels in uppercase to correspond to contstants
-                levels_taints_dict[level.upper()] = levels_taints_dict[level]
-            taints = self.__class__.taints = {}
-            for level in C.LOG_LEVELS:
-                # we want use values and use constant value as default
-                taint_list = levels_taints_dict.get(level, C.LOG_OPT_TAINTS_DICT[1][level])
-                ansi_list = []
-                for elt in taint_list:
-                    elt = elt.upper()
-                    try:
-                        ansi = getattr(A, 'FG_{}'.format(elt))
-                    except AttributeError:
-                        try:
-                            ansi = getattr(A, elt)
-                        except AttributeError:
-                            # we use raw string if element is unknown
-                            ansi = elt
-                    ansi_list.append(ansi)
-                taints[level] = ''.join(ansi_list)
-
-    def post_treatment(self):
-        pass
-
-    def manage_outputs(self, outputs_raw):
-        """ Parse output option in a backend agnostic way, and fill handlers consequently
-
-        @param outputs_raw: output option as enterred in environment variable or in configuration
-        """
-        if not outputs_raw:
-            return
-        outputs = outputs_raw.split(C.LOG_OPT_OUTPUT_SEP)
-        global handlers
-        if len(outputs) == 1:
-            handlers[C.LOG_OPT_OUTPUT_FILE] = [outputs.pop()]
-
-        for output in outputs:
-            if not output:
-                continue
-            if output[-1] == ')':
-                # we have options
-                opt_begin = output.rfind('(')
-                options = output[opt_begin+1:-1]
-                output = output[:opt_begin]
-            else:
-                options = None
-
-            if output not in (C.LOG_OPT_OUTPUT_DEFAULT, C.LOG_OPT_OUTPUT_FILE, C.LOG_OPT_OUTPUT_MEMORY):
-                raise ValueError("Invalid output [%s]" % output)
-
-            if output == C.LOG_OPT_OUTPUT_DEFAULT:
-                # no option for defaut handler
-                handlers[output] = None
-            elif output == C.LOG_OPT_OUTPUT_FILE:
-                if not options:
-                    ValueError("{handler} output need a path as option" .format(handle=output))
-                handlers.setdefault(output, []).append(options)
-                options = None # option are parsed, we can empty them
-            elif output == C.LOG_OPT_OUTPUT_MEMORY:
-                # we have memory handler, option can be the len limit or None
-                try:
-                    limit = int(options)
-                    options = None # option are parsed, we can empty them
-                except (TypeError, ValueError):
-                    limit = C.LOG_OPT_OUTPUT_MEMORY_LIMIT
-                handlers[output] = limit
-
-            if options: # we should not have unparsed options
-                raise ValueError("options [{options}] are not supported for {handler} output".format(options=options, handler=output))
-
-    @staticmethod
-    def memory_get(size=None):
-        """Return buffered logs
-
-        @param size: number of logs to return
-        """
-        raise NotImplementedError
-
-    @classmethod
-    def ansi_colors(cls, level, message):
-        """Colorise message depending on level for terminals
-
-        @param level: one of C.LOG_LEVELS
-        @param message: formatted message to log
-        @return: message with ANSI escape codes for coloration
-        """
-
-        try:
-            start = cls.taints[level]
-        except KeyError:
-            start = ''
-
-        if cls._color_location:
-            return message % {'color_start': start,
-                              'color_end': A.RESET}
-        else:
-            return '%s%s%s' % (start, message, A.RESET)
-
-    @staticmethod
-    def get_profile():
-        """Try to find profile value using introspection"""
-        raise NotImplementedError
-
-
-class ConfigureCustom(ConfigureBase):
-    LOGGER_CLASS = None
-
-    def __init__(self, logger_class, *args, **kwargs):
-        ConfigureCustom.LOGGER_CLASS = logger_class
-
-
-configure_cls = { None: ConfigureBase,
-                   C.LOG_BACKEND_CUSTOM: ConfigureCustom
-                 }  # XXX: (key: backend, value: Configure subclass) must be filled when new backend are added
-
-
-def configure(backend_, **options):
-    """Configure logging behaviour
-    @param backend: can be:
-        C.LOG_BACKEND_BASIC: use a basic print based logging
-        C.LOG_BACKEND_CUSTOM: use a given Logger subclass
-    """
-    global backend
-    if backend is not None:
-        raise exceptions.InternalError("Logging can only be configured once")
-    backend = backend_
-
-    try:
-        configure_class = configure_cls[backend]
-    except KeyError:
-        raise ValueError("unknown backend [{}]".format(backend))
-    if backend == C.LOG_BACKEND_CUSTOM:
-        logger_class = options.pop('logger_class')
-        configure_class(logger_class, **options)
-    else:
-        configure_class(**options)
-
-def memory_get(size=None):
-    if not C.LOG_OPT_OUTPUT_MEMORY in handlers:
-        raise ValueError('memory output is not used')
-    return configure_cls[backend].memory_get(size)
-
-def getLogger(name=C.LOG_BASE_LOGGER) -> Logger:
-    try:
-        logger_class = configure_cls[backend].LOGGER_CLASS
-    except KeyError:
-        raise ValueError("This method should not be called with backend [{}]".format(backend))
-    return _loggers.setdefault(name, logger_class(name))
-
-_root_logger = getLogger()
-
-def debug(msg, **kwargs):
-    _root_logger.debug(msg, **kwargs)
-
-def info(msg, **kwargs):
-    _root_logger.info(msg, **kwargs)
-
-def warning(msg, **kwargs):
-    _root_logger.warning(msg, **kwargs)
-
-def error(msg, **kwargs):
-    _root_logger.error(msg, **kwargs)
-
-def critical(msg, **kwargs):
-    _root_logger.critical(msg, **kwargs)
--- a/sat/core/log_config.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,411 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""High level logging functions"""
-# XXX: this module use standard logging module when possible, but as SàT can work in different cases where logging is not the best choice (twisted, pyjamas, etc), it is necessary to have a dedicated module. Additional feature like environment variables and colors are also managed.
-
-from sat.core.constants import Const as C
-from sat.core import log
-
-
-class TwistedLogger(log.Logger):
-    colors = True
-    force_colors = False
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        from twisted.logger import Logger
-        self.twisted_log = Logger()
-
-    def out(self, message, level=None, **kwargs):
-        """Actually log the message
-
-        @param message: formatted message
-        """
-        self.twisted_log.emit(
-            level=self.level_map[level],
-            format=message,
-            sat_logged=True,
-            **kwargs,
-        )
-
-
-class ConfigureBasic(log.ConfigureBase):
-    def configure_colors(self, colors, force_colors, levels_taints_dict):
-        super(ConfigureBasic, self).configure_colors(
-            colors, force_colors, levels_taints_dict
-        )
-        if colors:
-            import sys
-
-            try:
-                isatty = sys.stdout.isatty()
-            except AttributeError:
-                isatty = False
-            # FIXME: isatty should be tested on each handler, not globaly
-            if (force_colors or isatty):
-                # we need colors
-                log.Logger.post_treat = lambda logger, level, message: self.ansi_colors(
-                    level, message
-                )
-        elif force_colors:
-            raise ValueError("force_colors can't be used if colors is False")
-
-    @staticmethod
-    def get_profile():
-        """Try to find profile value using introspection"""
-        import inspect
-
-        stack = inspect.stack()
-        current_path = stack[0][1]
-        for frame_data in stack[:-1]:
-            if frame_data[1] != current_path:
-                if (
-                    log.backend == C.LOG_BACKEND_STANDARD
-                    and "/logging/__init__.py" in frame_data[1]
-                ):
-                    continue
-                break
-
-        frame = frame_data[0]
-        args = inspect.getargvalues(frame)
-        try:
-            profile = args.locals.get("profile") or args.locals["profile_key"]
-        except (TypeError, KeyError):
-            try:
-                try:
-                    profile = args.locals["self"].profile
-                except AttributeError:
-                    try:
-                        profile = args.locals["self"].parent.profile
-                    except AttributeError:
-                        profile = args.locals[
-                            "self"
-                        ].host.profile  # used in quick_frontend for single profile configuration
-            except Exception:
-                # we can't find profile, we return an empty value
-                profile = ""
-        return profile
-
-
-class ConfigureTwisted(ConfigureBasic):
-    LOGGER_CLASS = TwistedLogger
-
-    def pre_treatment(self):
-        from twisted import logger
-        global logger
-        self.level_map = {
-            C.LOG_LVL_DEBUG: logger.LogLevel.debug,
-            C.LOG_LVL_INFO: logger.LogLevel.info,
-            C.LOG_LVL_WARNING: logger.LogLevel.warn,
-            C.LOG_LVL_ERROR: logger.LogLevel.error,
-            C.LOG_LVL_CRITICAL: logger.LogLevel.critical,
-        }
-        self.LOGGER_CLASS.level_map = self.level_map
-
-    def configure_level(self, level):
-        self.level = self.level_map[level]
-
-    def configure_output(self, output):
-        import sys
-        from twisted.python import logfile
-        self.log_publisher = logger.LogPublisher()
-
-        if output is None:
-            output = C.LOG_OPT_OUTPUT_SEP + C.LOG_OPT_OUTPUT_DEFAULT
-        self.manage_outputs(output)
-
-        if C.LOG_OPT_OUTPUT_DEFAULT in log.handlers:
-            if self.backend_data is None:
-                raise ValueError(
-                    "You must pass options as backend_data with Twisted backend"
-                )
-            options = self.backend_data
-            log_file = logfile.LogFile.fromFullPath(options['logfile'])
-            self.log_publisher.addObserver(
-                logger.FileLogObserver(log_file, self.text_formatter))
-            # we also want output to stdout if we are in debug or nodaemon mode
-            if options.get("nodaemon", False) or options.get("debug", False):
-                self.log_publisher.addObserver(
-                    logger.FileLogObserver(sys.stdout, self.text_formatter))
-
-        if C.LOG_OPT_OUTPUT_FILE in log.handlers:
-
-            for path in log.handlers[C.LOG_OPT_OUTPUT_FILE]:
-                log_file = (
-                    sys.stdout if path == "-" else logfile.LogFile.fromFullPath(path)
-                )
-                self.log_publisher.addObserver(
-                    logger.FileLogObserver(log_file, self.text_formatter))
-
-        if C.LOG_OPT_OUTPUT_MEMORY in log.handlers:
-            raise NotImplementedError(
-                "Memory observer is not implemented in Twisted backend"
-            )
-
-    def configure_colors(self, colors, force_colors, levels_taints_dict):
-        super(ConfigureTwisted, self).configure_colors(
-            colors, force_colors, levels_taints_dict
-        )
-        self.LOGGER_CLASS.colors = colors
-        self.LOGGER_CLASS.force_colors = force_colors
-        if force_colors and not colors:
-            raise ValueError("colors must be True if force_colors is True")
-
-    def post_treatment(self):
-        """Install twistedObserver which manage non SàT logs"""
-        # from twisted import logger
-        import sys
-        filtering_obs = logger.FilteringLogObserver(
-            observer=self.log_publisher,
-            predicates=[
-                logger.LogLevelFilterPredicate(self.level),
-                ]
-        )
-        logger.globalLogBeginner.beginLoggingTo([filtering_obs])
-
-    def text_formatter(self, event):
-        if event.get('sat_logged', False):
-            timestamp = ''.join([logger.formatTime(event.get("log_time", None)), " "])
-            return f"{timestamp}{event.get('log_format', '')}\n"
-        else:
-            eventText = logger.eventAsText(
-                event, includeSystem=True)
-            if not eventText:
-                return None
-            return eventText.replace("\n", "\n\t") + "\n"
-
-
-class ConfigureStandard(ConfigureBasic):
-    def __init__(
-        self,
-        level=None,
-        fmt=None,
-        output=None,
-        logger=None,
-        colors=False,
-        levels_taints_dict=None,
-        force_colors=False,
-        backend_data=None,
-    ):
-        if fmt is None:
-            fmt = C.LOG_OPT_FORMAT[1]
-        if output is None:
-            output = C.LOG_OPT_OUTPUT[1]
-        super(ConfigureStandard, self).__init__(
-            level,
-            fmt,
-            output,
-            logger,
-            colors,
-            levels_taints_dict,
-            force_colors,
-            backend_data,
-        )
-
-    def pre_treatment(self):
-        """We use logging methods directly, instead of using Logger"""
-        import logging
-
-        log.getLogger = logging.getLogger
-        log.debug = logging.debug
-        log.info = logging.info
-        log.warning = logging.warning
-        log.error = logging.error
-        log.critical = logging.critical
-
-    def configure_level(self, level):
-        if level is None:
-            level = C.LOG_LVL_DEBUG
-        self.level = level
-
-    def configure_format(self, fmt):
-        super(ConfigureStandard, self).configure_format(fmt)
-        import logging
-
-        class SatFormatter(logging.Formatter):
-            """Formatter which manage SàT specificities"""
-            _format = fmt
-            _with_profile = "%(profile)s" in fmt
-
-            def __init__(self, can_colors=False):
-                super(SatFormatter, self).__init__(self._format)
-                self.can_colors = can_colors
-
-            def format(self, record):
-                if self._with_profile:
-                    record.profile = ConfigureStandard.get_profile()
-                do_color = self.with_colors and (self.can_colors or self.force_colors)
-                if ConfigureStandard._color_location:
-                    # we copy raw formatting strings for color_*
-                    # as formatting is handled in ansi_colors in this case
-                    if do_color:
-                        record.color_start = log.COLOR_START
-                        record.color_end = log.COLOR_END
-                    else:
-                        record.color_start = record.color_end = ""
-                s = super(SatFormatter, self).format(record)
-                if do_color:
-                    s = ConfigureStandard.ansi_colors(record.levelname, s)
-                return s
-
-        self.formatterClass = SatFormatter
-
-    def configure_output(self, output):
-        self.manage_outputs(output)
-
-    def configure_logger(self, logger):
-        self.name_filter = log.FilterName(logger) if logger else None
-
-    def configure_colors(self, colors, force_colors, levels_taints_dict):
-        super(ConfigureStandard, self).configure_colors(
-            colors, force_colors, levels_taints_dict
-        )
-        self.formatterClass.with_colors = colors
-        self.formatterClass.force_colors = force_colors
-        if not colors and force_colors:
-            raise ValueError("force_colors can't be used if colors is False")
-
-    def _add_handler(self, root_logger, hdlr, can_colors=False):
-        hdlr.setFormatter(self.formatterClass(can_colors))
-        root_logger.addHandler(hdlr)
-        root_logger.setLevel(self.level)
-        if self.name_filter is not None:
-            hdlr.addFilter(self.name_filter)
-
-    def post_treatment(self):
-        import logging
-
-        root_logger = logging.getLogger()
-        if len(root_logger.handlers) == 0:
-            for handler, options in list(log.handlers.items()):
-                if handler == C.LOG_OPT_OUTPUT_DEFAULT:
-                    hdlr = logging.StreamHandler()
-                    try:
-                        can_colors = hdlr.stream.isatty()
-                    except AttributeError:
-                        can_colors = False
-                    self._add_handler(root_logger, hdlr, can_colors=can_colors)
-                elif handler == C.LOG_OPT_OUTPUT_MEMORY:
-                    from logging.handlers import BufferingHandler
-
-                    class SatMemoryHandler(BufferingHandler):
-                        def emit(self, record):
-                            super(SatMemoryHandler, self).emit(self.format(record))
-
-                    hdlr = SatMemoryHandler(options)
-                    log.handlers[
-                        handler
-                    ] = (
-                        hdlr
-                    )  # we keep a reference to the handler to read the buffer later
-                    self._add_handler(root_logger, hdlr, can_colors=False)
-                elif handler == C.LOG_OPT_OUTPUT_FILE:
-                    import os.path
-
-                    for path in options:
-                        hdlr = logging.FileHandler(os.path.expanduser(path))
-                        self._add_handler(root_logger, hdlr, can_colors=False)
-                else:
-                    raise ValueError("Unknown handler type")
-        else:
-            root_logger.warning("Handlers already set on root logger")
-
-    @staticmethod
-    def memory_get(size=None):
-        """Return buffered logs
-
-        @param size: number of logs to return
-        """
-        mem_handler = log.handlers[C.LOG_OPT_OUTPUT_MEMORY]
-        return (
-            log_msg for log_msg in mem_handler.buffer[size if size is None else -size :]
-        )
-
-
-log.configure_cls[C.LOG_BACKEND_BASIC] = ConfigureBasic
-log.configure_cls[C.LOG_BACKEND_TWISTED] = ConfigureTwisted
-log.configure_cls[C.LOG_BACKEND_STANDARD] = ConfigureStandard
-
-
-def configure(backend, **options):
-    """Configure logging behaviour
-    @param backend: can be:
-        C.LOG_BACKEND_STANDARD: use standard logging module
-        C.LOG_BACKEND_TWISTED: use twisted logging module (with standard logging observer)
-        C.LOG_BACKEND_BASIC: use a basic print based logging
-        C.LOG_BACKEND_CUSTOM: use a given Logger subclass
-    """
-    return log.configure(backend, **options)
-
-
-def _parse_options(options):
-    """Parse string options as given in conf or environment variable, and return expected python value
-
-    @param options (dict): options with (key: name, value: string value)
-    """
-    COLORS = C.LOG_OPT_COLORS[0]
-    LEVEL = C.LOG_OPT_LEVEL[0]
-
-    if COLORS in options:
-        if options[COLORS].lower() in ("1", "true"):
-            options[COLORS] = True
-        elif options[COLORS] == "force":
-            options[COLORS] = True
-            options["force_colors"] = True
-        else:
-            options[COLORS] = False
-    if LEVEL in options:
-        level = options[LEVEL].upper()
-        if level not in C.LOG_LEVELS:
-            level = C.LOG_LVL_INFO
-        options[LEVEL] = level
-
-
-def sat_configure(backend=C.LOG_BACKEND_STANDARD, const=None, backend_data=None):
-    """Configure logging system for SàT, can be used by frontends
-
-    logs conf is read in SàT conf, then in environment variables. It must be done before Memory init
-    @param backend: backend to use, it can be:
-        - C.LOG_BACKEND_BASIC: print based backend
-        - C.LOG_BACKEND_TWISTED: Twisted logging backend
-        - C.LOG_BACKEND_STANDARD: standard logging backend
-    @param const: Const class to use instead of sat.core.constants.Const (mainly used to change default values)
-    """
-    if const is not None:
-        global C
-        C = const
-        log.C = const
-    from sat.tools import config
-    import os
-
-    log_conf = {}
-    sat_conf = config.parse_main_conf()
-    for opt_name, opt_default in C.LOG_OPTIONS():
-        try:
-            log_conf[opt_name] = os.environ[
-                "".join((C.ENV_PREFIX, C.LOG_OPT_PREFIX.upper(), opt_name.upper()))
-            ]
-        except KeyError:
-            log_conf[opt_name] = config.config_get(
-                sat_conf, C.LOG_OPT_SECTION, C.LOG_OPT_PREFIX + opt_name, opt_default
-            )
-
-    _parse_options(log_conf)
-    configure(backend, backend_data=backend_data, **log_conf)
--- a/sat/core/patches.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,156 +0,0 @@
-import copy
-from twisted.words.protocols.jabber import xmlstream, sasl, client as tclient, jid
-from wokkel import client
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-"""This module applies monkey patches to Twisted and Wokkel
-   First part handle certificate validation during XMPP connectionand are temporary
-   (until merged upstream).
-   Second part add a trigger point to send and onElement method of XmlStream
-   """
-
-
-## certificate validation patches
-
-
-class XMPPClient(client.XMPPClient):
-
-    def __init__(self, jid, password, host=None, port=5222,
-                 tls_required=True, configurationForTLS=None):
-        self.jid = jid
-        self.domain = jid.host.encode('idna')
-        self.host = host
-        self.port = port
-
-        factory = HybridClientFactory(
-            jid, password, tls_required=tls_required,
-            configurationForTLS=configurationForTLS)
-
-        client.StreamManager.__init__(self, factory)
-
-
-def HybridClientFactory(jid, password, tls_required=True, configurationForTLS=None):
-    a = HybridAuthenticator(jid, password, tls_required, configurationForTLS)
-
-    return xmlstream.XmlStreamFactory(a)
-
-
-class HybridAuthenticator(client.HybridAuthenticator):
-    res_binding = True
-
-    def __init__(self, jid, password, tls_required=True, configurationForTLS=None):
-        xmlstream.ConnectAuthenticator.__init__(self, jid.host)
-        self.jid = jid
-        self.password = password
-        self.tls_required = tls_required
-        self.configurationForTLS = configurationForTLS
-
-    def associateWithStream(self, xs):
-        xmlstream.ConnectAuthenticator.associateWithStream(self, xs)
-
-        tlsInit = xmlstream.TLSInitiatingInitializer(
-            xs, required=self.tls_required, configurationForTLS=self.configurationForTLS)
-        xs.initializers = [client.client.CheckVersionInitializer(xs),
-                           tlsInit,
-                           CheckAuthInitializer(xs, self.res_binding)]
-
-
-# XmlStream triggers
-
-
-class XmlStream(xmlstream.XmlStream):
-    """XmlStream which allows to add hooks"""
-
-    def __init__(self, authenticator):
-        xmlstream.XmlStream.__init__(self, authenticator)
-        # hooks at this level should not modify content
-        # so it's not needed to handle priority as with triggers
-        self._onElementHooks = []
-        self._sendHooks = []
-
-    def add_hook(self, hook_type, callback):
-        """Add a send or receive hook"""
-        conflict_msg = f"Hook conflict: can't add {hook_type} hook {callback}"
-        if hook_type == C.STREAM_HOOK_RECEIVE:
-            if callback not in self._onElementHooks:
-                self._onElementHooks.append(callback)
-            else:
-                log.warning(conflict_msg)
-        elif hook_type == C.STREAM_HOOK_SEND:
-            if callback not in self._sendHooks:
-                self._sendHooks.append(callback)
-            else:
-                log.warning(conflict_msg)
-        else:
-            raise ValueError(f"Invalid hook type: {hook_type}")
-
-    def onElement(self, element):
-        for hook in self._onElementHooks:
-            hook(element)
-        xmlstream.XmlStream.onElement(self, element)
-
-    def send(self, obj):
-        for hook in self._sendHooks:
-            hook(obj)
-        xmlstream.XmlStream.send(self, obj)
-
-
-# Binding activation (needed for stream management, XEP-0198)
-
-
-class CheckAuthInitializer(client.CheckAuthInitializer):
-
-    def __init__(self, xs, res_binding):
-        super(CheckAuthInitializer, self).__init__(xs)
-        self.res_binding = res_binding
-
-    def initialize(self):
-        # XXX: modification of client.CheckAuthInitializer which has optional
-        #      resource binding, and which doesn't do deprecated
-        #      SessionInitializer
-        if (sasl.NS_XMPP_SASL, 'mechanisms') in self.xmlstream.features:
-            inits = [(sasl.SASLInitiatingInitializer, True)]
-            if self.res_binding:
-                inits.append((tclient.BindInitializer, True)),
-
-            for initClass, required in inits:
-                init = initClass(self.xmlstream)
-                init.required = required
-                self.xmlstream.initializers.append(init)
-        elif (tclient.NS_IQ_AUTH_FEATURE, 'auth') in self.xmlstream.features:
-            self.xmlstream.initializers.append(
-                    tclient.IQAuthInitializer(self.xmlstream))
-        else:
-            raise Exception("No available authentication method found")
-
-
-# jid fix
-
-def internJID(jidstring):
-    """
-    Return interned JID.
-
-    @rtype: L{JID}
-    """
-    # XXX: this interJID return a copy of the cached jid
-    #      this avoid modification of cached jid as JID is mutable
-    # TODO: propose this upstream
-
-    if jidstring in jid.__internJIDs:
-        return copy.copy(jid.__internJIDs[jidstring])
-    else:
-        j = jid.JID(jidstring)
-        jid.__internJIDs[jidstring] = j
-        return copy.copy(j)
-
-
-def apply():
-    # certificate validation
-    client.XMPPClient = XMPPClient
-    # XmlStream triggers
-    xmlstream.XmlStreamFactory.protocol = XmlStream
-    # jid fix
-    jid.internJID = internJID
--- a/sat/core/sat_main.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1666 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import sys
-import os.path
-import uuid
-import hashlib
-import copy
-from pathlib import Path
-from typing import Optional, List, Tuple, Dict
-
-from wokkel.data_form import Option
-import sat
-from sat.core.i18n import _, D_, language_switch
-from sat.core import patches
-patches.apply()
-from twisted.application import service
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from twisted.internet import reactor
-from wokkel.xmppim import RosterItem
-from sat.core import xmpp
-from sat.core import exceptions
-from sat.core.core_types import SatXMPPEntity
-from sat.core.log import getLogger
-
-from sat.core.constants import Const as C
-from sat.memory import memory
-from sat.memory import cache
-from sat.memory import encryption
-from sat.tools import async_trigger as trigger
-from sat.tools import utils
-from sat.tools import image
-from sat.tools.common import dynamic_import
-from sat.tools.common import regex
-from sat.tools.common import data_format
-from sat.stdui import ui_contact_list, ui_profile_manager
-import sat.plugins
-
-
-log = getLogger(__name__)
-
-class SAT(service.Service):
-
-    def _init(self):
-        # we don't use __init__ to avoid doule initialisation with twistd
-        # this _init is called in startService
-        log.info(f"{C.APP_NAME} {self.full_version}")
-        self._cb_map = {}  # map from callback_id to callbacks
-        # dynamic menus. key: callback_id, value: menu data (dictionnary)
-        self._menus = {}
-        self._menus_paths = {}  # path to id. key: (menu_type, lower case tuple of path),
-                                # value: menu id
-        self.initialised = defer.Deferred()
-        self.profiles = {}
-        self.plugins = {}
-        # map for short name to whole namespace,
-        # extended by plugins with register_namespace
-        self.ns_map = {
-            "x-data": xmpp.NS_X_DATA,
-            "disco#info": xmpp.NS_DISCO_INFO,
-        }
-
-        self.memory = memory.Memory(self)
-
-        # trigger are used to change Libervia behaviour
-        self.trigger = (
-            trigger.TriggerManager()
-        )
-
-        bridge_name = (
-            os.getenv("LIBERVIA_BRIDGE_NAME")
-            or self.memory.config_get("", "bridge", "dbus")
-        )
-
-        bridge_module = dynamic_import.bridge(bridge_name)
-        if bridge_module is None:
-            log.error(f"Can't find bridge module of name {bridge_name}")
-            sys.exit(1)
-        log.info(f"using {bridge_name} bridge")
-        try:
-            self.bridge = bridge_module.bridge()
-        except exceptions.BridgeInitError:
-            log.exception("bridge can't be initialised, can't start Libervia Backend")
-            sys.exit(1)
-
-        defer.ensureDeferred(self._post_init())
-
-    @property
-    def version(self):
-        """Return the short version of Libervia"""
-        return C.APP_VERSION
-
-    @property
-    def full_version(self):
-        """Return the full version of Libervia
-
-        In developement mode, release name and extra data are returned too
-        """
-        version = self.version
-        if version[-1] == "D":
-            # we are in debug version, we add extra data
-            try:
-                return self._version_cache
-            except AttributeError:
-                self._version_cache = "{} « {} » ({})".format(
-                    version, C.APP_RELEASE_NAME, utils.get_repository_data(sat)
-                )
-                return self._version_cache
-        else:
-            return version
-
-    @property
-    def bridge_name(self):
-        return os.path.splitext(os.path.basename(self.bridge.__file__))[0]
-
-    async def _post_init(self):
-        try:
-            bridge_pi = self.bridge.post_init
-        except AttributeError:
-            pass
-        else:
-            try:
-                await bridge_pi()
-            except Exception:
-                log.exception("Could not initialize bridge")
-                # because init is not complete at this stage, we use callLater
-                reactor.callLater(0, self.stop)
-                return
-
-        self.bridge.register_method("ready_get", lambda: self.initialised)
-        self.bridge.register_method("version_get", lambda: self.full_version)
-        self.bridge.register_method("features_get", self.features_get)
-        self.bridge.register_method("profile_name_get", self.memory.get_profile_name)
-        self.bridge.register_method("profiles_list_get", self.memory.get_profiles_list)
-        self.bridge.register_method("entity_data_get", self.memory._get_entity_data)
-        self.bridge.register_method("entities_data_get", self.memory._get_entities_data)
-        self.bridge.register_method("profile_create", self.memory.create_profile)
-        self.bridge.register_method("profile_delete_async", self.memory.profile_delete_async)
-        self.bridge.register_method("profile_start_session", self.memory.start_session)
-        self.bridge.register_method(
-            "profile_is_session_started", self.memory._is_session_started
-        )
-        self.bridge.register_method("profile_set_default", self.memory.profile_set_default)
-        self.bridge.register_method("connect", self._connect)
-        self.bridge.register_method("disconnect", self.disconnect)
-        self.bridge.register_method("contact_get", self._contact_get)
-        self.bridge.register_method("contacts_get", self.contacts_get)
-        self.bridge.register_method("contacts_get_from_group", self.contacts_get_from_group)
-        self.bridge.register_method("main_resource_get", self.memory._get_main_resource)
-        self.bridge.register_method(
-            "presence_statuses_get", self.memory._get_presence_statuses
-        )
-        self.bridge.register_method("sub_waiting_get", self.memory.sub_waiting_get)
-        self.bridge.register_method("message_send", self._message_send)
-        self.bridge.register_method("message_encryption_start",
-                                    self._message_encryption_start)
-        self.bridge.register_method("message_encryption_stop",
-                                    self._message_encryption_stop)
-        self.bridge.register_method("message_encryption_get",
-                                    self._message_encryption_get)
-        self.bridge.register_method("encryption_namespace_get",
-                                    self._encryption_namespace_get)
-        self.bridge.register_method("encryption_plugins_get", self._encryption_plugins_get)
-        self.bridge.register_method("encryption_trust_ui_get", self._encryption_trust_ui_get)
-        self.bridge.register_method("config_get", self._get_config)
-        self.bridge.register_method("param_set", self.param_set)
-        self.bridge.register_method("param_get_a", self.memory.get_string_param_a)
-        self.bridge.register_method("private_data_get", self.memory._private_data_get)
-        self.bridge.register_method("private_data_set", self.memory._private_data_set)
-        self.bridge.register_method("private_data_delete", self.memory._private_data_delete)
-        self.bridge.register_method("param_get_a_async", self.memory.async_get_string_param_a)
-        self.bridge.register_method(
-            "params_values_from_category_get_async",
-            self.memory._get_params_values_from_category,
-        )
-        self.bridge.register_method("param_ui_get", self.memory._get_params_ui)
-        self.bridge.register_method(
-            "params_categories_get", self.memory.params_categories_get
-        )
-        self.bridge.register_method("params_register_app", self.memory.params_register_app)
-        self.bridge.register_method("history_get", self.memory._history_get)
-        self.bridge.register_method("presence_set", self._set_presence)
-        self.bridge.register_method("subscription", self.subscription)
-        self.bridge.register_method("contact_add", self._add_contact)
-        self.bridge.register_method("contact_update", self._update_contact)
-        self.bridge.register_method("contact_del", self._del_contact)
-        self.bridge.register_method("roster_resync", self._roster_resync)
-        self.bridge.register_method("is_connected", self.is_connected)
-        self.bridge.register_method("action_launch", self._action_launch)
-        self.bridge.register_method("actions_get", self.actions_get)
-        self.bridge.register_method("progress_get", self._progress_get)
-        self.bridge.register_method("progress_get_all", self._progress_get_all)
-        self.bridge.register_method("menus_get", self.get_menus)
-        self.bridge.register_method("menu_help_get", self.get_menu_help)
-        self.bridge.register_method("menu_launch", self._launch_menu)
-        self.bridge.register_method("disco_infos", self.memory.disco._disco_infos)
-        self.bridge.register_method("disco_items", self.memory.disco._disco_items)
-        self.bridge.register_method("disco_find_by_features", self._find_by_features)
-        self.bridge.register_method("params_template_save", self.memory.save_xml)
-        self.bridge.register_method("params_template_load", self.memory.load_xml)
-        self.bridge.register_method("session_infos_get", self.get_session_infos)
-        self.bridge.register_method("devices_infos_get", self._get_devices_infos)
-        self.bridge.register_method("namespaces_get", self.get_namespaces)
-        self.bridge.register_method("image_check", self._image_check)
-        self.bridge.register_method("image_resize", self._image_resize)
-        self.bridge.register_method("image_generate_preview", self._image_generate_preview)
-        self.bridge.register_method("image_convert", self._image_convert)
-
-
-        await self.memory.initialise()
-        self.common_cache = cache.Cache(self, None)
-        log.info(_("Memory initialised"))
-        try:
-            self._import_plugins()
-            ui_contact_list.ContactList(self)
-            ui_profile_manager.ProfileManager(self)
-        except Exception as e:
-            log.error(f"Could not initialize backend: {e}")
-            sys.exit(1)
-        self._add_base_menus()
-
-        self.initialised.callback(None)
-        log.info(_("Backend is ready"))
-
-        # profile autoconnection must be done after self.initialised is called because
-        # start_session waits for it.
-        autoconnect_dict = await self.memory.storage.get_ind_param_values(
-            category='Connection', name='autoconnect_backend',
-        )
-        profiles_autoconnect = [p for p, v in autoconnect_dict.items() if C.bool(v)]
-        if not self.trigger.point("profilesAutoconnect", profiles_autoconnect):
-            return
-        if profiles_autoconnect:
-            log.info(D_(
-                "Following profiles will be connected automatically: {profiles}"
-                ).format(profiles= ', '.join(profiles_autoconnect)))
-        connect_d_list = []
-        for profile in profiles_autoconnect:
-            connect_d_list.append(defer.ensureDeferred(self.connect(profile)))
-
-        if connect_d_list:
-            results = await defer.DeferredList(connect_d_list)
-            for idx, (success, result) in enumerate(results):
-                if not success:
-                    profile = profiles_autoconnect[0]
-                    log.warning(
-                        _("Can't autoconnect profile {profile}: {reason}").format(
-                            profile = profile,
-                            reason = result)
-                    )
-
-    def _add_base_menus(self):
-        """Add base menus"""
-        encryption.EncryptionHandler._import_menus(self)
-
-    def _unimport_plugin(self, plugin_path):
-        """remove a plugin from sys.modules if it is there"""
-        try:
-            del sys.modules[plugin_path]
-        except KeyError:
-            pass
-
-    def _import_plugins(self):
-        """import all plugins found in plugins directory"""
-        # FIXME: module imported but cancelled should be deleted
-        # TODO: make this more generic and reusable in tools.common
-        # FIXME: should use imp
-        # TODO: do not import all plugins if no needed: component plugins are not needed
-        #       if we just use a client, and plugin blacklisting should be possible in
-        #       sat.conf
-        plugins_path = Path(sat.plugins.__file__).parent
-        plugins_to_import = {}  # plugins we still have to import
-        for plug_path in plugins_path.glob("plugin_*"):
-            if plug_path.is_dir():
-                init_path = plug_path / f"__init__.{C.PLUGIN_EXT}"
-                if not init_path.exists():
-                    log.warning(
-                        f"{plug_path} doesn't appear to be a package, can't load it")
-                    continue
-                plug_name = plug_path.name
-            elif plug_path.is_file():
-                if plug_path.suffix != f".{C.PLUGIN_EXT}":
-                    continue
-                plug_name = plug_path.stem
-            else:
-                log.warning(
-                    f"{plug_path} is not a file or a dir, ignoring it")
-                continue
-            if not plug_name.isidentifier():
-                log.warning(
-                    f"{plug_name!r} is not a valid name for a plugin, ignoring it")
-                continue
-            plugin_path = f"sat.plugins.{plug_name}"
-            try:
-                __import__(plugin_path)
-            except exceptions.MissingModule as e:
-                self._unimport_plugin(plugin_path)
-                log.warning(
-                    "Can't import plugin [{path}] because of an unavailale third party "
-                    "module:\n{msg}".format(
-                        path=plugin_path, msg=e
-                    )
-                )
-                continue
-            except exceptions.CancelError as e:
-                log.info(
-                    "Plugin [{path}] cancelled its own import: {msg}".format(
-                        path=plugin_path, msg=e
-                    )
-                )
-                self._unimport_plugin(plugin_path)
-                continue
-            except Exception:
-                import traceback
-
-                log.error(
-                    _("Can't import plugin [{path}]:\n{error}").format(
-                        path=plugin_path, error=traceback.format_exc()
-                    )
-                )
-                self._unimport_plugin(plugin_path)
-                continue
-            mod = sys.modules[plugin_path]
-            plugin_info = mod.PLUGIN_INFO
-            import_name = plugin_info["import_name"]
-
-            plugin_modes = plugin_info["modes"] = set(
-                plugin_info.setdefault("modes", C.PLUG_MODE_DEFAULT)
-            )
-            if not plugin_modes.intersection(C.PLUG_MODE_BOTH):
-                log.error(
-                    f"Can't import plugin at {plugin_path}, invalid {C.PI_MODES!r} "
-                    f"value: {plugin_modes!r}"
-                )
-                continue
-
-            # if the plugin is an entry point, it must work in component mode
-            if plugin_info["type"] == C.PLUG_TYPE_ENTRY_POINT:
-                # if plugin is an entrypoint, we cache it
-                if C.PLUG_MODE_COMPONENT not in plugin_modes:
-                    log.error(
-                        _(
-                            "{type} type must be used with {mode} mode, ignoring plugin"
-                        ).format(type=C.PLUG_TYPE_ENTRY_POINT, mode=C.PLUG_MODE_COMPONENT)
-                    )
-                    self._unimport_plugin(plugin_path)
-                    continue
-
-            if import_name in plugins_to_import:
-                log.error(
-                    _(
-                        "Name conflict for import name [{import_name}], can't import "
-                        "plugin [{name}]"
-                    ).format(**plugin_info)
-                )
-                continue
-            plugins_to_import[import_name] = (plugin_path, mod, plugin_info)
-        while True:
-            try:
-                self._import_plugins_from_dict(plugins_to_import)
-            except ImportError:
-                pass
-            if not plugins_to_import:
-                break
-
-    def _import_plugins_from_dict(
-        self, plugins_to_import, import_name=None, optional=False
-    ):
-        """Recursively import and their dependencies in the right order
-
-        @param plugins_to_import(dict): key=import_name and values=(plugin_path, module,
-                                        plugin_info)
-        @param import_name(unicode, None): name of the plugin to import as found in
-                                           PLUGIN_INFO['import_name']
-        @param optional(bool): if False and plugin is not found, an ImportError exception
-                               is raised
-        """
-        if import_name in self.plugins:
-            log.debug("Plugin {} already imported, passing".format(import_name))
-            return
-        if not import_name:
-            import_name, (plugin_path, mod, plugin_info) = plugins_to_import.popitem()
-        else:
-            if not import_name in plugins_to_import:
-                if optional:
-                    log.warning(
-                        _("Recommended plugin not found: {}").format(import_name)
-                    )
-                    return
-                msg = "Dependency not found: {}".format(import_name)
-                log.error(msg)
-                raise ImportError(msg)
-            plugin_path, mod, plugin_info = plugins_to_import.pop(import_name)
-        dependencies = plugin_info.setdefault("dependencies", [])
-        recommendations = plugin_info.setdefault("recommendations", [])
-        for to_import in dependencies + recommendations:
-            if to_import not in self.plugins:
-                log.debug(
-                    "Recursively import dependency of [%s]: [%s]"
-                    % (import_name, to_import)
-                )
-                try:
-                    self._import_plugins_from_dict(
-                        plugins_to_import, to_import, to_import not in dependencies
-                    )
-                except ImportError as e:
-                    log.warning(
-                        _("Can't import plugin {name}: {error}").format(
-                            name=plugin_info["name"], error=e
-                        )
-                    )
-                    if optional:
-                        return
-                    raise e
-        log.info("importing plugin: {}".format(plugin_info["name"]))
-        # we instanciate the plugin here
-        try:
-            self.plugins[import_name] = getattr(mod, plugin_info["main"])(self)
-        except Exception as e:
-            log.exception(
-                f"Can't load plugin \"{plugin_info['name']}\", ignoring it: {e}"
-            )
-            if optional:
-                return
-            raise ImportError("Error during initiation")
-        if C.bool(plugin_info.get(C.PI_HANDLER, C.BOOL_FALSE)):
-            self.plugins[import_name].is_handler = True
-        else:
-            self.plugins[import_name].is_handler = False
-        # we keep metadata as a Class attribute
-        self.plugins[import_name]._info = plugin_info
-        # TODO: test xmppclient presence and register handler parent
-
-    def plugins_unload(self):
-        """Call unload method on every loaded plugin, if exists
-
-        @return (D): A deferred which return None when all method have been called
-        """
-        # TODO: in the futur, it should be possible to hot unload a plugin
-        #       pluging depending on the unloaded one should be unloaded too
-        #       for now, just a basic call on plugin.unload is done
-        defers_list = []
-        for plugin in self.plugins.values():
-            try:
-                unload = plugin.unload
-            except AttributeError:
-                continue
-            else:
-                defers_list.append(utils.as_deferred(unload))
-        return defers_list
-
-    def _connect(self, profile_key, password="", options=None):
-        profile = self.memory.get_profile_name(profile_key)
-        return defer.ensureDeferred(self.connect(profile, password, options))
-
-    async def connect(
-        self, profile, password="", options=None, max_retries=C.XMPP_MAX_RETRIES):
-        """Connect a profile (i.e. connect client.component to XMPP server)
-
-        Retrieve the individual parameters, authenticate the profile
-        and initiate the connection to the associated XMPP server.
-        @param profile: %(doc_profile)s
-        @param password (string): the Libervia profile password
-        @param options (dict): connection options. Key can be:
-            -
-        @param max_retries (int): max number of connection retries
-        @return (D(bool)):
-            - True if the XMPP connection was already established
-            - False if the XMPP connection has been initiated (it may still fail)
-        @raise exceptions.PasswordError: Profile password is wrong
-        """
-        if options is None:
-            options = {}
-
-        await self.memory.start_session(password, profile)
-
-        if self.is_connected(profile):
-            log.info(_("already connected !"))
-            return True
-
-        if self.memory.is_component(profile):
-            await xmpp.SatXMPPComponent.start_connection(self, profile, max_retries)
-        else:
-            await xmpp.SatXMPPClient.start_connection(self, profile, max_retries)
-
-        return False
-
-    def disconnect(self, profile_key):
-        """disconnect from jabber server"""
-        # FIXME: client should not be deleted if only disconnected
-        #        it shoud be deleted only when session is finished
-        if not self.is_connected(profile_key):
-            # is_connected is checked here and not on client
-            # because client is deleted when session is ended
-            log.info(_("not connected !"))
-            return defer.succeed(None)
-        client = self.get_client(profile_key)
-        return client.entity_disconnect()
-
-    def features_get(self, profile_key=C.PROF_KEY_NONE):
-        """Get available features
-
-        Return list of activated plugins and plugin specific data
-        @param profile_key: %(doc_profile_key)s
-            C.PROF_KEY_NONE can be used to have general plugins data (i.e. not profile
-            dependent)
-        @return (dict)[Deferred]: features data where:
-            - key is plugin import name, present only for activated plugins
-            - value is a an other dict, when meaning is specific to each plugin.
-                this dict is return by plugin's getFeature method.
-                If this method doesn't exists, an empty dict is returned.
-        """
-        try:
-            # FIXME: there is no method yet to check profile session
-            #        as soon as one is implemented, it should be used here
-            self.get_client(profile_key)
-        except KeyError:
-            log.warning("Requesting features for a profile outside a session")
-            profile_key = C.PROF_KEY_NONE
-        except exceptions.ProfileNotSetError:
-            pass
-
-        features = []
-        for import_name, plugin in self.plugins.items():
-            try:
-                features_d = utils.as_deferred(plugin.features_get, profile_key)
-            except AttributeError:
-                features_d = defer.succeed({})
-            features.append(features_d)
-
-        d_list = defer.DeferredList(features)
-
-        def build_features(result, import_names):
-            assert len(result) == len(import_names)
-            ret = {}
-            for name, (success, data) in zip(import_names, result):
-                if success:
-                    ret[name] = data
-                else:
-                    log.warning(
-                        "Error while getting features for {name}: {failure}".format(
-                            name=name, failure=data
-                        )
-                    )
-                    ret[name] = {}
-            return ret
-
-        d_list.addCallback(build_features, list(self.plugins.keys()))
-        return d_list
-
-    def _contact_get(self, entity_jid_s, profile_key):
-        client = self.get_client(profile_key)
-        entity_jid = jid.JID(entity_jid_s)
-        return defer.ensureDeferred(self.get_contact(client, entity_jid))
-
-    async def get_contact(self, client, entity_jid):
-        # we want to be sure that roster has been received
-        await client.roster.got_roster
-        item = client.roster.get_item(entity_jid)
-        if item is None:
-            raise exceptions.NotFound(f"{entity_jid} is not in roster!")
-        return (client.roster.get_attributes(item), list(item.groups))
-
-    def contacts_get(self, profile_key):
-        client = self.get_client(profile_key)
-
-        def got_roster(__):
-            ret = []
-            for item in client.roster.get_items():  # we get all items for client's roster
-                # and convert them to expected format
-                attr = client.roster.get_attributes(item)
-                # we use full() and not userhost() because jid with resources are allowed
-                # in roster, even if it's not common.
-                ret.append([item.entity.full(), attr, list(item.groups)])
-            return ret
-
-        return client.roster.got_roster.addCallback(got_roster)
-
-    def contacts_get_from_group(self, group, profile_key):
-        client = self.get_client(profile_key)
-        return [jid_.full() for jid_ in client.roster.get_jids_from_group(group)]
-
-    def purge_entity(self, profile):
-        """Remove reference to a profile client/component and purge cache
-
-        the garbage collector can then free the memory
-        """
-        try:
-            del self.profiles[profile]
-        except KeyError:
-            log.error(_("Trying to remove reference to a client not referenced"))
-        else:
-            self.memory.purge_profile_session(profile)
-
-    def startService(self):
-        self._init()
-        log.info("Salut à toi ô mon frère !")
-
-    def stopService(self):
-        log.info("Salut aussi à Rantanplan")
-        return self.plugins_unload()
-
-    def run(self):
-        log.debug(_("running app"))
-        reactor.run()
-
-    def stop(self):
-        log.debug(_("stopping app"))
-        reactor.stop()
-
-    ## Misc methods ##
-
-    def get_jid_n_stream(self, profile_key):
-        """Convenient method to get jid and stream from profile key
-        @return: tuple (jid, xmlstream) from profile, can be None"""
-        # TODO: deprecate this method (get_client is enough)
-        profile = self.memory.get_profile_name(profile_key)
-        if not profile or not self.profiles[profile].is_connected():
-            return (None, None)
-        return (self.profiles[profile].jid, self.profiles[profile].xmlstream)
-
-    def get_client(self, profile_key: str) -> xmpp.SatXMPPClient:
-        """Convenient method to get client from profile key
-
-        @return: the client
-        @raise exceptions.ProfileKeyUnknown: the profile or profile key doesn't exist
-        @raise exceptions.NotFound: client is not available
-            This happen if profile has not been used yet
-        """
-        profile = self.memory.get_profile_name(profile_key)
-        if not profile:
-            raise exceptions.ProfileKeyUnknown
-        try:
-            return self.profiles[profile]
-        except KeyError:
-            raise exceptions.NotFound(profile_key)
-
-    def get_clients(self, profile_key):
-        """Convenient method to get list of clients from profile key
-
-        Manage list through profile_key like C.PROF_KEY_ALL
-        @param profile_key: %(doc_profile_key)s
-        @return: list of clients
-        """
-        if not profile_key:
-            raise exceptions.DataError(_("profile_key must not be empty"))
-        try:
-            profile = self.memory.get_profile_name(profile_key, True)
-        except exceptions.ProfileUnknownError:
-            return []
-        if profile == C.PROF_KEY_ALL:
-            return list(self.profiles.values())
-        elif profile[0] == "@":  #  only profile keys can start with "@"
-            raise exceptions.ProfileKeyUnknown
-        return [self.profiles[profile]]
-
-    def _get_config(self, section, name):
-        """Get the main configuration option
-
-        @param section: section of the config file (None or '' for DEFAULT)
-        @param name: name of the option
-        @return: unicode representation of the option
-        """
-        return str(self.memory.config_get(section, name, ""))
-
-    def log_errback(self, failure_, msg=_("Unexpected error: {failure_}")):
-        """Generic errback logging
-
-        @param msg(unicode): error message ("failure_" key will be use for format)
-        can be used as last errback to show unexpected error
-        """
-        log.error(msg.format(failure_=failure_))
-        return failure_
-
-    #  namespaces
-
-    def register_namespace(self, short_name, namespace):
-        """associate a namespace to a short name"""
-        if short_name in self.ns_map:
-            raise exceptions.ConflictError("this short name is already used")
-        log.debug(f"registering namespace {short_name} => {namespace}")
-        self.ns_map[short_name] = namespace
-
-    def get_namespaces(self):
-        return self.ns_map
-
-    def get_namespace(self, short_name):
-        try:
-            return self.ns_map[short_name]
-        except KeyError:
-            raise exceptions.NotFound("namespace {short_name} is not registered"
-                                      .format(short_name=short_name))
-
-    def get_session_infos(self, profile_key):
-        """compile interesting data on current profile session"""
-        client = self.get_client(profile_key)
-        data = {
-            "jid": client.jid.full(),
-            "started": str(int(client.started))
-            }
-        return defer.succeed(data)
-
-    def _get_devices_infos(self, bare_jid, profile_key):
-        client = self.get_client(profile_key)
-        if not bare_jid:
-            bare_jid = None
-        d = defer.ensureDeferred(self.get_devices_infos(client, bare_jid))
-        d.addCallback(lambda data: data_format.serialise(data))
-        return d
-
-    async def get_devices_infos(self, client, bare_jid=None):
-        """compile data on an entity devices
-
-        @param bare_jid(jid.JID, None): bare jid of entity to check
-            None to use client own jid
-        @return (list[dict]): list of data, one item per resource.
-            Following keys can be set:
-                - resource(str): resource name
-        """
-        own_jid = client.jid.userhostJID()
-        if bare_jid is None:
-            bare_jid = own_jid
-        else:
-            bare_jid = jid.JID(bare_jid)
-        resources = self.memory.get_all_resources(client, bare_jid)
-        if bare_jid == own_jid:
-            # our own jid is not stored in memory's cache
-            resources.add(client.jid.resource)
-        ret_data = []
-        for resource in resources:
-            res_jid = copy.copy(bare_jid)
-            res_jid.resource = resource
-            cache_data = self.memory.entity_data_get(client, res_jid)
-            res_data = {
-                "resource": resource,
-            }
-            try:
-                presence = cache_data['presence']
-            except KeyError:
-                pass
-            else:
-                res_data['presence'] = {
-                    "show": presence.show,
-                    "priority": presence.priority,
-                    "statuses": presence.statuses,
-                }
-
-            disco = await self.get_disco_infos(client, res_jid)
-
-            for (category, type_), name in disco.identities.items():
-                identities = res_data.setdefault('identities', [])
-                identities.append({
-                    "name": name,
-                    "category": category,
-                    "type": type_,
-                })
-
-            ret_data.append(res_data)
-
-        return ret_data
-
-    # images
-
-    def _image_check(self, path):
-        report = image.check(self, path)
-        return data_format.serialise(report)
-
-    def _image_resize(self, path, width, height):
-        d = image.resize(path, (width, height))
-        d.addCallback(lambda new_image_path: str(new_image_path))
-        return d
-
-    def _image_generate_preview(self, path, profile_key):
-        client = self.get_client(profile_key)
-        d = defer.ensureDeferred(self.image_generate_preview(client, Path(path)))
-        d.addCallback(lambda preview_path: str(preview_path))
-        return d
-
-    async def image_generate_preview(self, client, path):
-        """Helper method to generate in cache a preview of an image
-
-        @param path(Path): path to the image
-        @return (Path): path to the generated preview
-        """
-        report = image.check(self, path, max_size=(300, 300))
-
-        if not report['too_large']:
-            # in the unlikely case that image is already smaller than a preview
-            preview_path = path
-        else:
-            # we use hash as id, to re-use potentially existing preview
-            path_hash = hashlib.sha256(str(path).encode()).hexdigest()
-            uid = f"{path.stem}_{path_hash}_preview"
-            filename = f"{uid}{path.suffix.lower()}"
-            metadata = client.cache.get_metadata(uid=uid)
-            if metadata is not None:
-                preview_path = metadata['path']
-            else:
-                with client.cache.cache_data(
-                    source='HOST_PREVIEW',
-                    uid=uid,
-                    filename=filename) as cache_f:
-
-                    preview_path = await image.resize(
-                        path,
-                        new_size=report['recommended_size'],
-                        dest=cache_f
-                    )
-
-        return preview_path
-
-    def _image_convert(self, source, dest, extra, profile_key):
-        client = self.get_client(profile_key) if profile_key else None
-        source = Path(source)
-        dest = None if not dest else Path(dest)
-        extra = data_format.deserialise(extra)
-        d = defer.ensureDeferred(self.image_convert(client, source, dest, extra))
-        d.addCallback(lambda dest_path: str(dest_path))
-        return d
-
-    async def image_convert(self, client, source, dest=None, extra=None):
-        """Helper method to convert an image from one format to an other
-
-        @param client(SatClient, None): client to use for caching
-            this parameter is only used if dest is None
-            if client is None, common cache will be used insted of profile cache
-        @param source(Path): path to the image to convert
-        @param dest(None, Path, file): where to save the converted file
-            - None: use a cache file (uid generated from hash of source)
-                file will be converted to PNG
-            - Path: path to the file to create/overwrite
-            - file: a file object which must be opened for writing in binary mode
-        @param extra(dict, None): conversion options
-            see [image.convert] for details
-        @return (Path): path to the converted image
-        @raise ValueError: an issue happened with source of dest
-        """
-        if not source.is_file:
-            raise ValueError(f"Source file {source} doesn't exist!")
-        if dest is None:
-            # we use hash as id, to re-use potentially existing conversion
-            path_hash = hashlib.sha256(str(source).encode()).hexdigest()
-            uid = f"{source.stem}_{path_hash}_convert_png"
-            filename = f"{uid}.png"
-            if client is None:
-                cache = self.common_cache
-            else:
-                cache = client.cache
-            metadata = cache.get_metadata(uid=uid)
-            if metadata is not None:
-                # there is already a conversion for this image in cache
-                return metadata['path']
-            else:
-                with cache.cache_data(
-                    source='HOST_IMAGE_CONVERT',
-                    uid=uid,
-                    filename=filename) as cache_f:
-
-                    converted_path = await image.convert(
-                        source,
-                        dest=cache_f,
-                        extra=extra
-                    )
-                return converted_path
-        else:
-            return await image.convert(source, dest, extra)
-
-
-    # local dirs
-
-    def get_local_path(
-        self,
-        client: Optional[SatXMPPEntity],
-        dir_name: str,
-        *extra_path: str,
-        component: bool = False,
-    ) -> Path:
-        """Retrieve path for local data
-
-        if path doesn't exist, it will be created
-        @param client: client instance
-            if not none, client.profile will be used as last path element
-        @param dir_name: name of the main path directory
-        @param *extra_path: extra path element(s) to use
-        @param component: if True, path will be prefixed with C.COMPONENTS_DIR
-        @return: path
-        """
-        local_dir = self.memory.config_get("", "local_dir")
-        if not local_dir:
-            raise exceptions.InternalError("local_dir must be set")
-        path_elts = []
-        if component:
-            path_elts.append(C.COMPONENTS_DIR)
-        path_elts.append(regex.path_escape(dir_name))
-        if extra_path:
-            path_elts.extend([regex.path_escape(p) for p in extra_path])
-        if client is not None:
-            path_elts.append(regex.path_escape(client.profile))
-        local_path = Path(*path_elts)
-        local_path.mkdir(0o700, parents=True, exist_ok=True)
-        return local_path
-
-    ## Client management ##
-
-    def param_set(self, name, value, category, security_limit, profile_key):
-        """set wanted paramater and notice observers"""
-        self.memory.param_set(name, value, category, security_limit, profile_key)
-
-    def is_connected(self, profile_key):
-        """Return connection status of profile
-
-        @param profile_key: key_word or profile name to determine profile name
-        @return: True if connected
-        """
-        profile = self.memory.get_profile_name(profile_key)
-        if not profile:
-            log.error(_("asking connection status for a non-existant profile"))
-            raise exceptions.ProfileUnknownError(profile_key)
-        if profile not in self.profiles:
-            return False
-        return self.profiles[profile].is_connected()
-
-    ## Encryption ##
-
-    def register_encryption_plugin(self, *args, **kwargs):
-        return encryption.EncryptionHandler.register_plugin(*args, **kwargs)
-
-    def _message_encryption_start(self, to_jid_s, namespace, replace=False,
-                                profile_key=C.PROF_KEY_NONE):
-        client = self.get_client(profile_key)
-        to_jid = jid.JID(to_jid_s)
-        return defer.ensureDeferred(
-            client.encryption.start(to_jid, namespace or None, replace))
-
-    def _message_encryption_stop(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
-        client = self.get_client(profile_key)
-        to_jid = jid.JID(to_jid_s)
-        return defer.ensureDeferred(
-            client.encryption.stop(to_jid))
-
-    def _message_encryption_get(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
-        client = self.get_client(profile_key)
-        to_jid = jid.JID(to_jid_s)
-        session_data = client.encryption.getSession(to_jid)
-        return client.encryption.get_bridge_data(session_data)
-
-    def _encryption_namespace_get(self, name):
-        return encryption.EncryptionHandler.get_ns_from_name(name)
-
-    def _encryption_plugins_get(self):
-        plugins = encryption.EncryptionHandler.getPlugins()
-        ret = []
-        for p in plugins:
-            ret.append({
-                "name": p.name,
-                "namespace": p.namespace,
-                "priority": p.priority,
-                "directed": p.directed,
-                })
-        return data_format.serialise(ret)
-
-    def _encryption_trust_ui_get(self, to_jid_s, namespace, profile_key):
-        client = self.get_client(profile_key)
-        to_jid = jid.JID(to_jid_s)
-        d = defer.ensureDeferred(
-            client.encryption.get_trust_ui(to_jid, namespace=namespace or None))
-        d.addCallback(lambda xmlui: xmlui.toXml())
-        return d
-
-    ## XMPP methods ##
-
-    def _message_send(
-            self, to_jid_s, message, subject=None, mess_type="auto", extra_s="",
-            profile_key=C.PROF_KEY_NONE):
-        client = self.get_client(profile_key)
-        to_jid = jid.JID(to_jid_s)
-        return client.sendMessage(
-            to_jid,
-            message,
-            subject,
-            mess_type,
-            data_format.deserialise(extra_s)
-        )
-
-    def _set_presence(self, to="", show="", statuses=None, profile_key=C.PROF_KEY_NONE):
-        return self.presence_set(jid.JID(to) if to else None, show, statuses, profile_key)
-
-    def presence_set(self, to_jid=None, show="", statuses=None,
-                    profile_key=C.PROF_KEY_NONE):
-        """Send our presence information"""
-        if statuses is None:
-            statuses = {}
-        profile = self.memory.get_profile_name(profile_key)
-        assert profile
-        priority = int(
-            self.memory.param_get_a("Priority", "Connection", profile_key=profile)
-        )
-        self.profiles[profile].presence.available(to_jid, show, statuses, priority)
-        # XXX: FIXME: temporary fix to work around openfire 3.7.0 bug (presence is not
-        #             broadcasted to generating resource)
-        if "" in statuses:
-            statuses[C.PRESENCE_STATUSES_DEFAULT] = statuses.pop("")
-        self.bridge.presence_update(
-            self.profiles[profile].jid.full(), show, int(priority), statuses, profile
-        )
-
-    def subscription(self, subs_type, raw_jid, profile_key):
-        """Called to manage subscription
-        @param subs_type: subsciption type (cf RFC 3921)
-        @param raw_jid: unicode entity's jid
-        @param profile_key: profile"""
-        profile = self.memory.get_profile_name(profile_key)
-        assert profile
-        to_jid = jid.JID(raw_jid)
-        log.debug(
-            _("subsciption request [%(subs_type)s] for %(jid)s")
-            % {"subs_type": subs_type, "jid": to_jid.full()}
-        )
-        if subs_type == "subscribe":
-            self.profiles[profile].presence.subscribe(to_jid)
-        elif subs_type == "subscribed":
-            self.profiles[profile].presence.subscribed(to_jid)
-        elif subs_type == "unsubscribe":
-            self.profiles[profile].presence.unsubscribe(to_jid)
-        elif subs_type == "unsubscribed":
-            self.profiles[profile].presence.unsubscribed(to_jid)
-
-    def _add_contact(self, to_jid_s, profile_key):
-        return self.contact_add(jid.JID(to_jid_s), profile_key)
-
-    def contact_add(self, to_jid, profile_key):
-        """Add a contact in roster list"""
-        profile = self.memory.get_profile_name(profile_key)
-        assert profile
-        # presence is sufficient, as a roster push will be sent according to
-        # RFC 6121 §3.1.2
-        self.profiles[profile].presence.subscribe(to_jid)
-
-    def _update_contact(self, to_jid_s, name, groups, profile_key):
-        client = self.get_client(profile_key)
-        return self.contact_update(client, jid.JID(to_jid_s), name, groups)
-
-    def contact_update(self, client, to_jid, name, groups):
-        """update a contact in roster list"""
-        roster_item = RosterItem(to_jid)
-        roster_item.name = name or u''
-        roster_item.groups = set(groups)
-        if not self.trigger.point("roster_update", client, roster_item):
-            return
-        return client.roster.setItem(roster_item)
-
-    def _del_contact(self, to_jid_s, profile_key):
-        return self.contact_del(jid.JID(to_jid_s), profile_key)
-
-    def contact_del(self, to_jid, profile_key):
-        """Remove contact from roster list"""
-        profile = self.memory.get_profile_name(profile_key)
-        assert profile
-        self.profiles[profile].presence.unsubscribe(to_jid)  # is not asynchronous
-        return self.profiles[profile].roster.removeItem(to_jid)
-
-    def _roster_resync(self, profile_key):
-        client = self.get_client(profile_key)
-        return client.roster.resync()
-
-    ## Discovery ##
-    # discovery methods are shortcuts to self.memory.disco
-    # the main difference with client.disco is that self.memory.disco manage cache
-
-    def hasFeature(self, *args, **kwargs):
-        return self.memory.disco.hasFeature(*args, **kwargs)
-
-    def check_feature(self, *args, **kwargs):
-        return self.memory.disco.check_feature(*args, **kwargs)
-
-    def check_features(self, *args, **kwargs):
-        return self.memory.disco.check_features(*args, **kwargs)
-
-    def has_identity(self, *args, **kwargs):
-        return self.memory.disco.has_identity(*args, **kwargs)
-
-    def get_disco_infos(self, *args, **kwargs):
-        return self.memory.disco.get_infos(*args, **kwargs)
-
-    def getDiscoItems(self, *args, **kwargs):
-        return self.memory.disco.get_items(*args, **kwargs)
-
-    def find_service_entity(self, *args, **kwargs):
-        return self.memory.disco.find_service_entity(*args, **kwargs)
-
-    def find_service_entities(self, *args, **kwargs):
-        return self.memory.disco.find_service_entities(*args, **kwargs)
-
-    def find_features_set(self, *args, **kwargs):
-        return self.memory.disco.find_features_set(*args, **kwargs)
-
-    def _find_by_features(self, namespaces, identities, bare_jids, service, roster, own_jid,
-                        local_device, profile_key):
-        client = self.get_client(profile_key)
-        identities = [tuple(i) for i in identities] if identities else None
-        return defer.ensureDeferred(self.find_by_features(
-            client, namespaces, identities, bare_jids, service, roster, own_jid,
-            local_device))
-
-    async def find_by_features(
-        self,
-        client: SatXMPPEntity,
-        namespaces: List[str],
-        identities: Optional[List[Tuple[str, str]]]=None,
-        bare_jids: bool=False,
-        service: bool=True,
-        roster: bool=True,
-        own_jid: bool=True,
-        local_device: bool=False
-    ) -> Tuple[
-        Dict[jid.JID, Tuple[str, str, str]],
-        Dict[jid.JID, Tuple[str, str, str]],
-        Dict[jid.JID, Tuple[str, str, str]]
-    ]:
-        """Retrieve all services or contacts managing a set a features
-
-        @param namespaces: features which must be handled
-        @param identities: if not None or empty,
-            only keep those identities
-            tuple must be (category, type)
-        @param bare_jids: retrieve only bare_jids if True
-            if False, retrieve full jid of connected devices
-        @param service: if True return service from our server
-        @param roster: if True, return entities in roster
-            full jid of all matching resources available will be returned
-        @param own_jid: if True, return profile's jid resources
-        @param local_device: if True, return profile's jid local resource
-            (i.e. client.jid)
-        @return: found entities in a tuple with:
-            - service entities
-            - own entities
-            - roster entities
-            Each element is a dict mapping from jid to a tuple with category, type and
-            name of the entity
-        """
-        assert isinstance(namespaces, list)
-        if not identities:
-            identities = None
-        if not namespaces and not identities:
-            raise exceptions.DataError(
-                "at least one namespace or one identity must be set"
-            )
-        found_service = {}
-        found_own = {}
-        found_roster = {}
-        if service:
-            services_jids = await self.find_features_set(client, namespaces)
-            services_jids = list(services_jids)  # we need a list to map results below
-            services_infos  = await defer.DeferredList(
-                [self.get_disco_infos(client, service_jid) for service_jid in services_jids]
-            )
-
-            for idx, (success, infos) in enumerate(services_infos):
-                service_jid = services_jids[idx]
-                if not success:
-                    log.warning(
-                        _("Can't find features for service {service_jid}, ignoring")
-                        .format(service_jid=service_jid.full()))
-                    continue
-                if (identities is not None
-                    and not set(infos.identities.keys()).issuperset(identities)):
-                    continue
-                found_identities = [
-                    (cat, type_, name or "")
-                    for (cat, type_), name in infos.identities.items()
-                ]
-                found_service[service_jid.full()] = found_identities
-
-        to_find = []
-        if own_jid:
-            to_find.append((found_own, [client.jid.userhostJID()]))
-        if roster:
-            to_find.append((found_roster, client.roster.get_jids()))
-
-        for found, jids in to_find:
-            full_jids = []
-            disco_defers = []
-
-            for jid_ in jids:
-                if jid_.resource:
-                    if bare_jids:
-                        continue
-                    resources = [jid_.resource]
-                else:
-                    if bare_jids:
-                        resources = [None]
-                    else:
-                        try:
-                            resources = self.memory.get_available_resources(client, jid_)
-                        except exceptions.UnknownEntityError:
-                            continue
-                        if not resources and jid_ == client.jid.userhostJID() and own_jid:
-                            # small hack to avoid missing our own resource when this
-                            # method is called at the very beginning of the session
-                            # and our presence has not been received yet
-                            resources = [client.jid.resource]
-                for resource in resources:
-                    full_jid = jid.JID(tuple=(jid_.user, jid_.host, resource))
-                    if full_jid == client.jid and not local_device:
-                        continue
-                    full_jids.append(full_jid)
-
-                    disco_defers.append(self.get_disco_infos(client, full_jid))
-
-            d_list = defer.DeferredList(disco_defers)
-            # XXX: 10 seconds may be too low for slow connections (e.g. mobiles)
-            #      but for discovery, that's also the time the user will wait the first time
-            #      before seing the page, if something goes wrong.
-            d_list.addTimeout(10, reactor)
-            infos_data = await d_list
-
-            for idx, (success, infos) in enumerate(infos_data):
-                full_jid = full_jids[idx]
-                if not success:
-                    log.warning(
-                        _("Can't retrieve {full_jid} infos, ignoring")
-                        .format(full_jid=full_jid.full()))
-                    continue
-                if infos.features.issuperset(namespaces):
-                    if identities is not None and not set(
-                        infos.identities.keys()
-                    ).issuperset(identities):
-                        continue
-                    found_identities = [
-                        (cat, type_, name or "")
-                        for (cat, type_), name in infos.identities.items()
-                    ]
-                    found[full_jid.full()] = found_identities
-
-        return (found_service, found_own, found_roster)
-
-    ## Generic HMI ##
-
-    def _kill_action(self, keep_id, client):
-        log.debug("Killing action {} for timeout".format(keep_id))
-        client.actions[keep_id]
-
-    def action_new(
-        self,
-        action_data,
-        security_limit=C.NO_SECURITY_LIMIT,
-        keep_id=None,
-        profile=C.PROF_KEY_NONE,
-    ):
-        """Shortcut to bridge.action_new which generate an id and keep for retrieval
-
-        @param action_data(dict): action data (see bridge documentation)
-        @param security_limit: %(doc_security_limit)s
-        @param keep_id(None, unicode): if not None, used to keep action for differed
-            retrieval. The value will be used as callback_id, be sure to use an unique
-            value.
-            Action will be deleted after 30 min.
-        @param profile: %(doc_profile)s
-        """
-        if keep_id is not None:
-            id_ = keep_id
-            client = self.get_client(profile)
-            action_timer = reactor.callLater(60 * 30, self._kill_action, keep_id, client)
-            client.actions[keep_id] = (action_data, id_, security_limit, action_timer)
-        else:
-            id_ = str(uuid.uuid4())
-
-        self.bridge.action_new(
-            data_format.serialise(action_data), id_, security_limit, profile
-        )
-
-    def actions_get(self, profile):
-        """Return current non answered actions
-
-        @param profile: %(doc_profile)s
-        """
-        client = self.get_client(profile)
-        return [
-            (data_format.serialise(action_tuple[0]), *action_tuple[1:-1])
-            for action_tuple in client.actions.values()
-        ]
-
-    def register_progress_cb(
-        self, progress_id, callback, metadata=None, profile=C.PROF_KEY_NONE
-    ):
-        """Register a callback called when progress is requested for id"""
-        if metadata is None:
-            metadata = {}
-        client = self.get_client(profile)
-        if progress_id in client._progress_cb:
-            raise exceptions.ConflictError("Progress ID is not unique !")
-        client._progress_cb[progress_id] = (callback, metadata)
-
-    def remove_progress_cb(self, progress_id, profile):
-        """Remove a progress callback"""
-        client = self.get_client(profile)
-        try:
-            del client._progress_cb[progress_id]
-        except KeyError:
-            log.error(_("Trying to remove an unknow progress callback"))
-
-    def _progress_get(self, progress_id, profile):
-        data = self.progress_get(progress_id, profile)
-        return {k: str(v) for k, v in data.items()}
-
-    def progress_get(self, progress_id, profile):
-        """Return a dict with progress information
-
-        @param progress_id(unicode): unique id of the progressing element
-        @param profile: %(doc_profile)s
-        @return (dict): data with the following keys:
-            'position' (int): current possition
-            'size' (int): end_position
-            if id doesn't exists (may be a finished progression), and empty dict is
-            returned
-        """
-        client = self.get_client(profile)
-        try:
-            data = client._progress_cb[progress_id][0](progress_id, profile)
-        except KeyError:
-            data = {}
-        return data
-
-    def _progress_get_all(self, profile_key):
-        progress_all = self.progress_get_all(profile_key)
-        for profile, progress_dict in progress_all.items():
-            for progress_id, data in progress_dict.items():
-                for key, value in data.items():
-                    data[key] = str(value)
-        return progress_all
-
-    def progress_get_all_metadata(self, profile_key):
-        """Return all progress metadata at once
-
-        @param profile_key: %(doc_profile)s
-            if C.PROF_KEY_ALL is used, all progress metadata from all profiles are
-            returned
-        @return (dict[dict[dict]]): a dict which map profile to progress_dict
-            progress_dict map progress_id to progress_data
-            progress_metadata is the same dict as sent by [progress_started]
-        """
-        clients = self.get_clients(profile_key)
-        progress_all = {}
-        for client in clients:
-            profile = client.profile
-            progress_dict = {}
-            progress_all[profile] = progress_dict
-            for (
-                progress_id,
-                (__, progress_metadata),
-            ) in client._progress_cb.items():
-                progress_dict[progress_id] = progress_metadata
-        return progress_all
-
-    def progress_get_all(self, profile_key):
-        """Return all progress status at once
-
-        @param profile_key: %(doc_profile)s
-            if C.PROF_KEY_ALL is used, all progress status from all profiles are returned
-        @return (dict[dict[dict]]): a dict which map profile to progress_dict
-            progress_dict map progress_id to progress_data
-            progress_data is the same dict as returned by [progress_get]
-        """
-        clients = self.get_clients(profile_key)
-        progress_all = {}
-        for client in clients:
-            profile = client.profile
-            progress_dict = {}
-            progress_all[profile] = progress_dict
-            for progress_id, (progress_cb, __) in client._progress_cb.items():
-                progress_dict[progress_id] = progress_cb(progress_id, profile)
-        return progress_all
-
-    def register_callback(self, callback, *args, **kwargs):
-        """Register a callback.
-
-        @param callback(callable): method to call
-        @param kwargs: can contain:
-            with_data(bool): True if the callback use the optional data dict
-            force_id(unicode): id to avoid generated id. Can lead to name conflict, avoid
-                               if possible
-            one_shot(bool): True to delete callback once it has been called
-        @return: id of the registered callback
-        """
-        callback_id = kwargs.pop("force_id", None)
-        if callback_id is None:
-            callback_id = str(uuid.uuid4())
-        else:
-            if callback_id in self._cb_map:
-                raise exceptions.ConflictError(_("id already registered"))
-        self._cb_map[callback_id] = (callback, args, kwargs)
-
-        if "one_shot" in kwargs:  # One Shot callback are removed after 30 min
-
-            def purge_callback():
-                try:
-                    self.remove_callback(callback_id)
-                except KeyError:
-                    pass
-
-            reactor.callLater(1800, purge_callback)
-
-        return callback_id
-
-    def remove_callback(self, callback_id):
-        """ Remove a previously registered callback
-        @param callback_id: id returned by [register_callback] """
-        log.debug("Removing callback [%s]" % callback_id)
-        del self._cb_map[callback_id]
-
-    def _action_launch(
-        self,
-        callback_id: str,
-        data_s: str,
-        profile_key: str
-    ) -> defer.Deferred:
-        d = self.launch_callback(
-            callback_id,
-            data_format.deserialise(data_s),
-            profile_key
-        )
-        d.addCallback(data_format.serialise)
-        return d
-
-    def launch_callback(
-        self,
-        callback_id: str,
-        data: Optional[dict] = None,
-        profile_key: str = C.PROF_KEY_NONE
-    ) -> defer.Deferred:
-        """Launch a specific callback
-
-        @param callback_id: id of the action (callback) to launch
-        @param data: optional data
-        @profile_key: %(doc_profile_key)s
-        @return: a deferred which fire a dict where key can be:
-            - xmlui: a XMLUI need to be displayed
-            - validated: if present, can be used to launch a callback, it can have the
-                values
-                - C.BOOL_TRUE
-                - C.BOOL_FALSE
-        """
-        # FIXME: is it possible to use this method without profile connected? If not,
-        #     client must be used instead of profile_key
-        # FIXME: security limit need to be checked here
-        try:
-            client = self.get_client(profile_key)
-        except exceptions.NotFound:
-            # client is not available yet
-            profile = self.memory.get_profile_name(profile_key)
-            if not profile:
-                raise exceptions.ProfileUnknownError(
-                    _("trying to launch action with a non-existant profile")
-                )
-        else:
-            profile = client.profile
-            # we check if the action is kept, and remove it
-            try:
-                action_tuple = client.actions[callback_id]
-            except KeyError:
-                pass
-            else:
-                action_tuple[-1].cancel()  # the last item is the action timer
-                del client.actions[callback_id]
-
-        try:
-            callback, args, kwargs = self._cb_map[callback_id]
-        except KeyError:
-            raise exceptions.DataError("Unknown callback id {}".format(callback_id))
-
-        if kwargs.get("with_data", False):
-            if data is None:
-                raise exceptions.DataError("Required data for this callback is missing")
-            args, kwargs = (
-                list(args)[:],
-                kwargs.copy(),
-            )  # we don't want to modify the original (kw)args
-            args.insert(0, data)
-            kwargs["profile"] = profile
-            del kwargs["with_data"]
-
-        if kwargs.pop("one_shot", False):
-            self.remove_callback(callback_id)
-
-        return utils.as_deferred(callback, *args, **kwargs)
-
-    # Menus management
-
-    def _get_menu_canonical_path(self, path):
-        """give canonical form of path
-
-        canonical form is a tuple of the path were every element is stripped and lowercase
-        @param path(iterable[unicode]): untranslated path to menu
-        @return (tuple[unicode]): canonical form of path
-        """
-        return tuple((p.lower().strip() for p in path))
-
-    def import_menu(self, path, callback, security_limit=C.NO_SECURITY_LIMIT,
-                   help_string="", type_=C.MENU_GLOBAL):
-        r"""register a new menu for frontends
-
-        @param path(iterable[unicode]): path to go to the menu
-            (category/subcategory/.../item) (e.g.: ("File", "Open"))
-            /!\ use D_() instead of _() for translations (e.g. (D_("File"), D_("Open")))
-            untranslated/lower case path can be used to identity a menu, for this reason
-            it must be unique independently of case.
-        @param callback(callable): method to be called when menuitem is selected, callable
-            or a callback id (string) as returned by [register_callback]
-        @param security_limit(int): %(doc_security_limit)s
-            /!\ security_limit MUST be added to data in launch_callback if used #TODO
-        @param help_string(unicode): string used to indicate what the menu do (can be
-            show as a tooltip).
-            /!\ use D_() instead of _() for translations
-        @param type(unicode): one of:
-            - C.MENU_GLOBAL: classical menu, can be shown in a menubar on top (e.g.
-                something like File/Open)
-            - C.MENU_ROOM: like a global menu, but only shown in multi-user chat
-                menu_data must contain a "room_jid" data
-            - C.MENU_SINGLE: like a global menu, but only shown in one2one chat
-                menu_data must contain a "jid" data
-            - C.MENU_JID_CONTEXT: contextual menu, used with any jid (e.g.: ad hoc
-                commands, jid is already filled)
-                menu_data must contain a "jid" data
-            - C.MENU_ROSTER_JID_CONTEXT: like JID_CONTEXT, but restricted to jids in
-                roster.
-                menu_data must contain a "room_jid" data
-            - C.MENU_ROSTER_GROUP_CONTEXT: contextual menu, used with group (e.g.: publish
-                microblog, group is already filled)
-                menu_data must contain a "group" data
-        @return (unicode): menu_id (same as callback_id)
-        """
-
-        if callable(callback):
-            callback_id = self.register_callback(callback, with_data=True)
-        elif isinstance(callback, str):
-            # The callback is already registered
-            callback_id = callback
-            try:
-                callback, args, kwargs = self._cb_map[callback_id]
-            except KeyError:
-                raise exceptions.DataError("Unknown callback id")
-            kwargs["with_data"] = True  # we have to be sure that we use extra data
-        else:
-            raise exceptions.DataError("Unknown callback type")
-
-        for menu_data in self._menus.values():
-            if menu_data["path"] == path and menu_data["type"] == type_:
-                raise exceptions.ConflictError(
-                    _("A menu with the same path and type already exists")
-                )
-
-        path_canonical = self._get_menu_canonical_path(path)
-        menu_key = (type_, path_canonical)
-
-        if menu_key in self._menus_paths:
-            raise exceptions.ConflictError(
-                "this menu path is already used: {path} ({menu_key})".format(
-                    path=path_canonical, menu_key=menu_key
-                )
-            )
-
-        menu_data = {
-            "path": tuple(path),
-            "path_canonical": path_canonical,
-            "security_limit": security_limit,
-            "help_string": help_string,
-            "type": type_,
-        }
-
-        self._menus[callback_id] = menu_data
-        self._menus_paths[menu_key] = callback_id
-
-        return callback_id
-
-    def get_menus(self, language="", security_limit=C.NO_SECURITY_LIMIT):
-        """Return all menus registered
-
-        @param language: language used for translation, or empty string for default
-        @param security_limit: %(doc_security_limit)s
-        @return: array of tuple with:
-            - menu id (same as callback_id)
-            - menu type
-            - raw menu path (array of strings)
-            - translated menu path
-            - extra (dict(unicode, unicode)): extra data where key can be:
-                - icon: name of the icon to use (TODO)
-                - help_url: link to a page with more complete documentation (TODO)
-        """
-        ret = []
-        for menu_id, menu_data in self._menus.items():
-            type_ = menu_data["type"]
-            path = menu_data["path"]
-            menu_security_limit = menu_data["security_limit"]
-            if security_limit != C.NO_SECURITY_LIMIT and (
-                menu_security_limit == C.NO_SECURITY_LIMIT
-                or menu_security_limit > security_limit
-            ):
-                continue
-            language_switch(language)
-            path_i18n = [_(elt) for elt in path]
-            language_switch()
-            extra = {}  # TODO: manage extra data like icon
-            ret.append((menu_id, type_, path, path_i18n, extra))
-
-        return ret
-
-    def _launch_menu(self, menu_type, path, data=None, security_limit=C.NO_SECURITY_LIMIT,
-                    profile_key=C.PROF_KEY_NONE):
-        client = self.get_client(profile_key)
-        return self.launch_menu(client, menu_type, path, data, security_limit)
-
-    def launch_menu(self, client, menu_type, path, data=None,
-        security_limit=C.NO_SECURITY_LIMIT):
-        """launch action a menu action
-
-        @param menu_type(unicode): type of menu to launch
-        @param path(iterable[unicode]): canonical path of the menu
-        @params data(dict): menu data
-        @raise NotFound: this path is not known
-        """
-        # FIXME: manage security_limit here
-        #        defaut security limit should be high instead of C.NO_SECURITY_LIMIT
-        canonical_path = self._get_menu_canonical_path(path)
-        menu_key = (menu_type, canonical_path)
-        try:
-            callback_id = self._menus_paths[menu_key]
-        except KeyError:
-            raise exceptions.NotFound(
-                "Can't find menu {path} ({menu_type})".format(
-                    path=canonical_path, menu_type=menu_type
-                )
-            )
-        return self.launch_callback(callback_id, data, client.profile)
-
-    def get_menu_help(self, menu_id, language=""):
-        """return the help string of the menu
-
-        @param menu_id: id of the menu (same as callback_id)
-        @param language: language used for translation, or empty string for default
-        @param return: translated help
-
-        """
-        try:
-            menu_data = self._menus[menu_id]
-        except KeyError:
-            raise exceptions.DataError("Trying to access an unknown menu")
-        language_switch(language)
-        help_string = _(menu_data["help_string"])
-        language_switch()
-        return help_string
--- a/sat/core/xmpp.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1953 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import calendar
-import copy
-from functools import partial
-import mimetypes
-from pathlib import Path
-import sys
-import time
-from typing import Callable, Dict, Tuple, Optional
-from urllib.parse import unquote, urlparse
-import uuid
-
-import shortuuid
-from twisted.internet import defer, error as internet_error
-from twisted.internet import ssl
-from twisted.python import failure
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.protocols.jabber import error
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from wokkel import client as wokkel_client, disco, generic, iwokkel, xmppim
-from wokkel import component
-from wokkel import delay
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core import core_types
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.memory import cache
-from sat.memory import encryption
-from sat.memory import persistent
-from sat.tools import xml_tools
-from sat.tools import utils
-from sat.tools.common import data_format
-
-log = getLogger(__name__)
-
-
-NS_X_DATA = "jabber:x:data"
-NS_DISCO_INFO = "http://jabber.org/protocol/disco#info"
-NS_XML_ELEMENT = "urn:xmpp:xml-element"
-NS_ROSTER_VER = "urn:xmpp:features:rosterver"
-# we use 2 "@" which is illegal in a jid, to be sure we are not mixing keys
-# with roster jids
-ROSTER_VER_KEY = "@version@"
-
-
-class ClientPluginWrapper:
-    """Use a plugin with default value if plugin is missing"""
-
-    def __init__(self, client, plugin_name, missing):
-        self.client = client
-        self.plugin = client.host_app.plugins.get(plugin_name)
-        if self.plugin is None:
-            self.plugin_name = plugin_name
-        self.missing = missing
-
-    def __getattr__(self, attr):
-        if self.plugin is None:
-            missing = self.missing
-            if isinstance(missing, type) and issubclass(missing, Exception):
-                raise missing(f"plugin {self.plugin_name!r} is not available")
-            elif isinstance(missing, Exception):
-                raise missing
-            else:
-                return lambda *args, **kwargs: missing
-        return partial(getattr(self.plugin, attr), self.client)
-
-
-class SatXMPPEntity(core_types.SatXMPPEntity):
-    """Common code for Client and Component"""
-    # profile is added there when start_connection begins and removed when it is finished
-    profiles_connecting = set()
-
-    def __init__(self, host_app, profile, max_retries):
-        factory = self.factory
-
-        # we monkey patch clientConnectionLost to handle network_enabled/network_disabled
-        # and to allow plugins to tune reconnection mechanism
-        clientConnectionFailed_ori = factory.clientConnectionFailed
-        clientConnectionLost_ori = factory.clientConnectionLost
-        factory.clientConnectionFailed = partial(
-            self.connection_terminated, term_type="failed", cb=clientConnectionFailed_ori)
-        factory.clientConnectionLost = partial(
-            self.connection_terminated, term_type="lost", cb=clientConnectionLost_ori)
-
-        factory.maxRetries = max_retries
-        factory.maxDelay = 30
-        # when self._connected_d is None, we are not connected
-        # else, it's a deferred which fire on disconnection
-        self._connected_d = None
-        self.profile = profile
-        self.host_app = host_app
-        self.cache = cache.Cache(host_app, profile)
-        self.mess_id2uid = {}  # map from message id to uid used in history.
-                               # Key: (full_jid, message_id) Value: uid
-        # this Deferred fire when entity is connected
-        self.conn_deferred = defer.Deferred()
-        self._progress_cb = {}  # callback called when a progress is requested
-                                # (key = progress id)
-        self.actions = {}  # used to keep track of actions for retrieval (key = action_id)
-        self.encryption = encryption.EncryptionHandler(self)
-
-    def __str__(self):
-        return f"Client for profile {self.profile}"
-
-    def __repr__(self):
-        return f"{super().__repr__()} - profile: {self.profile!r}"
-
-    ## initialisation ##
-
-    async def _call_connection_triggers(self, connection_timer):
-        """Call conneting trigger prepare connected trigger
-
-        @param plugins(iterable): plugins to use
-        @return (list[object, callable]): plugin to trigger tuples with:
-            - plugin instance
-            - profile_connected* triggers (to call after connection)
-        """
-        plugin_conn_cb = []
-        for plugin in self._get_plugins_list():
-            # we check if plugin handle client mode
-            if plugin.is_handler:
-                plugin.get_handler(self).setHandlerParent(self)
-
-            # profile_connecting/profile_connected methods handling
-
-            timer = connection_timer[plugin] = {
-                "total": 0
-            }
-            # profile connecting is called right now (before actually starting client)
-            connecting_cb = getattr(plugin, "profile_connecting", None)
-            if connecting_cb is not None:
-                connecting_start = time.time()
-                await utils.as_deferred(connecting_cb, self)
-                timer["connecting"] = time.time() - connecting_start
-                timer["total"] += timer["connecting"]
-
-            # profile connected is called after client is ready and roster is got
-            connected_cb = getattr(plugin, "profile_connected", None)
-            if connected_cb is not None:
-                plugin_conn_cb.append((plugin, connected_cb))
-
-        return plugin_conn_cb
-
-    def _get_plugins_list(self):
-        """Return list of plugin to use
-
-        need to be implemented by subclasses
-        this list is used to call profileConnect* triggers
-        @return(iterable[object]): plugins to use
-        """
-        raise NotImplementedError
-
-    def _create_sub_protocols(self):
-        return
-
-    def entity_connected(self):
-        """Called once connection is done
-
-        may return a Deferred, to perform initialisation tasks
-        """
-        return
-
-    @staticmethod
-    async def _run_profile_connected(
-        callback: Callable,
-        entity: "SatXMPPEntity",
-        timer: Dict[str, float]
-    ) -> None:
-        connected_start = time.time()
-        await utils.as_deferred(callback, entity)
-        timer["connected"] = time.time() - connected_start
-        timer["total"] += timer["connected"]
-
-    @classmethod
-    async def start_connection(cls, host, profile, max_retries):
-        """instantiate the entity and start the connection"""
-        # FIXME: reconnection doesn't seems to be handled correclty
-        #        (client is deleted then recreated from scratch)
-        #        most of methods called here should be called once on first connection
-        #        (e.g. adding subprotocols)
-        #        but client should not be deleted except if session is finished
-        #        (independently of connection/deconnection)
-        if profile in cls.profiles_connecting:
-            raise exceptions.CancelError(f"{profile} is already being connected")
-        cls.profiles_connecting.add(profile)
-        try:
-            try:
-                port = int(
-                    host.memory.param_get_a(
-                        C.FORCE_PORT_PARAM, "Connection", profile_key=profile
-                    )
-                )
-            except ValueError:
-                log.debug(_("Can't parse port value, using default value"))
-                port = (
-                    None
-                )  # will use default value 5222 or be retrieved from a DNS SRV record
-
-            password = await host.memory.param_get_a_async(
-                "Password", "Connection", profile_key=profile
-            )
-
-            entity_jid_s = await host.memory.param_get_a_async(
-                "JabberID", "Connection", profile_key=profile)
-            entity_jid = jid.JID(entity_jid_s)
-
-            if not entity_jid.resource and not cls.is_component and entity_jid.user:
-                # if no resource is specified, we create our own instead of using
-                # server returned one, as it will then stay stable in case of
-                # reconnection. we only do that for client and if there is a user part, to
-                # let server decide for anonymous login
-                resource_dict = await host.memory.storage.get_privates(
-                    "core:xmpp", ["resource"] , profile=profile)
-                try:
-                    resource = resource_dict["resource"]
-                except KeyError:
-                    resource = f"{C.APP_NAME_FILE}.{shortuuid.uuid()}"
-                    await host.memory.storage.set_private_value(
-                        "core:xmpp", "resource", resource, profile=profile)
-
-                log.info(_("We'll use the stable resource {resource}").format(
-                    resource=resource))
-                entity_jid.resource = resource
-
-            if profile in host.profiles:
-                if host.profiles[profile].is_connected():
-                    raise exceptions.InternalError(
-                        f"There is already a connected profile of name {profile!r} in "
-                        f"host")
-                log.debug(
-                    "removing unconnected profile {profile!r}")
-                del host.profiles[profile]
-            entity = host.profiles[profile] = cls(
-                host, profile, entity_jid, password,
-                host.memory.param_get_a(C.FORCE_SERVER_PARAM, "Connection",
-                                      profile_key=profile) or None,
-                port, max_retries,
-                )
-
-            await entity.encryption.load_sessions()
-
-            entity._create_sub_protocols()
-
-            entity.fallBack = SatFallbackHandler(host)
-            entity.fallBack.setHandlerParent(entity)
-
-            entity.versionHandler = SatVersionHandler(C.APP_NAME, host.full_version)
-            entity.versionHandler.setHandlerParent(entity)
-
-            entity.identityHandler = SatIdentityHandler()
-            entity.identityHandler.setHandlerParent(entity)
-
-            log.debug(_("setting plugins parents"))
-
-            connection_timer: Dict[str, Dict[str, float]] = {}
-            plugin_conn_cb = await entity._call_connection_triggers(connection_timer)
-
-            entity.startService()
-
-            await entity.conn_deferred
-
-            await defer.maybeDeferred(entity.entity_connected)
-
-            # Call profile_connected callback for all plugins,
-            # and print error message if any of them fails
-            conn_cb_list = []
-            for plugin, callback in plugin_conn_cb:
-                conn_cb_list.append(
-                    defer.ensureDeferred(
-                        cls._run_profile_connected(
-                            callback, entity, connection_timer[plugin]
-                        )
-                    )
-                )
-            list_d = defer.DeferredList(conn_cb_list)
-
-            def log_plugin_results(results):
-                if not results:
-                    log.info("no plugin loaded")
-                    return
-                all_succeed = all([success for success, result in results])
-                if not all_succeed:
-                    log.error(_("Plugins initialisation error"))
-                    for idx, (success, result) in enumerate(results):
-                        if not success:
-                            plugin_name = plugin_conn_cb[idx][0]._info["import_name"]
-                            log.error(f"error (plugin {plugin_name}): {result}")
-
-                log.debug(f"Plugin loading time for {profile!r} (longer to shorter):\n")
-                plugins_by_timer = sorted(
-                    connection_timer,
-                    key=lambda p: connection_timer[p]["total"],
-                    reverse=True
-                )
-                # total is the addition of all connecting and connected, doesn't really
-                # reflect the real loading time as connected are launched in a
-                # DeferredList
-                total_plugins = 0
-                # total real sum all connecting (which happen sequentially) and the
-                # longuest connected (connected happen in parallel, thus the longuest is
-                # roughly the total time for connected)
-                total_real = 0
-                total_real = max(t.get("connected", 0) for t in connection_timer.values())
-
-                for plugin in plugins_by_timer:
-                    name = plugin._info["import_name"]
-                    timer = connection_timer[plugin]
-                    total_plugins += timer["total"]
-                    try:
-                        connecting = f"{timer['connecting']:.2f}s"
-                    except KeyError:
-                        connecting = "n/a"
-                    else:
-                        total_real += timer["connecting"]
-                    try:
-                        connected = f"{timer['connected']:.2f}s"
-                    except KeyError:
-                        connected = "n/a"
-                    log.debug(
-                        f"  - {name}: total={timer['total']:.2f}s "
-                        f"connecting={connecting} connected={connected}"
-                    )
-                log.debug(
-                    f"  Plugins total={total_plugins:.2f}s real={total_real:.2f}s\n"
-                )
-
-            await list_d.addCallback(
-                log_plugin_results
-            )  # FIXME: we should have a timeout here, and a way to know if a plugin freeze
-            # TODO: mesure launch time of each plugin
-        finally:
-            cls.profiles_connecting.remove(profile)
-
-    def _disconnection_cb(self, __):
-        self._connected_d = None
-
-    def _disconnection_eb(self, failure_):
-        log.error(_("Error while disconnecting: {}".format(failure_)))
-
-    def _authd(self, xmlstream):
-        super(SatXMPPEntity, self)._authd(xmlstream)
-        log.debug(_("{profile} identified").format(profile=self.profile))
-        self.stream_initialized()
-
-    def _finish_connection(self, __):
-        if self.conn_deferred.called:
-            # can happen in case of forced disconnection by server
-            log.debug(f"{self} has already been connected")
-        else:
-            self.conn_deferred.callback(None)
-
-    def stream_initialized(self):
-        """Called after _authd"""
-        log.debug(_("XML stream is initialized"))
-        if not self.host_app.trigger.point("xml_init", self):
-            return
-        self.post_stream_init()
-
-    def post_stream_init(self):
-        """Workflow after stream initalisation."""
-        log.info(
-            _("********** [{profile}] CONNECTED **********").format(profile=self.profile)
-        )
-
-        # the following Deferred is used to know when we are connected
-        # so we need to be set it to None when connection is lost
-        self._connected_d = defer.Deferred()
-        self._connected_d.addCallback(self._clean_connection)
-        self._connected_d.addCallback(self._disconnection_cb)
-        self._connected_d.addErrback(self._disconnection_eb)
-
-        # we send the signal to the clients
-        self.host_app.bridge.connected(self.jid.full(), self.profile)
-
-        self.disco = SatDiscoProtocol(self)
-        self.disco.setHandlerParent(self)
-        self.discoHandler = disco.DiscoHandler()
-        self.discoHandler.setHandlerParent(self)
-        disco_d = defer.succeed(None)
-
-        if not self.host_app.trigger.point("Disco handled", disco_d, self.profile):
-            return
-
-        disco_d.addCallback(self._finish_connection)
-
-    def initializationFailed(self, reason):
-        log.error(
-            _(
-                "ERROR: XMPP connection failed for profile '%(profile)s': %(reason)s"
-                % {"profile": self.profile, "reason": reason}
-            )
-        )
-        self.conn_deferred.errback(reason.value)
-        try:
-            super(SatXMPPEntity, self).initializationFailed(reason)
-        except:
-            # we already chained an errback, no need to raise an exception
-            pass
-
-    ## connection ##
-
-    def connection_terminated(self, connector, reason, term_type, cb):
-        """Display disconnection reason, and call factory method
-
-        This method is monkey patched to factory, allowing plugins to handle finely
-        reconnection with the triggers.
-        @param connector(twisted.internet.base.BaseConnector): current connector
-        @param reason(failure.Failure): why connection has been terminated
-        @param term_type(unicode): on of 'failed' or 'lost'
-        @param cb(callable): original factory method
-
-        @trigger connection_failed(connector, reason): connection can't be established
-        @trigger connection_lost(connector, reason): connection was available but it not
-            anymore
-        """
-        # we save connector because it may be deleted when connection will be dropped
-        # if reconnection is disabled
-        self._saved_connector = connector
-        if reason is not None and not isinstance(reason.value,
-                                                 internet_error.ConnectionDone):
-            try:
-                reason_str = str(reason.value)
-            except Exception:
-                # FIXME: workaround for Android were p4a strips docstrings
-                #        while Twisted use docstring in __str__
-                # TODO: create a ticket upstream, Twisted should work when optimization
-                #       is used
-                reason_str = str(reason.value.__class__)
-            log.warning(f"[{self.profile}] Connection {term_type}: {reason_str}")
-        if not self.host_app.trigger.point("connection_" + term_type, connector, reason):
-            return
-        return cb(connector, reason)
-
-    def network_disabled(self):
-        """Indicate that network has been completely disabled
-
-        In other words, internet is not available anymore and transport must be stopped.
-        Retrying is disabled too, as it makes no sense to try without network, and it may
-        use resources (notably battery on mobiles).
-        """
-        log.info(_("stopping connection because of network disabled"))
-        self.factory.continueTrying = 0
-        self._network_disabled = True
-        if self.xmlstream is not None:
-            self.xmlstream.transport.abortConnection()
-
-    def network_enabled(self):
-        """Indicate that network has been (re)enabled
-
-        This happens when e.g. user activate WIFI connection.
-        """
-        try:
-            connector = self._saved_connector
-            network_disabled = self._network_disabled
-        except AttributeError:
-            # connection has not been stopped by network_disabled
-            # we don't have to restart it
-            log.debug(f"no connection to restart [{self.profile}]")
-            return
-        else:
-            del self._network_disabled
-            if not network_disabled:
-                raise exceptions.InternalError("network_disabled should be True")
-        log.info(_("network is available, trying to connect"))
-        # we want to be sure to start fresh
-        self.factory.resetDelay()
-        # we have a saved connector, meaning the connection has been stopped previously
-        # we can now try to reconnect
-        connector.connect()
-
-    def _connected(self, xs):
-        send_hooks = []
-        receive_hooks = []
-        self.host_app.trigger.point(
-            "stream_hooks", self, receive_hooks, send_hooks)
-        for hook in receive_hooks:
-            xs.add_hook(C.STREAM_HOOK_RECEIVE, hook)
-        for hook in send_hooks:
-            xs.add_hook(C.STREAM_HOOK_SEND, hook)
-        super(SatXMPPEntity, self)._connected(xs)
-
-    def disconnect_profile(self, reason):
-        if self._connected_d is not None:
-            self.host_app.bridge.disconnected(
-                self.profile
-            )  # we send the signal to the clients
-            log.info(
-                _("********** [{profile}] DISCONNECTED **********").format(
-                    profile=self.profile
-                )
-            )
-            # we purge only if no new connection attempt is expected
-            if not self.factory.continueTrying:
-                log.debug("continueTrying not set, purging entity")
-                self._connected_d.callback(None)
-                # and we remove references to this client
-                self.host_app.purge_entity(self.profile)
-
-        if not self.conn_deferred.called:
-            if reason is None:
-                err = error.StreamError("Server unexpectedly closed the connection")
-            else:
-                err = reason
-                try:
-                    if err.value.args[0][0][2] == "certificate verify failed":
-                        err = exceptions.InvalidCertificate(
-                            _("Your server certificate is not valid "
-                              "(its identity can't be checked).\n\n"
-                              "This should never happen and may indicate that "
-                              "somebody is trying to spy on you.\n"
-                              "Please contact your server administrator."))
-                        self.factory.stopTrying()
-                        try:
-                            # with invalid certificate, we should not retry to connect
-                            # so we delete saved connector to avoid reconnection if
-                            # network_enabled is called.
-                            del self._saved_connector
-                        except AttributeError:
-                            pass
-                except (IndexError, TypeError):
-                    pass
-            self.conn_deferred.errback(err)
-
-    def _disconnected(self, reason):
-        super(SatXMPPEntity, self)._disconnected(reason)
-        if not self.host_app.trigger.point("disconnected", self, reason):
-            return
-        self.disconnect_profile(reason)
-
-    @defer.inlineCallbacks
-    def _clean_connection(self, __):
-        """method called on disconnection
-
-        used to call profile_disconnected* triggers
-        """
-        trigger_name = "profile_disconnected"
-        for plugin in self._get_plugins_list():
-            disconnected_cb = getattr(plugin, trigger_name, None)
-            if disconnected_cb is not None:
-                yield disconnected_cb(self)
-
-    def is_connected(self):
-        """Return True is client is fully connected
-
-        client is considered fully connected if transport is started and all plugins
-        are initialised
-        """
-        try:
-            transport_connected = bool(self.xmlstream.transport.connected)
-        except AttributeError:
-            return False
-
-        return self._connected_d is not None and transport_connected
-
-    def entity_disconnect(self):
-        if not self.host_app.trigger.point("disconnecting", self):
-            return
-        log.info(_("Disconnecting..."))
-        self.stopService()
-        if self._connected_d is not None:
-            return self._connected_d
-        else:
-            return defer.succeed(None)
-
-    ## sending ##
-
-    def IQ(self, type_="set", timeout=60):
-        """shortcut to create an IQ element managing deferred
-
-        @param type_(unicode): IQ type ('set' or 'get')
-        @param timeout(None, int): timeout in seconds
-        @return((D)domish.Element: result stanza
-            errback is called if an error stanza is returned
-        """
-        iq_elt = xmlstream.IQ(self.xmlstream, type_)
-        iq_elt.timeout = timeout
-        return iq_elt
-
-    def sendError(self, iq_elt, condition, text=None, appCondition=None):
-        """Send error stanza build from iq_elt
-
-        @param iq_elt(domish.Element): initial IQ element
-        @param condition(unicode): error condition
-        """
-        iq_error_elt = error.StanzaError(
-            condition, text=text, appCondition=appCondition
-        ).toResponse(iq_elt)
-        self.xmlstream.send(iq_error_elt)
-
-    def generate_message_xml(
-        self,
-        data: core_types.MessageData,
-        post_xml_treatments: Optional[defer.Deferred] = None
-    ) -> core_types.MessageData:
-        """Generate <message/> stanza from message data
-
-        @param data: message data
-            domish element will be put in data['xml']
-            following keys are needed:
-                - from
-                - to
-                - uid: can be set to '' if uid attribute is not wanted
-                - message
-                - type
-                - subject
-                - extra
-        @param post_xml_treatments: a Deferred which will be called with data once XML is
-            generated
-        @return: message data
-        """
-        data["xml"] = message_elt = domish.Element((None, "message"))
-        message_elt["to"] = data["to"].full()
-        message_elt["from"] = data["from"].full()
-        message_elt["type"] = data["type"]
-        if data["uid"]:  # key must be present but can be set to ''
-            # by a plugin to avoid id on purpose
-            message_elt["id"] = data["uid"]
-        for lang, subject in data["subject"].items():
-            subject_elt = message_elt.addElement("subject", content=subject)
-            if lang:
-                subject_elt[(C.NS_XML, "lang")] = lang
-        for lang, message in data["message"].items():
-            body_elt = message_elt.addElement("body", content=message)
-            if lang:
-                body_elt[(C.NS_XML, "lang")] = lang
-        try:
-            thread = data["extra"]["thread"]
-        except KeyError:
-            if "thread_parent" in data["extra"]:
-                raise exceptions.InternalError(
-                    "thread_parent found while there is not associated thread"
-                )
-        else:
-            thread_elt = message_elt.addElement("thread", content=thread)
-            try:
-                thread_elt["parent"] = data["extra"]["thread_parent"]
-            except KeyError:
-                pass
-
-        if post_xml_treatments is not None:
-            post_xml_treatments.callback(data)
-        return data
-
-    @property
-    def is_admin(self) -> bool:
-        """True if a client is an administrator with extra privileges"""
-        return self.host_app.memory.is_admin(self.profile)
-
-    def add_post_xml_callbacks(self, post_xml_treatments):
-        """Used to add class level callbacks at the end of the workflow
-
-        @param post_xml_treatments(D): the same Deferred as in sendMessage trigger
-        """
-        raise NotImplementedError
-
-    async def a_send(self, obj):
-        # original send method accept string
-        # but we restrict to domish.Element to make trigger treatments easier
-        assert isinstance(obj, domish.Element)
-        # XXX: this trigger is the last one before sending stanza on wire
-        #      it is intended for things like end 2 end encryption.
-        #      *DO NOT* cancel (i.e. return False) without very good reason
-        #      (out of band transmission for instance).
-        #      e2e should have a priority of 0 here, and out of band transmission
-        #      a lower priority
-        if not (await self.host_app.trigger.async_point("send", self, obj)):
-            return
-        super().send(obj)
-
-    def send(self, obj):
-        defer.ensureDeferred(self.a_send(obj))
-
-    async def send_message_data(self, mess_data):
-        """Convenient method to send message data to stream
-
-        This method will send mess_data[u'xml'] to stream, but a trigger is there
-        The trigger can't be cancelled, it's a good place for e2e encryption which
-        don't handle full stanza encryption
-        This trigger can return a Deferred (it's an async_point)
-        @param mess_data(dict): message data as constructed by onMessage workflow
-        @return (dict): mess_data (so it can be used in a deferred chain)
-        """
-        # XXX: This is the last trigger before u"send" (last but one globally)
-        #      for sending message.
-        #      This is intented for e2e encryption which doesn't do full stanza
-        #      encryption (e.g. OTR)
-        #      This trigger point can't cancel the method
-        await self.host_app.trigger.async_point("send_message_data", self, mess_data,
-            triggers_no_cancel=True)
-        await self.a_send(mess_data["xml"])
-        return mess_data
-
-    def sendMessage(
-            self, to_jid, message, subject=None, mess_type="auto", extra=None, uid=None,
-            no_trigger=False):
-        r"""Send a message to an entity
-
-        @param to_jid(jid.JID): destinee of the message
-        @param message(dict): message body, key is the language (use '' when unknown)
-        @param subject(dict): message subject, key is the language (use '' when unknown)
-        @param mess_type(str): one of standard message type (cf RFC 6121 §5.2.2) or:
-            - auto: for automatic type detection
-            - info: for information ("info_type" can be specified in extra)
-        @param extra(dict, None): extra data. Key can be:
-            - info_type: information type, can be
-                TODO
-        @param uid(unicode, None): unique id:
-            should be unique at least in this XMPP session
-            if None, an uuid will be generated
-        @param no_trigger (bool): if True, sendMessage[suffix] trigger will no be used
-            useful when a message need to be sent without any modification
-            /!\ this will also skip encryption methods!
-        """
-        if subject is None:
-            subject = {}
-        if extra is None:
-            extra = {}
-
-        assert mess_type in C.MESS_TYPE_ALL
-
-        data = {  # dict is similar to the one used in client.onMessage
-            "from": self.jid,
-            "to": to_jid,
-            "uid": uid or str(uuid.uuid4()),
-            "message": message,
-            "subject": subject,
-            "type": mess_type,
-            "extra": extra,
-            "timestamp": time.time(),
-        }
-        # XXX: plugin can add their pre XML treatments to this deferred
-        pre_xml_treatments = defer.Deferred()
-        # XXX: plugin can add their post XML treatments to this deferred
-        post_xml_treatments = defer.Deferred()
-
-        if data["type"] == C.MESS_TYPE_AUTO:
-            # we try to guess the type
-            if data["subject"]:
-                data["type"] = C.MESS_TYPE_NORMAL
-            elif not data["to"].resource:
-                # we may have a groupchat message, we check if the we know this jid
-                try:
-                    entity_type = self.host_app.memory.get_entity_datum(
-                        self, data["to"], C.ENTITY_TYPE
-                    )
-                    # FIXME: should entity_type manage resources ?
-                except (exceptions.UnknownEntityError, KeyError):
-                    entity_type = "contact"
-
-                if entity_type == C.ENTITY_TYPE_MUC:
-                    data["type"] = C.MESS_TYPE_GROUPCHAT
-                else:
-                    data["type"] = C.MESS_TYPE_CHAT
-            else:
-                data["type"] = C.MESS_TYPE_CHAT
-
-        # FIXME: send_only is used by libervia's OTR plugin to avoid
-        #        the triggers from frontend, and no_trigger do the same
-        #        thing internally, this could be unified
-        send_only = data["extra"].get("send_only", False)
-
-        if not no_trigger and not send_only:
-            # is the session encrypted? If so we indicate it in data
-            self.encryption.set_encryption_flag(data)
-
-            if not self.host_app.trigger.point(
-                "sendMessage" + self.trigger_suffix,
-                self,
-                data,
-                pre_xml_treatments,
-                post_xml_treatments,
-            ):
-                return defer.succeed(None)
-
-        log.debug(_("Sending message (type {type}, to {to})")
-                    .format(type=data["type"], to=to_jid.full()))
-
-        pre_xml_treatments.addCallback(lambda __: self.generate_message_xml(data, post_xml_treatments))
-        pre_xml_treatments.addCallback(lambda __: post_xml_treatments)
-        pre_xml_treatments.addErrback(self._cancel_error_trap)
-        post_xml_treatments.addCallback(
-            lambda __: defer.ensureDeferred(self.send_message_data(data))
-        )
-        if send_only:
-            log.debug(_("Triggers, storage and echo have been inhibited by the "
-                        "'send_only' parameter"))
-        else:
-            self.add_post_xml_callbacks(post_xml_treatments)
-            post_xml_treatments.addErrback(self._cancel_error_trap)
-            post_xml_treatments.addErrback(self.host_app.log_errback)
-        pre_xml_treatments.callback(data)
-        return pre_xml_treatments
-
-    def _cancel_error_trap(self, failure):
-        """A message sending can be cancelled by a plugin treatment"""
-        failure.trap(exceptions.CancelError)
-
-    def is_message_printable(self, mess_data):
-        """Return True if a message contain payload to show in frontends"""
-        return (
-            mess_data["message"] or mess_data["subject"]
-            or mess_data["extra"].get(C.KEY_ATTACHMENTS)
-            or mess_data["type"] == C.MESS_TYPE_INFO
-        )
-
-    async def message_add_to_history(self, data):
-        """Store message into database (for local history)
-
-        @param data: message data dictionnary
-        @param client: profile's client
-        """
-        if data["type"] != C.MESS_TYPE_GROUPCHAT:
-            # we don't add groupchat message to history, as we get them back
-            # and they will be added then
-
-            # we need a message to store
-            if self.is_message_printable(data):
-                await self.host_app.memory.add_to_history(self, data)
-            else:
-                log.warning(
-                    "No message found"
-                )  # empty body should be managed by plugins before this point
-        return data
-
-    def message_get_bridge_args(self, data):
-        """Generate args to use with bridge from data dict"""
-        return (data["uid"], data["timestamp"], data["from"].full(),
-                data["to"].full(), data["message"], data["subject"],
-                data["type"], data_format.serialise(data["extra"]))
-
-
-    def message_send_to_bridge(self, data):
-        """Send message to bridge, so frontends can display it
-
-        @param data: message data dictionnary
-        @param client: profile's client
-        """
-        if data["type"] != C.MESS_TYPE_GROUPCHAT:
-            # we don't send groupchat message to bridge, as we get them back
-            # and they will be added the
-
-            # we need a message to send something
-            if self.is_message_printable(data):
-
-                # We send back the message, so all frontends are aware of it
-                self.host_app.bridge.message_new(
-                    *self.message_get_bridge_args(data),
-                    profile=self.profile
-                )
-            else:
-                log.warning(_("No message found"))
-        return data
-
-    ## helper methods ##
-
-    def p(self, plugin_name, missing=exceptions.MissingModule):
-        """Get a plugin if available
-
-        @param plugin_name(str): name of the plugin
-        @param missing(object): value to return if plugin is missing
-            if it is a subclass of Exception, it will be raised with a helping str as
-            argument.
-        @return (object): requested plugin wrapper, or default value
-            The plugin wrapper will return the method with client set as first
-            positional argument
-        """
-        return ClientPluginWrapper(self, plugin_name, missing)
-
-
-ExtraDict = dict  # TODO
-
-
-@implementer(iwokkel.IDisco)
-class SatXMPPClient(SatXMPPEntity, wokkel_client.XMPPClient):
-    trigger_suffix = ""
-    is_component = False
-
-    def __init__(self, host_app, profile, user_jid, password, host=None,
-                 port=C.XMPP_C2S_PORT, max_retries=C.XMPP_MAX_RETRIES):
-        # XXX: DNS SRV records are checked when the host is not specified.
-        # If no SRV record is found, the host is directly extracted from the JID.
-        self.started = time.time()
-
-        # Currently, we use "client/pc/Salut à Toi", but as
-        # SàT is multi-frontends and can be used on mobile devices, as a bot,
-        # with a web frontend,
-        # etc., we should implement a way to dynamically update identities through the
-        # bridge
-        self.identities = [disco.DiscoIdentity("client", "pc", C.APP_NAME)]
-        if sys.platform == "android":
-            # for now we consider Android devices to be always phones
-            self.identities = [disco.DiscoIdentity("client", "phone", C.APP_NAME)]
-
-        hosts_map = host_app.memory.config_get(None, "hosts_dict", {})
-        if host is None and user_jid.host in hosts_map:
-            host_data = hosts_map[user_jid.host]
-            if isinstance(host_data, str):
-                host = host_data
-            elif isinstance(host_data, dict):
-                if "host" in host_data:
-                    host = host_data["host"]
-                if "port" in host_data:
-                    port = host_data["port"]
-            else:
-                log.warning(
-                    _("invalid data used for host: {data}").format(data=host_data)
-                )
-                host_data = None
-            if host_data is not None:
-                log.info(
-                    "using {host}:{port} for host {host_ori} as requested in config"
-                    .format(host_ori=user_jid.host, host=host, port=port)
-                )
-
-        self.check_certificate = host_app.memory.param_get_a(
-            "check_certificate", "Connection", profile_key=profile)
-
-        if self.check_certificate:
-            tls_required, configurationForTLS = True, None
-        else:
-            tls_required = False
-            configurationForTLS = ssl.CertificateOptions(trustRoot=None)
-
-        wokkel_client.XMPPClient.__init__(
-            self, user_jid, password, host or None, port or C.XMPP_C2S_PORT,
-            tls_required=tls_required, configurationForTLS=configurationForTLS
-        )
-        SatXMPPEntity.__init__(self, host_app, profile, max_retries)
-
-        if not self.check_certificate:
-            msg = (_("Certificate validation is deactivated, this is unsecure and "
-                "somebody may be spying on you. If you have no good reason to disable "
-                "certificate validation, please activate \"Check certificate\" in your "
-                "settings in \"Connection\" tab."))
-            xml_tools.quick_note(host_app, self, msg, _("Security notice"),
-                level = C.XMLUI_DATA_LVL_WARNING)
-
-    @property
-    def server_jid(self):
-        return jid.JID(self.jid.host)
-
-    def _get_plugins_list(self):
-        for p in self.host_app.plugins.values():
-            if C.PLUG_MODE_CLIENT in p._info["modes"]:
-                yield p
-
-    def _create_sub_protocols(self):
-        self.messageProt = SatMessageProtocol(self.host_app)
-        self.messageProt.setHandlerParent(self)
-
-        self.roster = SatRosterProtocol(self.host_app)
-        self.roster.setHandlerParent(self)
-
-        self.presence = SatPresenceProtocol(self.host_app)
-        self.presence.setHandlerParent(self)
-
-    @classmethod
-    async def start_connection(cls, host, profile, max_retries):
-        try:
-            await super(SatXMPPClient, cls).start_connection(host, profile, max_retries)
-        except exceptions.CancelError as e:
-            log.warning(f"start_connection cancelled: {e}")
-            return
-        entity = host.profiles[profile]
-        # we finally send our presence
-        entity.presence.available()
-
-    def entity_connected(self):
-        # we want to be sure that we got the roster
-        return self.roster.got_roster
-
-    def add_post_xml_callbacks(self, post_xml_treatments):
-        post_xml_treatments.addCallback(self.messageProt.complete_attachments)
-        post_xml_treatments.addCallback(
-            lambda ret: defer.ensureDeferred(self.message_add_to_history(ret))
-        )
-        post_xml_treatments.addCallback(self.message_send_to_bridge)
-
-    def feedback(
-        self,
-        to_jid: jid.JID,
-        message: str,
-        extra: Optional[ExtraDict] = None
-    ) -> None:
-        """Send message to frontends
-
-        This message will be an info message, not recorded in history.
-        It can be used to give feedback of a command
-        @param to_jid: destinee jid
-        @param message: message to send to frontends
-        @param extra: extra data to use in particular, info subtype can be specified with
-            MESS_EXTRA_INFO
-        """
-        if extra is None:
-            extra = {}
-        self.host_app.bridge.message_new(
-            uid=str(uuid.uuid4()),
-            timestamp=time.time(),
-            from_jid=self.jid.full(),
-            to_jid=to_jid.full(),
-            message={"": message},
-            subject={},
-            mess_type=C.MESS_TYPE_INFO,
-            extra=data_format.serialise(extra),
-            profile=self.profile,
-        )
-
-    def _finish_connection(self, __):
-        d = self.roster.request_roster()
-        d.addCallback(lambda __: super(SatXMPPClient, self)._finish_connection(__))
-
-
-@implementer(iwokkel.IDisco)
-class SatXMPPComponent(SatXMPPEntity, component.Component):
-    """XMPP component
-
-    This component are similar but not identical to clients.
-    An entry point plugin is launched after component is connected.
-    Component need to instantiate MessageProtocol itself
-    """
-
-    trigger_suffix = (
-        "Component"
-    )  # used for to distinguish some trigger points set in SatXMPPEntity
-    is_component = True
-    # XXX: set to True from entry plugin to keep messages in history for sent messages
-    sendHistory = False
-    # XXX: same as sendHistory but for received messaged
-    receiveHistory = False
-
-    def __init__(self, host_app, profile, component_jid, password, host=None, port=None,
-                 max_retries=C.XMPP_MAX_RETRIES):
-        self.started = time.time()
-        if port is None:
-            port = C.XMPP_COMPONENT_PORT
-
-        ## entry point ##
-        entry_point = host_app.memory.get_entry_point(profile)
-        try:
-            self.entry_plugin = host_app.plugins[entry_point]
-        except KeyError:
-            raise exceptions.NotFound(
-                _("The requested entry point ({entry_point}) is not available").format(
-                    entry_point=entry_point
-                )
-            )
-
-        self.enabled_features = set()
-        self.identities = [disco.DiscoIdentity("component", "generic", C.APP_NAME)]
-        # jid is set automatically on bind by Twisted for Client, but not for Component
-        self.jid = component_jid
-        if host is None:
-            try:
-                host = component_jid.host.split(".", 1)[1]
-            except IndexError:
-                raise ValueError("Can't guess host from jid, please specify a host")
-        # XXX: component.Component expect unicode jid, while Client expect jid.JID.
-        #      this is not consistent, so we use jid.JID for SatXMPP*
-        component.Component.__init__(self, host, port, component_jid.full(), password)
-        SatXMPPEntity.__init__(self, host_app, profile, max_retries)
-
-    @property
-    def server_jid(self):
-        # FIXME: not the best way to get server jid, maybe use config option?
-        return jid.JID(self.jid.host.split(".", 1)[-1])
-
-    @property
-    def is_admin(self) -> bool:
-        return False
-
-    def _create_sub_protocols(self):
-        self.messageProt = SatMessageProtocol(self.host_app)
-        self.messageProt.setHandlerParent(self)
-
-    def _build_dependencies(self, current, plugins, required=True):
-        """build recursively dependencies needed for a plugin
-
-        this method build list of plugin needed for a component and raises
-        errors if they are not available or not allowed for components
-        @param current(object): parent plugin to check
-            use entry_point for first call
-        @param plugins(list): list of validated plugins, will be filled by the method
-            give an empty list for first call
-        @param required(bool): True if plugin is mandatory
-            for recursive calls only, should not be modified by inital caller
-        @raise InternalError: one of the plugin is not handling components
-        @raise KeyError: one plugin should be present in self.host_app.plugins but it
-                         is not
-        """
-        if C.PLUG_MODE_COMPONENT not in current._info["modes"]:
-            if not required:
-                return
-            else:
-                log.error(
-                    _(
-                        "Plugin {current_name} is needed for {entry_name}, "
-                        "but it doesn't handle component mode"
-                    ).format(
-                        current_name=current._info["import_name"],
-                        entry_name=self.entry_plugin._info["import_name"],
-                    )
-                )
-                raise exceptions.InternalError(_("invalid plugin mode"))
-
-        for import_name in current._info.get(C.PI_DEPENDENCIES, []):
-            # plugins are already loaded as dependencies
-            # so we know they are in self.host_app.plugins
-            dep = self.host_app.plugins[import_name]
-            self._build_dependencies(dep, plugins)
-
-        for import_name in current._info.get(C.PI_RECOMMENDATIONS, []):
-            # here plugins are only recommendations,
-            # so they may not exist in self.host_app.plugins
-            try:
-                dep = self.host_app.plugins[import_name]
-            except KeyError:
-                continue
-            self._build_dependencies(dep, plugins, required=False)
-
-        if current not in plugins:
-            # current can be required for several plugins and so
-            # it can already be present in the list
-            plugins.append(current)
-
-    def _get_plugins_list(self):
-        # XXX: for component we don't launch all plugins triggers
-        #      but only the ones from which there is a dependency
-        plugins = []
-        self._build_dependencies(self.entry_plugin, plugins)
-        return plugins
-
-    def entity_connected(self):
-        # we can now launch entry point
-        try:
-            start_cb = self.entry_plugin.componentStart
-        except AttributeError:
-            return
-        else:
-            return start_cb(self)
-
-    def add_post_xml_callbacks(self, post_xml_treatments):
-        if self.sendHistory:
-            post_xml_treatments.addCallback(
-                lambda ret: defer.ensureDeferred(self.message_add_to_history(ret))
-            )
-
-    def get_owner_from_jid(self, to_jid: jid.JID) -> jid.JID:
-        """Retrieve "owner" of a component resource from the destination jid of the request
-
-        This method needs plugin XEP-0106 for unescaping, if you use it you must add the
-        plugin to your dependencies.
-        A "user" part must be present in "to_jid" (otherwise, the component itself is addressed)
-        @param to_jid: destination JID of the request
-        """
-        try:
-            unescape = self.host_app.plugins['XEP-0106'].unescape
-        except KeyError:
-            raise exceptions.MissingPlugin("Plugin XEP-0106 is needed to retrieve owner")
-        else:
-            user = unescape(to_jid.user)
-        if '@' in user:
-            # a full jid is specified
-            return jid.JID(user)
-        else:
-            # only user part is specified, we use our own host to build the full jid
-            return jid.JID(None, (user, self.host, None))
-
-    def get_owner_and_peer(self, iq_elt: domish.Element) -> Tuple[jid.JID, jid.JID]:
-        """Retrieve owner of a component jid, and the jid of the requesting peer
-
-        "owner" is found by either unescaping full jid from node, or by combining node
-        with our host.
-        Peer jid is the requesting jid from the IQ element
-        @param iq_elt: IQ stanza sent from the requested
-        @return: owner and peer JIDs
-        """
-        to_jid = jid.JID(iq_elt['to'])
-        if to_jid.user:
-            owner = self.get_owner_from_jid(to_jid)
-        else:
-            owner = jid.JID(iq_elt["from"]).userhostJID()
-
-        peer_jid = jid.JID(iq_elt["from"])
-        return peer_jid, owner
-
-    def get_virtual_client(self, jid_: jid.JID) -> SatXMPPEntity:
-        """Get client for this component with a specified jid
-
-        This is needed to perform operations with a virtual JID corresponding to a virtual
-        entity (e.g. identified of a legacy network account) instead of the JID of the
-        gateway itself.
-        @param jid_: virtual JID to use
-        @return: virtual client
-        """
-        client = copy.copy(self)
-        client.jid = jid_
-        return client
-
-
-class SatMessageProtocol(xmppim.MessageProtocol):
-
-    def __init__(self, host):
-        xmppim.MessageProtocol.__init__(self)
-        self.host = host
-
-    @property
-    def client(self):
-        return self.parent
-
-    def normalize_ns(self, elt: domish.Element, namespace: Optional[str]) -> None:
-        if elt.uri == namespace:
-            elt.defaultUri = elt.uri = C.NS_CLIENT
-        for child in elt.elements():
-            self.normalize_ns(child, namespace)
-
-    def parse_message(self, message_elt):
-        """Parse a message XML and return message_data
-
-        @param message_elt(domish.Element): raw <message> xml
-        @param client(SatXMPPClient, None): client to map message id to uid
-            if None, mapping will not be done
-        @return(dict): message data
-        """
-        if message_elt.name != "message":
-            log.warning(_(
-                "parse_message used with a non <message/> stanza, ignoring: {xml}"
-                .format(xml=message_elt.toXml())))
-            return {}
-
-        if message_elt.uri == None:
-            # xmlns may be None when wokkel element parsing strip out root namespace
-            self.normalize_ns(message_elt, None)
-        elif message_elt.uri != C.NS_CLIENT:
-            log.warning(_(
-                "received <message> with a wrong namespace: {xml}"
-                .format(xml=message_elt.toXml())))
-
-        client = self.parent
-
-        if not message_elt.hasAttribute('to'):
-            message_elt['to'] = client.jid.full()
-
-        message = {}
-        subject = {}
-        extra = {}
-        data = {
-            "from": jid.JID(message_elt["from"]),
-            "to": jid.JID(message_elt["to"]),
-            "uid": message_elt.getAttribute(
-                "uid", str(uuid.uuid4())
-            ),  # XXX: uid is not a standard attribute but may be added by plugins
-            "message": message,
-            "subject": subject,
-            "type": message_elt.getAttribute("type", "normal"),
-            "extra": extra,
-        }
-
-        try:
-            message_id = data["extra"]["message_id"] = message_elt["id"]
-        except KeyError:
-            pass
-        else:
-            client.mess_id2uid[(data["from"], message_id)] = data["uid"]
-
-        # message
-        for e in message_elt.elements(C.NS_CLIENT, "body"):
-            message[e.getAttribute((C.NS_XML, "lang"), "")] = str(e)
-
-        # subject
-        for e in message_elt.elements(C.NS_CLIENT, "subject"):
-            subject[e.getAttribute((C.NS_XML, "lang"), "")] = str(e)
-
-        # delay and timestamp
-        try:
-            received_timestamp = message_elt._received_timestamp
-        except AttributeError:
-            # message_elt._received_timestamp should have been set in onMessage
-            # but if parse_message is called directly, it can be missing
-            log.debug("missing received timestamp for {message_elt}".format(
-                message_elt=message_elt))
-            received_timestamp = time.time()
-
-        try:
-            delay_elt = next(message_elt.elements(delay.NS_DELAY, "delay"))
-        except StopIteration:
-            data["timestamp"] = received_timestamp
-        else:
-            parsed_delay = delay.Delay.fromElement(delay_elt)
-            data["timestamp"] = calendar.timegm(parsed_delay.stamp.utctimetuple())
-            data["received_timestamp"] = received_timestamp
-            if parsed_delay.sender:
-                data["delay_sender"] = parsed_delay.sender.full()
-
-        self.host.trigger.point("message_parse", client,  message_elt, data)
-        return data
-
-    def _on_message_start_workflow(self, cont, client, message_elt, post_treat):
-        """Parse message and do post treatments
-
-        It is the first callback called after message_received trigger
-        @param cont(bool): workflow will continue only if this is True
-        @param message_elt(domish.Element): message stanza
-            may have be modified by triggers
-        @param post_treat(defer.Deferred): post parsing treatments
-        """
-        if not cont:
-            return
-        data = self.parse_message(message_elt)
-        post_treat.addCallback(self.complete_attachments)
-        post_treat.addCallback(self.skip_empty_message)
-        if not client.is_component or client.receiveHistory:
-            post_treat.addCallback(
-                lambda ret: defer.ensureDeferred(self.add_to_history(ret))
-            )
-        if not client.is_component:
-            post_treat.addCallback(self.bridge_signal, data)
-        post_treat.addErrback(self.cancel_error_trap)
-        post_treat.callback(data)
-
-    def onMessage(self, message_elt):
-        # TODO: handle threads
-        message_elt._received_timestamp = time.time()
-        client = self.parent
-        if not "from" in message_elt.attributes:
-            message_elt["from"] = client.jid.host
-        log.debug(_("got message from: {from_}").format(from_=message_elt["from"]))
-        if self.client.is_component and message_elt.uri == component.NS_COMPONENT_ACCEPT:
-            # we use client namespace all the time to simplify parsing
-            self.normalize_ns(message_elt, component.NS_COMPONENT_ACCEPT)
-
-        # plugin can add their treatments to this deferred
-        post_treat = defer.Deferred()
-
-        d = self.host.trigger.async_point(
-            "message_received", client, message_elt, post_treat
-        )
-
-        d.addCallback(self._on_message_start_workflow, client, message_elt, post_treat)
-
-    def complete_attachments(self, data):
-        """Complete missing metadata of attachments"""
-        for attachment in data['extra'].get(C.KEY_ATTACHMENTS, []):
-            if "name" not in attachment and "url" in attachment:
-                name = (Path(unquote(urlparse(attachment['url']).path)).name
-                        or C.FILE_DEFAULT_NAME)
-                attachment["name"] = name
-            if ((C.KEY_ATTACHMENTS_MEDIA_TYPE not in attachment
-                 and "name" in attachment)):
-                media_type = mimetypes.guess_type(attachment['name'], strict=False)[0]
-                if media_type:
-                    attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE] = media_type
-
-        return data
-
-    def skip_empty_message(self, data):
-        if not data["message"] and not data["extra"] and not data["subject"]:
-            raise failure.Failure(exceptions.CancelError("Cancelled empty message"))
-        return data
-
-    async def add_to_history(self, data):
-        if data.pop("history", None) == C.HISTORY_SKIP:
-            log.debug("history is skipped as requested")
-            data["extra"]["history"] = C.HISTORY_SKIP
-        else:
-            # we need a message to store
-            if self.parent.is_message_printable(data):
-                return await self.host.memory.add_to_history(self.parent, data)
-            else:
-                log.debug("not storing empty message to history: {data}"
-                    .format(data=data))
-
-    def bridge_signal(self, __, data):
-        try:
-            data["extra"]["received_timestamp"] = str(data["received_timestamp"])
-            data["extra"]["delay_sender"] = data["delay_sender"]
-        except KeyError:
-            pass
-        if self.client.encryption.isEncrypted(data):
-            data["extra"]["encrypted"] = True
-        if data is not None:
-            if self.parent.is_message_printable(data):
-                self.host.bridge.message_new(
-                    data["uid"],
-                    data["timestamp"],
-                    data["from"].full(),
-                    data["to"].full(),
-                    data["message"],
-                    data["subject"],
-                    data["type"],
-                    data_format.serialise(data["extra"]),
-                    profile=self.parent.profile,
-                )
-            else:
-                log.debug("Discarding bridge signal for empty message: {data}".format(
-                    data=data))
-        return data
-
-    def cancel_error_trap(self, failure_):
-        """A message sending can be cancelled by a plugin treatment"""
-        failure_.trap(exceptions.CancelError)
-
-
-class SatRosterProtocol(xmppim.RosterClientProtocol):
-
-    def __init__(self, host):
-        xmppim.RosterClientProtocol.__init__(self)
-        self.host = host
-        self.got_roster = defer.Deferred()  # called when roster is received and ready
-        # XXX: the two following dicts keep a local copy of the roster
-        self._jids = {}  # map from jids to RosterItem: key=jid value=RosterItem
-        self._groups = {}  # map from groups to jids: key=group value=set of jids
-
-    def __contains__(self, entity_jid):
-        return self.is_jid_in_roster(entity_jid)
-
-    @property
-    def versioning(self):
-        """True if server support roster versioning"""
-        return (NS_ROSTER_VER, 'ver') in self.parent.xmlstream.features
-
-    @property
-    def roster_cache(self):
-        """Cache of roster from storage
-
-        This property return a new PersistentDict on each call, it must be loaded
-        manually if necessary
-        """
-        return persistent.PersistentDict(NS_ROSTER_VER, self.parent.profile)
-
-    def _register_item(self, item):
-        """Register item in local cache
-
-        item must be already registered in self._jids before this method is called
-        @param item (RosterIem): item added
-        """
-        log.debug("registering item: {}".format(item.entity.full()))
-        if item.entity.resource:
-            log.warning(
-                "Received a roster item with a resource, this is not common but not "
-                "restricted by RFC 6121, this case may be not well tested."
-            )
-        if not item.subscriptionTo:
-            if not item.subscriptionFrom:
-                log.info(
-                    _("There's no subscription between you and [{}]!").format(
-                        item.entity.full()
-                    )
-                )
-            else:
-                log.info(_("You are not subscribed to [{}]!").format(item.entity.full()))
-        if not item.subscriptionFrom:
-            log.info(_("[{}] is not subscribed to you!").format(item.entity.full()))
-
-        for group in item.groups:
-            self._groups.setdefault(group, set()).add(item.entity)
-
-    @defer.inlineCallbacks
-    def _cache_roster(self, version):
-        """Serialise local roster and save it to storage
-
-        @param version(unicode): version of roster in local cache
-        """
-        roster_cache = self.roster_cache
-        yield roster_cache.clear()
-        roster_cache[ROSTER_VER_KEY] = version
-        for roster_jid, roster_item in self._jids.items():
-            roster_jid_s = roster_jid.full()
-            roster_item_elt = roster_item.toElement().toXml()
-            roster_cache[roster_jid_s] = roster_item_elt
-
-    @defer.inlineCallbacks
-    def resync(self):
-        """Ask full roster to resync database
-
-        this should not be necessary, but may be used if user suspsect roster
-        to be somehow corrupted
-        """
-        roster_cache = self.roster_cache
-        yield roster_cache.clear()
-        self._jids.clear()
-        self._groups.clear()
-        yield self.request_roster()
-
-    @defer.inlineCallbacks
-    def request_roster(self):
-        """Ask the server for Roster list """
-        if self.versioning:
-            log.info(_("our server support roster versioning, we use it"))
-            roster_cache = self.roster_cache
-            yield roster_cache.load()
-            try:
-                version = roster_cache[ROSTER_VER_KEY]
-            except KeyError:
-                log.info(_("no roster in cache, we start fresh"))
-                # u"" means we use versioning without valid roster in cache
-                version = ""
-            else:
-                log.info(_("We have roster v{version} in cache").format(version=version))
-                # we deserialise cached roster to our local cache
-                for roster_jid_s, roster_item_elt_s in roster_cache.items():
-                    if roster_jid_s == ROSTER_VER_KEY:
-                        continue
-                    roster_jid = jid.JID(roster_jid_s)
-                    roster_item_elt = generic.parseXml(roster_item_elt_s.encode('utf-8'))
-                    roster_item = xmppim.RosterItem.fromElement(roster_item_elt)
-                    self._jids[roster_jid] = roster_item
-                    self._register_item(roster_item)
-        else:
-            log.warning(_("our server doesn't support roster versioning"))
-            version = None
-
-        log.debug("requesting roster")
-        roster = yield self.getRoster(version=version)
-        if roster is None:
-            log.debug("empty roster result received, we'll get roster item with roster "
-                      "pushes")
-        else:
-            # a full roster is received
-            self._groups.clear()
-            self._jids = roster
-            for item in roster.values():
-                if not item.subscriptionTo and not item.subscriptionFrom and not item.ask:
-                    # XXX: current behaviour: we don't want contact in our roster list
-                    # if there is no presence subscription
-                    # may change in the future
-                    log.info(
-                        "Removing contact {} from roster because there is no presence "
-                        "subscription".format(
-                            item.jid
-                        )
-                    )
-                    self.removeItem(item.entity)  # FIXME: to be checked
-                else:
-                    self._register_item(item)
-            yield self._cache_roster(roster.version)
-
-        if not self.got_roster.called:
-            # got_roster may already be called if we use resync()
-            self.got_roster.callback(None)
-
-    def removeItem(self, to_jid):
-        """Remove a contact from roster list
-        @param to_jid: a JID instance
-        @return: Deferred
-        """
-        return xmppim.RosterClientProtocol.removeItem(self, to_jid)
-
-    def get_attributes(self, item):
-        """Return dictionary of attributes as used in bridge from a RosterItem
-
-        @param item: RosterItem
-        @return: dictionary of attributes
-        """
-        item_attr = {
-            "to": str(item.subscriptionTo),
-            "from": str(item.subscriptionFrom),
-            "ask": str(item.ask),
-        }
-        if item.name:
-            item_attr["name"] = item.name
-        return item_attr
-
-    def setReceived(self, request):
-        item = request.item
-        entity = item.entity
-        log.info(_("adding {entity} to roster").format(entity=entity.full()))
-        if request.version is not None:
-            # we update the cache in storage
-            roster_cache = self.roster_cache
-            roster_cache[entity.full()] = item.toElement().toXml()
-            roster_cache[ROSTER_VER_KEY] = request.version
-
-        try:  # update the cache for the groups the contact has been removed from
-            left_groups = set(self._jids[entity].groups).difference(item.groups)
-            for group in left_groups:
-                jids_set = self._groups[group]
-                jids_set.remove(entity)
-                if not jids_set:
-                    del self._groups[group]
-        except KeyError:
-            pass  # no previous item registration (or it's been cleared)
-        self._jids[entity] = item
-        self._register_item(item)
-        self.host.bridge.contact_new(
-            entity.full(), self.get_attributes(item), list(item.groups),
-            self.parent.profile
-        )
-
-    def removeReceived(self, request):
-        entity = request.item.entity
-        log.info(_("removing {entity} from roster").format(entity=entity.full()))
-        if request.version is not None:
-            # we update the cache in storage
-            roster_cache = self.roster_cache
-            try:
-                del roster_cache[request.item.entity.full()]
-            except KeyError:
-                # because we don't use load(), cache won't have the key, but it
-                # will be deleted from storage anyway
-                pass
-            roster_cache[ROSTER_VER_KEY] = request.version
-
-        # we first remove item from local cache (self._groups and self._jids)
-        try:
-            item = self._jids.pop(entity)
-        except KeyError:
-            log.error(
-                "Received a roster remove event for an item not in cache ({})".format(
-                    entity
-                )
-            )
-            return
-        for group in item.groups:
-            try:
-                jids_set = self._groups[group]
-                jids_set.remove(entity)
-                if not jids_set:
-                    del self._groups[group]
-            except KeyError:
-                log.warning(
-                    f"there is no cache for the group [{group}] of the removed roster "
-                    f"item [{entity}]"
-                )
-
-        # then we send the bridge signal
-        self.host.bridge.contact_deleted(entity.full(), self.parent.profile)
-
-    def get_groups(self):
-        """Return a list of groups"""
-        return list(self._groups.keys())
-
-    def get_item(self, entity_jid):
-        """Return RosterItem for a given jid
-
-        @param entity_jid(jid.JID): jid of the contact
-        @return(RosterItem, None): RosterItem instance
-            None if contact is not in cache
-        """
-        return self._jids.get(entity_jid, None)
-
-    def get_jids(self):
-        """Return all jids of the roster"""
-        return list(self._jids.keys())
-
-    def is_jid_in_roster(self, entity_jid):
-        """Return True if jid is in roster"""
-        if not isinstance(entity_jid, jid.JID):
-            raise exceptions.InternalError(
-                f"a JID is expected, not {type(entity_jid)}: {entity_jid!r}")
-        return entity_jid in self._jids
-
-    def is_subscribed_from(self, entity_jid: jid.JID) -> bool:
-        """Return True if entity is authorised to see our presence"""
-        try:
-            item = self._jids[entity_jid.userhostJID()]
-        except KeyError:
-            return False
-        return item.subscriptionFrom
-
-    def is_subscribed_to(self, entity_jid: jid.JID) -> bool:
-        """Return True if we are subscribed to entity"""
-        try:
-            item = self._jids[entity_jid.userhostJID()]
-        except KeyError:
-            return False
-        return item.subscriptionTo
-
-    def get_items(self):
-        """Return all items of the roster"""
-        return list(self._jids.values())
-
-    def get_jids_from_group(self, group):
-        try:
-            return self._groups[group]
-        except KeyError:
-            raise exceptions.UnknownGroupError(group)
-
-    def get_jids_set(self, type_, groups=None):
-        """Helper method to get a set of jids
-
-        @param type_(unicode): one of:
-            C.ALL: get all jids from roster
-            C.GROUP: get jids from groups (listed in "groups")
-        @groups(list[unicode]): list of groups used if type_==C.GROUP
-        @return (set(jid.JID)): set of selected jids
-        """
-        if type_ == C.ALL and groups is not None:
-            raise ValueError("groups must not be set for {} type".format(C.ALL))
-
-        if type_ == C.ALL:
-            return set(self.get_jids())
-        elif type_ == C.GROUP:
-            jids = set()
-            for group in groups:
-                jids.update(self.get_jids_from_group(group))
-            return jids
-        else:
-            raise ValueError("Unexpected type_ {}".format(type_))
-
-    def get_nick(self, entity_jid):
-        """Return a nick name for an entity
-
-        return nick choosed by user if available
-        else return user part of entity_jid
-        """
-        item = self.get_item(entity_jid)
-        if item is None:
-            return entity_jid.user
-        else:
-            return item.name or entity_jid.user
-
-
-class SatPresenceProtocol(xmppim.PresenceClientProtocol):
-
-    def __init__(self, host):
-        xmppim.PresenceClientProtocol.__init__(self)
-        self.host = host
-
-    @property
-    def client(self):
-        return self.parent
-
-    def send(self, obj):
-        presence_d = defer.succeed(None)
-        if not self.host.trigger.point("Presence send", self.parent, obj, presence_d):
-            return
-        presence_d.addCallback(lambda __: super(SatPresenceProtocol, self).send(obj))
-        return presence_d
-
-    def availableReceived(self, entity, show=None, statuses=None, priority=0):
-        if not statuses:
-            statuses = {}
-
-        if None in statuses:  # we only want string keys
-            statuses[C.PRESENCE_STATUSES_DEFAULT] = statuses.pop(None)
-
-        if not self.host.trigger.point(
-            "presence_received", self.parent, entity, show, priority, statuses
-        ):
-            return
-
-        self.host.memory.set_presence_status(
-            entity, show or "", int(priority), statuses, self.parent.profile
-        )
-
-        # now it's time to notify frontends
-        self.host.bridge.presence_update(
-            entity.full(), show or "", int(priority), statuses, self.parent.profile
-        )
-
-    def unavailableReceived(self, entity, statuses=None):
-        log.debug(
-            _("presence update for [%(entity)s] (unavailable, statuses=%(statuses)s)")
-            % {"entity": entity, C.PRESENCE_STATUSES: statuses}
-        )
-
-        if not statuses:
-            statuses = {}
-
-        if None in statuses:  # we only want string keys
-            statuses[C.PRESENCE_STATUSES_DEFAULT] = statuses.pop(None)
-
-        if not self.host.trigger.point(
-            "presence_received", self.parent, entity, C.PRESENCE_UNAVAILABLE, 0, statuses,
-        ):
-            return
-
-        # now it's time to notify frontends
-        # if the entity is not known yet in this session or is already unavailable,
-        # there is no need to send an unavailable signal
-        try:
-            presence = self.host.memory.get_entity_datum(
-                self.client, entity, "presence"
-            )
-        except (KeyError, exceptions.UnknownEntityError):
-            # the entity has not been seen yet in this session
-            pass
-        else:
-            if presence.show != C.PRESENCE_UNAVAILABLE:
-                self.host.bridge.presence_update(
-                    entity.full(),
-                    C.PRESENCE_UNAVAILABLE,
-                    0,
-                    statuses,
-                    self.parent.profile,
-                )
-
-        self.host.memory.set_presence_status(
-            entity, C.PRESENCE_UNAVAILABLE, 0, statuses, self.parent.profile
-        )
-
-    def available(self, entity=None, show=None, statuses=None, priority=None):
-        """Set a presence and statuses.
-
-        @param entity (jid.JID): entity
-        @param show (unicode): value in ('unavailable', '', 'away', 'xa', 'chat', 'dnd')
-        @param statuses (dict{unicode: unicode}): multilingual statuses with
-            the entry key beeing a language code on 2 characters or "default".
-        """
-        if priority is None:
-            try:
-                priority = int(
-                    self.host.memory.param_get_a(
-                        "Priority", "Connection", profile_key=self.parent.profile
-                    )
-                )
-            except ValueError:
-                priority = 0
-
-        if statuses is None:
-            statuses = {}
-
-        # default for us is None for wokkel
-        # so we must temporarily switch to wokkel's convention...
-        if C.PRESENCE_STATUSES_DEFAULT in statuses:
-            statuses[None] = statuses.pop(C.PRESENCE_STATUSES_DEFAULT)
-
-        presence_elt = xmppim.AvailablePresence(entity, show, statuses, priority)
-
-        # ... before switching back
-        if None in statuses:
-            statuses["default"] = statuses.pop(None)
-
-        if not self.host.trigger.point("presence_available", presence_elt, self.parent):
-            return
-        return self.send(presence_elt)
-
-    @defer.inlineCallbacks
-    def subscribed(self, entity):
-        yield self.parent.roster.got_roster
-        xmppim.PresenceClientProtocol.subscribed(self, entity)
-        self.host.memory.del_waiting_sub(entity.userhost(), self.parent.profile)
-        item = self.parent.roster.get_item(entity)
-        if (
-            not item or not item.subscriptionTo
-        ):  # we automatically subscribe to 'to' presence
-            log.debug(_('sending automatic "from" subscription request'))
-            self.subscribe(entity)
-
-    def unsubscribed(self, entity):
-        xmppim.PresenceClientProtocol.unsubscribed(self, entity)
-        self.host.memory.del_waiting_sub(entity.userhost(), self.parent.profile)
-
-    def subscribedReceived(self, entity):
-        log.debug(_("subscription approved for [%s]") % entity.userhost())
-        self.host.bridge.subscribe("subscribed", entity.userhost(), self.parent.profile)
-
-    def unsubscribedReceived(self, entity):
-        log.debug(_("unsubscription confirmed for [%s]") % entity.userhost())
-        self.host.bridge.subscribe("unsubscribed", entity.userhost(), self.parent.profile)
-
-    @defer.inlineCallbacks
-    def subscribeReceived(self, entity):
-        log.debug(_("subscription request from [%s]") % entity.userhost())
-        yield self.parent.roster.got_roster
-        item = self.parent.roster.get_item(entity)
-        if item and item.subscriptionTo:
-            # We automatically accept subscription if we are already subscribed to
-            # contact presence
-            log.debug(_("sending automatic subscription acceptance"))
-            self.subscribed(entity)
-        else:
-            self.host.memory.add_waiting_sub(
-                "subscribe", entity.userhost(), self.parent.profile
-            )
-            self.host.bridge.subscribe(
-                "subscribe", entity.userhost(), self.parent.profile
-            )
-
-    @defer.inlineCallbacks
-    def unsubscribeReceived(self, entity):
-        log.debug(_("unsubscription asked for [%s]") % entity.userhost())
-        yield self.parent.roster.got_roster
-        item = self.parent.roster.get_item(entity)
-        if item and item.subscriptionFrom:  # we automatically remove contact
-            log.debug(_("automatic contact deletion"))
-            self.host.contact_del(entity, self.parent.profile)
-        self.host.bridge.subscribe("unsubscribe", entity.userhost(), self.parent.profile)
-
-
-@implementer(iwokkel.IDisco)
-class SatDiscoProtocol(disco.DiscoClientProtocol):
-
-    def __init__(self, host):
-        disco.DiscoClientProtocol.__init__(self)
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        # those features are implemented in Wokkel (or sat_tmp.wokkel)
-        # and thus are always available
-        return [disco.DiscoFeature(NS_X_DATA),
-                disco.DiscoFeature(NS_XML_ELEMENT),
-                disco.DiscoFeature(NS_DISCO_INFO)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
-
-
-class SatFallbackHandler(generic.FallbackHandler):
-    def __init__(self, host):
-        generic.FallbackHandler.__init__(self)
-
-    def iqFallback(self, iq):
-        if iq.handled is True:
-            return
-        log.debug("iqFallback: xml = [%s]" % (iq.toXml()))
-        generic.FallbackHandler.iqFallback(self, iq)
-
-
-class SatVersionHandler(generic.VersionHandler):
-
-    def getDiscoInfo(self, requestor, target, node):
-        # XXX: We need to work around wokkel's behaviour (namespace not added if there
-        #      is a node) as it cause issues with XEP-0115 & PEP (XEP-0163): there is a
-        #      node when server ask for disco info, and not when we generate the key, so
-        #      the hash is used with different disco features, and when the server (seen
-        #      on ejabberd) generate its own hash for security check it reject our
-        #      features (resulting in e.g. no notification on PEP)
-        return generic.VersionHandler.getDiscoInfo(self, requestor, target, None)
-
-
-@implementer(iwokkel.IDisco)
-class SatIdentityHandler(XMPPHandler):
-    """Manage disco Identity of SàT."""
-    # TODO: dynamic identity update (see docstring). Note that a XMPP entity can have
-    #       several identities
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return self.parent.identities
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/memory/cache.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,281 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from io import BufferedIOBase
-import mimetypes
-from pathlib import Path
-import pickle as pickle
-import time
-from typing import Any, Dict, Optional
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools.common import regex
-
-
-log = getLogger(__name__)
-
-DEFAULT_EXT = ".raw"
-
-
-class Cache(object):
-    """generic file caching"""
-
-    def __init__(self, host, profile):
-        """
-        @param profile(unicode, None): name of the profile to set the cache for
-            if None, the cache will be common for all profiles
-        """
-        self.profile = profile
-        path_elts = [host.memory.config_get("", "local_dir"), C.CACHE_DIR]
-        if profile:
-            path_elts.extend(["profiles", regex.path_escape(profile)])
-        else:
-            path_elts.append("common")
-        self.cache_dir = Path(*path_elts)
-
-        self.cache_dir.mkdir(0o700, parents=True, exist_ok=True)
-        self.purge()
-
-    def purge(self):
-        # remove expired files from cache
-        # TODO: this should not be called only on startup, but at regular interval
-        #   (e.g. once a day)
-        purged = set()
-        # we sort files to have metadata files first
-        for cache_file in sorted(self.cache_dir.iterdir()):
-            if cache_file in purged:
-                continue
-            try:
-                with cache_file.open('rb') as f:
-                    cache_data = pickle.load(f)
-            except IOError:
-                log.warning(
-                    _("Can't read metadata file at {path}")
-                    .format(path=cache_file))
-                continue
-            except (pickle.UnpicklingError, EOFError):
-                log.debug(f"File at {cache_file} is not a metadata file")
-                continue
-            try:
-                eol = cache_data['eol']
-                filename = cache_data['filename']
-            except KeyError:
-                log.warning(
-                    _("Invalid cache metadata at {path}")
-                    .format(path=cache_file))
-                continue
-
-            filepath = self.getPath(filename)
-
-            if not filepath.exists():
-                log.warning(_(
-                    "cache {cache_file!r} references an inexisting file: {filepath!r}"
-                ).format(cache_file=str(cache_file), filepath=str(filepath)))
-                log.debug("purging cache with missing file")
-                cache_file.unlink()
-            elif eol < time.time():
-                log.debug(
-                    "purging expired cache {filepath!r} (expired for {time}s)"
-                    .format(filepath=str(filepath), time=int(time.time() - eol))
-                )
-                cache_file.unlink()
-                try:
-                    filepath.unlink()
-                except FileNotFoundError:
-                    log.warning(
-                        _("following file is missing while purging cache: {path}")
-                        .format(path=filepath)
-                    )
-                purged.add(cache_file)
-                purged.add(filepath)
-
-    def getPath(self, filename: str) -> Path:
-        """return cached file URL
-
-        @param filename: cached file name (cache data or actual file)
-        @return: path to the cached file
-        """
-        if not filename or "/" in filename:
-            log.error(
-                "invalid char found in file name, hack attempt? name:{}".format(filename)
-            )
-            raise exceptions.DataError("Invalid char found")
-        return self.cache_dir / filename
-
-    def get_metadata(self, uid: str, update_eol: bool = True) -> Optional[Dict[str, Any]]:
-        """Retrieve metadata for cached data
-
-        @param uid(unicode): unique identifier of file
-        @param update_eol(bool): True if eol must extended
-            if True, max_age will be added to eol (only if it is not already expired)
-        @return (dict, None): metadata with following keys:
-            see [cache_data] for data details, an additional "path" key is the full path to
-            cached file.
-            None if file is not in cache (or cache is invalid)
-        """
-
-        uid = uid.strip()
-        if not uid:
-            raise exceptions.InternalError("uid must not be empty")
-        cache_url = self.getPath(uid)
-        if not cache_url.exists():
-            return None
-
-        try:
-            with cache_url.open("rb") as f:
-                cache_data = pickle.load(f)
-        except (IOError, EOFError) as e:
-            log.warning(f"can't read cache at {cache_url}: {e}")
-            return None
-        except pickle.UnpicklingError:
-            log.warning(f"invalid cache found at {cache_url}")
-            return None
-
-        try:
-            eol = cache_data["eol"]
-        except KeyError:
-            log.warning("no End Of Life found for cached file {}".format(uid))
-            eol = 0
-        if eol < time.time():
-            log.debug(
-                "removing expired cache (expired for {}s)".format(time.time() - eol)
-            )
-            return None
-
-        if update_eol:
-            try:
-                max_age = cache_data["max_age"]
-            except KeyError:
-                log.warning(f"no max_age found for cache at {cache_url}, using default")
-                max_age = cache_data["max_age"] = C.DEFAULT_MAX_AGE
-            now = int(time.time())
-            cache_data["last_access"] = now
-            cache_data["eol"] = now + max_age
-            with cache_url.open("wb") as f:
-                pickle.dump(cache_data, f, protocol=2)
-
-        cache_data["path"] = self.getPath(cache_data["filename"])
-        return cache_data
-
-    def get_file_path(self, uid: str) -> Path:
-        """Retrieve absolute path to file
-
-        @param uid(unicode): unique identifier of file
-        @return (unicode, None): absolute path to cached file
-            None if file is not in cache (or cache is invalid)
-        """
-        metadata = self.get_metadata(uid)
-        if metadata is not None:
-            return metadata["path"]
-
-    def remove_from_cache(self, uid, metadata=None):
-        """Remove data from cache
-
-        @param uid(unicode): unique identifier cache file
-        """
-        cache_data = self.get_metadata(uid, update_eol=False)
-        if cache_data is None:
-            log.debug(f"cache with uid {uid!r} has already expired or been removed")
-            return
-
-        try:
-            filename = cache_data['filename']
-        except KeyError:
-            log.warning(_("missing filename for cache {uid!r}") .format(uid=uid))
-        else:
-            filepath = self.getPath(filename)
-            try:
-                filepath.unlink()
-            except FileNotFoundError:
-                log.warning(
-                    _("missing file referenced in cache {uid!r}: {filename}")
-                    .format(uid=uid, filename=filename)
-                )
-
-        cache_file = self.getPath(uid)
-        cache_file.unlink()
-        log.debug(f"cache with uid {uid!r} has been removed")
-
-    def cache_data(
-        self,
-        source: str,
-        uid: str,
-        mime_type: Optional[str] = None,
-        max_age: Optional[int] = None,
-        original_filename: Optional[str] = None
-    ) -> BufferedIOBase:
-        """create cache metadata and file object to use for actual data
-
-        @param source: source of the cache (should be plugin's import_name)
-        @param uid: an identifier of the file which must be unique
-        @param mime_type: MIME type of the file to cache
-            it will be used notably to guess file extension
-            It may be autogenerated if filename is specified
-        @param max_age: maximum age in seconds
-            the cache metadata will have an "eol" (end of life)
-            None to use default value
-            0 to ignore cache (file will be re-downloaded on each access)
-        @param original_filename: if not None, will be used to retrieve file extension and
-            guess
-            mime type, and stored in "original_filename"
-        @return: file object opened in write mode
-            you have to close it yourself (hint: use ``with`` statement)
-        """
-        if max_age is None:
-            max_age = C.DEFAULT_MAX_AGE
-        cache_data = {
-            "source": source,
-            # we also store max_age for updating eol
-            "max_age": max_age,
-        }
-        cache_url = self.getPath(uid)
-        if original_filename is not None:
-            cache_data["original_filename"] = original_filename
-            if mime_type is None:
-                # we have original_filename but not MIME type, we try to guess the later
-                mime_type = mimetypes.guess_type(original_filename, strict=False)[0]
-        if mime_type:
-            ext = mimetypes.guess_extension(mime_type, strict=False)
-            if ext is None:
-                log.warning(
-                    "can't find extension for MIME type {}".format(mime_type)
-                )
-                ext = DEFAULT_EXT
-            elif ext == ".jpe":
-                ext = ".jpg"
-        else:
-            ext = DEFAULT_EXT
-            mime_type = None
-        filename = uid + ext
-        now = int(time.time())
-        cache_data.update({
-            "filename": filename,
-            "creation": now,
-            "eol": now + max_age,
-            "mime_type": mime_type,
-        })
-        file_path = self.getPath(filename)
-
-        with open(cache_url, "wb") as f:
-            pickle.dump(cache_data, f, protocol=2)
-
-        return file_path.open("wb")
--- a/sat/memory/crypto.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,170 +0,0 @@
-#!/usr/bin/env python3
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from os import urandom
-from base64 import b64encode, b64decode
-from cryptography.hazmat.primitives import hashes
-from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
-from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
-from cryptography.hazmat.backends import default_backend
-
-
-crypto_backend = default_backend()
-
-
-class BlockCipher:
-
-    BLOCK_SIZE = 16
-    MAX_KEY_SIZE = 32
-    IV_SIZE = BLOCK_SIZE  # initialization vector size, 16 bits
-
-    @staticmethod
-    def encrypt(key, text, leave_empty=True):
-        """Encrypt a message.
-
-        Based on http://stackoverflow.com/a/12525165
-
-        @param key (unicode): the encryption key
-        @param text (unicode): the text to encrypt
-        @param leave_empty (bool): if True, empty text will be returned "as is"
-        @return (D(str)): base-64 encoded encrypted message
-        """
-        if leave_empty and text == "":
-            return ""
-        iv = BlockCipher.get_random_key()
-        key = key.encode()
-        key = (
-            key[: BlockCipher.MAX_KEY_SIZE]
-            if len(key) >= BlockCipher.MAX_KEY_SIZE
-            else BlockCipher.pad(key)
-        )
-
-        cipher = Cipher(algorithms.AES(key), modes.CFB8(iv), backend=crypto_backend)
-        encryptor = cipher.encryptor()
-        encrypted = encryptor.update(BlockCipher.pad(text.encode())) + encryptor.finalize()
-        return b64encode(iv + encrypted).decode()
-
-    @staticmethod
-    def decrypt(key, ciphertext, leave_empty=True):
-        """Decrypt a message.
-
-        Based on http://stackoverflow.com/a/12525165
-
-        @param key (unicode): the decryption key
-        @param ciphertext (base-64 encoded str): the text to decrypt
-        @param leave_empty (bool): if True, empty ciphertext will be returned "as is"
-        @return: Deferred: str or None if the password could not be decrypted
-        """
-        if leave_empty and ciphertext == "":
-            return ""
-        ciphertext = b64decode(ciphertext)
-        iv, ciphertext = (
-            ciphertext[: BlockCipher.IV_SIZE],
-            ciphertext[BlockCipher.IV_SIZE :],
-        )
-        key = key.encode()
-        key = (
-            key[: BlockCipher.MAX_KEY_SIZE]
-            if len(key) >= BlockCipher.MAX_KEY_SIZE
-            else BlockCipher.pad(key)
-        )
-
-        cipher = Cipher(algorithms.AES(key), modes.CFB8(iv), backend=crypto_backend)
-        decryptor = cipher.decryptor()
-        decrypted = decryptor.update(ciphertext) + decryptor.finalize()
-        return BlockCipher.unpad(decrypted)
-
-    @staticmethod
-    def get_random_key(size=None, base64=False):
-        """Return a random key suitable for block cipher encryption.
-
-        Note: a good value for the key length is to make it as long as the block size.
-
-        @param size: key length in bytes, positive or null (default: BlockCipher.IV_SIZE)
-        @param base64: if True, encode the result to base-64
-        @return: str (eventually base-64 encoded)
-        """
-        if size is None or size < 0:
-            size = BlockCipher.IV_SIZE
-        key = urandom(size)
-        return b64encode(key) if base64 else key
-
-    @staticmethod
-    def pad(s):
-        """Method from http://stackoverflow.com/a/12525165"""
-        bs = BlockCipher.BLOCK_SIZE
-        return s + (bs - len(s) % bs) * (chr(bs - len(s) % bs)).encode()
-
-    @staticmethod
-    def unpad(s):
-        """Method from http://stackoverflow.com/a/12525165"""
-        s = s.decode()
-        return s[0 : -ord(s[-1])]
-
-
-class PasswordHasher:
-
-    SALT_LEN = 16  # 128 bits
-
-    @staticmethod
-    def hash(password, salt=None, leave_empty=True):
-        """Hash a password.
-
-        @param password (str): the password to hash
-        @param salt (base-64 encoded str): if not None, use the given salt instead of a random value
-        @param leave_empty (bool): if True, empty password will be returned "as is"
-        @return: Deferred: base-64 encoded str
-        """
-        if leave_empty and password == "":
-            return ""
-        salt = (
-            b64decode(salt)[: PasswordHasher.SALT_LEN]
-            if salt
-            else urandom(PasswordHasher.SALT_LEN)
-        )
-
-        # we use PyCrypto's PBKDF2 arguments while porting to crytography, to stay
-        # compatible with existing installations. But this is temporary and we need
-        # to update them to more secure values.
-        kdf = PBKDF2HMAC(
-            # FIXME: SHA1() is not secure, it is used here for historical reasons
-            #   and must be changed as soon as possible
-            algorithm=hashes.SHA1(),
-            length=16,
-            salt=salt,
-            iterations=1000,
-            backend=crypto_backend
-        )
-        key = kdf.derive(password.encode())
-        return b64encode(salt + key).decode()
-
-    @staticmethod
-    def verify(attempt, pwd_hash):
-        """Verify a password attempt.
-
-        @param attempt (str): the attempt to check
-        @param pwd_hash (str): the hash of the password
-        @return: Deferred: boolean
-        """
-        assert isinstance(attempt, str)
-        assert isinstance(pwd_hash, str)
-        leave_empty = pwd_hash == ""
-        attempt_hash = PasswordHasher.hash(attempt, pwd_hash, leave_empty)
-        assert isinstance(attempt_hash, str)
-        return attempt_hash == pwd_hash
--- a/sat/memory/disco.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,499 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.core.log import getLogger
-from sat.core.core_types import SatXMPPEntity
-
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber.error import StanzaError
-from twisted.internet import defer
-from twisted.internet import reactor
-from twisted.python import failure
-from sat.core.constants import Const as C
-from sat.tools import xml_tools
-from sat.memory import persistent
-from wokkel import disco
-from base64 import b64encode
-from hashlib import sha1
-
-
-log = getLogger(__name__)
-
-
-TIMEOUT = 15
-CAP_HASH_ERROR = "ERROR"
-
-
-class HashGenerationError(Exception):
-    pass
-
-
-class ByteIdentity(object):
-    """This class manage identity as bytes (needed for i;octet sort), it is used for the hash generation"""
-
-    def __init__(self, identity, lang=None):
-        assert isinstance(identity, disco.DiscoIdentity)
-        self.category = identity.category.encode("utf-8")
-        self.idType = identity.type.encode("utf-8")
-        self.name = identity.name.encode("utf-8") if identity.name else b""
-        self.lang = lang.encode("utf-8") if lang is not None else b""
-
-    def __bytes__(self):
-        return b"%s/%s/%s/%s" % (self.category, self.idType, self.lang, self.name)
-
-
-class HashManager(object):
-    """map object which manage hashes
-
-    persistent storage is update when a new hash is added
-    """
-
-    def __init__(self, persistent):
-        self.hashes = {
-            CAP_HASH_ERROR: disco.DiscoInfo()  # used when we can't get disco infos
-        }
-        self.persistent = persistent
-
-    def __getitem__(self, key):
-        return self.hashes[key]
-
-    def __setitem__(self, hash_, disco_info):
-        if hash_ in self.hashes:
-            log.debug("ignoring hash set: it is already known")
-            return
-        self.hashes[hash_] = disco_info
-        self.persistent[hash_] = disco_info.toElement().toXml()
-
-    def __contains__(self, hash_):
-        return self.hashes.__contains__(hash_)
-
-    def load(self):
-        def fill_hashes(hashes):
-            for hash_, xml in hashes.items():
-                element = xml_tools.ElementParser()(xml)
-                disco_info = disco.DiscoInfo.fromElement(element)
-                for ext_form in disco_info.extensions.values():
-                    # wokkel doesn't call typeCheck on reception, so we do it here
-                    ext_form.typeCheck()
-                if not disco_info.features and not disco_info.identities:
-                    log.warning(
-                        _(
-                            "no feature/identity found in disco element (hash: {cap_hash}), ignoring: {xml}"
-                        ).format(cap_hash=hash_, xml=xml)
-                    )
-                else:
-                    self.hashes[hash_] = disco_info
-
-            log.info("Disco hashes loaded")
-
-        d = self.persistent.load()
-        d.addCallback(fill_hashes)
-        return d
-
-
-class Discovery(object):
-    """ Manage capabilities of entities """
-
-    def __init__(self, host):
-        self.host = host
-        # TODO: remove legacy hashes
-
-    def load(self):
-        """Load persistent hashes"""
-        self.hashes = HashManager(persistent.PersistentDict("disco"))
-        return self.hashes.load()
-
-    @defer.inlineCallbacks
-    def hasFeature(self, client, feature, jid_=None, node=""):
-        """Tell if an entity has the required feature
-
-        @param feature: feature namespace
-        @param jid_: jid of the target, or None for profile's server
-        @param node(unicode): optional node to use for disco request
-        @return: a Deferred which fire a boolean (True if feature is available)
-        """
-        disco_infos = yield self.get_infos(client, jid_, node)
-        defer.returnValue(feature in disco_infos.features)
-
-    @defer.inlineCallbacks
-    def check_feature(self, client, feature, jid_=None, node=""):
-        """Like hasFeature, but raise an exception is feature is not Found
-
-        @param feature: feature namespace
-        @param jid_: jid of the target, or None for profile's server
-        @param node(unicode): optional node to use for disco request
-
-        @raise: exceptions.FeatureNotFound
-        """
-        disco_infos = yield self.get_infos(client, jid_, node)
-        if not feature in disco_infos.features:
-            raise failure.Failure(exceptions.FeatureNotFound())
-
-    @defer.inlineCallbacks
-    def check_features(self, client, features, jid_=None, identity=None, node=""):
-        """Like check_feature, but check several features at once, and check also identity
-
-        @param features(iterable[unicode]): features to check
-        @param jid_(jid.JID): jid of the target, or None for profile's server
-        @param node(unicode): optional node to use for disco request
-        @param identity(None, tuple(unicode, unicode): if not None, the entity must have an identity with this (category, type) tuple
-
-        @raise: exceptions.FeatureNotFound
-        """
-        disco_infos = yield self.get_infos(client, jid_, node)
-        if not set(features).issubset(disco_infos.features):
-            raise failure.Failure(exceptions.FeatureNotFound())
-
-        if identity is not None and identity not in disco_infos.identities:
-            raise failure.Failure(exceptions.FeatureNotFound())
-
-    async def has_identity(
-        self,
-        client: SatXMPPEntity,
-        category: str,
-        type_: str,
-        jid_: Optional[jid.JID] = None,
-        node: str = ""
-    ) -> bool:
-        """Tell if an entity has the requested identity
-
-        @param category: identity category
-        @param type_: identity type
-        @param jid_: jid of the target, or None for profile's server
-        @param node(unicode): optional node to use for disco request
-        @return: True if the entity has the given identity
-        """
-        disco_infos = await self.get_infos(client, jid_, node)
-        return (category, type_) in disco_infos.identities
-
-    def get_infos(self, client, jid_=None, node="", use_cache=True):
-        """get disco infos from jid_, filling capability hash if needed
-
-        @param jid_: jid of the target, or None for profile's server
-        @param node(unicode): optional node to use for disco request
-        @param use_cache(bool): if True, use cached data if available
-        @return: a Deferred which fire disco.DiscoInfo
-        """
-        if jid_ is None:
-            jid_ = jid.JID(client.jid.host)
-        try:
-            if not use_cache:
-                # we ignore cache, so we pretend we haven't found it
-                raise KeyError
-            cap_hash = self.host.memory.entity_data_get(
-                client, jid_, [C.ENTITY_CAP_HASH]
-            )[C.ENTITY_CAP_HASH]
-        except (KeyError, exceptions.UnknownEntityError):
-            # capability hash is not available, we'll compute one
-            def infos_cb(disco_infos):
-                cap_hash = self.generate_hash(disco_infos)
-                for ext_form in disco_infos.extensions.values():
-                    # wokkel doesn't call typeCheck on reception, so we do it here
-                    # to avoid ending up with incorrect types. We have to do it after
-                    # the hash has been generated (str value is needed to compute the
-                    # hash)
-                    ext_form.typeCheck()
-                self.hashes[cap_hash] = disco_infos
-                self.host.memory.update_entity_data(
-                    client, jid_, C.ENTITY_CAP_HASH, cap_hash
-                )
-                return disco_infos
-
-            def infos_eb(fail):
-                if fail.check(defer.CancelledError):
-                    reason = "request time-out"
-                    fail = failure.Failure(exceptions.TimeOutError(str(fail.value)))
-                else:
-                    try:
-                        reason = str(fail.value)
-                    except AttributeError:
-                        reason = str(fail)
-
-                log.warning(
-                    "can't request disco infos from {jid}: {reason}".format(
-                        jid=jid_.full(), reason=reason
-                    )
-                )
-
-                # XXX we set empty disco in cache, to avoid getting an error or waiting
-                # for a timeout again the next time
-                self.host.memory.update_entity_data(
-                    client, jid_, C.ENTITY_CAP_HASH, CAP_HASH_ERROR
-                )
-                raise fail
-
-            d = client.disco.requestInfo(jid_, nodeIdentifier=node)
-            d.addCallback(infos_cb)
-            d.addErrback(infos_eb)
-            return d
-        else:
-            disco_infos = self.hashes[cap_hash]
-            return defer.succeed(disco_infos)
-
-    @defer.inlineCallbacks
-    def get_items(self, client, jid_=None, node="", use_cache=True):
-        """get disco items from jid_, cache them for our own server
-
-        @param jid_(jid.JID): jid of the target, or None for profile's server
-        @param node(unicode): optional node to use for disco request
-        @param use_cache(bool): if True, use cached data if available
-        @return: a Deferred which fire disco.DiscoItems
-        """
-        if jid_ is None:
-            jid_ = client.server_jid
-
-        if jid_ == client.server_jid and not node:
-            # we cache items only for our own server and if node is not set
-            try:
-                items = self.host.memory.entity_data_get(
-                    client, jid_, ["DISCO_ITEMS"]
-                )["DISCO_ITEMS"]
-                log.debug("[%s] disco items are in cache" % jid_.full())
-                if not use_cache:
-                    # we ignore cache, so we pretend we haven't found it
-                    raise KeyError
-            except (KeyError, exceptions.UnknownEntityError):
-                log.debug("Caching [%s] disco items" % jid_.full())
-                items = yield client.disco.requestItems(jid_, nodeIdentifier=node)
-                self.host.memory.update_entity_data(
-                    client, jid_, "DISCO_ITEMS", items
-                )
-        else:
-            try:
-                items = yield client.disco.requestItems(jid_, nodeIdentifier=node)
-            except StanzaError as e:
-                log.warning(
-                    "Error while requesting items for {jid}: {reason}".format(
-                        jid=jid_.full(), reason=e.condition
-                    )
-                )
-                items = disco.DiscoItems()
-
-        defer.returnValue(items)
-
-    def _infos_eb(self, failure_, entity_jid):
-        failure_.trap(StanzaError)
-        log.warning(
-            _("Error while requesting [%(jid)s]: %(error)s")
-            % {"jid": entity_jid.full(), "error": failure_.getErrorMessage()}
-        )
-
-    def find_service_entity(self, client, category, type_, jid_=None):
-        """Helper method to find first available entity from find_service_entities
-
-        args are the same as for [find_service_entities]
-        @return (jid.JID, None): found entity
-        """
-        d = self.host.find_service_entities(client, category, type_)
-        d.addCallback(lambda entities: entities.pop() if entities else None)
-        return d
-
-    def find_service_entities(self, client, category, type_, jid_=None):
-        """Return all available items of an entity which correspond to (category, type_)
-
-        @param category: identity's category
-        @param type_: identitiy's type
-        @param jid_: the jid of the target server (None for profile's server)
-        @return: a set of found entities
-        @raise defer.CancelledError: the request timed out
-        """
-        found_entities = set()
-
-        def infos_cb(infos, entity_jid):
-            if (category, type_) in infos.identities:
-                found_entities.add(entity_jid)
-
-        def got_items(items):
-            defers_list = []
-            for item in items:
-                info_d = self.get_infos(client, item.entity)
-                info_d.addCallbacks(
-                    infos_cb, self._infos_eb, [item.entity], None, [item.entity]
-                )
-                defers_list.append(info_d)
-            return defer.DeferredList(defers_list)
-
-        d = self.get_items(client, jid_)
-        d.addCallback(got_items)
-        d.addCallback(lambda __: found_entities)
-        reactor.callLater(
-            TIMEOUT, d.cancel
-        )  # FIXME: one bad service make a general timeout
-        return d
-
-    def find_features_set(self, client, features, identity=None, jid_=None):
-        """Return entities (including jid_ and its items) offering features
-
-        @param features: iterable of features which must be present
-        @param identity(None, tuple(unicode, unicode)): if not None, accept only this
-            (category/type) identity
-        @param jid_: the jid of the target server (None for profile's server)
-        @param profile: %(doc_profile)s
-        @return: a set of found entities
-        """
-        if jid_ is None:
-            jid_ = jid.JID(client.jid.host)
-        features = set(features)
-        found_entities = set()
-
-        def infos_cb(infos, entity):
-            if entity is None:
-                log.warning(_("received an item without jid"))
-                return
-            if identity is not None and identity not in infos.identities:
-                return
-            if features.issubset(infos.features):
-                found_entities.add(entity)
-
-        def got_items(items):
-            defer_list = []
-            for entity in [jid_] + [item.entity for item in items]:
-                infos_d = self.get_infos(client, entity)
-                infos_d.addCallbacks(infos_cb, self._infos_eb, [entity], None, [entity])
-                defer_list.append(infos_d)
-            return defer.DeferredList(defer_list)
-
-        d = self.get_items(client, jid_)
-        d.addCallback(got_items)
-        d.addCallback(lambda __: found_entities)
-        reactor.callLater(
-            TIMEOUT, d.cancel
-        )  # FIXME: one bad service make a general timeout
-        return d
-
-    def generate_hash(self, services):
-        """ Generate a unique hash for given service
-
-        hash algorithm is the one described in XEP-0115
-        @param services: iterable of disco.DiscoIdentity/disco.DiscoFeature, as returned by discoHandler.info
-
-        """
-        s = []
-        # identities
-        byte_identities = [
-            ByteIdentity(service)
-            for service in services
-            if isinstance(service, disco.DiscoIdentity)
-        ]  # FIXME: lang must be managed here
-        byte_identities.sort(key=lambda i: i.lang)
-        byte_identities.sort(key=lambda i: i.idType)
-        byte_identities.sort(key=lambda i: i.category)
-        for identity in byte_identities:
-            s.append(bytes(identity))
-            s.append(b"<")
-        # features
-        byte_features = [
-            service.encode("utf-8")
-            for service in services
-            if isinstance(service, disco.DiscoFeature)
-        ]
-        byte_features.sort()  # XXX: the default sort has the same behaviour as the requested RFC 4790 i;octet sort
-        for feature in byte_features:
-            s.append(feature)
-            s.append(b"<")
-
-        # extensions
-        ext = list(services.extensions.values())
-        ext.sort(key=lambda f: f.formNamespace.encode('utf-8'))
-        for extension in ext:
-            s.append(extension.formNamespace.encode('utf-8'))
-            s.append(b"<")
-            fields = extension.fieldList
-            fields.sort(key=lambda f: f.var.encode('utf-8'))
-            for field in fields:
-                s.append(field.var.encode('utf-8'))
-                s.append(b"<")
-                values = [v.encode('utf-8') for v in field.values]
-                values.sort()
-                for value in values:
-                    s.append(value)
-                    s.append(b"<")
-
-        cap_hash = b64encode(sha1(b"".join(s)).digest()).decode('utf-8')
-        log.debug(_("Capability hash generated: [{cap_hash}]").format(cap_hash=cap_hash))
-        return cap_hash
-
-    @defer.inlineCallbacks
-    def _disco_infos(
-        self, entity_jid_s, node="", use_cache=True, profile_key=C.PROF_KEY_NONE
-    ):
-        """Discovery method for the bridge
-        @param entity_jid_s: entity we want to discover
-        @param use_cache(bool): if True, use cached data if available
-        @param node(unicode): optional node to use
-
-        @return: list of tuples
-        """
-        client = self.host.get_client(profile_key)
-        entity = jid.JID(entity_jid_s)
-        disco_infos = yield self.get_infos(client, entity, node, use_cache)
-        extensions = {}
-        # FIXME: should extensions be serialised using tools.common.data_format?
-        for form_type, form in list(disco_infos.extensions.items()):
-            fields = []
-            for field in form.fieldList:
-                data = {"type": field.fieldType}
-                for attr in ("var", "label", "desc"):
-                    value = getattr(field, attr)
-                    if value is not None:
-                        data[attr] = value
-
-                values = [field.value] if field.value is not None else field.values
-                if field.fieldType == "boolean":
-                    values = [C.bool_const(v) for v in values]
-                fields.append((data, values))
-
-            extensions[form_type or ""] = fields
-
-        defer.returnValue((
-            [str(f) for f in disco_infos.features],
-            [(cat, type_, name or "")
-             for (cat, type_), name in list(disco_infos.identities.items())],
-            extensions))
-
-    def items2tuples(self, disco_items):
-        """convert disco items to tuple of strings
-
-        @param disco_items(iterable[disco.DiscoItem]): items
-        @return G(tuple[unicode,unicode,unicode]): serialised items
-        """
-        for item in disco_items:
-            if not item.entity:
-                log.warning(_("invalid item (no jid)"))
-                continue
-            yield (item.entity.full(), item.nodeIdentifier or "", item.name or "")
-
-    @defer.inlineCallbacks
-    def _disco_items(
-        self, entity_jid_s, node="", use_cache=True, profile_key=C.PROF_KEY_NONE
-    ):
-        """ Discovery method for the bridge
-
-        @param entity_jid_s: entity we want to discover
-        @param node(unicode): optional node to use
-        @param use_cache(bool): if True, use cached data if available
-        @return: list of tuples"""
-        client = self.host.get_client(profile_key)
-        entity = jid.JID(entity_jid_s)
-        disco_items = yield self.get_items(client, entity, node, use_cache)
-        ret = list(self.items2tuples(disco_items))
-        defer.returnValue(ret)
--- a/sat/memory/encryption.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,534 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import copy
-from functools import partial
-from typing import Optional
-from twisted.words.protocols.jabber import jid
-from twisted.internet import defer
-from twisted.python import failure
-from sat.core.core_types import EncryptionPlugin, EncryptionSession, MessageData
-from sat.core.i18n import D_, _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-from sat.tools.common import data_format
-from sat.tools import utils
-from sat.memory import persistent
-
-
-log = getLogger(__name__)
-
-
-class EncryptionHandler:
-    """Class to handle encryption sessions for a client"""
-    plugins = []  # plugin able to encrypt messages
-
-    def __init__(self, client):
-        self.client = client
-        self._sessions = {}  # bare_jid ==> encryption_data
-        self._stored_session = persistent.PersistentDict(
-            "core:encryption", profile=client.profile)
-
-    @property
-    def host(self):
-        return self.client.host_app
-
-    async def load_sessions(self):
-        """Load persistent sessions"""
-        await self._stored_session.load()
-        start_d_list = []
-        for entity_jid_s, namespace in self._stored_session.items():
-            entity = jid.JID(entity_jid_s)
-            start_d_list.append(defer.ensureDeferred(self.start(entity, namespace)))
-
-        if start_d_list:
-            result = await defer.DeferredList(start_d_list)
-            for idx, (success, err) in enumerate(result):
-                if not success:
-                    entity_jid_s, namespace = list(self._stored_session.items())[idx]
-                    log.warning(_(
-                        "Could not restart {namespace!r} encryption with {entity}: {err}"
-                        ).format(namespace=namespace, entity=entity_jid_s, err=err))
-            log.info(_("encryption sessions restored"))
-
-    @classmethod
-    def register_plugin(cls, plg_instance, name, namespace, priority=0, directed=False):
-        """Register a plugin handling an encryption algorithm
-
-        @param plg_instance(object): instance of the plugin
-            it must have the following methods:
-                - get_trust_ui(entity): return a XMLUI for trust management
-                    entity(jid.JID): entity to manage
-                    The returned XMLUI must be a form
-            if may have the following methods:
-                - start_encryption(entity): start encrypted session
-                    entity(jid.JID): entity to start encrypted session with
-                - stop_encryption(entity): start encrypted session
-                    entity(jid.JID): entity to stop encrypted session with
-            if they don't exists, those 2 methods will be ignored.
-
-        @param name(unicode): human readable name of the encryption algorithm
-        @param namespace(unicode): namespace of the encryption algorithm
-        @param priority(int): priority of this plugin to encrypt an message when not
-            selected manually
-        @param directed(bool): True if this plugin is directed (if it works with one
-                               device only at a time)
-        """
-        existing_ns = set()
-        existing_names = set()
-        for p in cls.plugins:
-            existing_ns.add(p.namespace.lower())
-            existing_names.add(p.name.lower())
-        if namespace.lower() in existing_ns:
-            raise exceptions.ConflictError("A plugin with this namespace already exists!")
-        if name.lower() in existing_names:
-            raise exceptions.ConflictError("A plugin with this name already exists!")
-        plugin = EncryptionPlugin(
-            instance=plg_instance,
-            name=name,
-            namespace=namespace,
-            priority=priority,
-            directed=directed)
-        cls.plugins.append(plugin)
-        cls.plugins.sort(key=lambda p: p.priority)
-        log.info(_("Encryption plugin registered: {name}").format(name=name))
-
-    @classmethod
-    def getPlugins(cls):
-        return cls.plugins
-
-    @classmethod
-    def get_plugin(cls, namespace):
-        try:
-            return next(p for p in cls.plugins if p.namespace == namespace)
-        except StopIteration:
-            raise exceptions.NotFound(_(
-                "Can't find requested encryption plugin: {namespace}").format(
-                    namespace=namespace))
-
-    @classmethod
-    def get_namespaces(cls):
-        """Get available plugin namespaces"""
-        return {p.namespace for p in cls.getPlugins()}
-
-    @classmethod
-    def get_ns_from_name(cls, name):
-        """Retrieve plugin namespace from its name
-
-        @param name(unicode): name of the plugin (case insensitive)
-        @return (unicode): namespace of the plugin
-        @raise exceptions.NotFound: there is not encryption plugin of this name
-        """
-        for p in cls.plugins:
-            if p.name.lower() == name.lower():
-                return p.namespace
-        raise exceptions.NotFound(_(
-            "Can't find a plugin with the name \"{name}\".".format(
-                name=name)))
-
-    def get_bridge_data(self, session):
-        """Retrieve session data serialized for bridge.
-
-        @param session(dict): encryption session
-        @return (unicode): serialized data for bridge
-        """
-        if session is None:
-            return ''
-        plugin = session['plugin']
-        bridge_data = {'name': plugin.name,
-                       'namespace': plugin.namespace}
-        if 'directed_devices' in session:
-            bridge_data['directed_devices'] = session['directed_devices']
-
-        return data_format.serialise(bridge_data)
-
-    async def _start_encryption(self, plugin, entity):
-        """Start encryption with a plugin
-
-        This method must be called just before adding a plugin session.
-        StartEncryptionn method of plugin will be called if it exists.
-        """
-        if not plugin.directed:
-            await self._stored_session.aset(entity.userhost(), plugin.namespace)
-        try:
-            start_encryption = plugin.instance.start_encryption
-        except AttributeError:
-            log.debug(f"No start_encryption method found for {plugin.namespace}")
-        else:
-            # we copy entity to avoid having the resource changed by stop_encryption
-            await utils.as_deferred(start_encryption, self.client, copy.copy(entity))
-
-    async def _stop_encryption(self, plugin, entity):
-        """Stop encryption with a plugin
-
-        This method must be called just before removing a plugin session.
-        StopEncryptionn method of plugin will be called if it exists.
-        """
-        try:
-            await self._stored_session.adel(entity.userhost())
-        except KeyError:
-            pass
-        try:
-            stop_encryption = plugin.instance.stop_encryption
-        except AttributeError:
-            log.debug(f"No stop_encryption method found for {plugin.namespace}")
-        else:
-            # we copy entity to avoid having the resource changed by stop_encryption
-            return utils.as_deferred(stop_encryption, self.client, copy.copy(entity))
-
-    async def start(self, entity, namespace=None, replace=False):
-        """Start an encryption session with an entity
-
-        @param entity(jid.JID): entity to start an encryption session with
-            must be bare jid is the algorithm encrypt for all devices
-        @param namespace(unicode, None): namespace of the encryption algorithm
-            to use.
-            None to select automatically an algorithm
-        @param replace(bool): if True and an encrypted session already exists,
-            it will be replaced by the new one
-        """
-        if not self.plugins:
-            raise exceptions.NotFound(_("No encryption plugin is registered, "
-                                        "an encryption session can't be started"))
-
-        if namespace is None:
-            plugin = self.plugins[0]
-        else:
-            plugin = self.get_plugin(namespace)
-
-        bare_jid = entity.userhostJID()
-        if bare_jid in self._sessions:
-            # we have already an encryption session with this contact
-            former_plugin = self._sessions[bare_jid]["plugin"]
-            if former_plugin.namespace == namespace:
-                log.info(_("Session with {bare_jid} is already encrypted with {name}. "
-                           "Nothing to do.").format(
-                               bare_jid=bare_jid, name=former_plugin.name))
-                return
-
-            if replace:
-                # there is a conflict, but replacement is requested
-                # so we stop previous encryption to use new one
-                del self._sessions[bare_jid]
-                await self._stop_encryption(former_plugin, entity)
-            else:
-                msg = (_("Session with {bare_jid} is already encrypted with {name}. "
-                         "Please stop encryption session before changing algorithm.")
-                       .format(bare_jid=bare_jid, name=plugin.name))
-                log.warning(msg)
-                raise exceptions.ConflictError(msg)
-
-        data = {"plugin": plugin}
-        if plugin.directed:
-            if not entity.resource:
-                entity.resource = self.host.memory.main_resource_get(self.client, entity)
-                if not entity.resource:
-                    raise exceptions.NotFound(
-                        _("No resource found for {destinee}, can't encrypt with {name}")
-                        .format(destinee=entity.full(), name=plugin.name))
-                log.info(_("No resource specified to encrypt with {name}, using "
-                           "{destinee}.").format(destinee=entity.full(),
-                                                  name=plugin.name))
-            # indicate that we encrypt only for some devices
-            directed_devices = data['directed_devices'] = [entity.resource]
-        elif entity.resource:
-            raise ValueError(_("{name} encryption must be used with bare jids."))
-
-        await self._start_encryption(plugin, entity)
-        self._sessions[entity.userhostJID()] = data
-        log.info(_("Encryption session has been set for {entity_jid} with "
-                   "{encryption_name}").format(
-                   entity_jid=entity.full(), encryption_name=plugin.name))
-        self.host.bridge.message_encryption_started(
-            entity.full(),
-            self.get_bridge_data(data),
-            self.client.profile)
-        msg = D_("Encryption session started: your messages with {destinee} are "
-                 "now end to end encrypted using {name} algorithm.").format(
-                 destinee=entity.full(), name=plugin.name)
-        directed_devices = data.get('directed_devices')
-        if directed_devices:
-            msg += "\n" + D_("Message are encrypted only for {nb_devices} device(s): "
-                              "{devices_list}.").format(
-                              nb_devices=len(directed_devices),
-                              devices_list = ', '.join(directed_devices))
-
-        self.client.feedback(bare_jid, msg)
-
-    async def stop(self, entity, namespace=None):
-        """Stop an encryption session with an entity
-
-        @param entity(jid.JID): entity with who the encryption session must be stopped
-            must be bare jid if the algorithm encrypt for all devices
-        @param namespace(unicode): namespace of the session to stop
-            when specified, used to check that we stop the right encryption session
-        """
-        session = self.getSession(entity.userhostJID())
-        if not session:
-            raise failure.Failure(
-                exceptions.NotFound(_("There is no encryption session with this "
-                                      "entity.")))
-        plugin = session['plugin']
-        if namespace is not None and plugin.namespace != namespace:
-            raise exceptions.InternalError(_(
-                "The encryption session is not run with the expected plugin: encrypted "
-                "with {current_name} and was expecting {expected_name}").format(
-                current_name=session['plugin'].namespace,
-                expected_name=namespace))
-        if entity.resource:
-            try:
-                directed_devices = session['directed_devices']
-            except KeyError:
-                raise exceptions.NotFound(_(
-                    "There is a session for the whole entity (i.e. all devices of the "
-                    "entity), not a directed one. Please use bare jid if you want to "
-                    "stop the whole encryption with this entity."))
-
-            try:
-                directed_devices.remove(entity.resource)
-            except ValueError:
-                raise exceptions.NotFound(_("There is no directed session with this "
-                                            "entity."))
-            else:
-                if not directed_devices:
-                    # if we have no more directed device sessions,
-                    # we stop the whole session
-                    # see comment below for deleting session before stopping encryption
-                    del self._sessions[entity.userhostJID()]
-                    await self._stop_encryption(plugin, entity)
-        else:
-            # plugin's stop_encryption may call stop again (that's the case with OTR)
-            # so we need to remove plugin from session before calling self._stop_encryption
-            del self._sessions[entity.userhostJID()]
-            await self._stop_encryption(plugin, entity)
-
-        log.info(_("encryption session stopped with entity {entity}").format(
-            entity=entity.full()))
-        self.host.bridge.message_encryption_stopped(
-            entity.full(),
-            {'name': plugin.name,
-             'namespace': plugin.namespace,
-            },
-            self.client.profile)
-        msg = D_("Encryption session finished: your messages with {destinee} are "
-                 "NOT end to end encrypted anymore.\nYour server administrators or "
-                 "{destinee} server administrators will be able to read them.").format(
-                 destinee=entity.full())
-
-        self.client.feedback(entity, msg)
-
-    def getSession(self, entity: jid.JID) -> Optional[EncryptionSession]:
-        """Get encryption session for this contact
-
-        @param entity(jid.JID): get the session for this entity
-            must be a bare jid
-        @return (dict, None): encryption session data
-            None if there is not encryption for this session with this jid
-        """
-        if entity.resource:
-            raise ValueError("Full jid given when expecting bare jid")
-        return self._sessions.get(entity)
-
-    def get_namespace(self, entity: jid.JID) -> Optional[str]:
-        """Helper method to get the current encryption namespace used
-
-        @param entity: get the namespace for this entity must be a bare jid
-        @return: the algorithm namespace currently used in this session, or None if no
-            e2ee is currently used.
-        """
-        session = self.getSession(entity)
-        if session is None:
-            return None
-        return session["plugin"].namespace
-
-    def get_trust_ui(self, entity_jid, namespace=None):
-        """Retrieve encryption UI
-
-        @param entity_jid(jid.JID): get the UI for this entity
-            must be a bare jid
-        @param namespace(unicode): namespace of the algorithm to manage
-            if None use current algorithm
-        @return D(xmlui): XMLUI for trust management
-            the xmlui is a form
-            None if there is not encryption for this session with this jid
-        @raise exceptions.NotFound: no algorithm/plugin found
-        @raise NotImplementedError: plugin doesn't handle UI management
-        """
-        if namespace is None:
-            session = self.getSession(entity_jid)
-            if not session:
-                raise exceptions.NotFound(
-                    "No encryption session currently active for {entity_jid}"
-                    .format(entity_jid=entity_jid.full()))
-            plugin = session['plugin']
-        else:
-            plugin = self.get_plugin(namespace)
-        try:
-            get_trust_ui = plugin.instance.get_trust_ui
-        except AttributeError:
-            raise NotImplementedError(
-                "Encryption plugin doesn't handle trust management UI")
-        else:
-            return utils.as_deferred(get_trust_ui, self.client, entity_jid)
-
-    ## Menus ##
-
-    @classmethod
-    def _import_menus(cls, host):
-        host.import_menu(
-             (D_("Encryption"), D_("unencrypted (plain text)")),
-             partial(cls._on_menu_unencrypted, host=host),
-             security_limit=0,
-             help_string=D_("End encrypted session"),
-             type_=C.MENU_SINGLE,
-        )
-        for plg in cls.getPlugins():
-            host.import_menu(
-                 (D_("Encryption"), plg.name),
-                 partial(cls._on_menu_name, host=host, plg=plg),
-                 security_limit=0,
-                 help_string=D_("Start {name} session").format(name=plg.name),
-                 type_=C.MENU_SINGLE,
-            )
-            host.import_menu(
-                 (D_("Encryption"), D_("⛨ {name} trust").format(name=plg.name)),
-                 partial(cls._on_menu_trust, host=host, plg=plg),
-                 security_limit=0,
-                 help_string=D_("Manage {name} trust").format(name=plg.name),
-                 type_=C.MENU_SINGLE,
-            )
-
-    @classmethod
-    def _on_menu_unencrypted(cls, data, host, profile):
-        client = host.get_client(profile)
-        peer_jid = jid.JID(data['jid']).userhostJID()
-        d = defer.ensureDeferred(client.encryption.stop(peer_jid))
-        d.addCallback(lambda __: {})
-        return d
-
-    @classmethod
-    def _on_menu_name(cls, data, host, plg, profile):
-        client = host.get_client(profile)
-        peer_jid = jid.JID(data['jid'])
-        if not plg.directed:
-            peer_jid = peer_jid.userhostJID()
-        d = defer.ensureDeferred(
-            client.encryption.start(peer_jid, plg.namespace, replace=True))
-        d.addCallback(lambda __: {})
-        return d
-
-    @classmethod
-    @defer.inlineCallbacks
-    def _on_menu_trust(cls, data, host, plg, profile):
-        client = host.get_client(profile)
-        peer_jid = jid.JID(data['jid']).userhostJID()
-        ui = yield client.encryption.get_trust_ui(peer_jid, plg.namespace)
-        defer.returnValue({'xmlui': ui.toXml()})
-
-    ## Triggers ##
-
-    def set_encryption_flag(self, mess_data):
-        """Set "encryption" key in mess_data if session with destinee is encrypted"""
-        to_jid = mess_data['to']
-        encryption = self._sessions.get(to_jid.userhostJID())
-        if encryption is not None:
-            plugin = encryption['plugin']
-            if mess_data["type"] == "groupchat" and plugin.directed:
-                raise exceptions.InternalError(
-                f"encryption flag must not be set for groupchat if encryption algorithm "
-                f"({encryption['plugin'].name}) is directed!")
-            mess_data[C.MESS_KEY_ENCRYPTION] = encryption
-            self.mark_as_encrypted(mess_data, plugin.namespace)
-
-    ## Misc ##
-
-    def mark_as_encrypted(self, mess_data, namespace):
-        """Helper method to mark a message as having been e2e encrypted.
-
-        This should be used in the post_treat workflow of message_received trigger of
-        the plugin
-        @param mess_data(dict): message data as used in post treat workflow
-        @param namespace(str): namespace of the algorithm used for encrypting the message
-        """
-        mess_data['extra'][C.MESS_KEY_ENCRYPTED] = True
-        from_bare_jid = mess_data['from'].userhostJID()
-        if from_bare_jid != self.client.jid.userhostJID():
-            session = self.getSession(from_bare_jid)
-            if session is None:
-                # if we are currently unencrypted, we start a session automatically
-                # to avoid sending unencrypted messages in an encrypted context
-                log.info(_(
-                    "Starting e2e session with {peer_jid} as we receive encrypted "
-                    "messages")
-                    .format(peer_jid=from_bare_jid)
-                )
-                defer.ensureDeferred(self.start(from_bare_jid, namespace))
-
-        return mess_data
-
-    def is_encryption_requested(
-        self,
-        mess_data: MessageData,
-        namespace: Optional[str] = None
-    ) -> bool:
-        """Helper method to check if encryption is requested in an outgoind message
-
-        @param mess_data: message data for outgoing message
-        @param namespace: if set, check if encryption is requested for the algorithm
-            specified
-        @return: True if the encryption flag is present
-        """
-        encryption = mess_data.get(C.MESS_KEY_ENCRYPTION)
-        if encryption is None:
-            return False
-        # we get plugin even if namespace is None to be sure that the key exists
-        plugin = encryption['plugin']
-        if namespace is None:
-            return True
-        return plugin.namespace == namespace
-
-    def isEncrypted(self, mess_data):
-        """Helper method to check if a message has the e2e encrypted flag
-
-        @param mess_data(dict): message data
-        @return (bool): True if the encrypted flag is present
-        """
-        return mess_data['extra'].get(C.MESS_KEY_ENCRYPTED, False)
-
-
-    def mark_as_trusted(self, mess_data):
-        """Helper methor to mark a message as sent from a trusted entity.
-
-        This should be used in the post_treat workflow of message_received trigger of
-        the plugin
-        @param mess_data(dict): message data as used in post treat workflow
-        """
-        mess_data[C.MESS_KEY_TRUSTED] = True
-        return mess_data
-
-    def mark_as_untrusted(self, mess_data):
-        """Helper methor to mark a message as sent from an untrusted entity.
-
-        This should be used in the post_treat workflow of message_received trigger of
-        the plugin
-        @param mess_data(dict): message data as used in post treat workflow
-        """
-        mess_data['trusted'] = False
-        return mess_data
--- a/sat/memory/memory.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1881 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import os.path
-import copy
-import shortuuid
-import mimetypes
-import time
-from functools import partial
-from typing import Optional, Tuple, Dict
-from pathlib import Path
-from uuid import uuid4
-from collections import namedtuple
-from twisted.python import failure
-from twisted.internet import defer, reactor, error
-from twisted.words.protocols.jabber import jid
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.memory.sqla import Storage
-from sat.memory.persistent import PersistentDict
-from sat.memory.params import Params
-from sat.memory.disco import Discovery
-from sat.memory.crypto import BlockCipher
-from sat.memory.crypto import PasswordHasher
-from sat.tools import config as tools_config
-from sat.tools.common import data_format
-from sat.tools.common import regex
-
-
-log = getLogger(__name__)
-
-
-PresenceTuple = namedtuple("PresenceTuple", ("show", "priority", "statuses"))
-MSG_NO_SESSION = "Session id doesn't exist or is finished"
-
-
-class Sessions(object):
-    """Sessions are data associated to key used for a temporary moment, with optional profile checking."""
-
-    DEFAULT_TIMEOUT = 600
-
-    def __init__(self, timeout=None, resettable_timeout=True):
-        """
-        @param timeout (int): nb of seconds before session destruction
-        @param resettable_timeout (bool): if True, the timeout is reset on each access
-        """
-        self._sessions = dict()
-        self.timeout = timeout or Sessions.DEFAULT_TIMEOUT
-        self.resettable_timeout = resettable_timeout
-
-    def new_session(self, session_data=None, session_id=None, profile=None):
-        """Create a new session
-
-        @param session_data: mutable data to use, default to a dict
-        @param session_id (str): force the session_id to the given string
-        @param profile: if set, the session is owned by the profile,
-                        and profile_get must be used instead of __getitem__
-        @return: session_id, session_data
-        """
-        if session_id is None:
-            session_id = str(uuid4())
-        elif session_id in self._sessions:
-            raise exceptions.ConflictError(
-                "Session id {} is already used".format(session_id)
-            )
-        timer = reactor.callLater(self.timeout, self._purge_session, session_id)
-        if session_data is None:
-            session_data = {}
-        self._sessions[session_id] = (
-            (timer, session_data) if profile is None else (timer, session_data, profile)
-        )
-        return session_id, session_data
-
-    def _purge_session(self, session_id):
-        try:
-            timer, session_data, profile = self._sessions[session_id]
-        except ValueError:
-            timer, session_data = self._sessions[session_id]
-            profile = None
-        try:
-            timer.cancel()
-        except error.AlreadyCalled:
-            # if the session is time-outed, the timer has been called
-            pass
-        del self._sessions[session_id]
-        log.debug(
-            "Session {} purged{}".format(
-                session_id,
-                " (profile {})".format(profile) if profile is not None else "",
-            )
-        )
-
-    def __len__(self):
-        return len(self._sessions)
-
-    def __contains__(self, session_id):
-        return session_id in self._sessions
-
-    def profile_get(self, session_id, profile):
-        try:
-            timer, session_data, profile_set = self._sessions[session_id]
-        except ValueError:
-            raise exceptions.InternalError(
-                "You need to use __getitem__ when profile is not set"
-            )
-        except KeyError:
-            raise failure.Failure(KeyError(MSG_NO_SESSION))
-        if profile_set != profile:
-            raise exceptions.InternalError("current profile differ from set profile !")
-        if self.resettable_timeout:
-            timer.reset(self.timeout)
-        return session_data
-
-    def __getitem__(self, session_id):
-        try:
-            timer, session_data = self._sessions[session_id]
-        except ValueError:
-            raise exceptions.InternalError(
-                "You need to use profile_get instead of __getitem__ when profile is set"
-            )
-        except KeyError:
-            raise failure.Failure(KeyError(MSG_NO_SESSION))
-        if self.resettable_timeout:
-            timer.reset(self.timeout)
-        return session_data
-
-    def __setitem__(self, key, value):
-        raise NotImplementedError("You need do use new_session to create a session")
-
-    def __delitem__(self, session_id):
-        """ delete the session data """
-        self._purge_session(session_id)
-
-    def keys(self):
-        return list(self._sessions.keys())
-
-    def iterkeys(self):
-        return iter(self._sessions.keys())
-
-
-class ProfileSessions(Sessions):
-    """ProfileSessions extends the Sessions class, but here the profile can be
-    used as the key to retrieve data or delete a session (instead of session id).
-    """
-
-    def _profile_get_all_ids(self, profile):
-        """Return a list of the sessions ids that are associated to the given profile.
-
-        @param profile: %(doc_profile)s
-        @return: a list containing the sessions ids
-        """
-        ret = []
-        for session_id in self._sessions.keys():
-            try:
-                timer, session_data, profile_set = self._sessions[session_id]
-            except ValueError:
-                continue
-            if profile == profile_set:
-                ret.append(session_id)
-        return ret
-
-    def profile_get_unique(self, profile):
-        """Return the data of the unique session that is associated to the given profile.
-
-        @param profile: %(doc_profile)s
-        @return:
-            - mutable data (default: dict) of the unique session
-            - None if no session is associated to the profile
-            - raise an error if more than one session are found
-        """
-        ids = self._profile_get_all_ids(profile)
-        if len(ids) > 1:
-            raise exceptions.InternalError(
-                "profile_get_unique has been used but more than one session has been found!"
-            )
-        return (
-            self.profile_get(ids[0], profile) if len(ids) == 1 else None
-        )  # XXX: timeout might be reset
-
-    def profile_del_unique(self, profile):
-        """Delete the unique session that is associated to the given profile.
-
-        @param profile: %(doc_profile)s
-        @return: None, but raise an error if more than one session are found
-        """
-        ids = self._profile_get_all_ids(profile)
-        if len(ids) > 1:
-            raise exceptions.InternalError(
-                "profile_del_unique has been used but more than one session has been found!"
-            )
-        if len(ids) == 1:
-            del self._sessions[ids[0]]
-
-
-class PasswordSessions(ProfileSessions):
-
-    # FIXME: temporary hack for the user personal key not to be lost. The session
-    # must actually be purged and later, when the personal key is needed, the
-    # profile password should be asked again in order to decrypt it.
-    def __init__(self, timeout=None):
-        ProfileSessions.__init__(self, timeout, resettable_timeout=False)
-
-    def _purge_session(self, session_id):
-        log.debug(
-            "FIXME: PasswordSessions should ask for the profile password after the session expired"
-        )
-
-
-class Memory:
-    """This class manage all the persistent information"""
-
-    def __init__(self, host):
-        log.info(_("Memory manager init"))
-        self.host = host
-        self._entities_cache = {}  # XXX: keep presence/last resource/other data in cache
-        #     /!\ an entity is not necessarily in roster
-        #     main key is bare jid, value is a dict
-        #     where main key is resource, or None for bare jid
-        self._key_signals = set()  # key which need a signal to frontends when updated
-        self.subscriptions = {}
-        self.auth_sessions = PasswordSessions()  # remember the authenticated profiles
-        self.disco = Discovery(host)
-        self.config = tools_config.parse_main_conf(log_filenames=True)
-        self._cache_path = Path(self.config_get("", "local_dir"), C.CACHE_DIR)
-        self.admins = self.config_get("", "admins_list", [])
-        self.admin_jids = set()
-
-
-    async def initialise(self):
-        self.storage = Storage()
-        await self.storage.initialise()
-        PersistentDict.storage = self.storage
-        self.params = Params(self.host, self.storage)
-        log.info(_("Loading default params template"))
-        self.params.load_default_params()
-        await self.load()
-        self.memory_data = PersistentDict("memory")
-        await self.memory_data.load()
-        await self.disco.load()
-        for admin in self.admins:
-            try:
-                admin_jid_s = await self.param_get_a_async(
-                    "JabberID", "Connection", profile_key=admin
-                )
-            except Exception as e:
-                log.warning(f"Can't retrieve jid of admin {admin!r}: {e}")
-            else:
-                if admin_jid_s is not None:
-                    try:
-                        admin_jid = jid.JID(admin_jid_s).userhostJID()
-                    except RuntimeError:
-                        log.warning(f"Invalid JID for admin {admin}: {admin_jid_s}")
-                    else:
-                        self.admin_jids.add(admin_jid)
-
-
-    ## Configuration ##
-
-    def config_get(self, section, name, default=None):
-        """Get the main configuration option
-
-        @param section: section of the config file (None or '' for DEFAULT)
-        @param name: name of the option
-        @param default: value to use if not found
-        @return: str, list or dict
-        """
-        return tools_config.config_get(self.config, section, name, default)
-
-    def load_xml(self, filename):
-        """Load parameters template from xml file
-
-        @param filename (str): input file
-        @return: bool: True in case of success
-        """
-        if not filename:
-            return False
-        filename = os.path.expanduser(filename)
-        if os.path.exists(filename):
-            try:
-                self.params.load_xml(filename)
-                log.debug(_("Parameters loaded from file: %s") % filename)
-                return True
-            except Exception as e:
-                log.error(_("Can't load parameters from file: %s") % e)
-        return False
-
-    def save_xml(self, filename):
-        """Save parameters template to xml file
-
-        @param filename (str): output file
-        @return: bool: True in case of success
-        """
-        if not filename:
-            return False
-        # TODO: need to encrypt files (at least passwords !) and set permissions
-        filename = os.path.expanduser(filename)
-        try:
-            self.params.save_xml(filename)
-            log.debug(_("Parameters saved to file: %s") % filename)
-            return True
-        except Exception as e:
-            log.error(_("Can't save parameters to file: %s") % e)
-        return False
-
-    def load(self):
-        """Load parameters and all memory things from db"""
-        # parameters data
-        return self.params.load_gen_params()
-
-    def load_individual_params(self, profile):
-        """Load individual parameters for a profile
-        @param profile: %(doc_profile)s"""
-        return self.params.load_ind_params(profile)
-
-    ## Profiles/Sessions management ##
-
-    def start_session(self, password, profile):
-        """"Iniatialise session for a profile
-
-        @param password(unicode): profile session password
-            or empty string is no password is set
-        @param profile: %(doc_profile)s
-        @raise exceptions.ProfileUnknownError if profile doesn't exists
-        @raise exceptions.PasswordError: the password does not match
-        """
-        profile = self.get_profile_name(profile)
-
-        def create_session(__):
-            """Called once params are loaded."""
-            self._entities_cache[profile] = {}
-            log.info("[{}] Profile session started".format(profile))
-            return False
-
-        def backend_initialised(__):
-            def do_start_session(__=None):
-                if self.is_session_started(profile):
-                    log.info("Session already started!")
-                    return True
-                try:
-                    # if there is a value at this point in self._entities_cache,
-                    # it is the load_individual_params Deferred, the session is starting
-                    session_d = self._entities_cache[profile]
-                except KeyError:
-                    # else we do request the params
-                    session_d = self._entities_cache[profile] = self.load_individual_params(
-                        profile
-                    )
-                    session_d.addCallback(create_session)
-                finally:
-                    return session_d
-
-            auth_d = defer.ensureDeferred(self.profile_authenticate(password, profile))
-            auth_d.addCallback(do_start_session)
-            return auth_d
-
-        if self.host.initialised.called:
-            return defer.succeed(None).addCallback(backend_initialised)
-        else:
-            return self.host.initialised.addCallback(backend_initialised)
-
-    def stop_session(self, profile):
-        """Delete a profile session
-
-        @param profile: %(doc_profile)s
-        """
-        if self.host.is_connected(profile):
-            log.debug("Disconnecting profile because of session stop")
-            self.host.disconnect(profile)
-        self.auth_sessions.profile_del_unique(profile)
-        try:
-            self._entities_cache[profile]
-        except KeyError:
-            log.warning("Profile was not in cache")
-
-    def _is_session_started(self, profile_key):
-        return self.is_session_started(self.get_profile_name(profile_key))
-
-    def is_session_started(self, profile):
-        try:
-            # XXX: if the value in self._entities_cache is a Deferred,
-            #      the session is starting but not started yet
-            return not isinstance(self._entities_cache[profile], defer.Deferred)
-        except KeyError:
-            return False
-
-    async def profile_authenticate(self, password, profile):
-        """Authenticate the profile.
-
-        @param password (unicode): the SàT profile password
-        @return: None in case of success (an exception is raised otherwise)
-        @raise exceptions.PasswordError: the password does not match
-        """
-        if not password and self.auth_sessions.profile_get_unique(profile):
-            # XXX: this allows any frontend to connect with the empty password as soon as
-            # the profile has been authenticated at least once before. It is OK as long as
-            # submitting a form with empty passwords is restricted to local frontends.
-            return
-
-        sat_cipher = await self.param_get_a_async(
-            C.PROFILE_PASS_PATH[1], C.PROFILE_PASS_PATH[0], profile_key=profile
-        )
-        valid = PasswordHasher.verify(password, sat_cipher)
-        if not valid:
-            log.warning(_("Authentication failure of profile {profile}").format(
-                profile=profile))
-            raise exceptions.PasswordError("The provided profile password doesn't match.")
-        return await self.new_auth_session(password, profile)
-
-    async def new_auth_session(self, key, profile):
-        """Start a new session for the authenticated profile.
-
-        If there is already an existing session, no new one is created
-        The personal key is loaded encrypted from a PersistentDict before being decrypted.
-
-        @param key: the key to decrypt the personal key
-        @param profile: %(doc_profile)s
-        """
-        data = await PersistentDict(C.MEMORY_CRYPTO_NAMESPACE, profile).load()
-        personal_key = BlockCipher.decrypt(key, data[C.MEMORY_CRYPTO_KEY])
-        # Create the session for this profile and store the personal key
-        session_data = self.auth_sessions.profile_get_unique(profile)
-        if not session_data:
-            self.auth_sessions.new_session(
-                {C.MEMORY_CRYPTO_KEY: personal_key}, profile=profile
-            )
-            log.debug("auth session created for profile %s" % profile)
-
-    def purge_profile_session(self, profile):
-        """Delete cache of data of profile
-        @param profile: %(doc_profile)s"""
-        log.info(_("[%s] Profile session purge" % profile))
-        self.params.purge_profile(profile)
-        try:
-            del self._entities_cache[profile]
-        except KeyError:
-            log.error(
-                _(
-                    "Trying to purge roster status cache for a profile not in memory: [%s]"
-                )
-                % profile
-            )
-
-    def get_profiles_list(self, clients=True, components=False):
-        """retrieve profiles list
-
-        @param clients(bool): if True return clients profiles
-        @param components(bool): if True return components profiles
-        @return (list[unicode]): selected profiles
-        """
-        if not clients and not components:
-            log.warning(_("requesting no profiles at all"))
-            return []
-        profiles = self.storage.get_profiles_list()
-        if clients and components:
-            return sorted(profiles)
-        is_component = self.storage.profile_is_component
-        if clients:
-            p_filter = lambda p: not is_component(p)
-        else:
-            p_filter = lambda p: is_component(p)
-
-        return sorted(p for p in profiles if p_filter(p))
-
-    def get_profile_name(self, profile_key, return_profile_keys=False):
-        """Return name of profile from keyword
-
-        @param profile_key: can be the profile name or a keyword (like @DEFAULT@)
-        @param return_profile_keys: if True, return unmanaged profile keys (like "@ALL@"). This keys must be managed by the caller
-        @return: requested profile name
-        @raise exceptions.ProfileUnknownError if profile doesn't exists
-        """
-        return self.params.get_profile_name(profile_key, return_profile_keys)
-
-    def profile_set_default(self, profile):
-        """Set default profile
-
-        @param profile: %(doc_profile)s
-        """
-        # we want to be sure that the profile exists
-        profile = self.get_profile_name(profile)
-
-        self.memory_data["Profile_default"] = profile
-
-    def create_profile(self, name, password, component=None):
-        """Create a new profile
-
-        @param name(unicode): profile name
-        @param password(unicode): profile password
-            Can be empty to disable password
-        @param component(None, unicode): set to entry point if this is a component
-        @return: Deferred
-        @raise exceptions.NotFound: component is not a known plugin import name
-        """
-        if not name:
-            raise ValueError("Empty profile name")
-        if name[0] == "@":
-            raise ValueError("A profile name can't start with a '@'")
-        if "\n" in name:
-            raise ValueError("A profile name can't contain line feed ('\\n')")
-
-        if name in self._entities_cache:
-            raise exceptions.ConflictError("A session for this profile exists")
-
-        if component:
-            if not component in self.host.plugins:
-                raise exceptions.NotFound(
-                    _(
-                        "Can't find component {component} entry point".format(
-                            component=component
-                        )
-                    )
-                )
-            # FIXME: PLUGIN_INFO is not currently accessible after import, but type shoul be tested here
-            #  if self.host.plugins[component].PLUGIN_INFO[u"type"] != C.PLUG_TYPE_ENTRY_POINT:
-            #      raise ValueError(_(u"Plugin {component} is not an entry point !".format(
-            #          component = component)))
-
-        d = self.params.create_profile(name, component)
-
-        def init_personal_key(__):
-            # be sure to call this after checking that the profile doesn't exist yet
-
-            # generated once for all and saved in a PersistentDict
-            personal_key = BlockCipher.get_random_key(
-                base64=True
-            ).decode('utf-8')
-            self.auth_sessions.new_session(
-                {C.MEMORY_CRYPTO_KEY: personal_key}, profile=name
-            )  # will be encrypted by param_set
-
-        def start_fake_session(__):
-            # avoid ProfileNotConnected exception in param_set
-            self._entities_cache[name] = None
-            self.params.load_ind_params(name)
-
-        def stop_fake_session(__):
-            del self._entities_cache[name]
-            self.params.purge_profile(name)
-
-        d.addCallback(init_personal_key)
-        d.addCallback(start_fake_session)
-        d.addCallback(
-            lambda __: self.param_set(
-                C.PROFILE_PASS_PATH[1], password, C.PROFILE_PASS_PATH[0], profile_key=name
-            )
-        )
-        d.addCallback(stop_fake_session)
-        d.addCallback(lambda __: self.auth_sessions.profile_del_unique(name))
-        return d
-
-    def profile_delete_async(self, name, force=False):
-        """Delete an existing profile
-
-        @param name: Name of the profile
-        @param force: force the deletion even if the profile is connected.
-        To be used for direct calls only (not through the bridge).
-        @return: a Deferred instance
-        """
-
-        def clean_memory(__):
-            self.auth_sessions.profile_del_unique(name)
-            try:
-                del self._entities_cache[name]
-            except KeyError:
-                pass
-
-        d = self.params.profile_delete_async(name, force)
-        d.addCallback(clean_memory)
-        return d
-
-    def is_component(self, profile_name):
-        """Tell if a profile is a component
-
-        @param profile_name(unicode): name of the profile
-        @return (bool): True if profile is a component
-        @raise exceptions.NotFound: profile doesn't exist
-        """
-        return self.storage.profile_is_component(profile_name)
-
-    def get_entry_point(self, profile_name):
-        """Get a component entry point
-
-        @param profile_name(unicode): name of the profile
-        @return (bool): True if profile is a component
-        @raise exceptions.NotFound: profile doesn't exist
-        """
-        return self.storage.get_entry_point(profile_name)
-
-    ## History ##
-
-    def add_to_history(self, client, data):
-        return self.storage.add_to_history(data, client.profile)
-
-    def _history_get_serialise(self, history_data):
-        return [
-            (uid, timestamp, from_jid, to_jid, message, subject, mess_type,
-             data_format.serialise(extra)) for uid, timestamp, from_jid, to_jid, message,
-            subject, mess_type, extra in history_data
-        ]
-
-    def _history_get(self, from_jid_s, to_jid_s, limit=C.HISTORY_LIMIT_NONE, between=True,
-                    filters=None, profile=C.PROF_KEY_NONE):
-        d = self.history_get(jid.JID(from_jid_s), jid.JID(to_jid_s), limit, between,
-                               filters, profile)
-        d.addCallback(self._history_get_serialise)
-        return d
-
-    def history_get(self, from_jid, to_jid, limit=C.HISTORY_LIMIT_NONE, between=True,
-                   filters=None, profile=C.PROF_KEY_NONE):
-        """Retrieve messages in history
-
-        @param from_jid (JID): source JID (full, or bare for catchall)
-        @param to_jid (JID): dest JID (full, or bare for catchall)
-        @param limit (int): maximum number of messages to get:
-            - 0 for no message (returns the empty list)
-            - C.HISTORY_LIMIT_NONE or None for unlimited
-            - C.HISTORY_LIMIT_DEFAULT to use the HISTORY_LIMIT parameter value
-        @param between (bool): confound source and dest (ignore the direction)
-        @param filters (dict[unicode, unicode]): pattern to filter the history results
-            (see bridge API for details)
-        @param profile (str): %(doc_profile)s
-        @return (D(list)): list of message data as in [message_new]
-        """
-        assert profile != C.PROF_KEY_NONE
-        if limit == C.HISTORY_LIMIT_DEFAULT:
-            limit = int(self.param_get_a(C.HISTORY_LIMIT, "General", profile_key=profile))
-        elif limit == C.HISTORY_LIMIT_NONE:
-            limit = None
-        if limit == 0:
-            return defer.succeed([])
-        return self.storage.history_get(from_jid, to_jid, limit, between, filters, profile)
-
-    ## Statuses ##
-
-    def _get_presence_statuses(self, profile_key):
-        ret = self.presence_statuses_get(profile_key)
-        return {entity.full(): data for entity, data in ret.items()}
-
-    def presence_statuses_get(self, profile_key):
-        """Get all the presence statuses of a profile
-
-        @param profile_key: %(doc_profile_key)s
-        @return: presence data: key=entity JID, value=presence data for this entity
-        """
-        client = self.host.get_client(profile_key)
-        profile_cache = self._get_profile_cache(client)
-        entities_presence = {}
-
-        for entity_jid, entity_data in profile_cache.items():
-            for resource, resource_data in entity_data.items():
-                full_jid = copy.copy(entity_jid)
-                full_jid.resource = resource
-                try:
-                    presence_data = self.get_entity_datum(client, full_jid, "presence")
-                except KeyError:
-                    continue
-                entities_presence.setdefault(entity_jid, {})[
-                    resource or ""
-                ] = presence_data
-
-        return entities_presence
-
-    def set_presence_status(self, entity_jid, show, priority, statuses, profile_key):
-        """Change the presence status of an entity
-
-        @param entity_jid: jid.JID of the entity
-        @param show: show status
-        @param priority: priority
-        @param statuses: dictionary of statuses
-        @param profile_key: %(doc_profile_key)s
-        """
-        client = self.host.get_client(profile_key)
-        presence_data = PresenceTuple(show, priority, statuses)
-        self.update_entity_data(
-            client, entity_jid, "presence", presence_data
-        )
-        if entity_jid.resource and show != C.PRESENCE_UNAVAILABLE:
-            # If a resource is available, bare jid should not have presence information
-            try:
-                self.del_entity_datum(client, entity_jid.userhostJID(), "presence")
-            except (KeyError, exceptions.UnknownEntityError):
-                pass
-
-    ## Resources ##
-
-    def _get_all_resource(self, jid_s, profile_key):
-        client = self.host.get_client(profile_key)
-        jid_ = jid.JID(jid_s)
-        return self.get_all_resources(client, jid_)
-
-    def get_all_resources(self, client, entity_jid):
-        """Return all resource from jid for which we have had data in this session
-
-        @param entity_jid: bare jid of the entity
-        return (set[unicode]): set of resources
-
-        @raise exceptions.UnknownEntityError: if entity is not in cache
-        @raise ValueError: entity_jid has a resource
-        """
-        # FIXME: is there a need to keep cache data for resources which are not connected anymore?
-        if entity_jid.resource:
-            raise ValueError(
-                "get_all_resources must be used with a bare jid (got {})".format(entity_jid)
-            )
-        profile_cache = self._get_profile_cache(client)
-        try:
-            entity_data = profile_cache[entity_jid.userhostJID()]
-        except KeyError:
-            raise exceptions.UnknownEntityError(
-                "Entity {} not in cache".format(entity_jid)
-            )
-        resources = set(entity_data.keys())
-        resources.discard(None)
-        return resources
-
-    def get_available_resources(self, client, entity_jid):
-        """Return available resource for entity_jid
-
-        This method differs from get_all_resources by returning only available resources
-        @param entity_jid: bare jid of the entit
-        return (list[unicode]): list of available resources
-
-        @raise exceptions.UnknownEntityError: if entity is not in cache
-        """
-        available = []
-        for resource in self.get_all_resources(client, entity_jid):
-            full_jid = copy.copy(entity_jid)
-            full_jid.resource = resource
-            try:
-                presence_data = self.get_entity_datum(client, full_jid, "presence")
-            except KeyError:
-                log.debug("Can't get presence data for {}".format(full_jid))
-            else:
-                if presence_data.show != C.PRESENCE_UNAVAILABLE:
-                    available.append(resource)
-        return available
-
-    def _get_main_resource(self, jid_s, profile_key):
-        client = self.host.get_client(profile_key)
-        jid_ = jid.JID(jid_s)
-        return self.main_resource_get(client, jid_) or ""
-
-    def main_resource_get(self, client, entity_jid):
-        """Return the main resource used by an entity
-
-        @param entity_jid: bare entity jid
-        @return (unicode): main resource or None
-        """
-        if entity_jid.resource:
-            raise ValueError(
-                "main_resource_get must be used with a bare jid (got {})".format(entity_jid)
-            )
-        try:
-            if self.host.plugins["XEP-0045"].is_joined_room(client, entity_jid):
-                return None  # MUC rooms have no main resource
-        except KeyError:  # plugin not found
-            pass
-        try:
-            resources = self.get_all_resources(client, entity_jid)
-        except exceptions.UnknownEntityError:
-            log.warning("Entity is not in cache, we can't find any resource")
-            return None
-        priority_resources = []
-        for resource in resources:
-            full_jid = copy.copy(entity_jid)
-            full_jid.resource = resource
-            try:
-                presence_data = self.get_entity_datum(client, full_jid, "presence")
-            except KeyError:
-                log.debug("No presence information for {}".format(full_jid))
-                continue
-            priority_resources.append((resource, presence_data.priority))
-        try:
-            return max(priority_resources, key=lambda res_tuple: res_tuple[1])[0]
-        except ValueError:
-            log.warning("No resource found at all for {}".format(entity_jid))
-            return None
-
-    ## Entities data ##
-
-    def _get_profile_cache(self, client):
-        """Check profile validity and return its cache
-
-        @param client: SatXMPPClient
-        @return (dict): profile cache
-        """
-        return self._entities_cache[client.profile]
-
-    def set_signal_on_update(self, key, signal=True):
-        """Set a signal flag on the key
-
-        When the key will be updated, a signal will be sent to frontends
-        @param key: key to signal
-        @param signal(boolean): if True, do the signal
-        """
-        if signal:
-            self._key_signals.add(key)
-        else:
-            self._key_signals.discard(key)
-
-    def get_all_entities_iter(self, client, with_bare=False):
-        """Return an iterator of full jids of all entities in cache
-
-        @param with_bare: if True, include bare jids
-        @return (list[unicode]): list of jids
-        """
-        profile_cache = self._get_profile_cache(client)
-        # we construct a list of all known full jids (bare jid of entities x resources)
-        for bare_jid, entity_data in profile_cache.items():
-            for resource in entity_data.keys():
-                if resource is None:
-                    continue
-                full_jid = copy.copy(bare_jid)
-                full_jid.resource = resource
-                yield full_jid
-
-    def update_entity_data(
-        self, client, entity_jid, key, value, silent=False
-    ):
-        """Set a misc data for an entity
-
-        If key was registered with set_signal_on_update, a signal will be sent to frontends
-        @param entity_jid: JID of the entity, C.ENTITY_ALL_RESOURCES for all resources of
-            all entities, C.ENTITY_ALL for all entities (all resources + bare jids)
-        @param key: key to set (eg: C.ENTITY_TYPE)
-        @param value: value for this key (eg: C.ENTITY_TYPE_MUC)
-        @param silent(bool): if True, doesn't send signal to frontend, even if there is a
-            signal flag (see set_signal_on_update)
-        """
-        profile_cache = self._get_profile_cache(client)
-        if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL):
-            entities = self.get_all_entities_iter(client, entity_jid == C.ENTITY_ALL)
-        else:
-            entities = (entity_jid,)
-
-        for jid_ in entities:
-            entity_data = profile_cache.setdefault(jid_.userhostJID(), {}).setdefault(
-                jid_.resource, {}
-            )
-
-            entity_data[key] = value
-            if key in self._key_signals and not silent:
-                self.host.bridge.entity_data_updated(
-                    jid_.full(),
-                    key,
-                    data_format.serialise(value),
-                    client.profile
-                )
-
-    def del_entity_datum(self, client, entity_jid, key):
-        """Delete a data for an entity
-
-        @param entity_jid: JID of the entity, C.ENTITY_ALL_RESOURCES for all resources of all entities,
-                           C.ENTITY_ALL for all entities (all resources + bare jids)
-        @param key: key to delete (eg: C.ENTITY_TYPE)
-
-        @raise exceptions.UnknownEntityError: if entity is not in cache
-        @raise KeyError: key is not in cache
-        """
-        profile_cache = self._get_profile_cache(client)
-        if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL):
-            entities = self.get_all_entities_iter(client, entity_jid == C.ENTITY_ALL)
-        else:
-            entities = (entity_jid,)
-
-        for jid_ in entities:
-            try:
-                entity_data = profile_cache[jid_.userhostJID()][jid_.resource]
-            except KeyError:
-                raise exceptions.UnknownEntityError(
-                    "Entity {} not in cache".format(jid_)
-                )
-            try:
-                del entity_data[key]
-            except KeyError as e:
-                if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL):
-                    continue  # we ignore KeyError when deleting keys from several entities
-                else:
-                    raise e
-
-    def _get_entities_data(self, entities_jids, keys_list, profile_key):
-        client = self.host.get_client(profile_key)
-        ret = self.entities_data_get(
-            client, [jid.JID(jid_) for jid_ in entities_jids], keys_list
-        )
-        return {
-            jid_.full(): {k: data_format.serialise(v) for k,v in data.items()}
-            for jid_, data in ret.items()
-        }
-
-    def entities_data_get(self, client, entities_jids, keys_list=None):
-        """Get a list of cached values for several entities at once
-
-        @param entities_jids: jids of the entities, or empty list for all entities in cache
-        @param keys_list (iterable,None): list of keys to get, None for everything
-        @param profile_key: %(doc_profile_key)s
-        @return: dict withs values for each key in keys_list.
-                 if there is no value of a given key, resulting dict will
-                 have nothing with that key nether
-                 if an entity doesn't exist in cache, it will not appear
-                 in resulting dict
-
-        @raise exceptions.UnknownEntityError: if entity is not in cache
-        """
-
-        def fill_entity_data(entity_cache_data):
-            entity_data = {}
-            if keys_list is None:
-                entity_data = entity_cache_data
-            else:
-                for key in keys_list:
-                    try:
-                        entity_data[key] = entity_cache_data[key]
-                    except KeyError:
-                        continue
-            return entity_data
-
-        profile_cache = self._get_profile_cache(client)
-        ret_data = {}
-        if entities_jids:
-            for entity in entities_jids:
-                try:
-                    entity_cache_data = profile_cache[entity.userhostJID()][
-                        entity.resource
-                    ]
-                except KeyError:
-                    continue
-                ret_data[entity.full()] = fill_entity_data(entity_cache_data, keys_list)
-        else:
-            for bare_jid, data in profile_cache.items():
-                for resource, entity_cache_data in data.items():
-                    full_jid = copy.copy(bare_jid)
-                    full_jid.resource = resource
-                    ret_data[full_jid] = fill_entity_data(entity_cache_data)
-
-        return ret_data
-
-    def _get_entity_data(self, entity_jid_s, keys_list=None, profile=C.PROF_KEY_NONE):
-        return self.entity_data_get(
-            self.host.get_client(profile), jid.JID(entity_jid_s), keys_list)
-
-    def entity_data_get(self, client, entity_jid, keys_list=None):
-        """Get a list of cached values for entity
-
-        @param entity_jid: JID of the entity
-        @param keys_list (iterable,None): list of keys to get, None for everything
-        @param profile_key: %(doc_profile_key)s
-        @return: dict withs values for each key in keys_list.
-                 if there is no value of a given key, resulting dict will
-                 have nothing with that key nether
-
-        @raise exceptions.UnknownEntityError: if entity is not in cache
-        """
-        profile_cache = self._get_profile_cache(client)
-        try:
-            entity_data = profile_cache[entity_jid.userhostJID()][entity_jid.resource]
-        except KeyError:
-            raise exceptions.UnknownEntityError(
-                "Entity {} not in cache (was requesting {})".format(
-                    entity_jid, keys_list
-                )
-            )
-        if keys_list is None:
-            return entity_data
-
-        return {key: entity_data[key] for key in keys_list if key in entity_data}
-
-    def get_entity_datum(self, client, entity_jid, key):
-        """Get a datum from entity
-
-        @param entity_jid: JID of the entity
-        @param key: key to get
-        @return: requested value
-
-        @raise exceptions.UnknownEntityError: if entity is not in cache
-        @raise KeyError: if there is no value for this key and this entity
-        """
-        return self.entity_data_get(client, entity_jid, (key,))[key]
-
-    def del_entity_cache(
-        self, entity_jid, delete_all_resources=True, profile_key=C.PROF_KEY_NONE
-    ):
-        """Remove all cached data for entity
-
-        @param entity_jid: JID of the entity to delete
-        @param delete_all_resources: if True also delete all known resources from cache (a bare jid must be given in this case)
-        @param profile_key: %(doc_profile_key)s
-
-        @raise exceptions.UnknownEntityError: if entity is not in cache
-        """
-        client = self.host.get_client(profile_key)
-        profile_cache = self._get_profile_cache(client)
-
-        if delete_all_resources:
-            if entity_jid.resource:
-                raise ValueError(_("Need a bare jid to delete all resources"))
-            try:
-                del profile_cache[entity_jid]
-            except KeyError:
-                raise exceptions.UnknownEntityError(
-                    "Entity {} not in cache".format(entity_jid)
-                )
-        else:
-            try:
-                del profile_cache[entity_jid.userhostJID()][entity_jid.resource]
-            except KeyError:
-                raise exceptions.UnknownEntityError(
-                    "Entity {} not in cache".format(entity_jid)
-                )
-
-    ## Encryption ##
-
-    def encrypt_value(self, value, profile):
-        """Encrypt a value for the given profile. The personal key must be loaded
-        already in the profile session, that should be the case if the profile is
-        already authenticated.
-
-        @param value (str): the value to encrypt
-        @param profile (str): %(doc_profile)s
-        @return: the deferred encrypted value
-        """
-        try:
-            personal_key = self.auth_sessions.profile_get_unique(profile)[
-                C.MEMORY_CRYPTO_KEY
-            ]
-        except TypeError:
-            raise exceptions.InternalError(
-                _("Trying to encrypt a value for %s while the personal key is undefined!")
-                % profile
-            )
-        return BlockCipher.encrypt(personal_key, value)
-
-    def decrypt_value(self, value, profile):
-        """Decrypt a value for the given profile. The personal key must be loaded
-        already in the profile session, that should be the case if the profile is
-        already authenticated.
-
-        @param value (str): the value to decrypt
-        @param profile (str): %(doc_profile)s
-        @return: the deferred decrypted value
-        """
-        try:
-            personal_key = self.auth_sessions.profile_get_unique(profile)[
-                C.MEMORY_CRYPTO_KEY
-            ]
-        except TypeError:
-            raise exceptions.InternalError(
-                _("Trying to decrypt a value for %s while the personal key is undefined!")
-                % profile
-            )
-        return BlockCipher.decrypt(personal_key, value)
-
-    def encrypt_personal_data(self, data_key, data_value, crypto_key, profile):
-        """Re-encrypt a personal data (saved to a PersistentDict).
-
-        @param data_key: key for the individual PersistentDict instance
-        @param data_value: the value to be encrypted
-        @param crypto_key: the key to encrypt the value
-        @param profile: %(profile_doc)s
-        @return: a deferred None value
-        """
-
-        def got_ind_memory(data):
-            data[data_key] = BlockCipher.encrypt(crypto_key, data_value)
-            return data.force(data_key)
-
-        def done(__):
-            log.debug(
-                _("Personal data (%(ns)s, %(key)s) has been successfuly encrypted")
-                % {"ns": C.MEMORY_CRYPTO_NAMESPACE, "key": data_key}
-            )
-
-        d = PersistentDict(C.MEMORY_CRYPTO_NAMESPACE, profile).load()
-        return d.addCallback(got_ind_memory).addCallback(done)
-
-    ## Subscription requests ##
-
-    def add_waiting_sub(self, type_, entity_jid, profile_key):
-        """Called when a subcription request is received"""
-        profile = self.get_profile_name(profile_key)
-        assert profile
-        if profile not in self.subscriptions:
-            self.subscriptions[profile] = {}
-        self.subscriptions[profile][entity_jid] = type_
-
-    def del_waiting_sub(self, entity_jid, profile_key):
-        """Called when a subcription request is finished"""
-        profile = self.get_profile_name(profile_key)
-        assert profile
-        if profile in self.subscriptions and entity_jid in self.subscriptions[profile]:
-            del self.subscriptions[profile][entity_jid]
-
-    def sub_waiting_get(self, profile_key):
-        """Called to get a list of currently waiting subscription requests"""
-        profile = self.get_profile_name(profile_key)
-        if not profile:
-            log.error(_("Asking waiting subscriptions for a non-existant profile"))
-            return {}
-        if profile not in self.subscriptions:
-            return {}
-
-        return self.subscriptions[profile]
-
-    ## Parameters ##
-
-    def get_string_param_a(self, name, category, attr="value", profile_key=C.PROF_KEY_NONE):
-        return self.params.get_string_param_a(name, category, attr, profile_key)
-
-    def param_get_a(self, name, category, attr="value", profile_key=C.PROF_KEY_NONE):
-        return self.params.param_get_a(name, category, attr, profile_key=profile_key)
-
-    def param_get_a_async(
-        self,
-        name,
-        category,
-        attr="value",
-        security_limit=C.NO_SECURITY_LIMIT,
-        profile_key=C.PROF_KEY_NONE,
-    ):
-        return self.params.param_get_a_async(
-            name, category, attr, security_limit, profile_key
-        )
-
-    def _get_params_values_from_category(
-        self, category, security_limit, app, extra_s, profile_key
-    ):
-        return self.params._get_params_values_from_category(
-            category, security_limit, app, extra_s, profile_key
-        )
-
-    def async_get_string_param_a(
-        self, name, category, attribute="value", security_limit=C.NO_SECURITY_LIMIT,
-        profile_key=C.PROF_KEY_NONE):
-
-        profile = self.get_profile_name(profile_key)
-        return defer.ensureDeferred(self.params.async_get_string_param_a(
-            name, category, attribute, security_limit, profile
-        ))
-
-    def _get_params_ui(self, security_limit, app, extra_s, profile_key):
-        return self.params._get_params_ui(security_limit, app, extra_s, profile_key)
-
-    def params_categories_get(self):
-        return self.params.params_categories_get()
-
-    def param_set(
-        self,
-        name,
-        value,
-        category,
-        security_limit=C.NO_SECURITY_LIMIT,
-        profile_key=C.PROF_KEY_NONE,
-    ):
-        return self.params.param_set(name, value, category, security_limit, profile_key)
-
-    def update_params(self, xml):
-        return self.params.update_params(xml)
-
-    def params_register_app(self, xml, security_limit=C.NO_SECURITY_LIMIT, app=""):
-        return self.params.params_register_app(xml, security_limit, app)
-
-    def set_default(self, name, category, callback, errback=None):
-        return self.params.set_default(name, category, callback, errback)
-
-    ## Private Data ##
-
-    def _private_data_set(self, namespace, key, data_s, profile_key):
-        client = self.host.get_client(profile_key)
-        # we accept any type
-        data = data_format.deserialise(data_s, type_check=None)
-        return defer.ensureDeferred(self.storage.set_private_value(
-            namespace, key, data, binary=True, profile=client.profile))
-
-    def _private_data_get(self, namespace, key, profile_key):
-        client = self.host.get_client(profile_key)
-        d = defer.ensureDeferred(
-            self.storage.get_privates(
-                namespace, [key], binary=True, profile=client.profile)
-        )
-        d.addCallback(lambda data_dict: data_format.serialise(data_dict.get(key)))
-        return d
-
-    def _private_data_delete(self, namespace, key, profile_key):
-        client = self.host.get_client(profile_key)
-        return defer.ensureDeferred(self.storage.del_private_value(
-            namespace, key, binary=True, profile=client.profile))
-
-    ## Files ##
-
-    def check_file_permission(
-            self,
-            file_data: dict,
-            peer_jid: Optional[jid.JID],
-            perms_to_check: Optional[Tuple[str]],
-            set_affiliation: bool = False
-    ) -> None:
-        """Check that an entity has the right permission on a file
-
-        @param file_data: data of one file, as returned by get_files
-        @param peer_jid: entity trying to access the file
-        @param perms_to_check: permissions to check
-            tuple of C.ACCESS_PERM_*
-        @param check_parents: if True, also check all parents until root node
-        @parma set_affiliation: if True, "affiliation" metadata will be set
-        @raise exceptions.PermissionError: peer_jid doesn't have all permission
-            in perms_to_check for file_data
-        @raise exceptions.InternalError: perms_to_check is invalid
-        """
-        # TODO: knowing if user is owner is not enough, we need to check permission
-        #   to see if user can modify/delete files, and set corresponding affiliation (publisher, member)
-        if peer_jid is None and perms_to_check is None:
-            return
-        peer_jid = peer_jid.userhostJID()
-        if peer_jid == file_data["owner"]:
-            if set_affiliation:
-                file_data['affiliation'] = 'owner'
-            # the owner has all rights, nothing to check
-            return
-        if not C.ACCESS_PERMS.issuperset(perms_to_check):
-            raise exceptions.InternalError(_("invalid permission"))
-
-        for perm in perms_to_check:
-            # we check each perm and raise PermissionError as soon as one condition is not valid
-            # we must never return here, we only return after the loop if nothing was blocking the access
-            try:
-                perm_data = file_data["access"][perm]
-                perm_type = perm_data["type"]
-            except KeyError:
-                # No permission is set.
-                # If we are in a root file/directory, we deny access
-                # otherwise, we use public permission, as the parent directory will
-                # block anyway, this avoid to have to recursively change permissions for
-                # all sub directories/files when modifying a permission
-                if not file_data.get('parent'):
-                    raise exceptions.PermissionError()
-                else:
-                    perm_type = C.ACCESS_TYPE_PUBLIC
-            if perm_type == C.ACCESS_TYPE_PUBLIC:
-                continue
-            elif perm_type == C.ACCESS_TYPE_WHITELIST:
-                try:
-                    jids = perm_data["jids"]
-                except KeyError:
-                    raise exceptions.PermissionError()
-                if peer_jid.full() in jids:
-                    continue
-                else:
-                    raise exceptions.PermissionError()
-            else:
-                raise exceptions.InternalError(
-                    _("unknown access type: {type}").format(type=perm_type)
-                )
-
-    async def check_permission_to_root(self, client, file_data, peer_jid, perms_to_check):
-        """do check_file_permission on file_data and all its parents until root"""
-        current = file_data
-        while True:
-            self.check_file_permission(current, peer_jid, perms_to_check)
-            parent = current["parent"]
-            if not parent:
-                break
-            files_data = await self.get_files(
-                client, peer_jid=None, file_id=parent, perms_to_check=None
-            )
-            try:
-                current = files_data[0]
-            except IndexError:
-                raise exceptions.DataError("Missing parent")
-
-    async def _get_parent_dir(
-        self, client, path, parent, namespace, owner, peer_jid, perms_to_check
-    ):
-        """Retrieve parent node from a path, or last existing directory
-
-        each directory of the path will be retrieved, until the last existing one
-        @return (tuple[unicode, list[unicode])): parent, remaining path elements:
-            - parent is the id of the last retrieved directory (or u'' for root)
-            - remaining path elements are the directories which have not been retrieved
-              (i.e. which don't exist)
-        """
-        # if path is set, we have to retrieve parent directory of the file(s) from it
-        if parent is not None:
-            raise exceptions.ConflictError(
-                _("You can't use path and parent at the same time")
-            )
-        path_elts = [_f for _f in path.split("/") if _f]
-        if {"..", "."}.intersection(path_elts):
-            raise ValueError(_('".." or "." can\'t be used in path'))
-
-        # we retrieve all directories from path until we get the parent container
-        # non existing directories will be created
-        parent = ""
-        for idx, path_elt in enumerate(path_elts):
-            directories = await self.storage.get_files(
-                client,
-                parent=parent,
-                type_=C.FILE_TYPE_DIRECTORY,
-                name=path_elt,
-                namespace=namespace,
-                owner=owner,
-            )
-            if not directories:
-                return (parent, path_elts[idx:])
-                # from this point, directories don't exist anymore, we have to create them
-            elif len(directories) > 1:
-                raise exceptions.InternalError(
-                    _("Several directories found, this should not happen")
-                )
-            else:
-                directory = directories[0]
-                self.check_file_permission(directory, peer_jid, perms_to_check)
-                parent = directory["id"]
-        return (parent, [])
-
-    def get_file_affiliations(self, file_data: dict) -> Dict[jid.JID, str]:
-        """Convert file access to pubsub like affiliations"""
-        affiliations = {}
-        access_data = file_data['access']
-
-        read_data = access_data.get(C.ACCESS_PERM_READ, {})
-        if read_data.get('type') == C.ACCESS_TYPE_WHITELIST:
-            for entity_jid_s in read_data['jids']:
-                entity_jid = jid.JID(entity_jid_s)
-                affiliations[entity_jid] = 'member'
-
-        write_data = access_data.get(C.ACCESS_PERM_WRITE, {})
-        if write_data.get('type') == C.ACCESS_TYPE_WHITELIST:
-            for entity_jid_s in write_data['jids']:
-                entity_jid = jid.JID(entity_jid_s)
-                affiliations[entity_jid] = 'publisher'
-
-        owner = file_data.get('owner')
-        if owner:
-            affiliations[owner] = 'owner'
-
-        return affiliations
-
-    def _set_file_affiliations_update(
-        self,
-        access: dict,
-        file_data: dict,
-        affiliations: Dict[jid.JID, str]
-    ) -> None:
-        read_data = access.setdefault(C.ACCESS_PERM_READ, {})
-        if read_data.get('type') != C.ACCESS_TYPE_WHITELIST:
-            read_data['type'] = C.ACCESS_TYPE_WHITELIST
-            if 'jids' not in read_data:
-                read_data['jids'] = []
-        read_whitelist = read_data['jids']
-        write_data = access.setdefault(C.ACCESS_PERM_WRITE, {})
-        if write_data.get('type') != C.ACCESS_TYPE_WHITELIST:
-            write_data['type'] = C.ACCESS_TYPE_WHITELIST
-            if 'jids' not in write_data:
-                write_data['jids'] = []
-        write_whitelist = write_data['jids']
-        for entity_jid, affiliation in affiliations.items():
-            entity_jid_s = entity_jid.full()
-            if affiliation == "none":
-                try:
-                    read_whitelist.remove(entity_jid_s)
-                except ValueError:
-                    log.warning(
-                        "removing affiliation from an entity without read permission: "
-                        f"{entity_jid}"
-                    )
-                try:
-                    write_whitelist.remove(entity_jid_s)
-                except ValueError:
-                    pass
-            elif affiliation == "publisher":
-                if entity_jid_s not in read_whitelist:
-                    read_whitelist.append(entity_jid_s)
-                if entity_jid_s not in write_whitelist:
-                    write_whitelist.append(entity_jid_s)
-            elif affiliation == "member":
-                if entity_jid_s not in read_whitelist:
-                    read_whitelist.append(entity_jid_s)
-                try:
-                    write_whitelist.remove(entity_jid_s)
-                except ValueError:
-                    pass
-            elif affiliation == "owner":
-                raise NotImplementedError('"owner" affiliation can\'t be set')
-            else:
-                raise ValueError(f"unknown affiliation: {affiliation!r}")
-
-    async def set_file_affiliations(
-        self,
-        client,
-        file_data: dict,
-        affiliations: Dict[jid.JID, str]
-    ) -> None:
-        """Apply pubsub like affiliation to file_data
-
-        Affiliations are converted to access types, then set in a whitelist.
-        Affiliation are mapped as follow:
-            - "owner" can't be set (for now)
-            - "publisher" gives read and write permissions
-            - "member" gives read permission only
-            - "none" removes both read and write permissions
-        """
-        file_id = file_data['id']
-        await self.file_update(
-            file_id,
-            'access',
-            update_cb=partial(
-                self._set_file_affiliations_update,
-                file_data=file_data,
-                affiliations=affiliations
-            ),
-        )
-
-    def _set_file_access_model_update(
-        self,
-        access: dict,
-        file_data: dict,
-        access_model: str
-    ) -> None:
-        read_data = access.setdefault(C.ACCESS_PERM_READ, {})
-        if access_model == "open":
-            requested_type = C.ACCESS_TYPE_PUBLIC
-        elif access_model == "whitelist":
-            requested_type = C.ACCESS_TYPE_WHITELIST
-        else:
-            raise ValueError(f"unknown access model: {access_model}")
-
-        read_data['type'] = requested_type
-        if requested_type == C.ACCESS_TYPE_WHITELIST and 'jids' not in read_data:
-            read_data['jids'] = []
-
-    async def set_file_access_model(
-        self,
-        client,
-        file_data: dict,
-        access_model: str,
-    ) -> None:
-        """Apply pubsub like access_model to file_data
-
-        Only 2 access models are supported so far:
-            - "open": set public access to file/dir
-            - "whitelist": set whitelist to file/dir
-        """
-        file_id = file_data['id']
-        await self.file_update(
-            file_id,
-            'access',
-            update_cb=partial(
-                self._set_file_access_model_update,
-                file_data=file_data,
-                access_model=access_model
-            ),
-        )
-
-    def get_files_owner(
-            self,
-            client,
-            owner: Optional[jid.JID],
-            peer_jid: Optional[jid.JID],
-            file_id: Optional[str] = None,
-            parent: Optional[str] = None
-    ) -> jid.JID:
-        """Get owner to use for a file operation
-
-        if owner is not explicitely set, a suitable one will be used (client.jid for
-        clients, peer_jid for components).
-        @raise exception.InternalError: we are one a component, and neither owner nor
-            peer_jid are set
-        """
-        if owner is not None:
-            return owner.userhostJID()
-        if client is None:
-            # client may be None when looking for file with public_id
-            return None
-        if file_id or parent:
-            # owner has already been filtered on parent file
-            return None
-        if not client.is_component:
-            return client.jid.userhostJID()
-        if peer_jid is None:
-            raise exceptions.InternalError(
-                "Owner must be set for component if peer_jid is None"
-            )
-        return peer_jid.userhostJID()
-
-    async def get_files(
-        self, client, peer_jid, file_id=None, version=None, parent=None, path=None,
-        type_=None, file_hash=None, hash_algo=None, name=None, namespace=None,
-        mime_type=None, public_id=None, owner=None, access=None, projection=None,
-        unique=False, perms_to_check=(C.ACCESS_PERM_READ,)):
-        """Retrieve files with with given filters
-
-        @param peer_jid(jid.JID, None): jid trying to access the file
-            needed to check permission.
-            Use None to ignore permission (perms_to_check must be None too)
-        @param file_id(unicode, None): id of the file
-            None to ignore
-        @param version(unicode, None): version of the file
-            None to ignore
-            empty string to look for current version
-        @param parent(unicode, None): id of the directory containing the files
-            None to ignore
-            empty string to look for root files/directories
-        @param path(Path, unicode, None): path to the directory containing the files
-        @param type_(unicode, None): type of file filter, can be one of C.FILE_TYPE_*
-        @param file_hash(unicode, None): hash of the file to retrieve
-        @param hash_algo(unicode, None): algorithm use for file_hash
-        @param name(unicode, None): name of the file to retrieve
-        @param namespace(unicode, None): namespace of the files to retrieve
-        @param mime_type(unicode, None): filter on this mime type
-        @param public_id(unicode, None): filter on this public id
-        @param owner(jid.JID, None): if not None, only get files from this owner
-        @param access(dict, None): get file with given access (see [set_file])
-        @param projection(list[unicode], None): name of columns to retrieve
-            None to retrieve all
-        @param unique(bool): if True will remove duplicates
-        @param perms_to_check(tuple[unicode],None): permission to check
-            must be a tuple of C.ACCESS_PERM_* or None
-            if None, permission will no be checked (peer_jid must be None too in this
-            case)
-        other params are the same as for [set_file]
-        @return (list[dict]): files corresponding to filters
-        @raise exceptions.NotFound: parent directory not found (when path is specified)
-        @raise exceptions.PermissionError: peer_jid can't use perms_to_check for one of
-                                           the file
-            on the path
-        """
-        if peer_jid is None and perms_to_check or perms_to_check is None and peer_jid:
-            raise exceptions.InternalError(
-                "if you want to disable permission check, both peer_jid and "
-                "perms_to_check must be None"
-            )
-        owner = self.get_files_owner(client, owner, peer_jid, file_id, parent)
-        if path is not None:
-            path = str(path)
-            # permission are checked by _get_parent_dir
-            parent, remaining_path_elts = await self._get_parent_dir(
-                client, path, parent, namespace, owner, peer_jid, perms_to_check
-            )
-            if remaining_path_elts:
-                # if we have remaining path elements,
-                # the parent directory is not found
-                raise failure.Failure(exceptions.NotFound())
-        if parent and peer_jid:
-            # if parent is given directly and permission check is requested,
-            # we need to check all the parents
-            parent_data = await self.storage.get_files(client, file_id=parent)
-            try:
-                parent_data = parent_data[0]
-            except IndexError:
-                raise exceptions.DataError("mising parent")
-            await self.check_permission_to_root(
-                client, parent_data, peer_jid, perms_to_check
-            )
-
-        files = await self.storage.get_files(
-            client,
-            file_id=file_id,
-            version=version,
-            parent=parent,
-            type_=type_,
-            file_hash=file_hash,
-            hash_algo=hash_algo,
-            name=name,
-            namespace=namespace,
-            mime_type=mime_type,
-            public_id=public_id,
-            owner=owner,
-            access=access,
-            projection=projection,
-            unique=unique,
-        )
-
-        if peer_jid:
-            # if permission are checked, we must remove all file that user can't access
-            to_remove = []
-            for file_data in files:
-                try:
-                    self.check_file_permission(
-                        file_data, peer_jid, perms_to_check, set_affiliation=True
-                    )
-                except exceptions.PermissionError:
-                    to_remove.append(file_data)
-            for file_data in to_remove:
-                files.remove(file_data)
-        return files
-
-    async def set_file(
-        self, client, name, file_id=None, version="", parent=None, path=None,
-        type_=C.FILE_TYPE_FILE, file_hash=None, hash_algo=None, size=None,
-        namespace=None, mime_type=None, public_id=None, created=None, modified=None,
-        owner=None, access=None, extra=None, peer_jid=None,
-        perms_to_check=(C.ACCESS_PERM_WRITE,)
-    ):
-        """Set a file metadata
-
-        @param name(unicode): basename of the file
-        @param file_id(unicode): unique id of the file
-        @param version(unicode): version of this file
-            empty string for current version or when there is no versioning
-        @param parent(unicode, None): id of the directory containing the files
-        @param path(unicode, None): virtual path of the file in the namespace
-            if set, parent must be None. All intermediate directories will be created
-            if needed, using current access.
-        @param type_(str, None): type of file filter, can be one of C.FILE_TYPE_*
-        @param file_hash(unicode): unique hash of the payload
-        @param hash_algo(unicode): algorithm used for hashing the file (usually sha-256)
-        @param size(int): size in bytes
-        @param namespace(unicode, None): identifier (human readable is better) to group
-                                         files
-            For instance, namespace could be used to group files in a specific photo album
-        @param mime_type(unicode): MIME type of the file, or None if not known/guessed
-        @param public_id(unicode): id used to share publicly the file via HTTP
-        @param created(int): UNIX time of creation
-        @param modified(int,None): UNIX time of last modification, or None to use
-                                   created date
-        @param owner(jid.JID, None): jid of the owner of the file (mainly useful for
-                                     component)
-            will be used to check permission (only bare jid is used, don't use with MUC).
-            Use None to ignore permission (perms_to_check must be None too)
-        @param access(dict, None): serialisable dictionary with access rules.
-            None (or empty dict) to use private access, i.e. allow only profile's jid to
-            access the file
-            key can be on on C.ACCESS_PERM_*,
-            then a sub dictionary with a type key is used (one of C.ACCESS_TYPE_*).
-            According to type, extra keys can be used:
-                - C.ACCESS_TYPE_PUBLIC: the permission is granted for everybody
-                - C.ACCESS_TYPE_WHITELIST: the permission is granted for jids (as unicode)
-                  in the 'jids' key
-            will be encoded to json in database
-        @param extra(dict, None): serialisable dictionary of any extra data
-            will be encoded to json in database
-        @param perms_to_check(tuple[unicode],None): permission to check
-            must be a tuple of C.ACCESS_PERM_* or None
-            if None, permission will not be checked (peer_jid must be None too in this
-            case)
-        @param profile(unicode): profile owning the file
-        """
-        if "/" in name:
-            raise ValueError('name must not contain a slash ("/")')
-        if file_id is None:
-            file_id = shortuuid.uuid()
-        if (
-            file_hash is not None
-            and hash_algo is None
-            or hash_algo is not None
-            and file_hash is None
-        ):
-            raise ValueError("file_hash and hash_algo must be set at the same time")
-        if mime_type is None:
-            mime_type, __ = mimetypes.guess_type(name)
-        else:
-            mime_type = mime_type.lower()
-        if public_id is not None:
-            assert len(public_id)>0
-        if created is None:
-            created = time.time()
-        if namespace is not None:
-            namespace = namespace.strip() or None
-        if type_ == C.FILE_TYPE_DIRECTORY:
-            if any((version, file_hash, size, mime_type)):
-                raise ValueError(
-                    "version, file_hash, size and mime_type can't be set for a directory"
-                )
-        owner = self.get_files_owner(client, owner, peer_jid, file_id, parent)
-
-        if path is not None:
-            path = str(path)
-            # _get_parent_dir will check permissions if peer_jid is set, so we use owner
-            parent, remaining_path_elts = await self._get_parent_dir(
-                client, path, parent, namespace, owner, owner, perms_to_check
-            )
-            # if remaining directories don't exist, we have to create them
-            for new_dir in remaining_path_elts:
-                new_dir_id = shortuuid.uuid()
-                await self.storage.set_file(
-                    client,
-                    name=new_dir,
-                    file_id=new_dir_id,
-                    version="",
-                    parent=parent,
-                    type_=C.FILE_TYPE_DIRECTORY,
-                    namespace=namespace,
-                    created=time.time(),
-                    owner=owner,
-                    access=access,
-                    extra={},
-                )
-                parent = new_dir_id
-        elif parent is None:
-            parent = ""
-
-        await self.storage.set_file(
-            client,
-            file_id=file_id,
-            version=version,
-            parent=parent,
-            type_=type_,
-            file_hash=file_hash,
-            hash_algo=hash_algo,
-            name=name,
-            size=size,
-            namespace=namespace,
-            mime_type=mime_type,
-            public_id=public_id,
-            created=created,
-            modified=modified,
-            owner=owner,
-            access=access,
-            extra=extra,
-        )
-
-    async def file_get_used_space(
-        self,
-        client,
-        peer_jid: jid.JID,
-        owner: Optional[jid.JID] = None
-    ) -> int:
-        """Get space taken by all files owned by an entity
-
-        @param peer_jid: entity requesting the size
-        @param owner: entity owning the file to check. If None, will be determined by
-            get_files_owner
-        @return: size of total space used by files of this owner
-        """
-        owner = self.get_files_owner(client, owner, peer_jid)
-        if peer_jid.userhostJID() != owner and client.profile not in self.admins:
-            raise exceptions.PermissionError("You are not allowed to check this size")
-        return await self.storage.file_get_used_space(client, owner)
-
-    def file_update(self, file_id, column, update_cb):
-        """Update a file column taking care of race condition
-
-        access is NOT checked in this method, it must be checked beforehand
-        @param file_id(unicode): id of the file to update
-        @param column(unicode): one of "access" or "extra"
-        @param update_cb(callable): method to update the value of the colum
-            the method will take older value as argument, and must update it in place
-            Note that the callable must be thread-safe
-        """
-        return self.storage.file_update(file_id, column, update_cb)
-
-    @defer.inlineCallbacks
-    def _delete_file(
-        self,
-        client,
-        peer_jid: jid.JID,
-        recursive: bool,
-        files_path: Path,
-        file_data: dict
-    ):
-        """Internal method to delete files/directories recursively
-
-        @param peer_jid(jid.JID): entity requesting the deletion (must be owner of files
-            to delete)
-        @param recursive(boolean): True if recursive deletion is needed
-        @param files_path(unicode): path of the directory containing the actual files
-        @param file_data(dict): data of the file to delete
-        """
-        if file_data['owner'] != peer_jid:
-            raise exceptions.PermissionError(
-                "file {file_name} can't be deleted, {peer_jid} is not the owner"
-                .format(file_name=file_data['name'], peer_jid=peer_jid.full()))
-        if file_data['type'] == C.FILE_TYPE_DIRECTORY:
-            sub_files = yield self.get_files(client, peer_jid, parent=file_data['id'])
-            if sub_files and not recursive:
-                raise exceptions.DataError(_("Can't delete directory, it is not empty"))
-            # we first delete the sub-files
-            for sub_file_data in sub_files:
-                if sub_file_data['type'] == C.FILE_TYPE_DIRECTORY:
-                    sub_file_path = files_path / sub_file_data['name']
-                else:
-                    sub_file_path = files_path
-                yield self._delete_file(
-                    client, peer_jid, recursive, sub_file_path, sub_file_data)
-            # then the directory itself
-            yield self.storage.file_delete(file_data['id'])
-        elif file_data['type'] == C.FILE_TYPE_FILE:
-            log.info(_("deleting file {name} with hash {file_hash}").format(
-                name=file_data['name'], file_hash=file_data['file_hash']))
-            yield self.storage.file_delete(file_data['id'])
-            references = yield self.get_files(
-                client, peer_jid, file_hash=file_data['file_hash'])
-            if references:
-                log.debug("there are still references to the file, we keep it")
-            else:
-                file_path = os.path.join(files_path, file_data['file_hash'])
-                log.info(_("no reference left to {file_path}, deleting").format(
-                    file_path=file_path))
-                try:
-                    os.unlink(file_path)
-                except FileNotFoundError:
-                    log.error(f"file at {file_path!r} doesn't exist but it was referenced in files database")
-        else:
-            raise exceptions.InternalError('Unexpected file type: {file_type}'
-                .format(file_type=file_data['type']))
-
-    async def file_delete(self, client, peer_jid, file_id, recursive=False):
-        """Delete a single file or a directory and all its sub-files
-
-        @param file_id(unicode): id of the file to delete
-        @param peer_jid(jid.JID): entity requesting the deletion,
-            must be owner of all files to delete
-        @param recursive(boolean): must be True to delete a directory and all sub-files
-        """
-        # FIXME: we only allow owner of file to delete files for now, but WRITE access
-        #        should be checked too
-        files_data = await self.get_files(client, peer_jid, file_id)
-        if not files_data:
-            raise exceptions.NotFound("Can't find the file with id {file_id}".format(
-                file_id=file_id))
-        file_data = files_data[0]
-        if file_data["type"] != C.FILE_TYPE_DIRECTORY and recursive:
-            raise ValueError("recursive can only be set for directories")
-        files_path = self.host.get_local_path(None, C.FILES_DIR)
-        await self._delete_file(client, peer_jid, recursive, files_path, file_data)
-
-    ## Cache ##
-
-    def get_cache_path(self, namespace: str, *args: str) -> Path:
-        """Get path to use to get a common path for a namespace
-
-        This can be used by plugins to manage permanent data. It's the responsability
-        of plugins to clean this directory from unused data.
-        @param namespace: unique namespace to use
-        @param args: extra identifier which will be added to the path
-        """
-        namespace = namespace.strip().lower()
-        return Path(
-            self._cache_path,
-            regex.path_escape(namespace),
-            *(regex.path_escape(a) for a in args)
-        )
-
-    ## Misc ##
-
-    def is_entity_available(self, client, entity_jid):
-        """Tell from the presence information if the given entity is available.
-
-        @param entity_jid (JID): the entity to check (if bare jid is used, all resources are tested)
-        @return (bool): True if entity is available
-        """
-        if not entity_jid.resource:
-            return bool(
-                self.get_available_resources(client, entity_jid)
-            )  # is any resource is available, entity is available
-        try:
-            presence_data = self.get_entity_datum(client, entity_jid, "presence")
-        except KeyError:
-            log.debug("No presence information for {}".format(entity_jid))
-            return False
-        return presence_data.show != C.PRESENCE_UNAVAILABLE
-
-    def is_admin(self, profile: str) -> bool:
-        """Tell if given profile has administrator privileges"""
-        return profile in self.admins
-
-    def is_admin_jid(self, entity: jid.JID) -> bool:
-        """Tells if an entity jid correspond to an admin one
-
-        It is sometime not possible to use the profile alone to check if an entity is an
-        admin (e.g. a request managed by a component). In this case we check if the JID
-        correspond to an admin profile
-        """
-        return entity.userhostJID() in self.admin_jids
--- a/sat/memory/migration/README	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,3 +0,0 @@
-This directory and subdirectories contains Alembic migration scripts.
-
-Please check Libervia documentation for details.
--- a/sat/memory/migration/alembic.ini	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,89 +0,0 @@
-# A generic, single database configuration.
-
-[alembic]
-# path to migration scripts
-script_location = %(here)s
-
-# template used to generate migration files
-# file_template = %%(rev)s_%%(slug)s
-
-# sys.path path, will be prepended to sys.path if present.
-# defaults to the current working directory.
-# prepend_sys_path = .
-
-# timezone to use when rendering the date
-# within the migration file as well as the filename.
-# string value is passed to dateutil.tz.gettz()
-# leave blank for localtime
-# timezone =
-
-# max length of characters to apply to the
-# "slug" field
-# truncate_slug_length = 40
-
-# set to 'true' to run the environment during
-# the 'revision' command, regardless of autogenerate
-# revision_environment = false
-
-# set to 'true' to allow .pyc and .pyo files without
-# a source .py file to be detected as revisions in the
-# versions/ directory
-# sourceless = false
-
-# version location specification; this defaults
-# to migration/versions.  When using multiple version
-# directories, initial revisions must be specified with --version-path
-# version_locations = %(here)s/bar %(here)s/bat migration/versions
-
-# the output encoding used when revision files
-# are written from script.py.mako
-# output_encoding = utf-8
-
-# sqlalchemy.url = driver://user:pass@localhost/dbname
-
-
-[post_write_hooks]
-# post_write_hooks defines scripts or Python functions that are run
-# on newly generated revision scripts.  See the documentation for further
-# detail and examples
-
-# format using "black" - use the console_scripts runner, against the "black" entrypoint
-# hooks = black
-# black.type = console_scripts
-# black.entrypoint = black
-# black.options = -l 79 REVISION_SCRIPT_FILENAME
-
-# Logging configuration
-[loggers]
-keys = root,sqlalchemy,alembic
-
-[handlers]
-keys = console
-
-[formatters]
-keys = generic
-
-[logger_root]
-level = WARN
-handlers = console
-qualname =
-
-[logger_sqlalchemy]
-level = WARN
-handlers =
-qualname = sqlalchemy.engine
-
-[logger_alembic]
-level = INFO
-handlers =
-qualname = alembic
-
-[handler_console]
-class = StreamHandler
-args = (sys.stderr,)
-level = NOTSET
-formatter = generic
-
-[formatter_generic]
-format = %(levelname)-5.5s [%(name)s] %(message)s
-datefmt = %H:%M:%S
--- a/sat/memory/migration/env.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,93 +0,0 @@
-import asyncio
-from logging.config import fileConfig
-from sqlalchemy import pool
-from sqlalchemy.ext.asyncio import create_async_engine
-from alembic import context
-from sat.memory import sqla_config
-from sat.memory.sqla_mapping import Base
-
-# this is the Alembic Config object, which provides
-# access to the values within the .ini file in use.
-config = context.config
-
-# Interpret the config file for Python logging.
-# This line sets up loggers basically.
-fileConfig(config.config_file_name)
-
-# add your model's MetaData object here
-# for 'autogenerate' support
-# from myapp import mymodel
-# target_metadata = mymodel.Base.metadata
-target_metadata = Base.metadata
-
-# other values from the config, defined by the needs of env.py,
-# can be acquired:
-# my_important_option = config.get_main_option("my_important_option")
-# ... etc.
-
-
-def run_migrations_offline():
-    """Run migrations in 'offline' mode.
-
-    This configures the context with just a URL
-    and not an Engine, though an Engine is acceptable
-    here as well.  By skipping the Engine creation
-    we don't even need a DBAPI to be available.
-
-    Calls to context.execute() here emit the given string to the
-    script output.
-
-    """
-    db_config = sqla_config.get_db_config()
-    context.configure(
-        url=db_config["url"],
-        target_metadata=target_metadata,
-        literal_binds=True,
-        dialect_opts={"paramstyle": "named"},
-    )
-
-    with context.begin_transaction():
-        context.run_migrations()
-
-
-def include_name(name, type_, parent_names):
-    if type_ == "table":
-        if name.startswith("pubsub_items_fts"):
-            return False
-    return True
-
-
-def do_run_migrations(connection):
-    context.configure(
-        connection=connection,
-        target_metadata=target_metadata,
-        render_as_batch=True,
-        include_name=include_name
-    )
-
-    with context.begin_transaction():
-        context.run_migrations()
-
-
-async def run_migrations_online():
-    """Run migrations in 'online' mode.
-
-    In this scenario we need to create an Engine
-    and associate a connection with the context.
-
-    """
-    db_config = sqla_config.get_db_config()
-    engine = create_async_engine(
-        db_config["url"],
-        poolclass=pool.NullPool,
-        future=True,
-    )
-
-    async with engine.connect() as connection:
-        await connection.run_sync(do_run_migrations)
-
-
-if context.is_offline_mode():
-    run_migrations_offline()
-else:
-    asyncio.run(run_migrations_online())
--- a/sat/memory/migration/script.py.mako	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,24 +0,0 @@
-"""${message}
-
-Revision ID: ${up_revision}
-Revises: ${down_revision | comma,n}
-Create Date: ${create_date}
-
-"""
-from alembic import op
-import sqlalchemy as sa
-${imports if imports else ""}
-
-# revision identifiers, used by Alembic.
-revision = ${repr(up_revision)}
-down_revision = ${repr(down_revision)}
-branch_labels = ${repr(branch_labels)}
-depends_on = ${repr(depends_on)}
-
-
-def upgrade():
-    ${upgrades if upgrades else "pass"}
-
-
-def downgrade():
-    ${downgrades if downgrades else "pass"}
--- a/sat/memory/migration/versions/129ac51807e4_create_virtual_table_for_full_text_.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,49 +0,0 @@
-"""create virtual table for Full-Text Search
-
-Revision ID: 129ac51807e4
-Revises: 8974efc51d22
-Create Date: 2021-08-13 19:13:54.112538
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '129ac51807e4'
-down_revision = '8974efc51d22'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    queries = [
-        "CREATE VIRTUAL TABLE pubsub_items_fts "
-        "USING fts5(data, content=pubsub_items, content_rowid=id)",
-        "CREATE TRIGGER pubsub_items_fts_sync_ins AFTER INSERT ON pubsub_items BEGIN"
-        "  INSERT INTO pubsub_items_fts(rowid, data) VALUES (new.id, new.data);"
-        "END",
-        "CREATE TRIGGER pubsub_items_fts_sync_del AFTER DELETE ON pubsub_items BEGIN"
-        "  INSERT INTO pubsub_items_fts(pubsub_items_fts, rowid, data) "
-        "VALUES('delete', old.id, old.data);"
-        "END",
-        "CREATE TRIGGER pubsub_items_fts_sync_upd AFTER UPDATE ON pubsub_items BEGIN"
-        "  INSERT INTO pubsub_items_fts(pubsub_items_fts, rowid, data) VALUES"
-        "('delete', old.id, old.data);"
-        "  INSERT INTO pubsub_items_fts(rowid, data) VALUES(new.id, new.data);"
-        "END",
-        "INSERT INTO pubsub_items_fts(rowid, data) SELECT id, data from pubsub_items"
-    ]
-    for q in queries:
-        op.execute(sa.DDL(q))
-
-
-def downgrade():
-    queries = [
-        "DROP TRIGGER IF EXISTS pubsub_items_fts_sync_ins",
-        "DROP TRIGGER IF EXISTS pubsub_items_fts_sync_del",
-        "DROP TRIGGER IF EXISTS pubsub_items_fts_sync_upd",
-        "DROP TABLE IF EXISTS pubsub_items_fts",
-    ]
-    for q in queries:
-        op.execute(sa.DDL(q))
--- a/sat/memory/migration/versions/4b002773cf92_add_origin_id_column_to_history_and_.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,60 +0,0 @@
-"""add origin_id column to history and adapt constraints
-
-Revision ID: 4b002773cf92
-Revises: 79e5f3313fa4
-Create Date: 2022-06-13 16:10:39.711634
-
-"""
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision = '4b002773cf92'
-down_revision = '79e5f3313fa4'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    with op.batch_alter_table('history', schema=None) as batch_op:
-        batch_op.add_column(sa.Column('origin_id', sa.Text(), nullable=True))
-        batch_op.create_unique_constraint('uq_origin_id', ['profile_id', 'origin_id', 'source'])
-
-    with op.batch_alter_table('message', schema=None) as batch_op:
-        batch_op.alter_column('history_uid',
-               existing_type=sa.TEXT(),
-               nullable=False)
-        batch_op.alter_column('message',
-               existing_type=sa.TEXT(),
-               nullable=False)
-
-    with op.batch_alter_table('subject', schema=None) as batch_op:
-        batch_op.alter_column('history_uid',
-               existing_type=sa.TEXT(),
-               nullable=False)
-        batch_op.alter_column('subject',
-               existing_type=sa.TEXT(),
-               nullable=False)
-
-
-def downgrade():
-    with op.batch_alter_table('subject', schema=None) as batch_op:
-        batch_op.alter_column('subject',
-               existing_type=sa.TEXT(),
-               nullable=True)
-        batch_op.alter_column('history_uid',
-               existing_type=sa.TEXT(),
-               nullable=True)
-
-    with op.batch_alter_table('message', schema=None) as batch_op:
-        batch_op.alter_column('message',
-               existing_type=sa.TEXT(),
-               nullable=True)
-        batch_op.alter_column('history_uid',
-               existing_type=sa.TEXT(),
-               nullable=True)
-
-    with op.batch_alter_table('history', schema=None) as batch_op:
-        batch_op.drop_constraint('uq_origin_id', type_='unique')
-        batch_op.drop_column('origin_id')
--- a/sat/memory/migration/versions/602caf848068_drop_message_types_table_fix_nullable.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,410 +0,0 @@
-"""drop message_types table + fix nullable + rename constraints
-
-Revision ID: 602caf848068
-Revises:
-Create Date: 2021-06-26 12:42:54.148313
-
-"""
-from alembic import op
-from sqlalchemy import (
-    Table,
-    Column,
-    MetaData,
-    TEXT,
-    INTEGER,
-    Text,
-    Integer,
-    Float,
-    Enum,
-    ForeignKey,
-    Index,
-    PrimaryKeyConstraint,
-)
-from sqlalchemy.sql import table, column
-
-
-# revision identifiers, used by Alembic.
-revision = "602caf848068"
-down_revision = None
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # we have to recreate former tables for batch_alter_table's reflexion, otherwise the
-    # database will be used, and this will keep unammed UNIQUE constraints in addition
-    # to the named ones that we create
-    metadata = MetaData(
-        naming_convention={
-            "ix": "ix_%(column_0_label)s",
-            "uq": "uq_%(table_name)s_%(column_0_name)s",
-            "ck": "ck_%(table_name)s_%(constraint_name)s",
-            "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
-            "pk": "pk_%(table_name)s",
-        },
-    )
-
-    old_profiles_table = Table(
-        "profiles",
-        metadata,
-        Column("id", Integer, primary_key=True, nullable=True, autoincrement=False),
-        Column("name", Text, unique=True),
-    )
-
-    old_components_table = Table(
-        "components",
-        metadata,
-        Column(
-            "profile_id",
-            ForeignKey("profiles.id", ondelete="CASCADE"),
-            nullable=True,
-            primary_key=True,
-        ),
-        Column("entry_point", Text, nullable=False),
-    )
-
-    old_message_table = Table(
-        "message",
-        metadata,
-        Column("id", Integer, primary_key=True, nullable=True, autoincrement=False),
-        Column("history_uid", ForeignKey("history.uid", ondelete="CASCADE")),
-        Column("message", Text),
-        Column("language", Text),
-        Index("message__history_uid", "history_uid"),
-    )
-
-    old_subject_table = Table(
-        "subject",
-        metadata,
-        Column("id", Integer, primary_key=True, nullable=True, autoincrement=False),
-        Column("history_uid", ForeignKey("history.uid", ondelete="CASCADE")),
-        Column("subject", Text),
-        Column("language", Text),
-        Index("subject__history_uid", "history_uid"),
-    )
-
-    old_thread_table = Table(
-        "thread",
-        metadata,
-        Column("id", Integer, primary_key=True, nullable=True, autoincrement=False),
-        Column("history_uid", ForeignKey("history.uid", ondelete="CASCADE")),
-        Column("thread_id", Text),
-        Column("parent_id", Text),
-        Index("thread__history_uid", "history_uid"),
-    )
-
-    old_history_table = Table(
-        "history",
-        metadata,
-        Column("uid", Text, primary_key=True, nullable=True),
-        Column("stanza_id", Text),
-        Column("update_uid", Text),
-        Column("profile_id", Integer, ForeignKey("profiles.id", ondelete="CASCADE")),
-        Column("source", Text),
-        Column("dest", Text),
-        Column("source_res", Text),
-        Column("dest_res", Text),
-        Column("timestamp", Float, nullable=False),
-        Column("received_timestamp", Float),
-        Column("type", Text, ForeignKey("message_types.type")),
-        Column("extra", Text),
-        Index("history__profile_id_timestamp", "profile_id", "timestamp"),
-        Index(
-            "history__profile_id_received_timestamp", "profile_id", "received_timestamp"
-        ),
-    )
-
-    old_param_gen_table = Table(
-        "param_gen",
-        metadata,
-        Column("category", Text, primary_key=True),
-        Column("name", Text, primary_key=True),
-        Column("value", Text),
-    )
-
-    old_param_ind_table = Table(
-        "param_ind",
-        metadata,
-        Column("category", Text, primary_key=True),
-        Column("name", Text, primary_key=True),
-        Column(
-            "profile_id", ForeignKey("profiles.id", ondelete="CASCADE"), primary_key=True
-        ),
-        Column("value", Text),
-    )
-
-    old_private_gen_table = Table(
-        "private_gen",
-        metadata,
-        Column("namespace", Text, primary_key=True),
-        Column("key", Text, primary_key=True),
-        Column("value", Text),
-    )
-
-    old_private_ind_table = Table(
-        "private_ind",
-        metadata,
-        Column("namespace", Text, primary_key=True),
-        Column("key", Text, primary_key=True),
-        Column(
-            "profile_id", ForeignKey("profiles.id", ondelete="CASCADE"), primary_key=True
-        ),
-        Column("value", Text),
-    )
-
-    old_private_gen_bin_table = Table(
-        "private_gen_bin",
-        metadata,
-        Column("namespace", Text, primary_key=True),
-        Column("key", Text, primary_key=True),
-        Column("value", Text),
-    )
-
-    old_private_ind_bin_table = Table(
-        "private_ind_bin",
-        metadata,
-        Column("namespace", Text, primary_key=True),
-        Column("key", Text, primary_key=True),
-        Column(
-            "profile_id", ForeignKey("profiles.id", ondelete="CASCADE"), primary_key=True
-        ),
-        Column("value", Text),
-    )
-
-    old_files_table = Table(
-        "files",
-        metadata,
-        Column("id", Text, primary_key=True),
-        Column("public_id", Text, unique=True),
-        Column("version", Text, primary_key=True),
-        Column("parent", Text, nullable=False),
-        Column(
-            "type",
-            Enum("file", "directory", name="file_type", create_constraint=True),
-            nullable=False,
-            server_default="file",
-        ),
-        Column("file_hash", Text),
-        Column("hash_algo", Text),
-        Column("name", Text, nullable=False),
-        Column("size", Integer),
-        Column("namespace", Text),
-        Column("media_type", Text),
-        Column("media_subtype", Text),
-        Column("created", Float, nullable=False),
-        Column("modified", Float),
-        Column("owner", Text),
-        Column("access", Text),
-        Column("extra", Text),
-        Column("profile_id", ForeignKey("profiles.id", ondelete="CASCADE")),
-        Index("files__profile_id_owner_parent", "profile_id", "owner", "parent"),
-        Index(
-            "files__profile_id_owner_media_type_media_subtype",
-            "profile_id",
-            "owner",
-            "media_type",
-            "media_subtype",
-        ),
-    )
-
-    op.drop_table("message_types")
-
-    with op.batch_alter_table(
-        "profiles", copy_from=old_profiles_table, schema=None
-    ) as batch_op:
-        batch_op.create_unique_constraint(batch_op.f("uq_profiles_name"), ["name"])
-
-    with op.batch_alter_table(
-        "components",
-        copy_from=old_components_table,
-        naming_convention={
-            "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
-        },
-        schema=None,
-    ) as batch_op:
-        batch_op.create_unique_constraint(batch_op.f("uq_profiles_name"), ["name"])
-
-    with op.batch_alter_table(
-        "history",
-        copy_from=old_history_table,
-        naming_convention={
-            "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
-        },
-        schema=None,
-    ) as batch_op:
-        batch_op.alter_column("uid", existing_type=TEXT(), nullable=False)
-        batch_op.alter_column(
-            "type",
-            type_=Enum(
-                "chat",
-                "error",
-                "groupchat",
-                "headline",
-                "normal",
-                "info",
-                name="message_type",
-                create_constraint=True,
-            ),
-            existing_type=TEXT(),
-            nullable=False,
-        )
-        batch_op.create_unique_constraint(
-            batch_op.f("uq_history_profile_id"),
-            ["profile_id", "stanza_id", "source", "dest"],
-        )
-        batch_op.drop_constraint("fk_history_type_message_types", type_="foreignkey")
-
-    with op.batch_alter_table(
-        "message", copy_from=old_message_table, schema=None
-    ) as batch_op:
-        batch_op.alter_column(
-            "id", existing_type=INTEGER(), nullable=False, autoincrement=False
-        )
-
-    with op.batch_alter_table(
-        "subject", copy_from=old_subject_table, schema=None
-    ) as batch_op:
-        batch_op.alter_column(
-            "id", existing_type=INTEGER(), nullable=False, autoincrement=False
-        )
-
-    with op.batch_alter_table(
-        "thread", copy_from=old_thread_table, schema=None
-    ) as batch_op:
-        batch_op.alter_column(
-            "id", existing_type=INTEGER(), nullable=False, autoincrement=False
-        )
-
-    with op.batch_alter_table(
-        "param_gen", copy_from=old_param_gen_table, schema=None
-    ) as batch_op:
-        batch_op.alter_column("category", existing_type=TEXT(), nullable=False)
-        batch_op.alter_column("name", existing_type=TEXT(), nullable=False)
-
-    with op.batch_alter_table(
-        "param_ind", copy_from=old_param_ind_table, schema=None
-    ) as batch_op:
-        batch_op.alter_column("category", existing_type=TEXT(), nullable=False)
-        batch_op.alter_column("name", existing_type=TEXT(), nullable=False)
-        batch_op.alter_column("profile_id", existing_type=INTEGER(), nullable=False)
-
-    with op.batch_alter_table(
-        "private_gen", copy_from=old_private_gen_table, schema=None
-    ) as batch_op:
-        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=False)
-        batch_op.alter_column("key", existing_type=TEXT(), nullable=False)
-
-    with op.batch_alter_table(
-        "private_ind", copy_from=old_private_ind_table, schema=None
-    ) as batch_op:
-        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=False)
-        batch_op.alter_column("key", existing_type=TEXT(), nullable=False)
-        batch_op.alter_column("profile_id", existing_type=INTEGER(), nullable=False)
-
-    with op.batch_alter_table(
-        "private_gen_bin", copy_from=old_private_gen_bin_table, schema=None
-    ) as batch_op:
-        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=False)
-        batch_op.alter_column("key", existing_type=TEXT(), nullable=False)
-
-    # found some invalid rows in local database, maybe old values made during development,
-    # but in doubt we have to delete them
-    op.execute("DELETE FROM private_ind_bin WHERE namespace IS NULL")
-
-    with op.batch_alter_table(
-        "private_ind_bin", copy_from=old_private_ind_bin_table, schema=None
-    ) as batch_op:
-        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=False)
-        batch_op.alter_column("key", existing_type=TEXT(), nullable=False)
-        batch_op.alter_column("profile_id", existing_type=INTEGER(), nullable=False)
-
-    with op.batch_alter_table(
-        "files", copy_from=old_files_table, schema=None
-    ) as batch_op:
-        batch_op.create_unique_constraint(batch_op.f("uq_files_public_id"), ["public_id"])
-        batch_op.alter_column(
-            "type",
-            type_=Enum("file", "directory", name="file_type", create_constraint=True),
-            existing_type=Text(),
-            nullable=False,
-        )
-
-
-def downgrade():
-    # downgrade doesn't restore the exact same state as before upgrade, as it
-    # would be useless and waste of resource to restore broken things such as
-    # anonymous constraints
-    with op.batch_alter_table("thread", schema=None) as batch_op:
-        batch_op.alter_column(
-            "id", existing_type=INTEGER(), nullable=True, autoincrement=False
-        )
-
-    with op.batch_alter_table("subject", schema=None) as batch_op:
-        batch_op.alter_column(
-            "id", existing_type=INTEGER(), nullable=True, autoincrement=False
-        )
-
-    with op.batch_alter_table("private_ind_bin", schema=None) as batch_op:
-        batch_op.alter_column("profile_id", existing_type=INTEGER(), nullable=True)
-        batch_op.alter_column("key", existing_type=TEXT(), nullable=True)
-        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=True)
-
-    with op.batch_alter_table("private_ind", schema=None) as batch_op:
-        batch_op.alter_column("profile_id", existing_type=INTEGER(), nullable=True)
-        batch_op.alter_column("key", existing_type=TEXT(), nullable=True)
-        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=True)
-
-    with op.batch_alter_table("private_gen_bin", schema=None) as batch_op:
-        batch_op.alter_column("key", existing_type=TEXT(), nullable=True)
-        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=True)
-
-    with op.batch_alter_table("private_gen", schema=None) as batch_op:
-        batch_op.alter_column("key", existing_type=TEXT(), nullable=True)
-        batch_op.alter_column("namespace", existing_type=TEXT(), nullable=True)
-
-    with op.batch_alter_table("param_ind", schema=None) as batch_op:
-        batch_op.alter_column("profile_id", existing_type=INTEGER(), nullable=True)
-        batch_op.alter_column("name", existing_type=TEXT(), nullable=True)
-        batch_op.alter_column("category", existing_type=TEXT(), nullable=True)
-
-    with op.batch_alter_table("param_gen", schema=None) as batch_op:
-        batch_op.alter_column("name", existing_type=TEXT(), nullable=True)
-        batch_op.alter_column("category", existing_type=TEXT(), nullable=True)
-
-    with op.batch_alter_table("message", schema=None) as batch_op:
-        batch_op.alter_column(
-            "id", existing_type=INTEGER(), nullable=True, autoincrement=False
-        )
-
-    op.create_table(
-        "message_types",
-        Column("type", TEXT(), nullable=True),
-        PrimaryKeyConstraint("type"),
-    )
-    message_types_table = table("message_types", column("type", TEXT()))
-    op.bulk_insert(
-        message_types_table,
-        [
-            {"type": "chat"},
-            {"type": "error"},
-            {"type": "groupchat"},
-            {"type": "headline"},
-            {"type": "normal"},
-            {"type": "info"},
-        ],
-    )
-
-    with op.batch_alter_table("history", schema=None) as batch_op:
-        batch_op.alter_column(
-            "type",
-            type_=TEXT(),
-            existing_type=TEXT(),
-            nullable=True,
-        )
-        batch_op.create_foreign_key(
-            batch_op.f("fk_history_type_message_types"),
-            "message_types",
-            ["type"],
-            ["type"],
-        )
-        batch_op.alter_column("uid", existing_type=TEXT(), nullable=True)
--- a/sat/memory/migration/versions/79e5f3313fa4_create_table_for_pubsub_subscriptions.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,33 +0,0 @@
-"""create table for pubsub subscriptions
-
-Revision ID: 79e5f3313fa4
-Revises: 129ac51807e4
-Create Date: 2022-03-14 17:15:00.689871
-
-"""
-from alembic import op
-import sqlalchemy as sa
-from sat.memory.sqla_mapping import JID
-
-
-# revision identifiers, used by Alembic.
-revision = '79e5f3313fa4'
-down_revision = '129ac51807e4'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    op.create_table('pubsub_subs',
-    sa.Column('id', sa.Integer(), nullable=False),
-    sa.Column('node_id', sa.Integer(), nullable=False),
-    sa.Column('subscriber', JID(), nullable=True),
-    sa.Column('state', sa.Enum('SUBSCRIBED', 'PENDING', name='state'), nullable=True),
-    sa.ForeignKeyConstraint(['node_id'], ['pubsub_nodes.id'], name=op.f('fk_pubsub_subs_node_id_pubsub_nodes'), ondelete='CASCADE'),
-    sa.PrimaryKeyConstraint('id', name=op.f('pk_pubsub_subs')),
-    sa.UniqueConstraint('node_id', 'subscriber', name=op.f('uq_pubsub_subs_node_id'))
-    )
-
-
-def downgrade():
-    op.drop_table('pubsub_subs')
--- a/sat/memory/migration/versions/8974efc51d22_create_tables_for_pubsub_caching.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,57 +0,0 @@
-"""create tables for Pubsub caching
-
-Revision ID: 8974efc51d22
-Revises: 602caf848068
-Create Date: 2021-07-27 16:38:54.658212
-
-"""
-from alembic import op
-import sqlalchemy as sa
-from sat.memory.sqla_mapping import JID, Xml
-
-
-# revision identifiers, used by Alembic.
-revision = '8974efc51d22'
-down_revision = '602caf848068'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.create_table('pubsub_nodes',
-    sa.Column('id', sa.Integer(), nullable=False),
-    sa.Column('profile_id', sa.Integer(), nullable=True),
-    sa.Column('service', JID(), nullable=True),
-    sa.Column('name', sa.Text(), nullable=False),
-    sa.Column('subscribed', sa.Boolean(create_constraint=True, name='subscribed_bool'), nullable=False),
-    sa.Column('analyser', sa.Text(), nullable=True),
-    sa.Column('sync_state', sa.Enum('IN_PROGRESS', 'COMPLETED', 'ERROR', 'NO_SYNC', name='sync_state', create_constraint=True), nullable=True),
-    sa.Column('sync_state_updated', sa.Float(), nullable=False),
-    sa.Column('type', sa.Text(), nullable=True),
-    sa.Column('subtype', sa.Text(), nullable=True),
-    sa.Column('extra', sa.JSON(), nullable=True),
-    sa.ForeignKeyConstraint(['profile_id'], ['profiles.id'], name=op.f('fk_pubsub_nodes_profile_id_profiles'), ondelete='CASCADE'),
-    sa.PrimaryKeyConstraint('id', name=op.f('pk_pubsub_nodes')),
-    sa.UniqueConstraint('profile_id', 'service', 'name', name=op.f('uq_pubsub_nodes_profile_id'))
-    )
-    op.create_table('pubsub_items',
-    sa.Column('id', sa.Integer(), nullable=False),
-    sa.Column('node_id', sa.Integer(), nullable=False),
-    sa.Column('name', sa.Text(), nullable=False),
-    sa.Column('data', Xml(), nullable=False),
-    sa.Column('created', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
-    sa.Column('updated', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
-    sa.Column('parsed', sa.JSON(), nullable=True),
-    sa.ForeignKeyConstraint(['node_id'], ['pubsub_nodes.id'], name=op.f('fk_pubsub_items_node_id_pubsub_nodes'), ondelete='CASCADE'),
-    sa.PrimaryKeyConstraint('id', name=op.f('pk_pubsub_items')),
-    sa.UniqueConstraint('node_id', 'name', name=op.f('uq_pubsub_items_node_id'))
-    )
-    # ### end Alembic commands ###
-
-
-def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
-    op.drop_table('pubsub_items')
-    op.drop_table('pubsub_nodes')
-    # ### end Alembic commands ###
--- a/sat/memory/params.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1173 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.memory.crypto import BlockCipher, PasswordHasher
-from xml.dom import minidom, NotFoundErr
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.internet import defer
-from twisted.python.failure import Failure
-from twisted.words.xish import domish
-from twisted.words.protocols.jabber import jid
-from sat.tools.xml_tools import params_xml_2_xmlui, get_text
-from sat.tools.common import data_format
-from xml.sax.saxutils import quoteattr
-
-# TODO: params should be rewritten using Twisted directly instead of minidom
-#       general params should be linked to sat.conf and kept synchronised
-#       this need an overall simplification to make maintenance easier
-
-
-def create_jid_elts(jids):
-    """Generator which return <jid/> elements from jids
-
-    @param jids(iterable[id.jID]): jids to use
-    @return (generator[domish.Element]): <jid/> elements
-    """
-    for jid_ in jids:
-        jid_elt = domish.Element((None, "jid"))
-        jid_elt.addContent(jid_.full())
-        yield jid_elt
-
-
-class Params(object):
-    """This class manage parameters with xml"""
-
-    ### TODO: add desciption in params
-
-    # TODO: when priority is changed, a new presence stanza must be emitted
-    # TODO: int type (Priority should be int instead of string)
-    default_xml = """
-    <params>
-    <general>
-    </general>
-    <individual>
-        <category name="General" label="%(category_general)s">
-            <param name="Password" value="" type="password" />
-            <param name="%(history_param)s" label="%(history_label)s" value="20" constraint="0;100" type="int" security="0" />
-            <param name="%(show_offline_contacts)s" label="%(show_offline_contacts_label)s" value="false" type="bool" security="0" />
-            <param name="%(show_empty_groups)s" label="%(show_empty_groups_label)s" value="true" type="bool" security="0" />
-        </category>
-        <category name="Connection" label="%(category_connection)s">
-            <param name="JabberID" value="name@example.org" type="string" security="10" />
-            <param name="Password" value="" type="password" security="10" />
-            <param name="Priority" value="50" type="int" constraint="-128;127" security="10" />
-            <param name="%(force_server_param)s" value="" type="string" security="50" />
-            <param name="%(force_port_param)s" value="" type="int" constraint="1;65535" security="50" />
-            <param name="autoconnect_backend" label="%(autoconnect_backend_label)s" value="false" type="bool" security="50" />
-            <param name="autoconnect" label="%(autoconnect_label)s" value="true" type="bool" security="50" />
-            <param name="autodisconnect" label="%(autodisconnect_label)s" value="false"  type="bool" security="50" />
-            <param name="check_certificate" label="%(check_certificate_label)s" value="true"  type="bool" security="4" />
-        </category>
-    </individual>
-    </params>
-    """ % {
-        "category_general": D_("General"),
-        "category_connection": D_("Connection"),
-        "history_param": C.HISTORY_LIMIT,
-        "history_label": D_("Chat history limit"),
-        "show_offline_contacts": C.SHOW_OFFLINE_CONTACTS,
-        "show_offline_contacts_label": D_("Show offline contacts"),
-        "show_empty_groups": C.SHOW_EMPTY_GROUPS,
-        "show_empty_groups_label": D_("Show empty groups"),
-        "force_server_param": C.FORCE_SERVER_PARAM,
-        "force_port_param": C.FORCE_PORT_PARAM,
-        "autoconnect_backend_label": D_("Connect on backend startup"),
-        "autoconnect_label": D_("Connect on frontend startup"),
-        "autodisconnect_label": D_("Disconnect on frontend closure"),
-        "check_certificate_label": D_("Check certificate (don't uncheck if unsure)"),
-    }
-
-    def load_default_params(self):
-        self.dom = minidom.parseString(Params.default_xml.encode("utf-8"))
-
-    def _merge_params(self, source_node, dest_node):
-        """Look for every node in source_node and recursively copy them to dest if they don't exists"""
-
-        def get_nodes_map(children):
-            ret = {}
-            for child in children:
-                if child.nodeType == child.ELEMENT_NODE:
-                    ret[(child.tagName, child.getAttribute("name"))] = child
-            return ret
-
-        source_map = get_nodes_map(source_node.childNodes)
-        dest_map = get_nodes_map(dest_node.childNodes)
-        source_set = set(source_map.keys())
-        dest_set = set(dest_map.keys())
-        to_add = source_set.difference(dest_set)
-
-        for node_key in to_add:
-            dest_node.appendChild(source_map[node_key].cloneNode(True))
-
-        to_recurse = source_set - to_add
-        for node_key in to_recurse:
-            self._merge_params(source_map[node_key], dest_map[node_key])
-
-    def load_xml(self, xml_file):
-        """Load parameters template from xml file"""
-        self.dom = minidom.parse(xml_file)
-        default_dom = minidom.parseString(Params.default_xml.encode("utf-8"))
-        self._merge_params(default_dom.documentElement, self.dom.documentElement)
-
-    def load_gen_params(self):
-        """Load general parameters data from storage
-
-        @return: deferred triggered once params are loaded
-        """
-        return self.storage.load_gen_params(self.params_gen)
-
-    def load_ind_params(self, profile, cache=None):
-        """Load individual parameters
-
-        set self.params cache or a temporary cache
-        @param profile: profile to load (*must exist*)
-        @param cache: if not None, will be used to store the value, as a short time cache
-        @return: deferred triggered once params are loaded
-        """
-        if cache is None:
-            self.params[profile] = {}
-        return self.storage.load_ind_params(
-            self.params[profile] if cache is None else cache, profile
-        )
-
-    def purge_profile(self, profile):
-        """Remove cache data of a profile
-
-        @param profile: %(doc_profile)s
-        """
-        try:
-            del self.params[profile]
-        except KeyError:
-            log.error(
-                _("Trying to purge cache of a profile not in memory: [%s]") % profile
-            )
-
-    def save_xml(self, filename):
-        """Save parameters template to xml file"""
-        with open(filename, "wb") as xml_file:
-            xml_file.write(self.dom.toxml("utf-8"))
-
-    def __init__(self, host, storage):
-        log.debug("Parameters init")
-        self.host = host
-        self.storage = storage
-        self.default_profile = None
-        self.params = {}
-        self.params_gen = {}
-
-    def create_profile(self, profile, component):
-        """Create a new profile
-
-        @param profile(unicode): name of the profile
-        @param component(unicode): entry point if profile is a component
-        @param callback: called when the profile actually exists in database and memory
-        @return: a Deferred instance
-        """
-        if self.storage.has_profile(profile):
-            log.info(_("The profile name already exists"))
-            return defer.fail(exceptions.ConflictError())
-        if not self.host.trigger.point("ProfileCreation", profile):
-            return defer.fail(exceptions.CancelError())
-        return self.storage.create_profile(profile, component or None)
-
-    def profile_delete_async(self, profile, force=False):
-        """Delete an existing profile
-
-        @param profile: name of the profile
-        @param force: force the deletion even if the profile is connected.
-        To be used for direct calls only (not through the bridge).
-        @return: a Deferred instance
-        """
-        if not self.storage.has_profile(profile):
-            log.info(_("Trying to delete an unknown profile"))
-            return defer.fail(Failure(exceptions.ProfileUnknownError(profile)))
-        if self.host.is_connected(profile):
-            if force:
-                self.host.disconnect(profile)
-            else:
-                log.info(_("Trying to delete a connected profile"))
-                return defer.fail(Failure(exceptions.ProfileConnected))
-        return self.storage.delete_profile(profile)
-
-    def get_profile_name(self, profile_key, return_profile_keys=False):
-        """return profile according to profile_key
-
-        @param profile_key: profile name or key which can be
-                            C.PROF_KEY_ALL for all profiles
-                            C.PROF_KEY_DEFAULT for default profile
-        @param return_profile_keys: if True, return unmanaged profile keys (like
-            C.PROF_KEY_ALL). This keys must be managed by the caller
-        @return: requested profile name
-        @raise exceptions.ProfileUnknownError: profile doesn't exists
-        @raise exceptions.ProfileNotSetError: if C.PROF_KEY_NONE is used
-        """
-        if profile_key == "@DEFAULT@":
-            default = self.host.memory.memory_data.get("Profile_default")
-            if not default:
-                log.info(_("No default profile, returning first one"))
-                try:
-                    default = self.host.memory.memory_data[
-                        "Profile_default"
-                    ] = self.storage.get_profiles_list()[0]
-                except IndexError:
-                    log.info(_("No profile exist yet"))
-                    raise exceptions.ProfileUnknownError(profile_key)
-            return (
-                default
-            )  # FIXME: temporary, must use real default value, and fallback to first one if it doesn't exists
-        elif profile_key == C.PROF_KEY_NONE:
-            raise exceptions.ProfileNotSetError
-        elif return_profile_keys and profile_key in [C.PROF_KEY_ALL]:
-            return profile_key  # this value must be managed by the caller
-        if not self.storage.has_profile(profile_key):
-            log.error(_("Trying to access an unknown profile (%s)") % profile_key)
-            raise exceptions.ProfileUnknownError(profile_key)
-        return profile_key
-
-    def __get_unique_node(self, parent, tag, name):
-        """return node with given tag
-
-        @param parent: parent of nodes to check (e.g. documentElement)
-        @param tag: tag to check (e.g. "category")
-        @param name: name to check (e.g. "JID")
-        @return: node if it exist or None
-        """
-        for node in parent.childNodes:
-            if node.nodeName == tag and node.getAttribute("name") == name:
-                # the node already exists
-                return node
-        # the node is new
-        return None
-
-    def update_params(self, xml, security_limit=C.NO_SECURITY_LIMIT, app=""):
-        """import xml in parameters, update if the param already exists
-
-        If security_limit is specified and greater than -1, the parameters
-        that have a security level greater than security_limit are skipped.
-        @param xml: parameters in xml form
-        @param security_limit: -1 means no security, 0 is the maximum security then the higher the less secure
-        @param app: name of the frontend registering the parameters or empty value
-        """
-        # TODO: should word with domish.Element
-        src_parent = minidom.parseString(xml.encode("utf-8")).documentElement
-
-        def pre_process_app_node(src_parent, security_limit, app):
-            """Parameters that are registered from a frontend must be checked"""
-            to_remove = []
-            for type_node in src_parent.childNodes:
-                if type_node.nodeName != C.INDIVIDUAL:
-                    to_remove.append(type_node)  # accept individual parameters only
-                    continue
-                for cat_node in type_node.childNodes:
-                    if cat_node.nodeName != "category":
-                        to_remove.append(cat_node)
-                        continue
-                    to_remove_count = (
-                        0
-                    )  # count the params to be removed from current category
-                    for node in cat_node.childNodes:
-                        if node.nodeName != "param" or not self.check_security_limit(
-                            node, security_limit
-                        ):
-                            to_remove.append(node)
-                            to_remove_count += 1
-                            continue
-                        node.setAttribute("app", app)
-                    if (
-                        len(cat_node.childNodes) == to_remove_count
-                    ):  # remove empty category
-                        for __ in range(0, to_remove_count):
-                            to_remove.pop()
-                        to_remove.append(cat_node)
-            for node in to_remove:
-                node.parentNode.removeChild(node)
-
-        def import_node(tgt_parent, src_parent):
-            for child in src_parent.childNodes:
-                if child.nodeName == "#text":
-                    continue
-                node = self.__get_unique_node(
-                    tgt_parent, child.nodeName, child.getAttribute("name")
-                )
-                if not node:  # The node is new
-                    tgt_parent.appendChild(child.cloneNode(True))
-                else:
-                    if child.nodeName == "param":
-                        # The child updates an existing parameter, we replace the node
-                        tgt_parent.replaceChild(child, node)
-                    else:
-                        # the node already exists, we recurse 1 more level
-                        import_node(node, child)
-
-        if app:
-            pre_process_app_node(src_parent, security_limit, app)
-        import_node(self.dom.documentElement, src_parent)
-
-    def params_register_app(self, xml, security_limit, app):
-        """Register frontend's specific parameters
-
-        If security_limit is specified and greater than -1, the parameters
-        that have a security level greater than security_limit are skipped.
-        @param xml: XML definition of the parameters to be added
-        @param security_limit: -1 means no security, 0 is the maximum security then the higher the less secure
-        @param app: name of the frontend registering the parameters
-        """
-        if not app:
-            log.warning(
-                _(
-                    "Trying to register frontends parameters with no specified app: aborted"
-                )
-            )
-            return
-        if not hasattr(self, "frontends_cache"):
-            self.frontends_cache = []
-        if app in self.frontends_cache:
-            log.debug(
-                _(
-                    "Trying to register twice frontends parameters for %(app)s: aborted"
-                    % {"app": app}
-                )
-            )
-            return
-        self.frontends_cache.append(app)
-        self.update_params(xml, security_limit, app)
-        log.debug("Frontends parameters registered for %(app)s" % {"app": app})
-
-    def __default_ok(self, value, name, category):
-        # FIXME: will not work with individual parameters
-        self.param_set(name, value, category)
-
-    def __default_ko(self, failure, name, category):
-        log.error(
-            _("Can't determine default value for [%(category)s/%(name)s]: %(reason)s")
-            % {"category": category, "name": name, "reason": str(failure.value)}
-        )
-
-    def set_default(self, name, category, callback, errback=None):
-        """Set default value of parameter
-
-        'default_cb' attibute of parameter must be set to 'yes'
-        @param name: name of the parameter
-        @param category: category of the parameter
-        @param callback: must return a string with the value (use deferred if needed)
-        @param errback: must manage the error with args failure, name, category
-        """
-        # TODO: send signal param update if value changed
-        # TODO: manage individual paramaters
-        log.debug(
-            "set_default called for %(category)s/%(name)s"
-            % {"category": category, "name": name}
-        )
-        node = self._get_param_node(name, category, "@ALL@")
-        if not node:
-            log.error(
-                _(
-                    "Requested param [%(name)s] in category [%(category)s] doesn't exist !"
-                )
-                % {"name": name, "category": category}
-            )
-            return
-        if node[1].getAttribute("default_cb") == "yes":
-            # del node[1].attributes['default_cb'] # default_cb is not used anymore as a flag to know if we have to set the default value,
-            # and we can still use it later e.g. to call a generic set_default method
-            value = self._get_param(category, name, C.GENERAL)
-            if value is None:  # no value set by the user: we have the default value
-                log.debug("Default value to set, using callback")
-                d = defer.maybeDeferred(callback)
-                d.addCallback(self.__default_ok, name, category)
-                d.addErrback(errback or self.__default_ko, name, category)
-
-    def _get_attr_internal(self, node, attr, value):
-        """Get attribute value.
-
-        /!\ This method would return encrypted password values.
-
-        @param node: XML param node
-        @param attr: name of the attribute to get (e.g.: 'value' or 'type')
-        @param value: user defined value
-        @return: value (can be str, bool, int, list, None)
-        """
-        if attr == "value":
-            value_to_use = (
-                value if value is not None else node.getAttribute(attr)
-            )  # we use value (user defined) if it exist, else we use node's default value
-            if node.getAttribute("type") == "bool":
-                return C.bool(value_to_use)
-            if node.getAttribute("type") == "int":
-                return int(value_to_use) if value_to_use else value_to_use
-            elif node.getAttribute("type") == "list":
-                if (
-                    not value_to_use
-                ):  # no user defined value, take default value from the XML
-                    options = [
-                        option
-                        for option in node.childNodes
-                        if option.nodeName == "option"
-                    ]
-                    selected = [
-                        option
-                        for option in options
-                        if option.getAttribute("selected") == "true"
-                    ]
-                    cat, param = (
-                        node.parentNode.getAttribute("name"),
-                        node.getAttribute("name"),
-                    )
-                    if len(selected) == 1:
-                        value_to_use = selected[0].getAttribute("value")
-                        log.info(
-                            _(
-                                "Unset parameter (%(cat)s, %(param)s) of type list will use the default option '%(value)s'"
-                            )
-                            % {"cat": cat, "param": param, "value": value_to_use}
-                        )
-                        return value_to_use
-                    if len(selected) == 0:
-                        log.error(
-                            _(
-                                "Parameter (%(cat)s, %(param)s) of type list has no default option!"
-                            )
-                            % {"cat": cat, "param": param}
-                        )
-                    else:
-                        log.error(
-                            _(
-                                "Parameter (%(cat)s, %(param)s) of type list has more than one default option!"
-                            )
-                            % {"cat": cat, "param": param}
-                        )
-                    raise exceptions.DataError
-            elif node.getAttribute("type") == "jids_list":
-                if value_to_use:
-                    jids = value_to_use.split(
-                        "\t"
-                    )  # FIXME: it's not good to use tabs as separator !
-                else:  # no user defined value, take default value from the XML
-                    jids = [get_text(jid_) for jid_ in node.getElementsByTagName("jid")]
-                to_delete = []
-                for idx, value in enumerate(jids):
-                    try:
-                        jids[idx] = jid.JID(value)
-                    except (RuntimeError, jid.InvalidFormat, AttributeError):
-                        log.warning(
-                            "Incorrect jid value found in jids list: [{}]".format(value)
-                        )
-                        to_delete.append(value)
-                for value in to_delete:
-                    jids.remove(value)
-                return jids
-            return value_to_use
-        return node.getAttribute(attr)
-
-    def _get_attr(self, node, attr, value):
-        """Get attribute value (synchronous).
-
-        /!\ This method can not be used to retrieve password values.
-        @param node: XML param node
-        @param attr: name of the attribute to get (e.g.: 'value' or 'type')
-        @param value: user defined value
-        @return (unicode, bool, int, list): value to retrieve
-        """
-        if attr == "value" and node.getAttribute("type") == "password":
-            raise exceptions.InternalError(
-                "To retrieve password values, use _async_get_attr instead of _get_attr"
-            )
-        return self._get_attr_internal(node, attr, value)
-
-    def _async_get_attr(self, node, attr, value, profile=None):
-        """Get attribute value.
-
-        Profile passwords are returned hashed (if not empty),
-        other passwords are returned decrypted (if not empty).
-        @param node: XML param node
-        @param attr: name of the attribute to get (e.g.: 'value' or 'type')
-        @param value: user defined value
-        @param profile: %(doc_profile)s
-        @return (unicode, bool, int, list): Deferred value to retrieve
-        """
-        value = self._get_attr_internal(node, attr, value)
-        if attr != "value" or node.getAttribute("type") != "password":
-            return defer.succeed(value)
-        param_cat = node.parentNode.getAttribute("name")
-        param_name = node.getAttribute("name")
-        if ((param_cat, param_name) == C.PROFILE_PASS_PATH) or not value:
-            return defer.succeed(
-                value
-            )  # profile password and empty passwords are returned "as is"
-        if not profile:
-            raise exceptions.ProfileNotSetError(
-                "The profile is needed to decrypt a password"
-            )
-        password = self.host.memory.decrypt_value(value, profile)
-
-        if password is None:
-            raise exceptions.InternalError("password should never be None")
-        return defer.succeed(password)
-
-    def _type_to_str(self, result):
-        """Convert result to string, according to its type """
-        if isinstance(result, bool):
-            return C.bool_const(result)
-        elif isinstance(result, (list, set, tuple)):
-            return ', '.join(self._type_to_str(r) for r in result)
-        else:
-            return str(result)
-
-    def get_string_param_a(self, name, category, attr="value", profile_key=C.PROF_KEY_NONE):
-        """ Same as param_get_a but for bridge: convert non string value to string """
-        return self._type_to_str(
-            self.param_get_a(name, category, attr, profile_key=profile_key)
-        )
-
-    def param_get_a(
-        self, name, category, attr="value", use_default=True, profile_key=C.PROF_KEY_NONE
-    ):
-        """Helper method to get a specific attribute.
-
-        /!\ This method would return encrypted password values,
-            to get the plain values you have to use param_get_a_async.
-        @param name: name of the parameter
-        @param category: category of the parameter
-        @param attr: name of the attribute (default: "value")
-        @parm use_default(bool): if True and attr=='value', return default value if not set
-            else return None if not set
-        @param profile: owner of the param (@ALL@ for everyone)
-        @return: attribute
-        """
-        # FIXME: looks really dirty and buggy, need to be reviewed/refactored
-        # FIXME: security_limit is not managed here !
-        node = self._get_param_node(name, category)
-        if not node:
-            log.error(
-                _(
-                    "Requested param [%(name)s] in category [%(category)s] doesn't exist !"
-                )
-                % {"name": name, "category": category}
-            )
-            raise exceptions.NotFound
-
-        if attr == "value" and node[1].getAttribute("type") == "password":
-            raise exceptions.InternalError(
-                "To retrieve password values, use param_get_a_async instead of param_get_a"
-            )
-
-        if node[0] == C.GENERAL:
-            value = self._get_param(category, name, C.GENERAL)
-            if value is None and attr == "value" and not use_default:
-                return value
-            return self._get_attr(node[1], attr, value)
-
-        assert node[0] == C.INDIVIDUAL
-
-        profile = self.get_profile_name(profile_key)
-        if not profile:
-            log.error(_("Requesting a param for an non-existant profile"))
-            raise exceptions.ProfileUnknownError(profile_key)
-
-        if profile not in self.params:
-            log.error(_("Requesting synchronous param for not connected profile"))
-            raise exceptions.ProfileNotConnected(profile)
-
-        if attr == "value":
-            value = self._get_param(category, name, profile=profile)
-            if value is None and attr == "value" and not use_default:
-                return value
-            return self._get_attr(node[1], attr, value)
-
-    async def async_get_string_param_a(
-        self, name, category, attr="value", security_limit=C.NO_SECURITY_LIMIT,
-        profile=C.PROF_KEY_NONE):
-        value = await self.param_get_a_async(
-            name, category, attr, security_limit, profile_key=profile)
-        return self._type_to_str(value)
-
-    def param_get_a_async(
-        self,
-        name,
-        category,
-        attr="value",
-        security_limit=C.NO_SECURITY_LIMIT,
-        profile_key=C.PROF_KEY_NONE,
-    ):
-        """Helper method to get a specific attribute.
-
-        @param name: name of the parameter
-        @param category: category of the parameter
-        @param attr: name of the attribute (default: "value")
-        @param profile: owner of the param (@ALL@ for everyone)
-        @return (defer.Deferred): parameter value, with corresponding type (bool, int, list, etc)
-        """
-        node = self._get_param_node(name, category)
-        if not node:
-            log.error(
-                _(
-                    "Requested param [%(name)s] in category [%(category)s] doesn't exist !"
-                )
-                % {"name": name, "category": category}
-            )
-            raise ValueError("Requested param doesn't exist")
-
-        if not self.check_security_limit(node[1], security_limit):
-            log.warning(
-                _(
-                    "Trying to get parameter '%(param)s' in category '%(cat)s' without authorization!!!"
-                    % {"param": name, "cat": category}
-                )
-            )
-            raise exceptions.PermissionError
-
-        if node[0] == C.GENERAL:
-            value = self._get_param(category, name, C.GENERAL)
-            return self._async_get_attr(node[1], attr, value)
-
-        assert node[0] == C.INDIVIDUAL
-
-        profile = self.get_profile_name(profile_key)
-        if not profile:
-            raise exceptions.InternalError(
-                _("Requesting a param for a non-existant profile")
-            )
-
-        if attr != "value":
-            return defer.succeed(node[1].getAttribute(attr))
-        try:
-            value = self._get_param(category, name, profile=profile)
-            return self._async_get_attr(node[1], attr, value, profile)
-        except exceptions.ProfileNotInCacheError:
-            # We have to ask data to the storage manager
-            d = self.storage.get_ind_param(category, name, profile)
-            return d.addCallback(
-                lambda value: self._async_get_attr(node[1], attr, value, profile)
-            )
-
-    def _get_params_values_from_category(
-        self, category, security_limit, app, extra_s, profile_key):
-        client = self.host.get_client(profile_key)
-        extra = data_format.deserialise(extra_s)
-        return defer.ensureDeferred(self.get_params_values_from_category(
-            client, category, security_limit, app, extra))
-
-    async def get_params_values_from_category(
-        self, client, category, security_limit, app='', extra=None):
-        """Get all parameters "attribute" for a category
-
-        @param category(unicode): the desired category
-        @param security_limit(int): NO_SECURITY_LIMIT (-1) to return all the params.
-            Otherwise sole the params which have a security level defined *and*
-            lower or equal to the specified value are returned.
-        @param app(str): see [get_params]
-        @param extra(dict): see [get_params]
-        @return (dict): key: param name, value: param value (converted to string if needed)
-        """
-        # TODO: manage category of general type (without existant profile)
-        if extra is None:
-            extra = {}
-        prof_xml = await self._construct_profile_xml(client, security_limit, app, extra)
-        ret = {}
-        for category_node in prof_xml.getElementsByTagName("category"):
-            if category_node.getAttribute("name") == category:
-                for param_node in category_node.getElementsByTagName("param"):
-                    name = param_node.getAttribute("name")
-                    if not name:
-                        log.warning(
-                            "ignoring attribute without name: {}".format(
-                                param_node.toxml()
-                            )
-                        )
-                        continue
-                    value = await self.async_get_string_param_a(
-                        name, category, security_limit=security_limit,
-                        profile=client.profile)
-
-                    ret[name] = value
-                break
-
-        prof_xml.unlink()
-        return ret
-
-    def _get_param(
-        self, category, name, type_=C.INDIVIDUAL, cache=None, profile=C.PROF_KEY_NONE
-    ):
-        """Return the param, or None if it doesn't exist
-
-        @param category: param category
-        @param name: param name
-        @param type_: GENERAL or INDIVIDUAL
-        @param cache: temporary cache, to use when profile is not logged
-        @param profile: the profile name (not profile key, i.e. name and not something like @DEFAULT@)
-        @return: param value or None if it doesn't exist
-        """
-        if type_ == C.GENERAL:
-            if (category, name) in self.params_gen:
-                return self.params_gen[(category, name)]
-            return None  # This general param has the default value
-        assert type_ == C.INDIVIDUAL
-        if profile == C.PROF_KEY_NONE:
-            raise exceptions.ProfileNotSetError
-        if profile in self.params:
-            cache = self.params[profile]  # if profile is in main cache, we use it,
-            # ignoring the temporary cache
-        elif (
-            cache is None
-        ):  # else we use the temporary cache if it exists, or raise an exception
-            raise exceptions.ProfileNotInCacheError
-        if (category, name) not in cache:
-            return None
-        return cache[(category, name)]
-
-    async def _construct_profile_xml(self, client, security_limit, app, extra):
-        """Construct xml for asked profile, filling values when needed
-
-        /!\ as noticed in doc, don't forget to unlink the minidom.Document
-        @param security_limit: NO_SECURITY_LIMIT (-1) to return all the params.
-        Otherwise sole the params which have a security level defined *and*
-        lower or equal to the specified value are returned.
-        @param app: name of the frontend requesting the parameters, or '' to get all parameters
-        @param profile: profile name (not key !)
-        @return: a deferred that fire a minidom.Document of the profile xml (cf warning above)
-        """
-        profile = client.profile
-
-        def check_node(node):
-            """Check the node against security_limit, app and extra"""
-            return (self.check_security_limit(node, security_limit)
-                    and self.check_app(node, app)
-                    and self.check_extra(node, extra))
-
-        if profile in self.params:
-            profile_cache = self.params[profile]
-        else:
-            # profile is not in cache, we load values in a short time cache
-            profile_cache = {}
-            await self.load_ind_params(profile, profile_cache)
-
-        # init the result document
-        prof_xml = minidom.parseString("<params/>")
-        cache = {}
-
-        for type_node in self.dom.documentElement.childNodes:
-            if type_node.nodeName != C.GENERAL and type_node.nodeName != C.INDIVIDUAL:
-                continue
-            # we use all params, general and individual
-            for cat_node in type_node.childNodes:
-                if cat_node.nodeName != "category":
-                    continue
-                category = cat_node.getAttribute("name")
-                dest_params = {}  # result (merged) params for category
-                if category not in cache:
-                    # we make a copy for the new xml
-                    cache[category] = dest_cat = cat_node.cloneNode(True)
-                    to_remove = []
-                    for node in dest_cat.childNodes:
-                        if node.nodeName != "param":
-                            continue
-                        if not check_node(node):
-                            to_remove.append(node)
-                            continue
-                        dest_params[node.getAttribute("name")] = node
-                    for node in to_remove:
-                        dest_cat.removeChild(node)
-                    new_node = True
-                else:
-                    # It's not a new node, we use the previously cloned one
-                    dest_cat = cache[category]
-                    new_node = False
-                params = cat_node.getElementsByTagName("param")
-
-                for param_node in params:
-                    # we have to merge new params (we are parsing individual parameters, we have to add them
-                    # to the previously parsed general ones)
-                    name = param_node.getAttribute("name")
-                    if not check_node(param_node):
-                        continue
-                    if name not in dest_params:
-                        # this is reached when a previous category exists
-                        dest_params[name] = param_node.cloneNode(True)
-                        dest_cat.appendChild(dest_params[name])
-
-                    profile_value = self._get_param(
-                        category,
-                        name,
-                        type_node.nodeName,
-                        cache=profile_cache,
-                        profile=profile,
-                    )
-                    if profile_value is not None:
-                        # there is a value for this profile, we must change the default
-                        if dest_params[name].getAttribute("type") == "list":
-                            for option in dest_params[name].getElementsByTagName(
-                                "option"
-                            ):
-                                if option.getAttribute("value") == profile_value:
-                                    option.setAttribute("selected", "true")
-                                else:
-                                    try:
-                                        option.removeAttribute("selected")
-                                    except NotFoundErr:
-                                        pass
-                        elif dest_params[name].getAttribute("type") == "jids_list":
-                            jids = profile_value.split("\t")
-                            for jid_elt in dest_params[name].getElementsByTagName(
-                                "jid"
-                            ):
-                                dest_params[name].removeChild(
-                                    jid_elt
-                                )  # remove all default
-                            for jid_ in jids:  # rebuilt the children with use values
-                                try:
-                                    jid.JID(jid_)
-                                except (
-                                    RuntimeError,
-                                    jid.InvalidFormat,
-                                    AttributeError,
-                                ):
-                                    log.warning(
-                                        "Incorrect jid value found in jids list: [{}]".format(
-                                            jid_
-                                        )
-                                    )
-                                else:
-                                    jid_elt = prof_xml.createElement("jid")
-                                    jid_elt.appendChild(prof_xml.createTextNode(jid_))
-                                    dest_params[name].appendChild(jid_elt)
-                        else:
-                            dest_params[name].setAttribute("value", profile_value)
-                if new_node:
-                    prof_xml.documentElement.appendChild(dest_cat)
-
-        to_remove = []
-        for cat_node in prof_xml.documentElement.childNodes:
-            # we remove empty categories
-            if cat_node.getElementsByTagName("param").length == 0:
-                to_remove.append(cat_node)
-        for node in to_remove:
-            prof_xml.documentElement.removeChild(node)
-
-        return prof_xml
-
-
-    def _get_params_ui(self, security_limit, app, extra_s, profile_key):
-        client = self.host.get_client(profile_key)
-        extra = data_format.deserialise(extra_s)
-        return defer.ensureDeferred(self.param_ui_get(client, security_limit, app, extra))
-
-    async def param_ui_get(self, client, security_limit, app, extra=None):
-        """Get XMLUI to handle parameters
-
-        @param security_limit: NO_SECURITY_LIMIT (-1) to return all the params.
-            Otherwise sole the params which have a security level defined *and*
-            lower or equal to the specified value are returned.
-        @param app: name of the frontend requesting the parameters, or '' to get all parameters
-        @param extra (dict, None): extra options. Key can be:
-            - ignore: list of (category/name) values to remove from parameters
-        @return(str): a SàT XMLUI for parameters
-        """
-        param_xml = await self.get_params(client, security_limit, app, extra)
-        return params_xml_2_xmlui(param_xml)
-
-    async def get_params(self, client, security_limit, app, extra=None):
-        """Construct xml for asked profile, take params xml as skeleton
-
-        @param security_limit: NO_SECURITY_LIMIT (-1) to return all the params.
-            Otherwise sole the params which have a security level defined *and*
-            lower or equal to the specified value are returned.
-        @param app: name of the frontend requesting the parameters, or '' to get all parameters
-        @param extra (dict, None): extra options. Key can be:
-            - ignore: list of (category/name) values to remove from parameters
-        @param profile_key: Profile key which can be either a magic (eg: @DEFAULT@) or the name of an existing profile.
-        @return: XML of parameters
-        """
-        if extra is None:
-            extra = {}
-        prof_xml = await self._construct_profile_xml(client, security_limit, app, extra)
-        return_xml = prof_xml.toxml()
-        prof_xml.unlink()
-        return "\n".join((line for line in return_xml.split("\n") if line))
-
-    def _get_param_node(self, name, category, type_="@ALL@"):  # FIXME: is type_ useful ?
-        """Return a node from the param_xml
-        @param name: name of the node
-        @param category: category of the node
-        @param type_: keyword for search:
-                                    @ALL@ search everywhere
-                                    @GENERAL@ only search in general type
-                                    @INDIVIDUAL@ only search in individual type
-        @return: a tuple (node type, node) or None if not found"""
-
-        for type_node in self.dom.documentElement.childNodes:
-            if (
-                (type_ == "@ALL@" or type_ == "@GENERAL@")
-                and type_node.nodeName == C.GENERAL
-            ) or (
-                (type_ == "@ALL@" or type_ == "@INDIVIDUAL@")
-                and type_node.nodeName == C.INDIVIDUAL
-            ):
-                for node in type_node.getElementsByTagName("category"):
-                    if node.getAttribute("name") == category:
-                        params = node.getElementsByTagName("param")
-                        for param in params:
-                            if param.getAttribute("name") == name:
-                                return (type_node.nodeName, param)
-        return None
-
-    def params_categories_get(self):
-        """return the categories availables"""
-        categories = []
-        for cat in self.dom.getElementsByTagName("category"):
-            name = cat.getAttribute("name")
-            if name not in categories:
-                categories.append(cat.getAttribute("name"))
-        return categories
-
-    def param_set(self, name, value, category, security_limit=C.NO_SECURITY_LIMIT,
-                 profile_key=C.PROF_KEY_NONE):
-        """Set a parameter, return None if the parameter is not in param xml.
-
-        Parameter of type 'password' that are not the SàT profile password are
-        stored encrypted (if not empty). The profile password is stored hashed
-        (if not empty).
-
-        @param name (str): the parameter name
-        @param value (str): the new value
-        @param category (str): the parameter category
-        @param security_limit (int)
-        @param profile_key (str): %(doc_profile_key)s
-        @return: a deferred None value when everything is done
-        """
-        # FIXME: param_set should accept the right type for value, not only str !
-        if profile_key != C.PROF_KEY_NONE:
-            profile = self.get_profile_name(profile_key)
-            if not profile:
-                log.error(_("Trying to set parameter for an unknown profile"))
-                raise exceptions.ProfileUnknownError(profile_key)
-
-        node = self._get_param_node(name, category, "@ALL@")
-        if not node:
-            log.error(
-                _("Requesting an unknown parameter (%(category)s/%(name)s)")
-                % {"category": category, "name": name}
-            )
-            return defer.succeed(None)
-
-        if not self.check_security_limit(node[1], security_limit):
-            msg = _(
-                "{profile!r} is trying to set parameter {name!r} in category "
-                "{category!r} without authorization!!!").format(
-                    profile=repr(profile),
-                    name=repr(name),
-                    category=repr(category)
-                )
-            log.warning(msg)
-            raise exceptions.PermissionError(msg)
-
-        type_ = node[1].getAttribute("type")
-        if type_ == "int":
-            if not value:  # replace with the default value (which might also be '')
-                value = node[1].getAttribute("value")
-            else:
-                try:
-                    int(value)
-                except ValueError:
-                    log.warning(_(
-                        "Trying to set parameter {name} in category {category} with"
-                        "an non-integer value"
-                    ).format(
-                        name=repr(name),
-                        category=repr(category)
-                    ))
-                    return defer.succeed(None)
-                if node[1].hasAttribute("constraint"):
-                    constraint = node[1].getAttribute("constraint")
-                    try:
-                        min_, max_ = [int(limit) for limit in constraint.split(";")]
-                    except ValueError:
-                        raise exceptions.InternalError(
-                            "Invalid integer parameter constraint: %s" % constraint
-                        )
-                    value = str(min(max(int(value), min_), max_))
-
-        log.info(
-            _("Setting parameter (%(category)s, %(name)s) = %(value)s")
-            % {
-                "category": category,
-                "name": name,
-                "value": value if type_ != "password" else "********",
-            }
-        )
-
-        if node[0] == C.GENERAL:
-            self.params_gen[(category, name)] = value
-            self.storage.set_gen_param(category, name, value)
-            for profile in self.storage.get_profiles_list():
-                if self.host.memory.is_session_started(profile):
-                    self.host.bridge.param_update(name, value, category, profile)
-                    self.host.trigger.point(
-                        "param_update_trigger", name, value, category, node[0], profile
-                    )
-            return defer.succeed(None)
-
-        assert node[0] == C.INDIVIDUAL
-        assert profile_key != C.PROF_KEY_NONE
-
-        if type_ == "button":
-            log.debug("Clicked param button %s" % node.toxml())
-            return defer.succeed(None)
-        elif type_ == "password":
-            try:
-                personal_key = self.host.memory.auth_sessions.profile_get_unique(profile)[
-                    C.MEMORY_CRYPTO_KEY
-                ]
-            except TypeError:
-                raise exceptions.InternalError(
-                    _("Trying to encrypt a password while the personal key is undefined!")
-                )
-            if (category, name) == C.PROFILE_PASS_PATH:
-                # using 'value' as the encryption key to encrypt another encryption key... could be confusing!
-                d = self.host.memory.encrypt_personal_data(
-                    data_key=C.MEMORY_CRYPTO_KEY,
-                    data_value=personal_key,
-                    crypto_key=value,
-                    profile=profile,
-                )
-                d.addCallback(
-                    lambda __: PasswordHasher.hash(value)
-                )  # profile password is hashed (empty value stays empty)
-            elif value:  # other non empty passwords are encrypted with the personal key
-                d = defer.succeed(BlockCipher.encrypt(personal_key, value))
-            else:
-                d = defer.succeed(value)
-        else:
-            d = defer.succeed(value)
-
-        def got_final_value(value):
-            if self.host.memory.is_session_started(profile):
-                self.params[profile][(category, name)] = value
-                self.host.bridge.param_update(name, value, category, profile)
-                self.host.trigger.point(
-                    "param_update_trigger", name, value, category, node[0], profile
-                )
-                return self.storage.set_ind_param(category, name, value, profile)
-            else:
-                raise exceptions.ProfileNotConnected
-
-        d.addCallback(got_final_value)
-        return d
-
-    def _get_nodes_of_types(self, attr_type, node_type="@ALL@"):
-        """Return all the nodes matching the given types.
-
-        TODO: using during the dev but not anymore... remove if not needed
-
-        @param attr_type (str): the attribute type (string, text, password, bool, int, button, list)
-        @param node_type (str): keyword for filtering:
-                                    @ALL@ search everywhere
-                                    @GENERAL@ only search in general type
-                                    @INDIVIDUAL@ only search in individual type
-        @return: dict{tuple: node}: a dict {key, value} where:
-            - key is a couple (attribute category, attribute name)
-            - value is a node
-        """
-        ret = {}
-        for type_node in self.dom.documentElement.childNodes:
-            if (
-                (node_type == "@ALL@" or node_type == "@GENERAL@")
-                and type_node.nodeName == C.GENERAL
-            ) or (
-                (node_type == "@ALL@" or node_type == "@INDIVIDUAL@")
-                and type_node.nodeName == C.INDIVIDUAL
-            ):
-                for cat_node in type_node.getElementsByTagName("category"):
-                    cat = cat_node.getAttribute("name")
-                    params = cat_node.getElementsByTagName("param")
-                    for param in params:
-                        if param.getAttribute("type") == attr_type:
-                            ret[(cat, param.getAttribute("name"))] = param
-        return ret
-
-    def check_security_limit(self, node, security_limit):
-        """Check the given node against the given security limit.
-        The value NO_SECURITY_LIMIT (-1) means that everything is allowed.
-        @return: True if this node can be accessed with the given security limit.
-        """
-        if security_limit < 0:
-            return True
-        if node.hasAttribute("security"):
-            if int(node.getAttribute("security")) <= security_limit:
-                return True
-        return False
-
-    def check_app(self, node, app):
-        """Check the given node against the given app.
-
-        @param node: parameter node
-        @param app: name of the frontend requesting the parameters, or '' to get all parameters
-        @return: True if this node concerns the given app.
-        """
-        if not app or not node.hasAttribute("app"):
-            return True
-        return node.getAttribute("app") == app
-
-    def check_extra(self, node, extra):
-        """Check the given node against the extra filters.
-
-        @param node: parameter node
-        @param app: name of the frontend requesting the parameters, or '' to get all parameters
-        @return: True if node doesn't match category/name of extra['ignore'] list
-        """
-        ignore_list = extra.get('ignore')
-        if not ignore_list:
-            return True
-        category = node.parentNode.getAttribute('name')
-        name = node.getAttribute('name')
-        ignore = [category, name] in ignore_list
-        if ignore:
-            log.debug(f"Ignoring parameter {category}/{name} as requested")
-            return False
-        return True
-
-
-def make_options(options, selected=None):
-    """Create option XML form dictionary
-
-    @param options(dict): option's name => option's label map
-    @param selected(None, str): value of selected option
-        None to use first value
-    @return (str): XML to use in parameters
-    """
-    str_list = []
-    if selected is None:
-        selected = next(iter(options.keys()))
-    selected_found = False
-    for value, label in options.items():
-        if value == selected:
-            selected = 'selected="true"'
-            selected_found = True
-        else:
-            selected = ''
-        str_list.append(
-            f'<option value={quoteattr(value)} label={quoteattr(label)} {selected}/>'
-        )
-    if not selected_found:
-        raise ValueError(f"selected value ({selected}) not found in options")
-    return '\n'.join(str_list)
--- a/sat/memory/persistent.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,317 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from twisted.internet import defer
-from twisted.python import failure
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-
-log = getLogger(__name__)
-
-
-class MemoryNotInitializedError(Exception):
-    pass
-
-
-class PersistentDict:
-    r"""A dictionary which save persistently each value assigned
-
-    /!\ be careful, each assignment means a database write
-    /!\ Memory must be initialised before loading/setting value with instances of this class"""
-    storage = None
-    binary = False
-
-    def __init__(self, namespace, profile=None):
-        """
-
-        @param namespace: unique namespace for this dictionary
-        @param profile(unicode, None): profile which *MUST* exists, or None for general values
-        """
-        if not self.storage:
-            log.error(_("PersistentDict can't be used before memory initialisation"))
-            raise MemoryNotInitializedError
-        self._cache = {}
-        self.namespace = namespace
-        self.profile = profile
-
-    def _set_cache(self, data):
-        self._cache = data
-
-    def load(self):
-        """Load persistent data from storage.
-
-        need to be called before any other operation
-        @return: defers the PersistentDict instance itself
-        """
-        d = defer.ensureDeferred(self.storage.get_privates(
-            self.namespace, binary=self.binary, profile=self.profile
-        ))
-        d.addCallback(self._set_cache)
-        d.addCallback(lambda __: self)
-        return d
-
-    def iteritems(self):
-        return iter(self._cache.items())
-
-    def items(self):
-        return self._cache.items()
-
-    def __repr__(self):
-        return self._cache.__repr__()
-
-    def __str__(self):
-        return self._cache.__str__()
-
-    def __lt__(self, other):
-        return self._cache.__lt__(other)
-
-    def __le__(self, other):
-        return self._cache.__le__(other)
-
-    def __eq__(self, other):
-        return self._cache.__eq__(other)
-
-    def __ne__(self, other):
-        return self._cache.__ne__(other)
-
-    def __gt__(self, other):
-        return self._cache.__gt__(other)
-
-    def __ge__(self, other):
-        return self._cache.__ge__(other)
-
-    def __cmp__(self, other):
-        return self._cache.__cmp__(other)
-
-    def __hash__(self):
-        return self._cache.__hash__()
-
-    def __bool__(self):
-        return self._cache.__len__() != 0
-
-    def __contains__(self, key):
-        return self._cache.__contains__(key)
-
-    def __iter__(self):
-        return self._cache.__iter__()
-
-    def __getitem__(self, key):
-        return self._cache.__getitem__(key)
-
-    def __setitem__(self, key, value):
-        defer.ensureDeferred(
-            self.storage.set_private_value(
-                self.namespace, key, value, self.binary, self.profile
-            )
-        )
-        return self._cache.__setitem__(key, value)
-
-    def __delitem__(self, key):
-        self.storage.del_private_value(self.namespace, key, self.binary, self.profile)
-        return self._cache.__delitem__(key)
-
-    def clear(self):
-        """Delete all values from this namespace"""
-        self._cache.clear()
-        return self.storage.del_private_namespace(self.namespace, self.binary, self.profile)
-
-    def get(self, key, default=None):
-        return self._cache.get(key, default)
-
-    def aset(self, key, value):
-        """Async set, return a Deferred fired when value is actually stored"""
-        self._cache.__setitem__(key, value)
-        return defer.ensureDeferred(
-            self.storage.set_private_value(
-                self.namespace, key, value, self.binary, self.profile
-            )
-        )
-
-    def adel(self, key):
-        """Async del, return a Deferred fired when value is actually deleted"""
-        self._cache.__delitem__(key)
-        return self.storage.del_private_value(
-            self.namespace, key, self.binary, self.profile)
-
-    def setdefault(self, key, default):
-        try:
-            return self._cache[key]
-        except:
-            self.__setitem__(key, default)
-            return default
-
-    def force(self, name):
-        """Force saving of an attribute to storage
-
-        @return: deferred fired when data is actually saved
-        """
-        return defer.ensureDeferred(
-            self.storage.set_private_value(
-                self.namespace, name, self._cache[name], self.binary, self.profile
-            )
-        )
-
-
-class PersistentBinaryDict(PersistentDict):
-    """Persistent dict where value can be any python data (instead of string only)"""
-    binary = True
-
-
-class LazyPersistentBinaryDict(PersistentBinaryDict):
-    r"""PersistentBinaryDict which get key/value when needed
-
-    This Persistent need more database access, it is suitable for largest data,
-    to save memory.
-    /!\ most of methods return a Deferred
-    """
-    # TODO: missing methods should be implemented using database access
-    # TODO: a cache would be useful (which is deleted after a timeout)
-
-    def load(self):
-        # we show a warning as calling load on LazyPersistentBinaryDict sounds like a code mistake
-        log.warning(_("Calling load on LazyPersistentBinaryDict while it's not needed"))
-
-    def iteritems(self):
-        raise NotImplementedError
-
-    def items(self):
-        d = defer.ensureDeferred(self.storage.get_privates(
-            self.namespace, binary=self.binary, profile=self.profile
-        ))
-        d.addCallback(lambda data_dict: data_dict.items())
-        return d
-
-    def all(self):
-        return defer.ensureDeferred(self.storage.get_privates(
-            self.namespace, binary=self.binary, profile=self.profile
-        ))
-
-    def __repr__(self):
-        return self.__str__()
-
-    def __str__(self):
-        return "LazyPersistentBinaryDict (namespace: {})".format(self.namespace)
-
-    def __lt__(self, other):
-        raise NotImplementedError
-
-    def __le__(self, other):
-        raise NotImplementedError
-
-    def __eq__(self, other):
-        raise NotImplementedError
-
-    def __ne__(self, other):
-        raise NotImplementedError
-
-    def __gt__(self, other):
-        raise NotImplementedError
-
-    def __ge__(self, other):
-        raise NotImplementedError
-
-    def __cmp__(self, other):
-        raise NotImplementedError
-
-    def __hash__(self):
-        return hash(str(self.__class__) + self.namespace + (self.profile or ''))
-
-    def __bool__(self):
-        raise NotImplementedError
-
-    def __contains__(self, key):
-        raise NotImplementedError
-
-    def __iter__(self):
-        raise NotImplementedError
-
-    def _data2value(self, data, key):
-        try:
-            return data[key]
-        except KeyError as e:
-            # we return a Failure here to avoid the jump
-            # into debugger in debug mode.
-            raise failure.Failure(e)
-
-    def __getitem__(self, key):
-        """get the value as a Deferred"""
-        d = defer.ensureDeferred(self.storage.get_privates(
-            self.namespace, keys=[key], binary=self.binary, profile=self.profile
-        ))
-        d.addCallback(self._data2value, key)
-        return d
-
-    def __setitem__(self, key, value):
-        defer.ensureDeferred(
-            self.storage.set_private_value(
-                self.namespace, key, value, self.binary, self.profile
-            )
-        )
-
-    def __delitem__(self, key):
-        self.storage.del_private_value(self.namespace, key, self.binary, self.profile)
-
-    def _default_or_exception(self, failure_, default):
-        failure_.trap(KeyError)
-        return default
-
-    def get(self, key, default=None):
-        d = self.__getitem__(key)
-        d.addErrback(self._default_or_exception, default=default)
-        return d
-
-    def aset(self, key, value):
-        """Async set, return a Deferred fired when value is actually stored"""
-        # FIXME: redundant with force, force must be removed
-        # XXX: similar as PersistentDict.aset, but doesn't use cache
-        return defer.ensureDeferred(
-            self.storage.set_private_value(
-                self.namespace, key, value, self.binary, self.profile
-            )
-        )
-
-    def adel(self, key):
-        """Async del, return a Deferred fired when value is actually deleted"""
-        # XXX: similar as PersistentDict.adel, but doesn't use cache
-        return self.storage.del_private_value(
-            self.namespace, key, self.binary, self.profile)
-
-    def setdefault(self, key, default):
-        raise NotImplementedError
-
-    def force(self, name, value):
-        """Force saving of an attribute to storage
-
-        @param value(object): value is needed for LazyPersistentBinaryDict
-        @return: deferred fired when data is actually saved
-        """
-        return defer.ensureDeferred(
-            self.storage.set_private_value(
-                self.namespace, name, value, self.binary, self.profile
-            )
-        )
-
-    def remove(self, key):
-        """Delete a key from sotrage, and return a deferred called when it's done
-
-        @param key(unicode): key to delete
-        @return (D): A deferred fired when delete is done
-        """
-        return self.storage.del_private_value(self.namespace, key, self.binary, self.profile)
--- a/sat/memory/sqla.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1704 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import asyncio
-from asyncio.subprocess import PIPE
-import copy
-from datetime import datetime
-from pathlib import Path
-import sys
-import time
-from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union
-
-from alembic import config as al_config, script as al_script
-from alembic.runtime import migration as al_migration
-from sqlalchemy import and_, delete, event, func, or_, update
-from sqlalchemy import Integer, literal_column, text
-from sqlalchemy.dialects.sqlite import insert
-from sqlalchemy.engine import Connection, Engine
-from sqlalchemy.exc import IntegrityError, NoResultFound
-from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
-from sqlalchemy.future import select
-from sqlalchemy.orm import (
-    contains_eager,
-    joinedload,
-    selectinload,
-    sessionmaker,
-    subqueryload,
-)
-from sqlalchemy.orm.attributes import Mapped
-from sqlalchemy.orm.decl_api import DeclarativeMeta
-from sqlalchemy.sql.functions import coalesce, count, now, sum as sum_
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from twisted.words.xish import domish
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.memory import migration
-from sat.memory import sqla_config
-from sat.memory.sqla_mapping import (
-    Base,
-    Component,
-    File,
-    History,
-    Message,
-    NOT_IN_EXTRA,
-    ParamGen,
-    ParamInd,
-    PrivateGen,
-    PrivateGenBin,
-    PrivateInd,
-    PrivateIndBin,
-    Profile,
-    PubsubItem,
-    PubsubNode,
-    Subject,
-    SyncState,
-    Thread,
-)
-from sat.tools.common import uri
-from sat.tools.utils import aio, as_future
-
-
-log = getLogger(__name__)
-migration_path = Path(migration.__file__).parent
-#: mapping of Libervia search query operators to SQLAlchemy method name
-OP_MAP = {
-    "==": "__eq__",
-    "eq": "__eq__",
-    "!=": "__ne__",
-    "ne": "__ne__",
-    ">": "__gt__",
-    "gt": "__gt__",
-    "<": "__le__",
-    "le": "__le__",
-    "between": "between",
-    "in": "in_",
-    "not_in": "not_in",
-    "overlap": "in_",
-    "ioverlap": "in_",
-    "disjoint": "in_",
-    "idisjoint": "in_",
-    "like": "like",
-    "ilike": "ilike",
-    "not_like": "notlike",
-    "not_ilike": "notilike",
-}
-
-
-@event.listens_for(Engine, "connect")
-def set_sqlite_pragma(dbapi_connection, connection_record):
-    cursor = dbapi_connection.cursor()
-    cursor.execute("PRAGMA foreign_keys=ON")
-    cursor.close()
-
-
-class Storage:
-
-    def __init__(self):
-        self.initialized = defer.Deferred()
-        # we keep cache for the profiles (key: profile name, value: profile id)
-        # profile id to name
-        self.profiles: Dict[str, int] = {}
-        # profile id to component entry point
-        self.components: Dict[int, str] = {}
-
-    def get_profile_by_id(self, profile_id):
-        return self.profiles.get(profile_id)
-
-    async def migrate_apply(self, *args: str, log_output: bool = False) -> None:
-        """Do a migration command
-
-        Commands are applied by running Alembic in a subprocess.
-        Arguments are alembic executables commands
-
-        @param log_output: manage stdout and stderr:
-            - if False, stdout and stderr are buffered, and logged only in case of error
-            - if True, stdout and stderr will be logged during the command execution
-        @raise exceptions.DatabaseError: something went wrong while running the
-            process
-        """
-        stdout, stderr = 2 * (None,) if log_output else 2 * (PIPE,)
-        proc = await asyncio.create_subprocess_exec(
-            sys.executable, "-m", "alembic", *args,
-            stdout=stdout, stderr=stderr, cwd=migration_path
-        )
-        log_out, log_err = await proc.communicate()
-        if proc.returncode != 0:
-            msg = _(
-                "Can't {operation} database (exit code {exit_code})"
-            ).format(
-                operation=args[0],
-                exit_code=proc.returncode
-            )
-            if log_out or log_err:
-                msg += f":\nstdout: {log_out.decode()}\nstderr: {log_err.decode()}"
-            log.error(msg)
-
-            raise exceptions.DatabaseError(msg)
-
-    async def create_db(self, engine: AsyncEngine, db_config: dict) -> None:
-        """Create a new database
-
-        The database is generated from SQLAlchemy model, then stamped by Alembic
-        """
-        # the dir may not exist if it's not the XDG recommended one
-        db_config["path"].parent.mkdir(0o700, True, True)
-        async with engine.begin() as conn:
-            await conn.run_sync(Base.metadata.create_all)
-
-        log.debug("stamping the database")
-        await self.migrate_apply("stamp", "head")
-        log.debug("stamping done")
-
-    def _check_db_is_up_to_date(self, conn: Connection) -> bool:
-        al_ini_path = migration_path / "alembic.ini"
-        al_cfg = al_config.Config(al_ini_path)
-        directory = al_script.ScriptDirectory.from_config(al_cfg)
-        context = al_migration.MigrationContext.configure(conn)
-        return set(context.get_current_heads()) == set(directory.get_heads())
-
-    def _sqlite_set_journal_mode_wal(self, conn: Connection) -> None:
-        """Check if journal mode is WAL, and set it if necesssary"""
-        result = conn.execute(text("PRAGMA journal_mode"))
-        if result.scalar() != "wal":
-            log.info("WAL mode not activated, activating it")
-            conn.execute(text("PRAGMA journal_mode=WAL"))
-
-    async def check_and_update_db(self, engine: AsyncEngine, db_config: dict) -> None:
-        """Check that database is up-to-date, and update if necessary"""
-        async with engine.connect() as conn:
-            up_to_date = await conn.run_sync(self._check_db_is_up_to_date)
-        if up_to_date:
-            log.debug("Database is up-to-date")
-        else:
-            log.info("Database needs to be updated")
-            log.info("updating…")
-            await self.migrate_apply("upgrade", "head", log_output=True)
-            log.info("Database is now up-to-date")
-
-    @aio
-    async def initialise(self) -> None:
-        log.info(_("Connecting database"))
-
-        db_config = sqla_config.get_db_config()
-        engine = create_async_engine(
-            db_config["url"],
-            future=True,
-        )
-
-        new_base = not db_config["path"].exists()
-        if new_base:
-            log.info(_("The database is new, creating the tables"))
-            await self.create_db(engine, db_config)
-        else:
-            await self.check_and_update_db(engine, db_config)
-
-        async with engine.connect() as conn:
-            await conn.run_sync(self._sqlite_set_journal_mode_wal)
-
-        self.session = sessionmaker(
-            engine, expire_on_commit=False, class_=AsyncSession
-        )
-
-        async with self.session() as session:
-            result = await session.execute(select(Profile))
-            for p in result.scalars():
-                self.profiles[p.name] = p.id
-            result = await session.execute(select(Component))
-            for c in result.scalars():
-                self.components[c.profile_id] = c.entry_point
-
-        self.initialized.callback(None)
-
-    ## Generic
-
-    @aio
-    async def get(
-        self,
-        client: SatXMPPEntity,
-        db_cls: DeclarativeMeta,
-        db_id_col: Mapped,
-        id_value: Any,
-        joined_loads = None
-    ) -> Optional[DeclarativeMeta]:
-        stmt = select(db_cls).where(db_id_col==id_value)
-        if client is not None:
-            stmt = stmt.filter_by(profile_id=self.profiles[client.profile])
-        if joined_loads is not None:
-            for joined_load in joined_loads:
-                stmt = stmt.options(joinedload(joined_load))
-        async with self.session() as session:
-            result = await session.execute(stmt)
-        if joined_loads is not None:
-            result = result.unique()
-        return result.scalar_one_or_none()
-
-    @aio
-    async def add(self, db_obj: DeclarativeMeta) -> None:
-        """Add an object to database"""
-        async with self.session() as session:
-            async with session.begin():
-                session.add(db_obj)
-
-    @aio
-    async def delete(
-        self,
-        db_obj: Union[DeclarativeMeta, List[DeclarativeMeta]],
-        session_add: Optional[List[DeclarativeMeta]] = None
-    ) -> None:
-        """Delete an object from database
-
-        @param db_obj: object to delete or list of objects to delete
-        @param session_add: other objects to add to session.
-            This is useful when parents of deleted objects needs to be updated too, or if
-            other objects needs to be updated in the same transaction.
-        """
-        if not db_obj:
-            return
-        if not isinstance(db_obj, list):
-            db_obj = [db_obj]
-        async with self.session() as session:
-            async with session.begin():
-                if session_add is not None:
-                    for obj in session_add:
-                        session.add(obj)
-                for obj in db_obj:
-                    await session.delete(obj)
-                await session.commit()
-
-    ## Profiles
-
-    def get_profiles_list(self) -> List[str]:
-        """"Return list of all registered profiles"""
-        return list(self.profiles.keys())
-
-    def has_profile(self, profile_name: str) -> bool:
-        """return True if profile_name exists
-
-        @param profile_name: name of the profile to check
-        """
-        return profile_name in self.profiles
-
-    def profile_is_component(self, profile_name: str) -> bool:
-        try:
-            return self.profiles[profile_name] in self.components
-        except KeyError:
-            raise exceptions.NotFound("the requested profile doesn't exists")
-
-    def get_entry_point(self, profile_name: str) -> str:
-        try:
-            return self.components[self.profiles[profile_name]]
-        except KeyError:
-            raise exceptions.NotFound("the requested profile doesn't exists or is not a component")
-
-    @aio
-    async def create_profile(self, name: str, component_ep: Optional[str] = None) -> None:
-        """Create a new profile
-
-        @param name: name of the profile
-        @param component: if not None, must point to a component entry point
-        """
-        async with self.session() as session:
-            profile = Profile(name=name)
-            async with session.begin():
-                session.add(profile)
-            self.profiles[profile.name] = profile.id
-            if component_ep is not None:
-                async with session.begin():
-                    component = Component(profile=profile, entry_point=component_ep)
-                    session.add(component)
-                self.components[profile.id] = component_ep
-        return profile
-
-    @aio
-    async def delete_profile(self, name: str) -> None:
-        """Delete profile
-
-        @param name: name of the profile
-        """
-        async with self.session() as session:
-            result = await session.execute(select(Profile).where(Profile.name == name))
-            profile = result.scalar()
-            await session.delete(profile)
-            await session.commit()
-        del self.profiles[profile.name]
-        if profile.id in self.components:
-            del self.components[profile.id]
-        log.info(_("Profile {name!r} deleted").format(name = name))
-
-    ## Params
-
-    @aio
-    async def load_gen_params(self, params_gen: dict) -> None:
-        """Load general parameters
-
-        @param params_gen: dictionary to fill
-        """
-        log.debug(_("loading general parameters from database"))
-        async with self.session() as session:
-            result = await session.execute(select(ParamGen))
-        for p in result.scalars():
-            params_gen[(p.category, p.name)] = p.value
-
-    @aio
-    async def load_ind_params(self, params_ind: dict, profile: str) -> None:
-        """Load individual parameters
-
-        @param params_ind: dictionary to fill
-        @param profile: a profile which *must* exist
-        """
-        log.debug(_("loading individual parameters from database"))
-        async with self.session() as session:
-            result = await session.execute(
-                select(ParamInd).where(ParamInd.profile_id == self.profiles[profile])
-            )
-        for p in result.scalars():
-            params_ind[(p.category, p.name)] = p.value
-
-    @aio
-    async def get_ind_param(self, category: str, name: str, profile: str) -> Optional[str]:
-        """Ask database for the value of one specific individual parameter
-
-        @param category: category of the parameter
-        @param name: name of the parameter
-        @param profile: %(doc_profile)s
-        """
-        async with self.session() as session:
-            result = await session.execute(
-                select(ParamInd.value)
-                .filter_by(
-                    category=category,
-                    name=name,
-                    profile_id=self.profiles[profile]
-                )
-            )
-        return result.scalar_one_or_none()
-
-    @aio
-    async def get_ind_param_values(self, category: str, name: str) -> Dict[str, str]:
-        """Ask database for the individual values of a parameter for all profiles
-
-        @param category: category of the parameter
-        @param name: name of the parameter
-        @return dict: profile => value map
-        """
-        async with self.session() as session:
-            result = await session.execute(
-                select(ParamInd)
-                .filter_by(
-                    category=category,
-                    name=name
-                )
-                .options(subqueryload(ParamInd.profile))
-            )
-        return {param.profile.name: param.value for param in result.scalars()}
-
-    @aio
-    async def set_gen_param(self, category: str, name: str, value: Optional[str]) -> None:
-        """Save the general parameters in database
-
-        @param category: category of the parameter
-        @param name: name of the parameter
-        @param value: value to set
-        """
-        async with self.session() as session:
-            stmt = insert(ParamGen).values(
-                category=category,
-                name=name,
-                value=value
-            ).on_conflict_do_update(
-                index_elements=(ParamGen.category, ParamGen.name),
-                set_={
-                    ParamGen.value: value
-                }
-            )
-            await session.execute(stmt)
-            await session.commit()
-
-    @aio
-    async def set_ind_param(
-        self,
-        category:str,
-        name: str,
-        value: Optional[str],
-        profile: str
-    ) -> None:
-        """Save the individual parameters in database
-
-        @param category: category of the parameter
-        @param name: name of the parameter
-        @param value: value to set
-        @param profile: a profile which *must* exist
-        """
-        async with self.session() as session:
-            stmt = insert(ParamInd).values(
-                category=category,
-                name=name,
-                profile_id=self.profiles[profile],
-                value=value
-            ).on_conflict_do_update(
-                index_elements=(ParamInd.category, ParamInd.name, ParamInd.profile_id),
-                set_={
-                    ParamInd.value: value
-                }
-            )
-            await session.execute(stmt)
-            await session.commit()
-
-    def _jid_filter(self, jid_: jid.JID, dest: bool = False):
-        """Generate condition to filter on a JID, using relevant columns
-
-        @param dest: True if it's the destinee JID, otherwise it's the source one
-        @param jid_: JID to filter by
-        """
-        if jid_.resource:
-            if dest:
-                return and_(
-                    History.dest == jid_.userhost(),
-                    History.dest_res == jid_.resource
-                )
-            else:
-                return and_(
-                    History.source == jid_.userhost(),
-                    History.source_res == jid_.resource
-                )
-        else:
-            if dest:
-                return History.dest == jid_.userhost()
-            else:
-                return History.source == jid_.userhost()
-
-    @aio
-    async def history_get(
-        self,
-        from_jid: Optional[jid.JID],
-        to_jid: Optional[jid.JID],
-        limit: Optional[int] = None,
-        between: bool = True,
-        filters: Optional[Dict[str, str]] = None,
-        profile: Optional[str] = None,
-    ) -> List[Tuple[
-        str, int, str, str, Dict[str, str], Dict[str, str], str, str, str]
-    ]:
-        """Retrieve messages in history
-
-        @param from_jid: source JID (full, or bare for catchall)
-        @param to_jid: dest JID (full, or bare for catchall)
-        @param limit: maximum number of messages to get:
-            - 0 for no message (returns the empty list)
-            - None for unlimited
-        @param between: confound source and dest (ignore the direction)
-        @param filters: pattern to filter the history results
-        @return: list of messages as in [message_new], minus the profile which is already
-            known.
-        """
-        # we have to set a default value to profile because it's last argument
-        # and thus follow other keyword arguments with default values
-        # but None should not be used for it
-        assert profile is not None
-        if limit == 0:
-            return []
-        if filters is None:
-            filters = {}
-
-        stmt = (
-            select(History)
-            .filter_by(
-                profile_id=self.profiles[profile]
-            )
-            .outerjoin(History.messages)
-            .outerjoin(History.subjects)
-            .outerjoin(History.thread)
-            .options(
-                contains_eager(History.messages),
-                contains_eager(History.subjects),
-                contains_eager(History.thread),
-            )
-            .order_by(
-                # timestamp may be identical for 2 close messages (specially when delay is
-                # used) that's why we order ties by received_timestamp. We'll reverse the
-                # order when returning the result. We use DESC here so LIMIT keep the last
-                # messages
-                History.timestamp.desc(),
-                History.received_timestamp.desc()
-            )
-        )
-
-
-        if not from_jid and not to_jid:
-            # no jid specified, we want all one2one communications
-            pass
-        elif between:
-            if not from_jid or not to_jid:
-                # we only have one jid specified, we check all messages
-                # from or to this jid
-                jid_ = from_jid or to_jid
-                stmt = stmt.where(
-                    or_(
-                        self._jid_filter(jid_),
-                        self._jid_filter(jid_, dest=True)
-                    )
-                )
-            else:
-                # we have 2 jids specified, we check all communications between
-                # those 2 jids
-                stmt = stmt.where(
-                    or_(
-                        and_(
-                            self._jid_filter(from_jid),
-                            self._jid_filter(to_jid, dest=True),
-                        ),
-                        and_(
-                            self._jid_filter(to_jid),
-                            self._jid_filter(from_jid, dest=True),
-                        )
-                    )
-                )
-        else:
-            # we want one communication in specific direction (from somebody or
-            # to somebody).
-            if from_jid is not None:
-                stmt = stmt.where(self._jid_filter(from_jid))
-            if to_jid is not None:
-                stmt = stmt.where(self._jid_filter(to_jid, dest=True))
-
-        if filters:
-            if 'timestamp_start' in filters:
-                stmt = stmt.where(History.timestamp >= float(filters['timestamp_start']))
-            if 'before_uid' in filters:
-                # orignially this query was using SQLITE's rowid. This has been changed
-                # to use coalesce(received_timestamp, timestamp) to be SQL engine independant
-                stmt = stmt.where(
-                    coalesce(
-                        History.received_timestamp,
-                        History.timestamp
-                    ) < (
-                        select(coalesce(History.received_timestamp, History.timestamp))
-                        .filter_by(uid=filters["before_uid"])
-                    ).scalar_subquery()
-                )
-            if 'body' in filters:
-                # TODO: use REGEXP (function to be defined) instead of GLOB: https://www.sqlite.org/lang_expr.html
-                stmt = stmt.where(Message.message.like(f"%{filters['body']}%"))
-            if 'search' in filters:
-                search_term = f"%{filters['search']}%"
-                stmt = stmt.where(or_(
-                    Message.message.like(search_term),
-                    History.source_res.like(search_term)
-                ))
-            if 'types' in filters:
-                types = filters['types'].split()
-                stmt = stmt.where(History.type.in_(types))
-            if 'not_types' in filters:
-                types = filters['not_types'].split()
-                stmt = stmt.where(History.type.not_in(types))
-            if 'last_stanza_id' in filters:
-                # this request get the last message with a "stanza_id" that we
-                # have in history. This is mainly used to retrieve messages sent
-                # while we were offline, using MAM (XEP-0313).
-                if (filters['last_stanza_id'] is not True
-                    or limit != 1):
-                    raise ValueError("Unexpected values for last_stanza_id filter")
-                stmt = stmt.where(History.stanza_id.is_not(None))
-            if 'origin_id' in filters:
-                stmt = stmt.where(History.origin_id == filters["origin_id"])
-
-        if limit is not None:
-            stmt = stmt.limit(limit)
-
-        async with self.session() as session:
-            result = await session.execute(stmt)
-
-        result = result.scalars().unique().all()
-        result.reverse()
-        return [h.as_tuple() for h in result]
-
-    @aio
-    async def add_to_history(self, data: dict, profile: str) -> None:
-        """Store a new message in history
-
-        @param data: message data as build by SatMessageProtocol.onMessage
-        """
-        extra = {k: v for k, v in data["extra"].items() if k not in NOT_IN_EXTRA}
-        messages = [Message(message=mess, language=lang)
-                    for lang, mess in data["message"].items()]
-        subjects = [Subject(subject=mess, language=lang)
-                    for lang, mess in data["subject"].items()]
-        if "thread" in data["extra"]:
-            thread = Thread(thread_id=data["extra"]["thread"],
-                            parent_id=data["extra"].get["thread_parent"])
-        else:
-            thread = None
-        try:
-            async with self.session() as session:
-                async with session.begin():
-                    session.add(History(
-                        uid=data["uid"],
-                        origin_id=data["extra"].get("origin_id"),
-                        stanza_id=data["extra"].get("stanza_id"),
-                        update_uid=data["extra"].get("update_uid"),
-                        profile_id=self.profiles[profile],
-                        source_jid=data["from"],
-                        dest_jid=data["to"],
-                        timestamp=data["timestamp"],
-                        received_timestamp=data.get("received_timestamp"),
-                        type=data["type"],
-                        extra=extra,
-                        messages=messages,
-                        subjects=subjects,
-                        thread=thread,
-                    ))
-        except IntegrityError as e:
-            if "unique" in str(e.orig).lower():
-                log.debug(
-                    f"message {data['uid']!r} is already in history, not storing it again"
-                )
-            else:
-                log.error(f"Can't store message {data['uid']!r} in history: {e}")
-        except Exception as e:
-            log.critical(
-                f"Can't store message, unexpected exception (uid: {data['uid']}): {e}"
-            )
-
-    ## Private values
-
-    def _get_private_class(self, binary, profile):
-        """Get ORM class to use for private values"""
-        if profile is None:
-            return PrivateGenBin if binary else PrivateGen
-        else:
-            return PrivateIndBin if binary else PrivateInd
-
-
-    @aio
-    async def get_privates(
-        self,
-        namespace:str,
-        keys: Optional[Iterable[str]] = None,
-        binary: bool = False,
-        profile: Optional[str] = None
-    ) -> Dict[str, Any]:
-        """Get private value(s) from databases
-
-        @param namespace: namespace of the values
-        @param keys: keys of the values to get None to get all keys/values
-        @param binary: True to deserialise binary values
-        @param profile: profile to use for individual values
-            None to use general values
-        @return: gotten keys/values
-        """
-        if keys is not None:
-            keys = list(keys)
-        log.debug(
-            f"getting {'general' if profile is None else 'individual'}"
-            f"{' binary' if binary else ''} private values from database for namespace "
-            f"{namespace}{f' with keys {keys!r}' if keys is not None else ''}"
-        )
-        cls = self._get_private_class(binary, profile)
-        stmt = select(cls).filter_by(namespace=namespace)
-        if keys:
-            stmt = stmt.where(cls.key.in_(list(keys)))
-        if profile is not None:
-            stmt = stmt.filter_by(profile_id=self.profiles[profile])
-        async with self.session() as session:
-            result = await session.execute(stmt)
-        return {p.key: p.value for p in result.scalars()}
-
-    @aio
-    async def set_private_value(
-        self,
-        namespace: str,
-        key:str,
-        value: Any,
-        binary: bool = False,
-        profile: Optional[str] = None
-    ) -> None:
-        """Set a private value in database
-
-        @param namespace: namespace of the values
-        @param key: key of the value to set
-        @param value: value to set
-        @param binary: True if it's a binary values
-            binary values need to be serialised, used for everything but strings
-        @param profile: profile to use for individual value
-            if None, it's a general value
-        """
-        cls = self._get_private_class(binary, profile)
-
-        values = {
-            "namespace": namespace,
-            "key": key,
-            "value": value
-        }
-        index_elements = [cls.namespace, cls.key]
-
-        if profile is not None:
-            values["profile_id"] = self.profiles[profile]
-            index_elements.append(cls.profile_id)
-
-        async with self.session() as session:
-            await session.execute(
-                insert(cls).values(**values).on_conflict_do_update(
-                    index_elements=index_elements,
-                    set_={
-                        cls.value: value
-                    }
-                )
-            )
-            await session.commit()
-
-    @aio
-    async def del_private_value(
-        self,
-        namespace: str,
-        key: str,
-        binary: bool = False,
-        profile: Optional[str] = None
-    ) -> None:
-        """Delete private value from database
-
-        @param category: category of the privateeter
-        @param key: key of the private value
-        @param binary: True if it's a binary values
-        @param profile: profile to use for individual value
-            if None, it's a general value
-        """
-        cls = self._get_private_class(binary, profile)
-
-        stmt = delete(cls).filter_by(namespace=namespace, key=key)
-
-        if profile is not None:
-            stmt = stmt.filter_by(profile_id=self.profiles[profile])
-
-        async with self.session() as session:
-            await session.execute(stmt)
-            await session.commit()
-
-    @aio
-    async def del_private_namespace(
-        self,
-        namespace: str,
-        binary: bool = False,
-        profile: Optional[str] = None
-    ) -> None:
-        """Delete all data from a private namespace
-
-        Be really cautious when you use this method, as all data with given namespace are
-        removed.
-        Params are the same as for del_private_value
-        """
-        cls = self._get_private_class(binary, profile)
-
-        stmt = delete(cls).filter_by(namespace=namespace)
-
-        if profile is not None:
-            stmt = stmt.filter_by(profile_id=self.profiles[profile])
-
-        async with self.session() as session:
-            await session.execute(stmt)
-            await session.commit()
-
-    ## Files
-
-    @aio
-    async def get_files(
-        self,
-        client: Optional[SatXMPPEntity],
-        file_id: Optional[str] = None,
-        version: Optional[str] = '',
-        parent: Optional[str] = None,
-        type_: Optional[str] = None,
-        file_hash: Optional[str] = None,
-        hash_algo: Optional[str] = None,
-        name: Optional[str] = None,
-        namespace: Optional[str] = None,
-        mime_type: Optional[str] = None,
-        public_id: Optional[str] = None,
-        owner: Optional[jid.JID] = None,
-        access: Optional[dict] = None,
-        projection: Optional[List[str]] = None,
-        unique: bool = False
-    ) -> List[dict]:
-        """Retrieve files with with given filters
-
-        @param file_id: id of the file
-            None to ignore
-        @param version: version of the file
-            None to ignore
-            empty string to look for current version
-        @param parent: id of the directory containing the files
-            None to ignore
-            empty string to look for root files/directories
-        @param projection: name of columns to retrieve
-            None to retrieve all
-        @param unique: if True will remove duplicates
-        other params are the same as for [set_file]
-        @return: files corresponding to filters
-        """
-        if projection is None:
-            projection = [
-                'id', 'version', 'parent', 'type', 'file_hash', 'hash_algo', 'name',
-                'size', 'namespace', 'media_type', 'media_subtype', 'public_id',
-                'created', 'modified', 'owner', 'access', 'extra'
-            ]
-
-        stmt = select(*[getattr(File, f) for f in projection])
-
-        if unique:
-            stmt = stmt.distinct()
-
-        if client is not None:
-            stmt = stmt.filter_by(profile_id=self.profiles[client.profile])
-        else:
-            if public_id is None:
-                raise exceptions.InternalError(
-                    "client can only be omitted when public_id is set"
-                )
-        if file_id is not None:
-            stmt = stmt.filter_by(id=file_id)
-        if version is not None:
-            stmt = stmt.filter_by(version=version)
-        if parent is not None:
-            stmt = stmt.filter_by(parent=parent)
-        if type_ is not None:
-            stmt = stmt.filter_by(type=type_)
-        if file_hash is not None:
-            stmt = stmt.filter_by(file_hash=file_hash)
-        if hash_algo is not None:
-            stmt = stmt.filter_by(hash_algo=hash_algo)
-        if name is not None:
-            stmt = stmt.filter_by(name=name)
-        if namespace is not None:
-            stmt = stmt.filter_by(namespace=namespace)
-        if mime_type is not None:
-            if '/' in mime_type:
-                media_type, media_subtype = mime_type.split("/", 1)
-                stmt = stmt.filter_by(media_type=media_type, media_subtype=media_subtype)
-            else:
-                stmt = stmt.filter_by(media_type=mime_type)
-        if public_id is not None:
-            stmt = stmt.filter_by(public_id=public_id)
-        if owner is not None:
-            stmt = stmt.filter_by(owner=owner)
-        if access is not None:
-            raise NotImplementedError('Access check is not implemented yet')
-            # a JSON comparison is needed here
-
-        async with self.session() as session:
-            result = await session.execute(stmt)
-
-        return [dict(r) for r in result]
-
-    @aio
-    async def set_file(
-        self,
-        client: SatXMPPEntity,
-        name: str,
-        file_id: str,
-        version: str = "",
-        parent: str = "",
-        type_: str = C.FILE_TYPE_FILE,
-        file_hash: Optional[str] = None,
-        hash_algo: Optional[str] = None,
-        size: int = None,
-        namespace: Optional[str] = None,
-        mime_type: Optional[str] = None,
-        public_id: Optional[str] = None,
-        created: Optional[float] = None,
-        modified: Optional[float] = None,
-        owner: Optional[jid.JID] = None,
-        access: Optional[dict] = None,
-        extra: Optional[dict] = None
-    ) -> None:
-        """Set a file metadata
-
-        @param client: client owning the file
-        @param name: name of the file (must not contain "/")
-        @param file_id: unique id of the file
-        @param version: version of this file
-        @param parent: id of the directory containing this file
-            Empty string if it is a root file/directory
-        @param type_: one of:
-            - file
-            - directory
-        @param file_hash: unique hash of the payload
-        @param hash_algo: algorithm used for hashing the file (usually sha-256)
-        @param size: size in bytes
-        @param namespace: identifier (human readable is better) to group files
-            for instance, namespace could be used to group files in a specific photo album
-        @param mime_type: media type of the file, or None if not known/guessed
-        @param public_id: ID used to server the file publicly via HTTP
-        @param created: UNIX time of creation
-        @param modified: UNIX time of last modification, or None to use created date
-        @param owner: jid of the owner of the file (mainly useful for component)
-        @param access: serialisable dictionary with access rules. See [memory.memory] for
-            details
-        @param extra: serialisable dictionary of any extra data
-            will be encoded to json in database
-        """
-        if mime_type is None:
-            media_type = media_subtype = None
-        elif '/' in mime_type:
-            media_type, media_subtype = mime_type.split('/', 1)
-        else:
-            media_type, media_subtype = mime_type, None
-
-        async with self.session() as session:
-            async with session.begin():
-                session.add(File(
-                    id=file_id,
-                    version=version.strip(),
-                    parent=parent,
-                    type=type_,
-                    file_hash=file_hash,
-                    hash_algo=hash_algo,
-                    name=name,
-                    size=size,
-                    namespace=namespace,
-                    media_type=media_type,
-                    media_subtype=media_subtype,
-                    public_id=public_id,
-                    created=time.time() if created is None else created,
-                    modified=modified,
-                    owner=owner,
-                    access=access,
-                    extra=extra,
-                    profile_id=self.profiles[client.profile]
-                ))
-
-    @aio
-    async def file_get_used_space(self, client: SatXMPPEntity, owner: jid.JID) -> int:
-        async with self.session() as session:
-            result = await session.execute(
-                select(sum_(File.size)).filter_by(
-                    owner=owner,
-                    type=C.FILE_TYPE_FILE,
-                    profile_id=self.profiles[client.profile]
-                ))
-        return result.scalar_one_or_none() or 0
-
-    @aio
-    async def file_delete(self, file_id: str) -> None:
-        """Delete file metadata from the database
-
-        @param file_id: id of the file to delete
-        NOTE: file itself must still be removed, this method only handle metadata in
-            database
-        """
-        async with self.session() as session:
-            await session.execute(delete(File).filter_by(id=file_id))
-            await session.commit()
-
-    @aio
-    async def file_update(
-        self,
-        file_id: str,
-        column: str,
-        update_cb: Callable[[dict], None]
-    ) -> None:
-        """Update a column value using a method to avoid race conditions
-
-        the older value will be retrieved from database, then update_cb will be applied to
-        update it, and file will be updated checking that older value has not been changed
-        meanwhile by an other user. If it has changed, it tries again a couple of times
-        before failing
-        @param column: column name (only "access" or "extra" are allowed)
-        @param update_cb: method to update the value of the colum
-            the method will take older value as argument, and must update it in place
-            update_cb must not care about serialization,
-            it get the deserialized data (i.e. a Python object) directly
-        @raise exceptions.NotFound: there is not file with this id
-        """
-        if column not in ('access', 'extra'):
-            raise exceptions.InternalError('bad column name')
-        orm_col = getattr(File, column)
-
-        for i in range(5):
-            async with self.session() as session:
-                try:
-                    value = (await session.execute(
-                        select(orm_col).filter_by(id=file_id)
-                    )).scalar_one()
-                except NoResultFound:
-                    raise exceptions.NotFound
-                old_value = copy.deepcopy(value)
-                update_cb(value)
-                stmt = update(File).filter_by(id=file_id).values({column: value})
-                if not old_value:
-                    # because JsonDefaultDict convert NULL to an empty dict, we have to
-                    # test both for empty dict and None when we have an empty dict
-                    stmt = stmt.where((orm_col == None) | (orm_col == old_value))
-                else:
-                    stmt = stmt.where(orm_col == old_value)
-                result = await session.execute(stmt)
-                await session.commit()
-
-            if result.rowcount == 1:
-                break
-
-            log.warning(
-                _("table not updated, probably due to race condition, trying again "
-                  "({tries})").format(tries=i+1)
-            )
-
-        else:
-            raise exceptions.DatabaseError(
-                _("Can't update file {file_id} due to race condition")
-                .format(file_id=file_id)
-            )
-
-    @aio
-    async def get_pubsub_node(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        name: str,
-        with_items: bool = False,
-        with_subscriptions: bool = False,
-        create: bool = False,
-        create_kwargs: Optional[dict] = None
-    ) -> Optional[PubsubNode]:
-        """Retrieve a PubsubNode from DB
-
-        @param service: service hosting the node
-        @param name: node's name
-        @param with_items: retrieve items in the same query
-        @param with_subscriptions: retrieve subscriptions in the same query
-        @param create: if the node doesn't exist in DB, create it
-        @param create_kwargs: keyword arguments to use with ``set_pubsub_node`` if the node
-            needs to be created.
-        """
-        async with self.session() as session:
-            stmt = (
-                select(PubsubNode)
-                .filter_by(
-                    service=service,
-                    name=name,
-                    profile_id=self.profiles[client.profile],
-                )
-            )
-            if with_items:
-                stmt = stmt.options(
-                    joinedload(PubsubNode.items)
-                )
-            if with_subscriptions:
-                stmt = stmt.options(
-                    joinedload(PubsubNode.subscriptions)
-                )
-            result = await session.execute(stmt)
-        ret = result.unique().scalar_one_or_none()
-        if ret is None and create:
-            # we auto-create the node
-            if create_kwargs is None:
-                create_kwargs = {}
-            try:
-                return await as_future(self.set_pubsub_node(
-                    client, service, name, **create_kwargs
-                ))
-            except IntegrityError as e:
-                if "unique" in str(e.orig).lower():
-                    # the node may already exist, if it has been created just after
-                    # get_pubsub_node above
-                    log.debug("ignoring UNIQUE constraint error")
-                    cached_node = await as_future(self.get_pubsub_node(
-                        client,
-                        service,
-                        name,
-                        with_items=with_items,
-                        with_subscriptions=with_subscriptions
-                    ))
-                else:
-                    raise e
-        else:
-            return ret
-
-    @aio
-    async def set_pubsub_node(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        name: str,
-        analyser: Optional[str] = None,
-        type_: Optional[str] = None,
-        subtype: Optional[str] = None,
-        subscribed: bool = False,
-    ) -> PubsubNode:
-        node = PubsubNode(
-            profile_id=self.profiles[client.profile],
-            service=service,
-            name=name,
-            subscribed=subscribed,
-            analyser=analyser,
-            type_=type_,
-            subtype=subtype,
-            subscriptions=[],
-        )
-        async with self.session() as session:
-            async with session.begin():
-                session.add(node)
-        return node
-
-    @aio
-    async def update_pubsub_node_sync_state(
-        self,
-        node: PubsubNode,
-        state: SyncState
-    ) -> None:
-        async with self.session() as session:
-            async with session.begin():
-                await session.execute(
-                    update(PubsubNode)
-                    .filter_by(id=node.id)
-                    .values(
-                        sync_state=state,
-                        sync_state_updated=time.time(),
-                    )
-                )
-
-    @aio
-    async def delete_pubsub_node(
-        self,
-        profiles: Optional[List[str]],
-        services: Optional[List[jid.JID]],
-        names: Optional[List[str]]
-    ) -> None:
-        """Delete items cached for a node
-
-        @param profiles: profile names from which nodes must be deleted.
-            None to remove nodes from ALL profiles
-        @param services: JIDs of pubsub services from which nodes must be deleted.
-            None to remove nodes from ALL services
-        @param names: names of nodes which must be deleted.
-            None to remove ALL nodes whatever is their names
-        """
-        stmt = delete(PubsubNode)
-        if profiles is not None:
-            stmt = stmt.where(
-                PubsubNode.profile.in_(
-                    [self.profiles[p] for p in profiles]
-                )
-            )
-        if services is not None:
-            stmt = stmt.where(PubsubNode.service.in_(services))
-        if names is not None:
-            stmt = stmt.where(PubsubNode.name.in_(names))
-        async with self.session() as session:
-            await session.execute(stmt)
-            await session.commit()
-
-    @aio
-    async def cache_pubsub_items(
-        self,
-        client: SatXMPPEntity,
-        node: PubsubNode,
-        items: List[domish.Element],
-        parsed_items: Optional[List[dict]] = None,
-    ) -> None:
-        """Add items to database, using an upsert taking care of "updated" field"""
-        if parsed_items is not None and len(items) != len(parsed_items):
-            raise exceptions.InternalError(
-                "parsed_items must have the same lenght as items"
-            )
-        async with self.session() as session:
-            async with session.begin():
-                for idx, item in enumerate(items):
-                    parsed = parsed_items[idx] if parsed_items else None
-                    stmt = insert(PubsubItem).values(
-                        node_id = node.id,
-                        name = item["id"],
-                        data = item,
-                        parsed = parsed,
-                    ).on_conflict_do_update(
-                        index_elements=(PubsubItem.node_id, PubsubItem.name),
-                        set_={
-                            PubsubItem.data: item,
-                            PubsubItem.parsed: parsed,
-                            PubsubItem.updated: now()
-                        }
-                    )
-                    await session.execute(stmt)
-                await session.commit()
-
-    @aio
-    async def delete_pubsub_items(
-        self,
-        node: PubsubNode,
-        items_names: Optional[List[str]] = None
-    ) -> None:
-        """Delete items cached for a node
-
-        @param node: node from which items must be deleted
-        @param items_names: names of items to delete
-            if None, ALL items will be deleted
-        """
-        stmt = delete(PubsubItem)
-        if node is not None:
-            if isinstance(node, list):
-                stmt = stmt.where(PubsubItem.node_id.in_([n.id for n in node]))
-            else:
-                stmt = stmt.filter_by(node_id=node.id)
-        if items_names is not None:
-            stmt = stmt.where(PubsubItem.name.in_(items_names))
-        async with self.session() as session:
-            await session.execute(stmt)
-            await session.commit()
-
-    @aio
-    async def purge_pubsub_items(
-        self,
-        services: Optional[List[jid.JID]] = None,
-        names: Optional[List[str]] = None,
-        types: Optional[List[str]] = None,
-        subtypes: Optional[List[str]] = None,
-        profiles: Optional[List[str]] = None,
-        created_before: Optional[datetime] = None,
-        updated_before: Optional[datetime] = None,
-    ) -> None:
-        """Delete items cached for a node
-
-        @param node: node from which items must be deleted
-        @param items_names: names of items to delete
-            if None, ALL items will be deleted
-        """
-        stmt = delete(PubsubItem)
-        node_fields = {
-            "service": services,
-            "name": names,
-            "type_": types,
-            "subtype": subtypes,
-        }
-        if profiles is not None:
-            node_fields["profile_id"] = [self.profiles[p] for p in profiles]
-
-        if any(x is not None for x in node_fields.values()):
-            sub_q = select(PubsubNode.id)
-            for col, values in node_fields.items():
-                if values is None:
-                    continue
-                sub_q = sub_q.where(getattr(PubsubNode, col).in_(values))
-            stmt = (
-                stmt
-                .where(PubsubItem.node_id.in_(sub_q))
-                .execution_options(synchronize_session=False)
-            )
-
-        if created_before is not None:
-            stmt = stmt.where(PubsubItem.created < created_before)
-
-        if updated_before is not None:
-            stmt = stmt.where(PubsubItem.updated < updated_before)
-
-        async with self.session() as session:
-            await session.execute(stmt)
-            await session.commit()
-
-    @aio
-    async def get_items(
-        self,
-        node: PubsubNode,
-        max_items: Optional[int] = None,
-        item_ids: Optional[list[str]] = None,
-        before: Optional[str] = None,
-        after: Optional[str] = None,
-        from_index: Optional[int] = None,
-        order_by: Optional[List[str]] = None,
-        desc: bool = True,
-        force_rsm: bool = False,
-    ) -> Tuple[List[PubsubItem], dict]:
-        """Get Pubsub Items from cache
-
-        @param node: retrieve items from this node (must be synchronised)
-        @param max_items: maximum number of items to retrieve
-        @param before: get items which are before the item with this name in given order
-            empty string is not managed here, use desc order to reproduce RSM
-            behaviour.
-        @param after: get items which are after the item with this name in given order
-        @param from_index: get items with item index (as defined in RSM spec)
-            starting from this number
-        @param order_by: sorting order of items (one of C.ORDER_BY_*)
-        @param desc: direction or ordering
-        @param force_rsm: if True, force the use of RSM worklow.
-            RSM workflow is automatically used if any of before, after or
-            from_index is used, but if only RSM max_items is used, it won't be
-            used by default. This parameter let's use RSM workflow in this
-            case. Note that in addition to RSM metadata, the result will not be
-            the same (max_items without RSM will returns most recent items,
-            i.e. last items in modification order, while max_items with RSM
-            will return the oldest ones (i.e. first items in modification
-            order).
-            to be used when max_items is used from RSM
-        """
-
-        metadata = {
-            "service": node.service,
-            "node": node.name,
-            "uri": uri.build_xmpp_uri(
-                "pubsub",
-                path=node.service.full(),
-                node=node.name,
-            ),
-        }
-        if max_items is None:
-            max_items = 20
-
-        use_rsm = any((before, after, from_index is not None))
-        if force_rsm and not use_rsm:
-            #
-            use_rsm = True
-            from_index = 0
-
-        stmt = (
-            select(PubsubItem)
-            .filter_by(node_id=node.id)
-            .limit(max_items)
-        )
-
-        if item_ids is not None:
-            stmt = stmt.where(PubsubItem.name.in_(item_ids))
-
-        if not order_by:
-            order_by = [C.ORDER_BY_MODIFICATION]
-
-        order = []
-        for order_type in order_by:
-            if order_type == C.ORDER_BY_MODIFICATION:
-                if desc:
-                    order.extend((PubsubItem.updated.desc(), PubsubItem.id.desc()))
-                else:
-                    order.extend((PubsubItem.updated.asc(), PubsubItem.id.asc()))
-            elif order_type == C.ORDER_BY_CREATION:
-                if desc:
-                    order.append(PubsubItem.id.desc())
-                else:
-                    order.append(PubsubItem.id.asc())
-            else:
-                raise exceptions.InternalError(f"Unknown order type {order_type!r}")
-
-        stmt = stmt.order_by(*order)
-
-        if use_rsm:
-            # CTE to have result row numbers
-            row_num_q = select(
-                PubsubItem.id,
-                PubsubItem.name,
-                # row_number starts from 1, but RSM index must start from 0
-                (func.row_number().over(order_by=order)-1).label("item_index")
-            ).filter_by(node_id=node.id)
-
-            row_num_cte = row_num_q.cte()
-
-            if max_items > 0:
-                # as we can't simply use PubsubItem.id when we order by modification,
-                # we need to use row number
-                item_name = before or after
-                row_num_limit_q = (
-                    select(row_num_cte.c.item_index)
-                    .where(row_num_cte.c.name==item_name)
-                ).scalar_subquery()
-
-                stmt = (
-                    select(row_num_cte.c.item_index, PubsubItem)
-                    .join(row_num_cte, PubsubItem.id == row_num_cte.c.id)
-                    .limit(max_items)
-                )
-                if before:
-                    stmt = (
-                        stmt
-                        .where(row_num_cte.c.item_index<row_num_limit_q)
-                        .order_by(row_num_cte.c.item_index.desc())
-                    )
-                elif after:
-                    stmt = (
-                        stmt
-                        .where(row_num_cte.c.item_index>row_num_limit_q)
-                        .order_by(row_num_cte.c.item_index.asc())
-                    )
-                else:
-                    stmt = (
-                        stmt
-                        .where(row_num_cte.c.item_index>=from_index)
-                        .order_by(row_num_cte.c.item_index.asc())
-                    )
-                    # from_index is used
-
-            async with self.session() as session:
-                if max_items == 0:
-                    items = result = []
-                else:
-                    result = await session.execute(stmt)
-                    result = result.all()
-                    if before:
-                        result.reverse()
-                    items = [row[-1] for row in result]
-                rows_count = (
-                    await session.execute(row_num_q.with_only_columns(count()))
-                ).scalar_one()
-
-            try:
-                index = result[0][0]
-            except IndexError:
-                index = None
-
-            try:
-                first = result[0][1].name
-            except IndexError:
-                first = None
-                last = None
-            else:
-                last = result[-1][1].name
-
-            metadata["rsm"] = {
-                k: v for k, v in {
-                    "index": index,
-                    "count": rows_count,
-                    "first": first,
-                    "last": last,
-                }.items() if v is not None
-            }
-            metadata["complete"] = (index or 0) + len(result) == rows_count
-
-            return items, metadata
-
-        async with self.session() as session:
-            result = await session.execute(stmt)
-
-        result = result.scalars().all()
-        if desc:
-            result.reverse()
-        return result, metadata
-
-    def _get_sqlite_path(
-        self,
-        path: List[Union[str, int]]
-    ) -> str:
-        """generate path suitable to query JSON element with SQLite"""
-        return f"${''.join(f'[{p}]' if isinstance(p, int) else f'.{p}' for p in path)}"
-
-    @aio
-    async def search_pubsub_items(
-        self,
-        query: dict,
-    ) -> Tuple[List[PubsubItem]]:
-        """Search for pubsub items in cache
-
-        @param query: search terms. Keys can be:
-            :fts (str):
-                Full-Text Search query. Currently SQLite FT5 engine is used, its query
-                syntax can be used, see `FTS5 Query documentation
-                <https://sqlite.org/fts5.html#full_text_query_syntax>`_
-            :profiles (list[str]):
-                filter on nodes linked to those profiles
-            :nodes (list[str]):
-                filter on nodes with those names
-            :services (list[jid.JID]):
-                filter on nodes from those services
-            :types (list[str|None]):
-                filter on nodes with those types. None can be used to filter on nodes with
-                no type set
-            :subtypes (list[str|None]):
-                filter on nodes with those subtypes. None can be used to filter on nodes with
-                no subtype set
-            :names (list[str]):
-                filter on items with those names
-            :parsed (list[dict]):
-                Filter on a parsed data field. The dict must contain 3 keys: ``path``
-                which is a list of str or int giving the path to the field of interest
-                (str for a dict key, int for a list index), ``operator`` with indicate the
-                operator to use to check the condition, and ``value`` which depends of
-                field type and operator.
-
-                See documentation for details on operators (it's currently explained at
-                ``doc/libervia-cli/pubsub_cache.rst`` in ``search`` command
-                documentation).
-
-            :order-by (list[dict]):
-                Indicates how to order results. The dict can contain either a ``order``
-                for a well-know order or a ``path`` for a parsed data field path
-                (``order`` and ``path`` can't be used at the same time), an an optional
-                ``direction`` which can be ``asc`` or ``desc``. See documentation for
-                details on well-known orders (it's currently explained at
-                ``doc/libervia-cli/pubsub_cache.rst`` in ``search`` command
-                documentation).
-
-            :index (int):
-                starting index of items to return from the query result. It's translated
-                to SQL's OFFSET
-
-            :limit (int):
-                maximum number of items to return. It's translated to SQL's LIMIT.
-
-        @result: found items (the ``node`` attribute will be filled with suitable
-            PubsubNode)
-        """
-        # TODO: FTS and parsed data filters use SQLite specific syntax
-        #   when other DB engines will be used, this will have to be adapted
-        stmt = select(PubsubItem)
-
-        # Full-Text Search
-        fts = query.get("fts")
-        if fts:
-            fts_select = text(
-                "SELECT rowid, rank FROM pubsub_items_fts(:fts_query)"
-            ).bindparams(fts_query=fts).columns(rowid=Integer).subquery()
-            stmt = (
-                stmt
-                .select_from(fts_select)
-                .outerjoin(PubsubItem, fts_select.c.rowid == PubsubItem.id)
-            )
-
-        # node related filters
-        profiles = query.get("profiles")
-        if (profiles
-            or any(query.get(k) for k in ("nodes", "services", "types", "subtypes"))
-        ):
-            stmt = stmt.join(PubsubNode).options(contains_eager(PubsubItem.node))
-            if profiles:
-                try:
-                    stmt = stmt.where(
-                        PubsubNode.profile_id.in_(self.profiles[p] for p in profiles)
-                    )
-                except KeyError as e:
-                    raise exceptions.ProfileUnknownError(
-                        f"This profile doesn't exist: {e.args[0]!r}"
-                    )
-            for key, attr in (
-                ("nodes", "name"),
-                ("services", "service"),
-                ("types", "type_"),
-                ("subtypes", "subtype")
-            ):
-                value = query.get(key)
-                if not value:
-                    continue
-                if key in ("types", "subtypes") and None in value:
-                    # NULL can't be used with SQL's IN, so we have to add a condition with
-                    # IS NULL, and use a OR if there are other values to check
-                    value.remove(None)
-                    condition = getattr(PubsubNode, attr).is_(None)
-                    if value:
-                        condition = or_(
-                            getattr(PubsubNode, attr).in_(value),
-                            condition
-                        )
-                else:
-                    condition = getattr(PubsubNode, attr).in_(value)
-                stmt = stmt.where(condition)
-        else:
-            stmt = stmt.options(selectinload(PubsubItem.node))
-
-        # names
-        names = query.get("names")
-        if names:
-            stmt = stmt.where(PubsubItem.name.in_(names))
-
-        # parsed data filters
-        parsed = query.get("parsed", [])
-        for filter_ in parsed:
-            try:
-                path = filter_["path"]
-                operator = filter_["op"]
-                value = filter_["value"]
-            except KeyError as e:
-                raise ValueError(
-                    f'missing mandatory key {e.args[0]!r} in "parsed" filter'
-                )
-            try:
-                op_attr = OP_MAP[operator]
-            except KeyError:
-                raise ValueError(f"invalid operator: {operator!r}")
-            sqlite_path = self._get_sqlite_path(path)
-            if operator in ("overlap", "ioverlap", "disjoint", "idisjoint"):
-                col = literal_column("json_each.value")
-                if operator[0] == "i":
-                    col = func.lower(col)
-                    value = [str(v).lower() for v in value]
-                condition = (
-                    select(1)
-                    .select_from(func.json_each(PubsubItem.parsed, sqlite_path))
-                    .where(col.in_(value))
-                ).scalar_subquery()
-                if operator in ("disjoint", "idisjoint"):
-                    condition = condition.is_(None)
-                stmt = stmt.where(condition)
-            elif operator == "between":
-                try:
-                    left, right = value
-                except (ValueError, TypeError):
-                    raise ValueError(_(
-                        "invalid value for \"between\" filter, you must use a 2 items "
-                        "array: {value!r}"
-                    ).format(value=value))
-                col = func.json_extract(PubsubItem.parsed, sqlite_path)
-                stmt = stmt.where(col.between(left, right))
-            else:
-                # we use func.json_extract instead of generic JSON way because SQLAlchemy
-                # add a JSON_QUOTE to the value, and we want SQL value
-                col = func.json_extract(PubsubItem.parsed, sqlite_path)
-                stmt = stmt.where(getattr(col, op_attr)(value))
-
-        # order
-        order_by = query.get("order-by") or [{"order": "creation"}]
-
-        for order_data in order_by:
-            order, path = order_data.get("order"), order_data.get("path")
-            if order and path:
-                raise ValueError(_(
-                    '"order" and "path" can\'t be used at the same time in '
-                    '"order-by" data'
-                ))
-            if order:
-                if order == "creation":
-                    col = PubsubItem.id
-                elif order == "modification":
-                    col = PubsubItem.updated
-                elif order == "item_id":
-                    col = PubsubItem.name
-                elif order == "rank":
-                    if not fts:
-                        raise ValueError(
-                            "'rank' order can only be used with Full-Text Search (fts)"
-                        )
-                    col = literal_column("rank")
-                else:
-                    raise NotImplementedError(f"Unknown {order!r} order")
-            else:
-                # we have a JSON path
-                # sqlite_path = self._get_sqlite_path(path)
-                col = PubsubItem.parsed[path]
-            direction = order_data.get("direction", "ASC").lower()
-            if not direction in ("asc", "desc"):
-                raise ValueError(f"Invalid order-by direction: {direction!r}")
-            stmt = stmt.order_by(getattr(col, direction)())
-
-        # offset, limit
-        index = query.get("index")
-        if index:
-            stmt = stmt.offset(index)
-        limit = query.get("limit")
-        if limit:
-            stmt = stmt.limit(limit)
-
-        async with self.session() as session:
-            result = await session.execute(stmt)
-
-        return result.scalars().all()
--- a/sat/memory/sqla_config.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,40 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from pathlib import Path
-from urllib.parse import quote
-from sat.core.constants import Const as C
-from sat.tools import config
-
-
-def get_db_config() -> dict:
-    """Get configuration for database
-
-    @return: dict with following keys:
-        - type: only "sqlite" for now
-        - path: path to the sqlite DB
-    """
-    main_conf = config.parse_main_conf()
-    local_dir = Path(config.config_get(main_conf, "", "local_dir"))
-    database_path = (local_dir / C.SAVEFILE_DATABASE).expanduser()
-    url = f"sqlite+aiosqlite:///{quote(str(database_path))}?timeout=30"
-    return {
-        "type": "sqlite",
-        "path": database_path,
-        "url": url,
-    }
--- a/sat/memory/sqla_mapping.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,640 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Dict, Any
-from datetime import datetime
-import enum
-import json
-import pickle
-import time
-
-from sqlalchemy import (
-    Boolean,
-    Column,
-    DDL,
-    DateTime,
-    Enum,
-    Float,
-    ForeignKey,
-    Index,
-    Integer,
-    JSON,
-    MetaData,
-    Text,
-    UniqueConstraint,
-    event,
-)
-from sqlalchemy.orm import declarative_base, relationship
-from sqlalchemy.sql.functions import now
-from sqlalchemy.types import TypeDecorator
-from twisted.words.protocols.jabber import jid
-from wokkel import generic
-
-
-Base = declarative_base(
-    metadata=MetaData(
-        naming_convention={
-            "ix": 'ix_%(column_0_label)s',
-            "uq": "uq_%(table_name)s_%(column_0_name)s",
-            "ck": "ck_%(table_name)s_%(constraint_name)s",
-            "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
-            "pk": "pk_%(table_name)s"
-        }
-    )
-)
-# keys which are in message data extra but not stored in extra field this is
-# because those values are stored in separate fields
-NOT_IN_EXTRA = ('origin_id', 'stanza_id', 'received_timestamp', 'update_uid')
-
-
-class SyncState(enum.Enum):
-    #: synchronisation is currently in progress
-    IN_PROGRESS = 1
-    #: synchronisation is done
-    COMPLETED = 2
-    #: something wrong happened during synchronisation, won't sync
-    ERROR = 3
-    #: synchronisation won't be done even if a syncing analyser matches
-    NO_SYNC = 4
-
-
-class SubscriptionState(enum.Enum):
-    SUBSCRIBED = 1
-    PENDING = 2
-
-
-class LegacyPickle(TypeDecorator):
-    """Handle troubles with data pickled by former version of SàT
-
-    This type is temporary until we do migration to a proper data type
-    """
-    # Blob is used on SQLite but gives errors when used here, while Text works fine
-    impl = Text
-    cache_ok = True
-
-    def process_bind_param(self, value, dialect):
-        if value is None:
-            return None
-        return pickle.dumps(value, 0)
-
-    def process_result_value(self, value, dialect):
-        if value is None:
-            return None
-        # value types are inconsistent (probably a consequence of Python 2/3 port
-        # and/or SQLite dynamic typing)
-        try:
-            value = value.encode()
-        except AttributeError:
-            pass
-        # "utf-8" encoding is needed to handle Python 2 pickled data
-        return pickle.loads(value, encoding="utf-8")
-
-
-class Json(TypeDecorator):
-    """Handle JSON field in DB independant way"""
-    # Blob is used on SQLite but gives errors when used here, while Text works fine
-    impl = Text
-    cache_ok = True
-
-    def process_bind_param(self, value, dialect):
-        if value is None:
-            return None
-        return json.dumps(value)
-
-    def process_result_value(self, value, dialect):
-        if value is None:
-            return None
-        return json.loads(value)
-
-
-class JsonDefaultDict(Json):
-    """Json type which convert NULL to empty dict instead of None"""
-
-    def process_result_value(self, value, dialect):
-        if value is None:
-            return {}
-        return json.loads(value)
-
-
-class Xml(TypeDecorator):
-    impl = Text
-    cache_ok = True
-
-    def process_bind_param(self, value, dialect):
-        if value is None:
-            return None
-        return value.toXml()
-
-    def process_result_value(self, value, dialect):
-        if value is None:
-            return None
-        return generic.parseXml(value.encode())
-
-
-class JID(TypeDecorator):
-    """Store twisted JID in text fields"""
-    impl = Text
-    cache_ok = True
-
-    def process_bind_param(self, value, dialect):
-        if value is None:
-            return None
-        return value.full()
-
-    def process_result_value(self, value, dialect):
-        if value is None:
-            return None
-        return jid.JID(value)
-
-
-class Profile(Base):
-    __tablename__ = "profiles"
-
-    id = Column(
-        Integer,
-        primary_key=True,
-        nullable=True,
-    )
-    name = Column(Text, unique=True)
-
-    params = relationship("ParamInd", back_populates="profile", passive_deletes=True)
-    private_data = relationship(
-        "PrivateInd", back_populates="profile", passive_deletes=True
-    )
-    private_bin_data = relationship(
-        "PrivateIndBin", back_populates="profile", passive_deletes=True
-    )
-
-
-class Component(Base):
-    __tablename__ = "components"
-
-    profile_id = Column(
-        ForeignKey("profiles.id", ondelete="CASCADE"),
-        nullable=True,
-        primary_key=True
-    )
-    entry_point = Column(Text, nullable=False)
-    profile = relationship("Profile")
-
-
-class History(Base):
-    __tablename__ = "history"
-    __table_args__ = (
-        UniqueConstraint("profile_id", "stanza_id", "source", "dest"),
-        UniqueConstraint("profile_id", "origin_id", "source", name="uq_origin_id"),
-        Index("history__profile_id_timestamp", "profile_id", "timestamp"),
-        Index(
-            "history__profile_id_received_timestamp", "profile_id", "received_timestamp"
-        )
-    )
-
-    uid = Column(Text, primary_key=True)
-    origin_id = Column(Text)
-    stanza_id = Column(Text)
-    update_uid = Column(Text)
-    profile_id = Column(ForeignKey("profiles.id", ondelete="CASCADE"))
-    source = Column(Text)
-    dest = Column(Text)
-    source_res = Column(Text)
-    dest_res = Column(Text)
-    timestamp = Column(Float, nullable=False)
-    received_timestamp = Column(Float)
-    type = Column(
-        Enum(
-            "chat",
-            "error",
-            "groupchat",
-            "headline",
-            "normal",
-            # info is not XMPP standard, but used to keep track of info like join/leave
-            # in a MUC
-            "info",
-            name="message_type",
-            create_constraint=True,
-        ),
-        nullable=False,
-    )
-    extra = Column(LegacyPickle)
-
-    profile = relationship("Profile")
-    messages = relationship("Message", backref="history", passive_deletes=True)
-    subjects = relationship("Subject", backref="history", passive_deletes=True)
-    thread = relationship(
-        "Thread", uselist=False, back_populates="history", passive_deletes=True
-    )
-
-    def __init__(self, *args, **kwargs):
-        source_jid = kwargs.pop("source_jid", None)
-        if source_jid is not None:
-            kwargs["source"] = source_jid.userhost()
-            kwargs["source_res"] = source_jid.resource
-        dest_jid = kwargs.pop("dest_jid", None)
-        if dest_jid is not None:
-            kwargs["dest"] = dest_jid.userhost()
-            kwargs["dest_res"] = dest_jid.resource
-        super().__init__(*args, **kwargs)
-
-    @property
-    def source_jid(self) -> jid.JID:
-        return jid.JID(f"{self.source}/{self.source_res or ''}")
-
-    @source_jid.setter
-    def source_jid(self, source_jid: jid.JID) -> None:
-        self.source = source_jid.userhost
-        self.source_res = source_jid.resource
-
-    @property
-    def dest_jid(self):
-        return jid.JID(f"{self.dest}/{self.dest_res or ''}")
-
-    @dest_jid.setter
-    def dest_jid(self, dest_jid: jid.JID) -> None:
-        self.dest = dest_jid.userhost
-        self.dest_res = dest_jid.resource
-
-    def __repr__(self):
-        dt = datetime.fromtimestamp(self.timestamp)
-        return f"History<{self.source_jid.full()}->{self.dest_jid.full()} [{dt}]>"
-
-    def serialise(self):
-        extra = self.extra or {}
-        if self.origin_id is not None:
-            extra["origin_id"] = self.origin_id
-        if self.stanza_id is not None:
-            extra["stanza_id"] = self.stanza_id
-        if self.update_uid is not None:
-            extra["update_uid"] = self.update_uid
-        if self.received_timestamp is not None:
-            extra["received_timestamp"] = self.received_timestamp
-        if self.thread is not None:
-            extra["thread"] = self.thread.thread_id
-            if self.thread.parent_id is not None:
-                extra["thread_parent"] = self.thread.parent_id
-
-
-        return {
-            "from": f"{self.source}/{self.source_res}" if self.source_res
-                else self.source,
-            "to": f"{self.dest}/{self.dest_res}" if self.dest_res else self.dest,
-            "uid": self.uid,
-            "message": {m.language or '': m.message for m in self.messages},
-            "subject": {m.language or '': m.subject for m in self.subjects},
-            "type": self.type,
-            "extra": extra,
-            "timestamp": self.timestamp,
-        }
-
-    def as_tuple(self):
-        d = self.serialise()
-        return (
-            d['uid'], d['timestamp'], d['from'], d['to'], d['message'], d['subject'],
-            d['type'], d['extra']
-        )
-
-    @staticmethod
-    def debug_collection(history_collection):
-        for idx, history in enumerate(history_collection):
-            history.debug_msg(idx)
-
-    def debug_msg(self, idx=None):
-        """Print messages"""
-        dt = datetime.fromtimestamp(self.timestamp)
-        if idx is not None:
-            dt = f"({idx}) {dt}"
-        parts = []
-        parts.append(f"[{dt}]<{self.source_jid.full()}->{self.dest_jid.full()}> ")
-        for message in self.messages:
-            if message.language:
-                parts.append(f"[{message.language}] ")
-            parts.append(f"{message.message}\n")
-        print("".join(parts))
-
-
-class Message(Base):
-    __tablename__ = "message"
-    __table_args__ = (
-        Index("message__history_uid", "history_uid"),
-    )
-
-    id = Column(
-        Integer,
-        primary_key=True,
-    )
-    history_uid = Column(ForeignKey("history.uid", ondelete="CASCADE"), nullable=False)
-    message = Column(Text, nullable=False)
-    language = Column(Text)
-
-    def serialise(self) -> Dict[str, Any]:
-        s = {}
-        if self.message:
-            s["message"] = str(self.message)
-        if self.language:
-            s["language"] = str(self.language)
-        return s
-
-    def __repr__(self):
-        lang_str = f"[{self.language}]" if self.language else ""
-        msg = f"{self.message[:20]}…" if len(self.message)>20 else self.message
-        content = f"{lang_str}{msg}"
-        return f"Message<{content}>"
-
-
-class Subject(Base):
-    __tablename__ = "subject"
-    __table_args__ = (
-        Index("subject__history_uid", "history_uid"),
-    )
-
-    id = Column(
-        Integer,
-        primary_key=True,
-    )
-    history_uid = Column(ForeignKey("history.uid", ondelete="CASCADE"), nullable=False)
-    subject = Column(Text, nullable=False)
-    language = Column(Text)
-
-    def serialise(self) -> Dict[str, Any]:
-        s = {}
-        if self.subject:
-            s["subject"] = str(self.subject)
-        if self.language:
-            s["language"] = str(self.language)
-        return s
-
-    def __repr__(self):
-        lang_str = f"[{self.language}]" if self.language else ""
-        msg = f"{self.subject[:20]}…" if len(self.subject)>20 else self.subject
-        content = f"{lang_str}{msg}"
-        return f"Subject<{content}>"
-
-
-class Thread(Base):
-    __tablename__ = "thread"
-    __table_args__ = (
-        Index("thread__history_uid", "history_uid"),
-    )
-
-    id = Column(
-        Integer,
-        primary_key=True,
-    )
-    history_uid = Column(ForeignKey("history.uid", ondelete="CASCADE"))
-    thread_id = Column(Text)
-    parent_id = Column(Text)
-
-    history = relationship("History", uselist=False, back_populates="thread")
-
-    def __repr__(self):
-        return f"Thread<{self.thread_id} [parent: {self.parent_id}]>"
-
-
-class ParamGen(Base):
-    __tablename__ = "param_gen"
-
-    category = Column(Text, primary_key=True)
-    name = Column(Text, primary_key=True)
-    value = Column(Text)
-
-
-class ParamInd(Base):
-    __tablename__ = "param_ind"
-
-    category = Column(Text, primary_key=True)
-    name = Column(Text, primary_key=True)
-    profile_id = Column(
-        ForeignKey("profiles.id", ondelete="CASCADE"), primary_key=True
-    )
-    value = Column(Text)
-
-    profile = relationship("Profile", back_populates="params")
-
-
-class PrivateGen(Base):
-    __tablename__ = "private_gen"
-
-    namespace = Column(Text, primary_key=True)
-    key = Column(Text, primary_key=True)
-    value = Column(Text)
-
-
-class PrivateInd(Base):
-    __tablename__ = "private_ind"
-
-    namespace = Column(Text, primary_key=True)
-    key = Column(Text, primary_key=True)
-    profile_id = Column(
-        ForeignKey("profiles.id", ondelete="CASCADE"), primary_key=True
-    )
-    value = Column(Text)
-
-    profile = relationship("Profile", back_populates="private_data")
-
-
-class PrivateGenBin(Base):
-    __tablename__ = "private_gen_bin"
-
-    namespace = Column(Text, primary_key=True)
-    key = Column(Text, primary_key=True)
-    value = Column(LegacyPickle)
-
-
-class PrivateIndBin(Base):
-    __tablename__ = "private_ind_bin"
-
-    namespace = Column(Text, primary_key=True)
-    key = Column(Text, primary_key=True)
-    profile_id = Column(
-        ForeignKey("profiles.id", ondelete="CASCADE"), primary_key=True
-    )
-    value = Column(LegacyPickle)
-
-    profile = relationship("Profile", back_populates="private_bin_data")
-
-
-class File(Base):
-    __tablename__ = "files"
-    __table_args__ = (
-        Index("files__profile_id_owner_parent", "profile_id", "owner", "parent"),
-        Index(
-            "files__profile_id_owner_media_type_media_subtype",
-            "profile_id",
-            "owner",
-            "media_type",
-            "media_subtype"
-        )
-    )
-
-    id = Column(Text, primary_key=True)
-    public_id = Column(Text, unique=True)
-    version = Column(Text, primary_key=True)
-    parent = Column(Text, nullable=False)
-    type = Column(
-        Enum(
-            "file", "directory",
-            name="file_type",
-            create_constraint=True
-        ),
-        nullable=False,
-        server_default="file",
-    )
-    file_hash = Column(Text)
-    hash_algo = Column(Text)
-    name = Column(Text, nullable=False)
-    size = Column(Integer)
-    namespace = Column(Text)
-    media_type = Column(Text)
-    media_subtype = Column(Text)
-    created = Column(Float, nullable=False)
-    modified = Column(Float)
-    owner = Column(JID)
-    access = Column(JsonDefaultDict)
-    extra = Column(JsonDefaultDict)
-    profile_id = Column(ForeignKey("profiles.id", ondelete="CASCADE"))
-
-    profile = relationship("Profile")
-
-
-class PubsubNode(Base):
-    __tablename__ = "pubsub_nodes"
-    __table_args__ = (
-        UniqueConstraint("profile_id", "service", "name"),
-    )
-
-    id = Column(Integer, primary_key=True)
-    profile_id = Column(
-        ForeignKey("profiles.id", ondelete="CASCADE")
-    )
-    service = Column(JID)
-    name = Column(Text, nullable=False)
-    subscribed = Column(
-        Boolean(create_constraint=True, name="subscribed_bool"), nullable=False
-    )
-    analyser = Column(Text)
-    sync_state = Column(
-        Enum(
-            SyncState,
-            name="sync_state",
-            create_constraint=True,
-        ),
-        nullable=True
-    )
-    sync_state_updated = Column(
-        Float,
-        nullable=False,
-        default=time.time()
-    )
-    type_ = Column(
-        Text, name="type", nullable=True
-    )
-    subtype = Column(
-        Text, nullable=True
-    )
-    extra = Column(JSON)
-
-    items = relationship("PubsubItem", back_populates="node", passive_deletes=True)
-    subscriptions = relationship("PubsubSub", back_populates="node", passive_deletes=True)
-
-    def __str__(self):
-        return f"Pubsub node {self.name!r} at {self.service}"
-
-
-class PubsubSub(Base):
-    """Subscriptions to pubsub nodes
-
-    Used by components managing a pubsub service
-    """
-    __tablename__ = "pubsub_subs"
-    __table_args__ = (
-        UniqueConstraint("node_id", "subscriber"),
-    )
-
-    id = Column(Integer, primary_key=True)
-    node_id = Column(ForeignKey("pubsub_nodes.id", ondelete="CASCADE"), nullable=False)
-    subscriber = Column(JID)
-    state = Column(
-        Enum(
-            SubscriptionState,
-            name="state",
-            create_constraint=True,
-        ),
-        nullable=True
-    )
-
-    node = relationship("PubsubNode", back_populates="subscriptions")
-
-
-class PubsubItem(Base):
-    __tablename__ = "pubsub_items"
-    __table_args__ = (
-        UniqueConstraint("node_id", "name"),
-    )
-    id = Column(Integer, primary_key=True)
-    node_id = Column(ForeignKey("pubsub_nodes.id", ondelete="CASCADE"), nullable=False)
-    name = Column(Text, nullable=False)
-    data = Column(Xml, nullable=False)
-    created = Column(DateTime, nullable=False, server_default=now())
-    updated = Column(DateTime, nullable=False, server_default=now(), onupdate=now())
-    parsed = Column(JSON)
-
-    node = relationship("PubsubNode", back_populates="items")
-
-
-## Full-Text Search
-
-# create
-
-@event.listens_for(PubsubItem.__table__, "after_create")
-def fts_create(target, connection, **kw):
-    """Full-Text Search table creation"""
-    if connection.engine.name == "sqlite":
-        # Using SQLite FTS5
-        queries = [
-            "CREATE VIRTUAL TABLE pubsub_items_fts "
-            "USING fts5(data, content=pubsub_items, content_rowid=id)",
-            "CREATE TRIGGER pubsub_items_fts_sync_ins AFTER INSERT ON pubsub_items BEGIN"
-            "  INSERT INTO pubsub_items_fts(rowid, data) VALUES (new.id, new.data);"
-            "END",
-            "CREATE TRIGGER pubsub_items_fts_sync_del AFTER DELETE ON pubsub_items BEGIN"
-            "  INSERT INTO pubsub_items_fts(pubsub_items_fts, rowid, data) "
-            "VALUES('delete', old.id, old.data);"
-            "END",
-            "CREATE TRIGGER pubsub_items_fts_sync_upd AFTER UPDATE ON pubsub_items BEGIN"
-            "  INSERT INTO pubsub_items_fts(pubsub_items_fts, rowid, data) VALUES"
-            "('delete', old.id, old.data);"
-            "  INSERT INTO pubsub_items_fts(rowid, data) VALUES(new.id, new.data);"
-            "END"
-        ]
-        for q in queries:
-            connection.execute(DDL(q))
-
-# drop
-
-@event.listens_for(PubsubItem.__table__, "before_drop")
-def fts_drop(target, connection, **kw):
-    "Full-Text Search table drop" ""
-    if connection.engine.name == "sqlite":
-        # Using SQLite FTS5
-        queries = [
-            "DROP TRIGGER IF EXISTS pubsub_items_fts_sync_ins",
-            "DROP TRIGGER IF EXISTS pubsub_items_fts_sync_del",
-            "DROP TRIGGER IF EXISTS pubsub_items_fts_sync_upd",
-            "DROP TABLE IF EXISTS pubsub_items_fts",
-        ]
-        for q in queries:
-            connection.execute(DDL(q))
--- a/sat/plugins/plugin_adhoc_dbus.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,478 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for adding D-Bus to Ad-Hoc Commands
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import D_, _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from wokkel import data_form
-
-try:
-    from lxml import etree
-except ImportError:
-    etree = None
-    log.warning("Missing module lxml, please download/install it from http://lxml.de/ ."
-                "Auto D-Bus discovery will be disabled")
-from collections import OrderedDict
-import os.path
-import uuid
-try:
-    import dbus
-    from dbus.mainloop.glib import DBusGMainLoop
-except ImportError:
-    dbus = None
-    log.warning("Missing module dbus, please download/install it, "
-                "auto D-Bus discovery will be disabled")
-
-else:
-    DBusGMainLoop(set_as_default=True)
-
-NS_MEDIA_PLAYER = "org.libervia.mediaplayer"
-FD_NAME = "org.freedesktop.DBus"
-FD_PATH = "/org/freedekstop/DBus"
-INTROSPECT_IFACE = "org.freedesktop.DBus.Introspectable"
-MPRIS_PREFIX = "org.mpris.MediaPlayer2"
-CMD_GO_BACK = "GoBack"
-CMD_GO_FWD = "GoFW"
-SEEK_OFFSET = 5 * 1000 * 1000
-MPRIS_COMMANDS = ["org.mpris.MediaPlayer2.Player." + cmd for cmd in (
-    "Previous", CMD_GO_BACK, "PlayPause", CMD_GO_FWD, "Next")]
-MPRIS_PATH = "/org/mpris/MediaPlayer2"
-MPRIS_PROPERTIES = OrderedDict((
-    ("org.mpris.MediaPlayer2", (
-        "Identity",
-        )),
-    ("org.mpris.MediaPlayer2.Player", (
-        "Metadata",
-        "PlaybackStatus",
-        "Volume",
-        )),
-    ))
-MPRIS_METADATA_KEY = "Metadata"
-MPRIS_METADATA_MAP = OrderedDict((
-    ("xesam:title", "Title"),
-    ))
-
-INTROSPECT_METHOD = "Introspect"
-IGNORED_IFACES_START = (
-    "org.freedesktop",
-    "org.qtproject",
-    "org.kde.KMainWindow",
-)  # commands in interface starting with these values will be ignored
-FLAG_LOOP = "LOOP"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Ad-Hoc Commands - D-Bus",
-    C.PI_IMPORT_NAME: "AD_HOC_DBUS",
-    C.PI_TYPE: "Misc",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0050"],
-    C.PI_MAIN: "AdHocDBus",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Add D-Bus management to Ad-Hoc commands"""),
-}
-
-
-class AdHocDBus(object):
-
-    def __init__(self, host):
-        log.info(_("plugin Ad-Hoc D-Bus initialization"))
-        self.host = host
-        if etree is not None:
-            host.bridge.add_method(
-                "ad_hoc_dbus_add_auto",
-                ".plugin",
-                in_sign="sasasasasasass",
-                out_sign="(sa(sss))",
-                method=self._ad_hoc_dbus_add_auto,
-                async_=True,
-            )
-        host.bridge.add_method(
-            "ad_hoc_remotes_get",
-            ".plugin",
-            in_sign="s",
-            out_sign="a(sss)",
-            method=self._ad_hoc_remotes_get,
-            async_=True,
-        )
-        self._c = host.plugins["XEP-0050"]
-        host.register_namespace("mediaplayer", NS_MEDIA_PLAYER)
-        if dbus is not None:
-            self.session_bus = dbus.SessionBus()
-            self.fd_object = self.session_bus.get_object(
-                FD_NAME, FD_PATH, introspect=False)
-
-    def profile_connected(self, client):
-        if dbus is not None:
-            self._c.add_ad_hoc_command(
-                client, self.local_media_cb, D_("Media Players"),
-                node=NS_MEDIA_PLAYER,
-                timeout=60*60*6  # 6 hours timeout, to avoid breaking remote
-                                 # in the middle of a movie
-            )
-
-    def _dbus_async_call(self, proxy, method, *args, **kwargs):
-        """ Call a DBus method asynchronously and return a deferred
-
-        @param proxy: DBus object proxy, as returner by get_object
-        @param method: name of the method to call
-        @param args: will be transmitted to the method
-        @param kwargs: will be transmetted to the method, except for the following poped
-                       values:
-                       - interface: name of the interface to use
-        @return: a deferred
-
-        """
-        d = defer.Deferred()
-        interface = kwargs.pop("interface", None)
-        kwargs["reply_handler"] = lambda ret=None: d.callback(ret)
-        kwargs["error_handler"] = d.errback
-        proxy.get_dbus_method(method, dbus_interface=interface)(*args, **kwargs)
-        return d
-
-    def _dbus_get_property(self, proxy, interface, name):
-        return self._dbus_async_call(
-            proxy, "Get", interface, name, interface="org.freedesktop.DBus.Properties")
-
-
-    def _dbus_list_names(self):
-        return self._dbus_async_call(self.fd_object, "ListNames")
-
-    def _dbus_introspect(self, proxy):
-        return self._dbus_async_call(proxy, INTROSPECT_METHOD, interface=INTROSPECT_IFACE)
-
-    def _accept_method(self, method):
-        """ Return True if we accept the method for a command
-        @param method: etree.Element
-        @return: True if the method is acceptable
-
-        """
-        if method.xpath(
-            "arg[@direction='in']"
-        ):  # we don't accept method with argument for the moment
-            return False
-        return True
-
-    @defer.inlineCallbacks
-    def _introspect(self, methods, bus_name, proxy):
-        log.debug("introspecting path [%s]" % proxy.object_path)
-        introspect_xml = yield self._dbus_introspect(proxy)
-        el = etree.fromstring(introspect_xml)
-        for node in el.iterchildren("node", "interface"):
-            if node.tag == "node":
-                new_path = os.path.join(proxy.object_path, node.get("name"))
-                new_proxy = self.session_bus.get_object(
-                    bus_name, new_path, introspect=False
-                )
-                yield self._introspect(methods, bus_name, new_proxy)
-            elif node.tag == "interface":
-                name = node.get("name")
-                if any(name.startswith(ignored) for ignored in IGNORED_IFACES_START):
-                    log.debug("interface [%s] is ignored" % name)
-                    continue
-                log.debug("introspecting interface [%s]" % name)
-                for method in node.iterchildren("method"):
-                    if self._accept_method(method):
-                        method_name = method.get("name")
-                        log.debug("method accepted: [%s]" % method_name)
-                        methods.add((proxy.object_path, name, method_name))
-
-    def _ad_hoc_dbus_add_auto(self, prog_name, allowed_jids, allowed_groups, allowed_magics,
-                          forbidden_jids, forbidden_groups, flags, profile_key):
-        client = self.host.get_client(profile_key)
-        return self.ad_hoc_dbus_add_auto(
-            client, prog_name, allowed_jids, allowed_groups, allowed_magics,
-            forbidden_jids, forbidden_groups, flags)
-
-    @defer.inlineCallbacks
-    def ad_hoc_dbus_add_auto(self, client, prog_name, allowed_jids=None, allowed_groups=None,
-                         allowed_magics=None, forbidden_jids=None, forbidden_groups=None,
-                         flags=None):
-        bus_names = yield self._dbus_list_names()
-        bus_names = [bus_name for bus_name in bus_names if "." + prog_name in bus_name]
-        if not bus_names:
-            log.info("Can't find any bus for [%s]" % prog_name)
-            defer.returnValue(("", []))
-        bus_names.sort()
-        for bus_name in bus_names:
-            if bus_name.endswith(prog_name):
-                break
-        log.info("bus name found: [%s]" % bus_name)
-        proxy = self.session_bus.get_object(bus_name, "/", introspect=False)
-        methods = set()
-
-        yield self._introspect(methods, bus_name, proxy)
-
-        if methods:
-            self._add_command(
-                client,
-                prog_name,
-                bus_name,
-                methods,
-                allowed_jids=allowed_jids,
-                allowed_groups=allowed_groups,
-                allowed_magics=allowed_magics,
-                forbidden_jids=forbidden_jids,
-                forbidden_groups=forbidden_groups,
-                flags=flags,
-            )
-
-        defer.returnValue((str(bus_name), methods))
-
-    def _add_command(self, client, adhoc_name, bus_name, methods, allowed_jids=None,
-                    allowed_groups=None, allowed_magics=None, forbidden_jids=None,
-                    forbidden_groups=None, flags=None):
-        if flags is None:
-            flags = set()
-
-        def d_bus_callback(client, command_elt, session_data, action, node):
-            actions = session_data.setdefault("actions", [])
-            names_map = session_data.setdefault("names_map", {})
-            actions.append(action)
-
-            if len(actions) == 1:
-                # it's our first request, we ask the desired new status
-                status = self._c.STATUS.EXECUTING
-                form = data_form.Form("form", title=_("Command selection"))
-                options = []
-                for path, iface, command in methods:
-                    label = command.rsplit(".", 1)[-1]
-                    name = str(uuid.uuid4())
-                    names_map[name] = (path, iface, command)
-                    options.append(data_form.Option(name, label))
-
-                field = data_form.Field(
-                    "list-single", "command", options=options, required=True
-                )
-                form.addField(field)
-
-                payload = form.toElement()
-                note = None
-
-            elif len(actions) == 2:
-                # we should have the answer here
-                try:
-                    x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
-                    answer_form = data_form.Form.fromElement(x_elt)
-                    command = answer_form["command"]
-                except (KeyError, StopIteration):
-                    raise self._c.AdHocError(self._c.ERROR.BAD_PAYLOAD)
-
-                if command not in names_map:
-                    raise self._c.AdHocError(self._c.ERROR.BAD_PAYLOAD)
-
-                path, iface, command = names_map[command]
-                proxy = self.session_bus.get_object(bus_name, path)
-
-                self._dbus_async_call(proxy, command, interface=iface)
-
-                # job done, we can end the session, except if we have FLAG_LOOP
-                if FLAG_LOOP in flags:
-                    # We have a loop, so we clear everything and we execute again the
-                    # command as we had a first call (command_elt is not used, so None
-                    # is OK)
-                    del actions[:]
-                    names_map.clear()
-                    return d_bus_callback(
-                        client, None, session_data, self._c.ACTION.EXECUTE, node
-                    )
-                form = data_form.Form("form", title=_("Updated"))
-                form.addField(data_form.Field("fixed", "Command sent"))
-                status = self._c.STATUS.COMPLETED
-                payload = None
-                note = (self._c.NOTE.INFO, _("Command sent"))
-            else:
-                raise self._c.AdHocError(self._c.ERROR.INTERNAL)
-
-            return (payload, status, None, note)
-
-        self._c.add_ad_hoc_command(
-            client,
-            d_bus_callback,
-            adhoc_name,
-            allowed_jids=allowed_jids,
-            allowed_groups=allowed_groups,
-            allowed_magics=allowed_magics,
-            forbidden_jids=forbidden_jids,
-            forbidden_groups=forbidden_groups,
-        )
-
-    ## Local media ##
-
-    def _ad_hoc_remotes_get(self, profile):
-        return self.ad_hoc_remotes_get(self.host.get_client(profile))
-
-    @defer.inlineCallbacks
-    def ad_hoc_remotes_get(self, client):
-        """Retrieve available remote media controlers in our devices
-        @return (list[tuple[unicode, unicode, unicode]]): list of devices with:
-            - entity full jid
-            - device name
-            - device label
-        """
-        found_data = yield defer.ensureDeferred(self.host.find_by_features(
-            client, [self.host.ns_map['commands']], service=False, roster=False,
-            own_jid=True, local_device=True))
-
-        remotes = []
-
-        for found in found_data:
-            for device_jid_s in found:
-                device_jid = jid.JID(device_jid_s)
-                cmd_list = yield self._c.list(client, device_jid)
-                for cmd in cmd_list:
-                    if cmd.nodeIdentifier == NS_MEDIA_PLAYER:
-                        try:
-                            result_elt = yield self._c.do(client, device_jid,
-                                                          NS_MEDIA_PLAYER, timeout=5)
-                            command_elt = self._c.get_command_elt(result_elt)
-                            form = data_form.findForm(command_elt, NS_MEDIA_PLAYER)
-                            if form is None:
-                                continue
-                            mp_options = form.fields['media_player'].options
-                            session_id = command_elt.getAttribute('sessionid')
-                            if mp_options and session_id:
-                                # we just want to discover player, so we cancel the
-                                # session
-                                self._c.do(client, device_jid, NS_MEDIA_PLAYER,
-                                           action=self._c.ACTION.CANCEL,
-                                           session_id=session_id)
-
-                            for opt in mp_options:
-                                remotes.append((device_jid_s,
-                                                opt.value,
-                                                opt.label or opt.value))
-                        except Exception as e:
-                            log.warning(_(
-                                "Can't retrieve remote controllers on {device_jid}: "
-                                "{reason}".format(device_jid=device_jid, reason=e)))
-                        break
-        defer.returnValue(remotes)
-
-    def do_mpris_command(self, proxy, command):
-        iface, command = command.rsplit(".", 1)
-        if command == CMD_GO_BACK:
-            command = 'Seek'
-            args = [-SEEK_OFFSET]
-        elif command == CMD_GO_FWD:
-            command = 'Seek'
-            args = [SEEK_OFFSET]
-        else:
-            args = []
-        return self._dbus_async_call(proxy, command, *args, interface=iface)
-
-    def add_mpris_metadata(self, form, metadata):
-        """Serialise MRPIS Metadata according to MPRIS_METADATA_MAP"""
-        for mpris_key, name in MPRIS_METADATA_MAP.items():
-            if mpris_key in metadata:
-                value = str(metadata[mpris_key])
-                form.addField(data_form.Field(fieldType="fixed",
-                                              var=name,
-                                              value=value))
-
-    @defer.inlineCallbacks
-    def local_media_cb(self, client, command_elt, session_data, action, node):
-        try:
-            x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
-            command_form = data_form.Form.fromElement(x_elt)
-        except StopIteration:
-            command_form = None
-
-        if command_form is None or len(command_form.fields) == 0:
-            # root request, we looks for media players
-            bus_names = yield self._dbus_list_names()
-            bus_names = [b for b in bus_names if b.startswith(MPRIS_PREFIX)]
-            if len(bus_names) == 0:
-                note = (self._c.NOTE.INFO, D_("No media player found."))
-                defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
-            options = []
-            status = self._c.STATUS.EXECUTING
-            form = data_form.Form("form", title=D_("Media Player Selection"),
-                                  formNamespace=NS_MEDIA_PLAYER)
-            for bus in bus_names:
-                player_name = bus[len(MPRIS_PREFIX)+1:]
-                if not player_name:
-                    log.warning(_("Ignoring MPRIS bus without suffix"))
-                    continue
-                options.append(data_form.Option(bus, player_name))
-            field = data_form.Field(
-                "list-single", "media_player", options=options, required=True
-            )
-            form.addField(field)
-            payload = form.toElement()
-            defer.returnValue((payload, status, None, None))
-        else:
-            # player request
-            try:
-                bus_name = command_form["media_player"]
-            except KeyError:
-                raise ValueError(_("missing media_player value"))
-
-            if not bus_name.startswith(MPRIS_PREFIX):
-                log.warning(_("Media player ad-hoc command trying to use non MPRIS bus. "
-                              "Hack attempt? Refused bus: {bus_name}").format(
-                              bus_name=bus_name))
-                note = (self._c.NOTE.ERROR, D_("Invalid player name."))
-                defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
-
-            try:
-                proxy = self.session_bus.get_object(bus_name, MPRIS_PATH)
-            except dbus.exceptions.DBusException as e:
-                log.warning(_("Can't get D-Bus proxy: {reason}").format(reason=e))
-                note = (self._c.NOTE.ERROR, D_("Media player is not available anymore"))
-                defer.returnValue((None, self._c.STATUS.COMPLETED, None, note))
-            try:
-                command = command_form["command"]
-            except KeyError:
-                pass
-            else:
-                yield self.do_mpris_command(proxy, command)
-
-            # we construct the remote control form
-            form = data_form.Form("form", title=D_("Media Player Selection"))
-            form.addField(data_form.Field(fieldType="hidden",
-                                          var="media_player",
-                                          value=bus_name))
-            for iface, properties_names in MPRIS_PROPERTIES.items():
-                for name in properties_names:
-                    try:
-                        value = yield self._dbus_get_property(proxy, iface, name)
-                    except Exception as e:
-                        log.warning(_("Can't retrieve attribute {name}: {reason}")
-                                    .format(name=name, reason=e))
-                        continue
-                    if name == MPRIS_METADATA_KEY:
-                        self.add_mpris_metadata(form, value)
-                    else:
-                        form.addField(data_form.Field(fieldType="fixed",
-                                                      var=name,
-                                                      value=str(value)))
-
-            commands = [data_form.Option(c, c.rsplit(".", 1)[1]) for c in MPRIS_COMMANDS]
-            form.addField(data_form.Field(fieldType="list-single",
-                                          var="command",
-                                          options=commands,
-                                          required=True))
-
-            payload = form.toElement()
-            status = self._c.STATUS.EXECUTING
-            defer.returnValue((payload, status, None, None))
--- a/sat/plugins/plugin_app_manager_docker/__init__.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,115 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin to manage Docker
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from pathlib import Path
-from twisted.python.procutils import which
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-from sat.tools.common import async_process
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Docker Applications Manager",
-    C.PI_IMPORT_NAME: "APP_MANAGER_DOCKER",
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_DEPENDENCIES: ["APP_MANAGER"],
-    C.PI_MAIN: "AppManagerDocker",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _(
-        """Applications Manager for Docker"""),
-}
-
-
-class AppManagerDocker:
-    name = "docker-compose"
-    discover_path = Path(__file__).parent
-
-    def __init__(self, host):
-        log.info(_("Docker App Manager initialization"))
-        try:
-            self.docker_compose_path = which('docker-compose')[0]
-        except IndexError:
-            raise exceptions.NotFound(
-                '"docker-compose" executable not found, Docker can\'t be used with '
-                'application manager')
-        self.host = host
-        self._am = host.plugins['APP_MANAGER']
-        self._am.register(self)
-
-    async def start(self, app_data: dict) -> None:
-        await self._am.start_common(app_data)
-        working_dir = app_data['_instance_dir_path']
-        try:
-            override = app_data['override']
-        except KeyError:
-            pass
-        else:
-            log.debug("writting override file")
-            override_path = working_dir / "docker-compose.override.yml"
-            with override_path.open("w") as f:
-                self._am.dump(override, f)
-        await async_process.run(
-            self.docker_compose_path,
-            "up",
-            "--detach",
-            path=str(working_dir),
-        )
-
-    async def stop(self, app_data: dict) -> None:
-        working_dir = app_data['_instance_dir_path']
-        await async_process.run(
-            self.docker_compose_path,
-            "down",
-            path=str(working_dir),
-        )
-
-    async def compute_expose(self, app_data: dict) -> dict:
-        working_dir = app_data['_instance_dir_path']
-        expose = app_data['expose']
-        ports = expose.get('ports', {})
-        for name, port_data in list(ports.items()):
-            try:
-                service = port_data['service']
-                private = port_data['private']
-                int(private)
-            except (KeyError, ValueError):
-                log.warning(
-                    f"invalid value found for {name!r} port in {app_data['_file_path']}")
-                continue
-            exposed_port = await async_process.run(
-                self.docker_compose_path,
-                "port",
-                service,
-                str(private),
-                path=str(working_dir),
-            )
-            exposed_port = exposed_port.decode().strip()
-            try:
-                addr, port = exposed_port.split(':')
-                int(port)
-            except ValueError:
-                log.warning(
-                    f"invalid exposed port for {name}, ignoring: {exposed_port!r}")
-                del ports[name]
-            else:
-                ports[name] = exposed_port
--- a/sat/plugins/plugin_app_manager_docker/sat_app_weblate.yaml	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,37 +0,0 @@
-type: docker-compose
-prepare:
-  git: https://github.com/WeblateOrg/docker-compose.git
-files:
-  settings-override.py:
-    content: |
-      USE_X_FORWARDED_HOST = True
-override:
-  version: "3"
-  services:
-    weblate:
-      ports:
-        - "8080"
-      environment:
-        WEBLATE_DEBUG: 0
-        WEBLATE_URL_PREFIX: !sat_param [url_prefix, /weblate]
-        WEBLATE_EMAIL_HOST: !sat_conf ["", "email_server"]
-        WEBLATE_EMAIL_HOST_USER: !sat_conf ["", "email_username"]
-        WEBLATE_EMAIL_HOST_PASSWORD: !sat_conf ["", "email_password"]
-        WEBLATE_SERVER_EMAIL: !sat_conf ["", "email_from", "weblate@example.com"]
-        WEBLATE_DEFAULT_FROM_EMAIL: !sat_conf ["", "email_from", "weblate@example.com"]
-        WEBLATE_SITE_DOMAIN: !sat_conf ["", "public_url"]
-        WEBLATE_ADMIN_PASSWORD: !sat_generate_pwd
-        WEBLATE_ADMIN_EMAIL: !sat_conf ["", "email_admins_list", "", "first"]
-        WEBLATE_ENABLE_HTTPS: !sat_conf ["", "weblate_enable_https", "1"]
-      volumes:
-        - ./settings-override.py:/app/data/settings-override.py:ro
-expose:
-  url_prefix: [override, services, weblate, environment, WEBLATE_URL_PREFIX]
-  front_url: !sat_param [front_url, /translate]
-  web_label: Translate
-  ports:
-    web:
-      service: weblate
-      private: 8080
-  passwords:
-    admin: [override, services, weblate, environment, WEBLATE_ADMIN_PASSWORD]
--- a/sat/plugins/plugin_blog_import.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,323 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT plugin for import external blogs
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.internet import defer
-from twisted.web import client as web_client
-from twisted.words.xish import domish
-from sat.core import exceptions
-from sat.tools import xml_tools
-import os
-import os.path
-import tempfile
-import urllib.parse
-import shortuuid
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "blog import",
-    C.PI_IMPORT_NAME: "BLOG_IMPORT",
-    C.PI_TYPE: (C.PLUG_TYPE_BLOG, C.PLUG_TYPE_IMPORT),
-    C.PI_DEPENDENCIES: ["IMPORT", "XEP-0060", "XEP-0277", "TEXT_SYNTAXES", "UPLOAD"],
-    C.PI_MAIN: "BlogImportPlugin",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _(
-        """Blog import management:
-This plugin manage the different blog importers which can register to it, and handle generic importing tasks."""
-    ),
-}
-
-OPT_HOST = "host"
-OPT_UPLOAD_IMAGES = "upload_images"
-OPT_UPLOAD_IGNORE_HOST = "upload_ignore_host"
-OPT_IGNORE_TLS = "ignore_tls_errors"
-URL_REDIRECT_PREFIX = "url_redirect_"
-
-
-class BlogImportPlugin(object):
-    BOOL_OPTIONS = (OPT_UPLOAD_IMAGES, OPT_IGNORE_TLS)
-    JSON_OPTIONS = ()
-    OPT_DEFAULTS = {OPT_UPLOAD_IMAGES: True, OPT_IGNORE_TLS: False}
-
-    def __init__(self, host):
-        log.info(_("plugin Blog import initialization"))
-        self.host = host
-        self._u = host.plugins["UPLOAD"]
-        self._p = host.plugins["XEP-0060"]
-        self._m = host.plugins["XEP-0277"]
-        self._s = self.host.plugins["TEXT_SYNTAXES"]
-        host.plugins["IMPORT"].initialize(self, "blog")
-
-    def import_item(
-        self, client, item_import_data, session, options, return_data, service, node
-    ):
-        """import_item specialized for blog import
-
-        @param item_import_data(dict):
-            * mandatory keys:
-                'blog' (dict): microblog data of the blog post (cf. http://wiki.goffi.org/wiki/Bridge_API_-_Microblogging/en)
-                    the importer MUST NOT create node or call XEP-0277 plugin itself
-                    'comments*' key MUST NOT be used in this microblog_data, see bellow for comments
-                    It is recommanded to use a unique id in the "id" key which is constant per blog item,
-                    so if the import fail, a new import will overwrite the failed items and avoid duplicates.
-
-                'comments' (list[list[dict]],None): Dictionaries must have the same keys as main item (i.e. 'blog' and 'comments')
-                    a list of list is used because XEP-0277 can handler several comments nodes,
-                    but in most cases, there will we only one item it the first list (something like [[{comment1_data},{comment2_data}, ...]])
-                    blog['allow_comments'] must be True if there is any comment, and False (or not present) if comments are not allowed.
-                    If allow_comments is False and some comments are present, an exceptions.DataError will be raised
-            * optional keys:
-                'url' (unicode): former url of the post (only the path, without host part)
-                    if present the association to the new path will be displayed to user, so it can make redirections if necessary
-        @param options(dict, None): Below are the generic options,
-            blog importer can have specific ones. All options have unicode values
-            generic options:
-                - OPT_HOST (unicode): original host
-                - OPT_UPLOAD_IMAGES (bool): upload images to XMPP server if True
-                    see OPT_UPLOAD_IGNORE_HOST.
-                    Default: True
-                - OPT_UPLOAD_IGNORE_HOST (unicode): don't upload images from this host
-                - OPT_IGNORE_TLS (bool): ignore TLS error for image upload.
-                    Default: False
-        @param return_data(dict): will contain link between former posts and new items
-
-        """
-        mb_data = item_import_data["blog"]
-        try:
-            item_id = mb_data["id"]
-        except KeyError:
-            item_id = mb_data["id"] = str(shortuuid.uuid())
-
-        try:
-            # we keep the link between old url and new blog item
-            # so the user can redirect its former blog urls
-            old_uri = item_import_data["url"]
-        except KeyError:
-            pass
-        else:
-            new_uri = return_data[URL_REDIRECT_PREFIX + old_uri] = self._p.get_node_uri(
-                service if service is not None else client.jid.userhostJID(),
-                node or self._m.namespace,
-                item_id,
-            )
-            log.info("url link from {old} to {new}".format(old=old_uri, new=new_uri))
-
-        return mb_data
-
-    @defer.inlineCallbacks
-    def import_sub_items(self, client, item_import_data, mb_data, session, options):
-        # comments data
-        if len(item_import_data["comments"]) != 1:
-            raise NotImplementedError("can't manage multiple comment links")
-        allow_comments = C.bool(mb_data.get("allow_comments", C.BOOL_FALSE))
-        if allow_comments:
-            comments_service = yield self._m.get_comments_service(client)
-            comments_node = self._m.get_comments_node(mb_data["id"])
-            mb_data["comments_service"] = comments_service.full()
-            mb_data["comments_node"] = comments_node
-            recurse_kwargs = {
-                "items_import_data": item_import_data["comments"][0],
-                "service": comments_service,
-                "node": comments_node,
-            }
-            defer.returnValue(recurse_kwargs)
-        else:
-            if item_import_data["comments"][0]:
-                raise exceptions.DataError(
-                    "allow_comments set to False, but comments are there"
-                )
-            defer.returnValue(None)
-
-    def publish_item(self, client, mb_data, service, node, session):
-        log.debug(
-            "uploading item [{id}]: {title}".format(
-                id=mb_data["id"], title=mb_data.get("title", "")
-            )
-        )
-        return self._m.send(client, mb_data, service, node)
-
-    @defer.inlineCallbacks
-    def item_filters(self, client, mb_data, session, options):
-        """Apply filters according to options
-
-        modify mb_data in place
-        @param posts_data(list[dict]): data as returned by importer callback
-        @param options(dict): dict as given in [blogImport]
-        """
-        # FIXME: blog filters don't work on text content
-        # TODO: text => XHTML conversion should handler links with <a/>
-        #       filters can then be used by converting text to XHTML
-        if not options:
-            return
-
-        # we want only XHTML content
-        for prefix in (
-            "content",
-        ):  # a tuple is use, if title need to be added in the future
-            try:
-                rich = mb_data["{}_rich".format(prefix)]
-            except KeyError:
-                pass
-            else:
-                if "{}_xhtml".format(prefix) in mb_data:
-                    raise exceptions.DataError(
-                        "importer gave {prefix}_rich and {prefix}_xhtml at the same time, this is not allowed".format(
-                            prefix=prefix
-                        )
-                    )
-                # we convert rich syntax to XHTML here, so we can handle filters easily
-                converted = yield self._s.convert(
-                    rich, self._s.get_current_syntax(client.profile), safe=False
-                )
-                mb_data["{}_xhtml".format(prefix)] = converted
-                del mb_data["{}_rich".format(prefix)]
-
-            try:
-                mb_data["txt"]
-            except KeyError:
-                pass
-            else:
-                if "{}_xhtml".format(prefix) in mb_data:
-                    log.warning(
-                        "{prefix}_text will be replaced by converted {prefix}_xhtml, so filters can be handled".format(
-                            prefix=prefix
-                        )
-                    )
-                    del mb_data["{}_text".format(prefix)]
-                else:
-                    log.warning(
-                        "importer gave a text {prefix}, blog filters don't work on text {prefix}".format(
-                            prefix=prefix
-                        )
-                    )
-                    return
-
-        # at this point, we have only XHTML version of content
-        try:
-            top_elt = xml_tools.ElementParser()(
-                mb_data["content_xhtml"], namespace=C.NS_XHTML
-            )
-        except domish.ParserError:
-            # we clean the xml and try again our luck
-            cleaned = yield self._s.clean_xhtml(mb_data["content_xhtml"])
-            top_elt = xml_tools.ElementParser()(cleaned, namespace=C.NS_XHTML)
-        opt_host = options.get(OPT_HOST)
-        if opt_host:
-            # we normalise the domain
-            parsed_host = urllib.parse.urlsplit(opt_host)
-            opt_host = urllib.parse.urlunsplit(
-                (
-                    parsed_host.scheme or "http",
-                    parsed_host.netloc or parsed_host.path,
-                    "",
-                    "",
-                    "",
-                )
-            )
-
-        tmp_dir = tempfile.mkdtemp()
-        try:
-            # TODO: would be nice to also update the hyperlinks to these images, e.g. when you have <a href="{url}"><img src="{url}"></a>
-            for img_elt in xml_tools.find_all(top_elt, names=["img"]):
-                yield self.img_filters(client, img_elt, options, opt_host, tmp_dir)
-        finally:
-            os.rmdir(tmp_dir)  # XXX: tmp_dir should be empty, or something went wrong
-
-        # we now replace the content with filtered one
-        mb_data["content_xhtml"] = top_elt.toXml()
-
-    @defer.inlineCallbacks
-    def img_filters(self, client, img_elt, options, opt_host, tmp_dir):
-        """Filters handling images
-
-        url without host are fixed (if possible)
-        according to options, images are uploaded to XMPP server
-        @param img_elt(domish.Element): <img/> element to handle
-        @param options(dict): filters options
-        @param opt_host(unicode): normalised host given in options
-        @param tmp_dir(str): path to temp directory
-        """
-        try:
-            url = img_elt["src"]
-            if url[0] == "/":
-                if not opt_host:
-                    log.warning(
-                        "host was not specified, we can't deal with src without host ({url}) and have to ignore the following <img/>:\n{xml}".format(
-                            url=url, xml=img_elt.toXml()
-                        )
-                    )
-                    return
-                else:
-                    url = urllib.parse.urljoin(opt_host, url)
-            filename = url.rsplit("/", 1)[-1].strip()
-            if not filename:
-                raise KeyError
-        except (KeyError, IndexError):
-            log.warning("ignoring invalid img element: {}".format(img_elt.toXml()))
-            return
-
-        # we change the url for the normalized one
-        img_elt["src"] = url
-
-        if options.get(OPT_UPLOAD_IMAGES, False):
-            # upload is requested
-            try:
-                ignore_host = options[OPT_UPLOAD_IGNORE_HOST]
-            except KeyError:
-                pass
-            else:
-                # host is the ignored one, we skip
-                parsed_url = urllib.parse.urlsplit(url)
-                if ignore_host in parsed_url.hostname:
-                    log.info(
-                        "Don't upload image at {url} because of {opt} option".format(
-                            url=url, opt=OPT_UPLOAD_IGNORE_HOST
-                        )
-                    )
-                    return
-
-            # we download images and re-upload them via XMPP
-            tmp_file = os.path.join(tmp_dir, filename).encode("utf-8")
-            upload_options = {"ignore_tls_errors": options.get(OPT_IGNORE_TLS, False)}
-
-            try:
-                yield web_client.downloadPage(url.encode("utf-8"), tmp_file)
-                filename = filename.replace(
-                    "%", "_"
-                )  # FIXME: tmp workaround for a bug in prosody http upload
-                __, download_d = yield self._u.upload(
-                    client, tmp_file, filename, extra=upload_options
-                )
-                download_url = yield download_d
-            except Exception as e:
-                log.warning(
-                    "can't download image at {url}: {reason}".format(url=url, reason=e)
-                )
-            else:
-                img_elt["src"] = download_url
-
-            try:
-                os.unlink(tmp_file)
-            except OSError:
-                pass
--- a/sat/plugins/plugin_blog_import_dokuwiki.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,414 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT plugin to import dokuwiki blogs
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core import exceptions
-from sat.tools import xml_tools
-from twisted.internet import threads
-from collections import OrderedDict
-import calendar
-import urllib.request, urllib.parse, urllib.error
-import urllib.parse
-import tempfile
-import re
-import time
-import os.path
-
-try:
-    from dokuwiki import DokuWiki, DokuWikiError  # this is a new dependency
-except ImportError:
-    raise exceptions.MissingModule(
-        'Missing module dokuwiki, please install it with "pip install dokuwiki"'
-    )
-try:
-    from PIL import Image  # this is already needed by plugin XEP-0054
-except:
-    raise exceptions.MissingModule(
-        "Missing module pillow, please download/install it from https://python-pillow.github.io"
-    )
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Dokuwiki import",
-    C.PI_IMPORT_NAME: "IMPORT_DOKUWIKI",
-    C.PI_TYPE: C.PLUG_TYPE_BLOG,
-    C.PI_DEPENDENCIES: ["BLOG_IMPORT"],
-    C.PI_MAIN: "DokuwikiImport",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Blog importer for Dokuwiki blog engine."""),
-}
-
-SHORT_DESC = D_("import posts from Dokuwiki blog engine")
-
-LONG_DESC = D_(
-    """This importer handle Dokuwiki blog engine.
-
-To use it, you need an admin access to a running Dokuwiki website
-(local or on the Internet). The importer retrieves the data using
-the XMLRPC Dokuwiki API.
-
-You can specify a namespace (that could be a namespace directory
-or a single post) or leave it empty to use the root namespace "/"
-and import all the posts.
-
-You can specify a new media repository to modify the internal
-media links and make them point to the URL of your choice, but
-note that the upload is not done automatically: a temporary
-directory will be created on your local drive and you will
-need to upload it yourself to your repository via SSH or FTP.
-
-Following options are recognized:
-
-location: DokuWiki site URL
-user: DokuWiki admin user
-passwd: DokuWiki admin password
-namespace: DokuWiki namespace to import (default: root namespace "/")
-media_repo: URL to the new remote media repository (default: none)
-limit: maximal number of posts to import (default: 100)
-
-Example of usage (with jp frontend):
-
-jp import dokuwiki -p dave --pwd xxxxxx --connect
-    http://127.0.1.1 -o user souliane -o passwd qwertz
-    -o namespace public:2015:10
-    -o media_repo http://media.diekulturvermittlung.at
-
-This retrieves the 100 last blog posts from http://127.0.1.1 that
-are inside the namespace "public:2015:10" using the Dokuwiki user
-"souliane", and it imports them to sat profile dave's microblog node.
-Internal Dokuwiki media that were hosted on http://127.0.1.1 are now
-pointing to http://media.diekulturvermittlung.at.
-"""
-)
-DEFAULT_MEDIA_REPO = ""
-DEFAULT_NAMESPACE = "/"
-DEFAULT_LIMIT = 100  # you might get a DBUS timeout (no reply) if it lasts too long
-
-
-class Importer(DokuWiki):
-    def __init__(
-        self, url, user, passwd, media_repo=DEFAULT_MEDIA_REPO, limit=DEFAULT_LIMIT
-    ):
-        """
-
-        @param url (unicode): DokuWiki site URL
-        @param user (unicode): DokuWiki admin user
-        @param passwd (unicode): DokuWiki admin password
-        @param media_repo (unicode): New remote media repository
-        """
-        DokuWiki.__init__(self, url, user, passwd)
-        self.url = url
-        self.media_repo = media_repo
-        self.temp_dir = tempfile.mkdtemp() if self.media_repo else None
-        self.limit = limit
-        self.posts_data = OrderedDict()
-
-    def get_post_id(self, post):
-        """Return a unique and constant post id
-
-        @param post(dict): parsed post data
-        @return (unicode): post unique item id
-        """
-        return str(post["id"])
-
-    def get_post_updated(self, post):
-        """Return the update date.
-
-        @param post(dict): parsed post data
-        @return (unicode): update date
-        """
-        return str(post["mtime"])
-
-    def get_post_published(self, post):
-        """Try to parse the date from the message ID, else use "mtime".
-
-        The date can be extracted if the message ID looks like one of:
-            - namespace:YYMMDD_short_title
-            - namespace:YYYYMMDD_short_title
-        @param post (dict):  parsed post data
-        @return (unicode): publication date
-        """
-        id_, default = str(post["id"]), str(post["mtime"])
-        try:
-            date = id_.split(":")[-1].split("_")[0]
-        except KeyError:
-            return default
-        try:
-            time_struct = time.strptime(date, "%y%m%d")
-        except ValueError:
-            try:
-                time_struct = time.strptime(date, "%Y%m%d")
-            except ValueError:
-                return default
-        return str(calendar.timegm(time_struct))
-
-    def process_post(self, post, profile_jid):
-        """Process a single page.
-
-        @param post (dict): parsed post data
-        @param profile_jid
-        """
-        # get main information
-        id_ = self.get_post_id(post)
-        updated = self.get_post_updated(post)
-        published = self.get_post_published(post)
-
-        # manage links
-        backlinks = self.pages.backlinks(id_)
-        for link in self.pages.links(id_):
-            if link["type"] != "extern":
-                assert link["type"] == "local"
-                page = link["page"]
-                backlinks.append(page[1:] if page.startswith(":") else page)
-
-        self.pages.get(id_)
-        content_xhtml = self.process_content(self.pages.html(id_), backlinks, profile_jid)
-
-        # XXX: title is already in content_xhtml and difficult to remove, so leave it
-        # title = content.split("\n")[0].strip(u"\ufeff= ")
-
-        # build the extra data dictionary
-        mb_data = {
-            "id": id_,
-            "published": published,
-            "updated": updated,
-            "author": profile_jid.user,
-            # "content": content,  # when passed, it is displayed in Libervia instead of content_xhtml
-            "content_xhtml": content_xhtml,
-            # "title": title,
-            "allow_comments": "true",
-        }
-
-        # find out if the message access is public or restricted
-        namespace = id_.split(":")[0]
-        if namespace and namespace.lower() not in ("public", "/"):
-            mb_data["group"] = namespace  # roster group must exist
-
-        self.posts_data[id_] = {"blog": mb_data, "comments": [[]]}
-
-    def process(self, client, namespace=DEFAULT_NAMESPACE):
-        """Process a namespace or a single page.
-
-        @param namespace (unicode): DokuWiki namespace (or page) to import
-        """
-        profile_jid = client.jid
-        log.info("Importing data from DokuWiki %s" % self.version)
-        try:
-            pages_list = self.pages.list(namespace)
-        except DokuWikiError:
-            log.warning(
-                'Could not list Dokuwiki pages: please turn the "display_errors" setting to "Off" in the php.ini of the webserver hosting DokuWiki.'
-            )
-            return
-
-        if not pages_list:  # namespace is actually a page?
-            names = namespace.split(":")
-            real_namespace = ":".join(names[0:-1])
-            pages_list = self.pages.list(real_namespace)
-            pages_list = [page for page in pages_list if page["id"] == namespace]
-            namespace = real_namespace
-
-        count = 0
-        for page in pages_list:
-            self.process_post(page, profile_jid)
-            count += 1
-            if count >= self.limit:
-                break
-
-        return (iter(self.posts_data.values()), len(self.posts_data))
-
-    def process_content(self, text, backlinks, profile_jid):
-        """Do text substitutions and file copy.
-
-        @param text (unicode): message content
-        @param backlinks (list[unicode]): list of backlinks
-        """
-        text = text.strip("\ufeff")  # this is at the beginning of the file (BOM)
-
-        for backlink in backlinks:
-            src = '/doku.php?id=%s"' % backlink
-            tgt = '/blog/%s/%s" target="#"' % (profile_jid.user, backlink)
-            text = text.replace(src, tgt)
-
-        subs = {}
-
-        link_pattern = r"""<(img|a)[^>]* (src|href)="([^"]+)"[^>]*>"""
-        for tag in re.finditer(link_pattern, text):
-            type_, attr, link = tag.group(1), tag.group(2), tag.group(3)
-            assert (type_ == "img" and attr == "src") or (type_ == "a" and attr == "href")
-            if re.match(r"^\w*://", link):  # absolute URL to link directly
-                continue
-            if self.media_repo:
-                self.move_media(link, subs)
-            elif link not in subs:
-                subs[link] = urllib.parse.urljoin(self.url, link)
-
-        for url, new_url in subs.items():
-            text = text.replace(url, new_url)
-        return text
-
-    def move_media(self, link, subs):
-        """Move a media from the DokuWiki host to the new repository.
-
-        This also updates the hyperlinks to internal media files.
-        @param link (unicode): media link
-        @param subs (dict): substitutions data
-        """
-        url = urllib.parse.urljoin(self.url, link)
-        user_media = re.match(r"(/lib/exe/\w+.php\?)(.*)", link)
-        thumb_width = None
-
-        if user_media:  # media that has been added by the user
-            params = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
-            try:
-                media = params["media"][0]
-            except KeyError:
-                log.warning("No media found in fetch URL: %s" % user_media.group(2))
-                return
-            if re.match(r"^\w*://", media):  # external URL to link directly
-                subs[link] = media
-                return
-            try:  # create thumbnail
-                thumb_width = params["w"][0]
-            except KeyError:
-                pass
-
-            filename = media.replace(":", "/")
-            # XXX: avoid "precondition failed" error (only keep the media parameter)
-            url = urllib.parse.urljoin(self.url, "/lib/exe/fetch.php?media=%s" % media)
-
-        elif link.startswith("/lib/plugins/"):
-            # other link added by a plugin or something else
-            filename = link[13:]
-        else:  # fake alert... there's no media (or we don't handle it yet)
-            return
-
-        filepath = os.path.join(self.temp_dir, filename)
-        self.download_media(url, filepath)
-
-        if thumb_width:
-            filename = os.path.join("thumbs", thumb_width, filename)
-            thumbnail = os.path.join(self.temp_dir, filename)
-            self.create_thumbnail(filepath, thumbnail, thumb_width)
-
-        new_url = os.path.join(self.media_repo, filename)
-        subs[link] = new_url
-
-    def download_media(self, source, dest):
-        """Copy media to localhost.
-
-        @param source (unicode): source url
-        @param dest (unicode): target path
-        """
-        dirname = os.path.dirname(dest)
-        if not os.path.exists(dest):
-            if not os.path.exists(dirname):
-                os.makedirs(dirname)
-            urllib.request.urlretrieve(source, dest)
-            log.debug("DokuWiki media file copied to %s" % dest)
-
-    def create_thumbnail(self, source, dest, width):
-        """Create a thumbnail.
-
-        @param source (unicode): source file path
-        @param dest (unicode): destination file path
-        @param width (unicode): thumbnail's width
-        """
-        thumb_dir = os.path.dirname(dest)
-        if not os.path.exists(thumb_dir):
-            os.makedirs(thumb_dir)
-        try:
-            im = Image.open(source)
-            im.thumbnail((width, int(width) * im.size[0] / im.size[1]))
-            im.save(dest)
-            log.debug("DokuWiki media thumbnail created: %s" % dest)
-        except IOError:
-            log.error("Cannot create DokuWiki media thumbnail %s" % dest)
-
-
-class DokuwikiImport(object):
-    def __init__(self, host):
-        log.info(_("plugin Dokuwiki import initialization"))
-        self.host = host
-        self._blog_import = host.plugins["BLOG_IMPORT"]
-        self._blog_import.register("dokuwiki", self.dk_import, SHORT_DESC, LONG_DESC)
-
-    def dk_import(self, client, location, options=None):
-        """import from DokuWiki to PubSub
-
-        @param location (unicode): DokuWiki site URL
-        @param options (dict, None): DokuWiki import parameters
-            - user (unicode): DokuWiki admin user
-            - passwd (unicode): DokuWiki admin password
-            - namespace (unicode): DokuWiki namespace to import
-            - media_repo (unicode): New remote media repository
-        """
-        options[self._blog_import.OPT_HOST] = location
-        try:
-            user = options["user"]
-        except KeyError:
-            raise exceptions.DataError('parameter "user" is required')
-        try:
-            passwd = options["passwd"]
-        except KeyError:
-            raise exceptions.DataError('parameter "passwd" is required')
-
-        opt_upload_images = options.get(self._blog_import.OPT_UPLOAD_IMAGES, None)
-        try:
-            media_repo = options["media_repo"]
-            if opt_upload_images:
-                options[
-                    self._blog_import.OPT_UPLOAD_IMAGES
-                ] = False  # force using --no-images-upload
-            info_msg = _(
-                "DokuWiki media files will be *downloaded* to {temp_dir} - to finish the import you have to upload them *manually* to {media_repo}"
-            )
-        except KeyError:
-            media_repo = DEFAULT_MEDIA_REPO
-            if opt_upload_images:
-                info_msg = _(
-                    "DokuWiki media files will be *uploaded* to the XMPP server. Hyperlinks to these media may not been updated though."
-                )
-            else:
-                info_msg = _(
-                    "DokuWiki media files will *stay* on {location} - some of them may be protected by DokuWiki ACL and will not be accessible."
-                )
-
-        try:
-            namespace = options["namespace"]
-        except KeyError:
-            namespace = DEFAULT_NAMESPACE
-        try:
-            limit = options["limit"]
-        except KeyError:
-            limit = DEFAULT_LIMIT
-
-        dk_importer = Importer(location, user, passwd, media_repo, limit)
-        info_msg = info_msg.format(
-            temp_dir=dk_importer.temp_dir, media_repo=media_repo, location=location
-        )
-        self.host.action_new(
-            {"xmlui": xml_tools.note(info_msg).toXml()}, profile=client.profile
-        )
-        d = threads.deferToThread(dk_importer.process, client, namespace)
-        return d
--- a/sat/plugins/plugin_blog_import_dotclear.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,279 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT plugin for import external blogs
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core import exceptions
-from sat.tools.common import data_format
-from twisted.internet import threads
-from collections import OrderedDict
-import itertools
-import time
-import cgi
-import os.path
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Dotclear import",
-    C.PI_IMPORT_NAME: "IMPORT_DOTCLEAR",
-    C.PI_TYPE: C.PLUG_TYPE_BLOG,
-    C.PI_DEPENDENCIES: ["BLOG_IMPORT"],
-    C.PI_MAIN: "DotclearImport",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Blog importer for Dotclear blog engine."""),
-}
-
-SHORT_DESC = D_("import posts from Dotclear blog engine")
-
-LONG_DESC = D_(
-    """This importer handle Dotclear blog engine.
-
-To use it, you'll need to export your blog to a flat file.
-You must go in your admin interface and select Plugins/Maintenance then Backup.
-Export only one blog if you have many, i.e. select "Download database of current blog"
-Depending on your configuration, your may need to use import/Export plugin and export as a flat file.
-
-location: you must use the absolute path to your backup for the location parameter
-"""
-)
-POST_ID_PREFIX = "sat_dc_"
-KNOWN_DATA_TYPES = (
-    "link",
-    "setting",
-    "post",
-    "meta",
-    "media",
-    "post_media",
-    "comment",
-    "captcha",
-)
-ESCAPE_MAP = {"r": "\r", "n": "\n", '"': '"', "\\": "\\"}
-
-
-class DotclearParser(object):
-    # XXX: we have to parse all file to build data
-    #      this can be ressource intensive on huge blogs
-
-    def __init__(self):
-        self.posts_data = OrderedDict()
-        self.tags = {}
-
-    def get_post_id(self, post):
-        """Return a unique and constant post id
-
-        @param post(dict): parsed post data
-        @return (unicode): post unique item id
-        """
-        return "{}_{}_{}_{}:{}".format(
-            POST_ID_PREFIX,
-            post["blog_id"],
-            post["user_id"],
-            post["post_id"],
-            post["post_url"],
-        )
-
-    def get_comment_id(self, comment):
-        """Return a unique and constant comment id
-
-        @param comment(dict): parsed comment
-        @return (unicode): comment unique comment id
-        """
-        post_id = comment["post_id"]
-        parent_item_id = self.posts_data[post_id]["blog"]["id"]
-        return "{}_comment_{}".format(parent_item_id, comment["comment_id"])
-
-    def getTime(self, data, key):
-        """Parse time as given by dotclear, with timezone handling
-
-        @param data(dict): dotclear data (post or comment)
-        @param key(unicode): key to get (e.g. "post_creadt")
-        @return (float): Unix time
-        """
-        return time.mktime(time.strptime(data[key], "%Y-%m-%d %H:%M:%S"))
-
-    def read_fields(self, fields_data):
-        buf = []
-        idx = 0
-        while True:
-            if fields_data[idx] != '"':
-                raise exceptions.ParsingError
-            while True:
-                idx += 1
-                try:
-                    char = fields_data[idx]
-                except IndexError:
-                    raise exceptions.ParsingError("Data was expected")
-                if char == '"':
-                    # we have reached the end of this field,
-                    # we try to parse a new one
-                    yield "".join(buf)
-                    buf = []
-                    idx += 1
-                    try:
-                        separator = fields_data[idx]
-                    except IndexError:
-                        return
-                    if separator != ",":
-                        raise exceptions.ParsingError("Field separator was expeceted")
-                    idx += 1
-                    break  # we have a new field
-                elif char == "\\":
-                    idx += 1
-                    try:
-                        char = ESCAPE_MAP[fields_data[idx]]
-                    except IndexError:
-                        raise exceptions.ParsingError("Escaped char was expected")
-                    except KeyError:
-                        char = fields_data[idx]
-                        log.warning("Unknown key to escape: {}".format(char))
-                buf.append(char)
-
-    def parseFields(self, headers, data):
-        return dict(zip(headers, self.read_fields(data)))
-
-    def post_handler(self, headers, data, index):
-        post = self.parseFields(headers, data)
-        log.debug("({}) post found: {}".format(index, post["post_title"]))
-        mb_data = {
-            "id": self.get_post_id(post),
-            "published": self.getTime(post, "post_creadt"),
-            "updated": self.getTime(post, "post_upddt"),
-            "author": post["user_id"],  # there use info are not in the archive
-            # TODO: option to specify user info
-            "content_xhtml": "{}{}".format(
-                post["post_content_xhtml"], post["post_excerpt_xhtml"]
-            ),
-            "title": post["post_title"],
-            "allow_comments": C.bool_const(bool(int(post["post_open_comment"]))),
-        }
-        self.posts_data[post["post_id"]] = {
-            "blog": mb_data,
-            "comments": [[]],
-            "url": "/post/{}".format(post["post_url"]),
-        }
-
-    def meta_handler(self, headers, data, index):
-        meta = self.parseFields(headers, data)
-        if meta["meta_type"] == "tag":
-            tags = self.tags.setdefault(meta["post_id"], set())
-            tags.add(meta["meta_id"])
-
-    def meta_finished_handler(self):
-        for post_id, tags in self.tags.items():
-            data_format.iter2dict("tag", tags, self.posts_data[post_id]["blog"])
-        del self.tags
-
-    def comment_handler(self, headers, data, index):
-        comment = self.parseFields(headers, data)
-        if comment["comment_site"]:
-            # we don't use atom:uri because it's used for jid in XMPP
-            content = '{}\n<hr>\n<a href="{}">author website</a>'.format(
-                comment["comment_content"],
-                cgi.escape(comment["comment_site"]).replace('"', "%22"),
-            )
-        else:
-            content = comment["comment_content"]
-        mb_data = {
-            "id": self.get_comment_id(comment),
-            "published": self.getTime(comment, "comment_dt"),
-            "updated": self.getTime(comment, "comment_upddt"),
-            "author": comment["comment_author"],
-            # we don't keep email addresses to avoid the author to be spammed
-            # (they would be available publicly else)
-            # 'author_email': comment['comment_email'],
-            "content_xhtml": content,
-        }
-        self.posts_data[comment["post_id"]]["comments"][0].append(
-            {"blog": mb_data, "comments": [[]]}
-        )
-
-    def parse(self, db_path):
-        with open(db_path) as f:
-            signature = f.readline()
-            try:
-                version = signature.split("|")[1]
-            except IndexError:
-                version = None
-            log.debug("Dotclear version: {}".format(version))
-            data_type = None
-            data_headers = None
-            index = None
-            while True:
-                buf = f.readline()
-                if not buf:
-                    break
-                if buf.startswith("["):
-                    header = buf.split(" ", 1)
-                    data_type = header[0][1:]
-                    if data_type not in KNOWN_DATA_TYPES:
-                        log.warning("unkown data type: {}".format(data_type))
-                    index = 0
-                    try:
-                        data_headers = header[1].split(",")
-                        # we need to remove the ']' from the last header
-                        last_header = data_headers[-1]
-                        data_headers[-1] = last_header[: last_header.rfind("]")]
-                    except IndexError:
-                        log.warning("Can't read data)")
-                else:
-                    if data_type is None:
-                        continue
-                    buf = buf.strip()
-                    if not buf and data_type in KNOWN_DATA_TYPES:
-                        try:
-                            finished_handler = getattr(
-                                self, "{}FinishedHandler".format(data_type)
-                            )
-                        except AttributeError:
-                            pass
-                        else:
-                            finished_handler()
-                        log.debug("{} data finished".format(data_type))
-                        data_type = None
-                        continue
-                    assert data_type
-                    try:
-                        fields_handler = getattr(self, "{}Handler".format(data_type))
-                    except AttributeError:
-                        pass
-                    else:
-                        fields_handler(data_headers, buf, index)
-                    index += 1
-        return (iter(self.posts_data.values()), len(self.posts_data))
-
-
-class DotclearImport(object):
-    def __init__(self, host):
-        log.info(_("plugin Dotclear import initialization"))
-        self.host = host
-        host.plugins["BLOG_IMPORT"].register(
-            "dotclear", self.dc_import, SHORT_DESC, LONG_DESC
-        )
-
-    def dc_import(self, client, location, options=None):
-        if not os.path.isabs(location):
-            raise exceptions.DataError(
-                "An absolute path to backup data need to be given as location"
-            )
-        dc_parser = DotclearParser()
-        d = threads.deferToThread(dc_parser.parse, location)
-        return d
--- a/sat/plugins/plugin_comp_ap_gateway/__init__.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2781 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia ActivityPub Gateway
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import base64
-import calendar
-import hashlib
-import json
-from pathlib import Path
-from pprint import pformat
-import re
-from typing import (
-    Any,
-    Awaitable,
-    Callable,
-    Dict,
-    List,
-    Optional,
-    Set,
-    Tuple,
-    Union,
-    overload,
-)
-from urllib import parse
-
-from cryptography.exceptions import InvalidSignature
-from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.primitives import hashes
-from cryptography.hazmat.primitives.asymmetric import rsa
-from cryptography.hazmat.primitives.asymmetric import padding
-import dateutil
-from dateutil.parser import parserinfo
-import shortuuid
-from sqlalchemy.exc import IntegrityError
-import treq
-from treq.response import _Response as TReqResponse
-from twisted.internet import defer, reactor, threads
-from twisted.web import http
-from twisted.words.protocols.jabber import error, jid
-from twisted.words.xish import domish
-from wokkel import pubsub, rsm
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.memory import persistent
-from sat.memory.sqla_mapping import History, SubscriptionState
-from sat.tools import utils
-from sat.tools.common import data_format, date_utils, tls, uri
-from sat.tools.common.async_utils import async_lru
-
-from .ad_hoc import APAdHocService
-from .events import APEvents
-from .constants import (
-    ACTIVITY_OBJECT_MANDATORY,
-    ACTIVITY_TARGET_MANDATORY,
-    ACTIVITY_TYPES,
-    ACTIVITY_TYPES_LOWER,
-    COMMENTS_MAX_PARENTS,
-    CONF_SECTION,
-    IMPORT_NAME,
-    LRU_MAX_SIZE,
-    MEDIA_TYPE_AP,
-    NS_AP,
-    NS_AP_PUBLIC,
-    PUBLIC_TUPLE,
-    TYPE_ACTOR,
-    TYPE_EVENT,
-    TYPE_FOLLOWERS,
-    TYPE_ITEM,
-    TYPE_LIKE,
-    TYPE_MENTION,
-    TYPE_REACTION,
-    TYPE_TOMBSTONE,
-    TYPE_JOIN,
-    TYPE_LEAVE
-)
-from .http_server import HTTPServer
-from .pubsub_service import APPubsubService
-from .regex import RE_MENTION
-
-
-log = getLogger(__name__)
-
-IMPORT_NAME = "ap-gateway"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "ActivityPub Gateway component",
-    C.PI_IMPORT_NAME: IMPORT_NAME,
-    C.PI_MODES: [C.PLUG_MODE_COMPONENT],
-    C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT,
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [
-        "XEP-0050", "XEP-0054", "XEP-0060", "XEP-0084", "XEP-0106", "XEP-0277",
-        "XEP-0292", "XEP-0329", "XEP-0372", "XEP-0424", "XEP-0465", "XEP-0470",
-        "XEP-0447", "XEP-0471", "PUBSUB_CACHE", "TEXT_SYNTAXES", "IDENTITY"
-    ],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "APGateway",
-    C.PI_HANDLER: C.BOOL_TRUE,
-    C.PI_DESCRIPTION: _(
-        "Gateway for bidirectional communication between XMPP and ActivityPub."
-    ),
-}
-
-HEXA_ENC = r"(?P<hex>[0-9a-fA-f]{2})"
-RE_PERIOD_ENC = re.compile(f"\\.{HEXA_ENC}")
-RE_PERCENT_ENC = re.compile(f"%{HEXA_ENC}")
-RE_ALLOWED_UNQUOTED = re.compile(r"^[a-zA-Z0-9_-]+$")
-
-
-class APGateway:
-    IMPORT_NAME = IMPORT_NAME
-    # show data send or received through HTTP, used for debugging
-    # 1: log POST objects
-    # 2: log POST and GET objects
-    # 3: log POST and GET objects with HTTP headers for GET requests
-    verbose = 0
-
-    def __init__(self, host):
-        self.host = host
-        self.initialised = False
-        self.client = None
-        self._p = host.plugins["XEP-0060"]
-        self._a = host.plugins["XEP-0084"]
-        self._e = host.plugins["XEP-0106"]
-        self._m = host.plugins["XEP-0277"]
-        self._v = host.plugins["XEP-0292"]
-        self._refs = host.plugins["XEP-0372"]
-        self._r = host.plugins["XEP-0424"]
-        self._sfs = host.plugins["XEP-0447"]
-        self._pps = host.plugins["XEP-0465"]
-        self._pa = host.plugins["XEP-0470"]
-        self._c = host.plugins["PUBSUB_CACHE"]
-        self._t = host.plugins["TEXT_SYNTAXES"]
-        self._i = host.plugins["IDENTITY"]
-        self._events = host.plugins["XEP-0471"]
-        self._p.add_managed_node(
-            "",
-            items_cb=self._items_received,
-            # we want to be sure that the callbacks are launched before pubsub cache's
-            # one, as we need to inspect items before they are actually removed from cache
-            # or updated
-            priority=1000
-        )
-        self.pubsub_service = APPubsubService(self)
-        self.ad_hoc = APAdHocService(self)
-        self.ap_events = APEvents(self)
-        host.trigger.add("message_received", self._message_received_trigger, priority=-1000)
-        host.trigger.add("XEP-0424_retractReceived", self._on_message_retract)
-        host.trigger.add("XEP-0372_ref_received", self._on_reference_received)
-
-        host.bridge.add_method(
-            "ap_send",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._publish_message,
-            async_=True,
-        )
-
-    def get_handler(self, __):
-        return self.pubsub_service
-
-    async def init(self, client):
-        if self.initialised:
-            return
-
-        self.initialised = True
-        log.info(_("ActivityPub Gateway initialization"))
-
-        # RSA keys
-        stored_data = await self.host.memory.storage.get_privates(
-            IMPORT_NAME, ["rsa_key"], profile=client.profile
-        )
-        private_key_pem = stored_data.get("rsa_key")
-        if private_key_pem is None:
-            self.private_key = await threads.deferToThread(
-                rsa.generate_private_key,
-                public_exponent=65537,
-                key_size=4096,
-            )
-            private_key_pem = self.private_key.private_bytes(
-                encoding=serialization.Encoding.PEM,
-                format=serialization.PrivateFormat.PKCS8,
-                encryption_algorithm=serialization.NoEncryption()
-            ).decode()
-            await self.host.memory.storage.set_private_value(
-                IMPORT_NAME, "rsa_key", private_key_pem, profile=client.profile
-            )
-        else:
-            self.private_key = serialization.load_pem_private_key(
-                private_key_pem.encode(),
-                password=None,
-            )
-        self.public_key = self.private_key.public_key()
-        self.public_key_pem = self.public_key.public_bytes(
-            encoding=serialization.Encoding.PEM,
-            format=serialization.PublicFormat.SubjectPublicKeyInfo
-        ).decode()
-
-        # params
-        # URL and port
-        self.public_url = self.host.memory.config_get(
-            CONF_SECTION, "public_url"
-        ) or self.host.memory.config_get(
-            CONF_SECTION, "xmpp_domain"
-        )
-        if self.public_url is None:
-            log.error(
-                '"public_url" not set in configuration, this is mandatory to have'
-                "ActivityPub Gateway running. Please set this option it to public facing "
-                f"url in {CONF_SECTION!r} configuration section."
-            )
-            return
-        if parse.urlparse(self.public_url).scheme:
-            log.error(
-                "Scheme must not be specified in \"public_url\", please remove it from "
-                "\"public_url\" configuration option. ActivityPub Gateway won't be run."
-            )
-            return
-        self.http_port = int(self.host.memory.config_get(
-            CONF_SECTION, 'http_port', 8123))
-        connection_type = self.host.memory.config_get(
-            CONF_SECTION, 'http_connection_type', 'https')
-        if connection_type not in ('http', 'https'):
-            raise exceptions.ConfigError(
-                'bad ap-gateay http_connection_type, you must use one of "http" or '
-                '"https"'
-            )
-        self.max_items = int(self.host.memory.config_get(
-            CONF_SECTION, 'new_node_max_items', 50
-
-        ))
-        self.comments_max_depth = int(self.host.memory.config_get(
-            CONF_SECTION, 'comments_max_depth', 0
-        ))
-        self.ap_path = self.host.memory.config_get(CONF_SECTION, 'ap_path', '_ap')
-        self.base_ap_url = parse.urljoin(f"https://{self.public_url}", f"{self.ap_path}/")
-        # True (default) if we provide gateway only to entities/services from our server
-        self.local_only = C.bool(
-            self.host.memory.config_get(CONF_SECTION, 'local_only', C.BOOL_TRUE)
-        )
-        # if True (default), mention will be parsed in non-private content coming from
-        # XMPP. This is necessary as XEP-0372 are coming separately from item where the
-        # mention is done, which is hard to impossible to translate to ActivityPub (where
-        # mention specified inside the item directly). See documentation for details.
-        self.auto_mentions = C.bool(
-            self.host.memory.config_get(CONF_SECTION, "auto_mentions", C.BOOL_TRUE)
-        )
-
-        html_redirect: Dict[str, Union[str, dict]] = self.host.memory.config_get(
-            CONF_SECTION, 'html_redirect_dict', {}
-        )
-        self.html_redirect: Dict[str, List[dict]] = {}
-        for url_type, target in html_redirect.items():
-            if isinstance(target, str):
-                target = {"url": target}
-            elif not isinstance(target, dict):
-                raise exceptions.ConfigError(
-                    f"html_redirect target must be a URL or a dict, not {target!r}"
-                )
-            filters = target.setdefault("filters", {})
-            if "url" not in target:
-                log.warning(f"invalid HTML redirection, missing target URL: {target}")
-                continue
-            # a slash in the url_type is a syntactic shortcut to have a node filter
-            if "/" in url_type:
-                url_type, node_filter = url_type.split("/", 1)
-                filters["node"] = node_filter
-            self.html_redirect.setdefault(url_type, []).append(target)
-
-        # HTTP server launch
-        self.server = HTTPServer(self)
-        if connection_type == 'http':
-            reactor.listenTCP(self.http_port, self.server)
-        else:
-            options = tls.get_options_from_config(
-                self.host.memory.config, CONF_SECTION)
-            tls.tls_options_check(options)
-            context_factory = tls.get_tls_context_factory(options)
-            reactor.listenSSL(self.http_port, self.server, context_factory)
-
-    async def profile_connecting(self, client):
-        self.client = client
-        client.sendHistory = True
-        client._ap_storage = persistent.LazyPersistentBinaryDict(
-            IMPORT_NAME,
-            client.profile
-        )
-        await self.init(client)
-
-    def profile_connected(self, client):
-        self.ad_hoc.init(client)
-
-    async def _items_received(
-        self,
-        client: SatXMPPEntity,
-        itemsEvent: pubsub.ItemsEvent
-    ) -> None:
-        """Callback called when pubsub items are received
-
-        if the items are adressed to a JID corresponding to an AP actor, they are
-        converted to AP items and sent to the corresponding AP server.
-
-        If comments nodes are linked, they are automatically subscribed to get new items
-        from there too.
-        """
-        if client != self.client:
-            return
-        # we need recipient as JID and not gateway own JID to be able to use methods such
-        # as "subscribe"
-        client = self.client.get_virtual_client(itemsEvent.sender)
-        recipient = itemsEvent.recipient
-        if not recipient.user:
-            log.debug("ignoring items event without local part specified")
-            return
-
-        ap_account = self._e.unescape(recipient.user)
-
-        if self._pa.is_attachment_node(itemsEvent.nodeIdentifier):
-            await self.convert_and_post_attachments(
-                client, ap_account, itemsEvent.sender, itemsEvent.nodeIdentifier,
-                itemsEvent.items
-            )
-        else:
-            await self.convert_and_post_items(
-                client, ap_account, itemsEvent.sender, itemsEvent.nodeIdentifier,
-                itemsEvent.items
-            )
-
-    async def get_virtual_client(self, actor_id: str) -> SatXMPPEntity:
-        """Get client for this component with a specified jid
-
-        This is needed to perform operations with the virtual JID corresponding to the AP
-        actor instead of the JID of the gateway itself.
-        @param actor_id: ID of the actor
-        @return: virtual client
-        """
-        local_jid = await self.get_jid_from_id(actor_id)
-        return self.client.get_virtual_client(local_jid)
-
-    def is_activity(self, data: dict) -> bool:
-        """Return True if the data has an activity type"""
-        try:
-            return (data.get("type") or "").lower() in ACTIVITY_TYPES_LOWER
-        except (KeyError, TypeError):
-            return False
-
-    async def ap_get(self, url: str) -> dict:
-        """Retrieve AP JSON from given URL
-
-        @raise error.StanzaError: "service-unavailable" is sent when something went wrong
-            with AP server
-        """
-        resp = await treq.get(
-            url,
-            headers = {
-                "Accept": [MEDIA_TYPE_AP],
-                "Content-Type": [MEDIA_TYPE_AP],
-            }
-        )
-        if resp.code >= 300:
-            text = await resp.text()
-            if resp.code == 404:
-                raise exceptions.NotFound(f"Can't find resource at {url}")
-            else:
-                msg = f"HTTP error {resp.code} (url: {url}): {text}"
-                raise exceptions.ExternalRequestError(msg)
-        try:
-            return await treq.json_content(resp)
-        except Exception as e:
-            raise error.StanzaError(
-                "service-unavailable",
-                text=f"Can't get AP data at {url}: {e}"
-            )
-
-    @overload
-    async def ap_get_object(self, data: dict, key: str) -> Optional[dict]:
-        ...
-
-    @overload
-    async def ap_get_object(
-        self, data: Union[str, dict], key: None = None
-    ) -> dict:
-        ...
-
-    async def ap_get_object(self, data, key = None):
-        """Retrieve an AP object, dereferencing when necessary
-
-        This method is to be used with attributes marked as "Functional" in
-        https://www.w3.org/TR/activitystreams-vocabulary
-        @param data: AP object where an other object is looked for, or the object itself
-        @param key: name of the object to look for, or None if data is the object directly
-        @return: found object if any
-        """
-        if key is not None:
-            value = data.get(key)
-        else:
-            value = data
-        if value is None:
-            if key is None:
-                raise ValueError("None can't be used with ap_get_object is key is None")
-            return None
-        elif isinstance(value, dict):
-            return value
-        elif isinstance(value, str):
-            if self.is_local_url(value):
-                return await self.ap_get_local_object(value)
-            else:
-                return await self.ap_get(value)
-        else:
-            raise NotImplementedError(
-                "was expecting a string or a dict, got {type(value)}: {value!r}}"
-            )
-
-    async def ap_get_local_object(
-        self,
-        url: str
-    ) -> dict:
-        """Retrieve or generate local object
-
-        for now, only handle XMPP items to convert to AP
-        """
-        url_type, url_args = self.parse_apurl(url)
-        if url_type == TYPE_ITEM:
-            try:
-                account, item_id = url_args
-            except ValueError:
-                raise ValueError(f"invalid URL: {url}")
-            author_jid, node = await self.get_jid_and_node(account)
-            if node is None:
-                node = self._m.namespace
-            cached_node = await self.host.memory.storage.get_pubsub_node(
-                self.client, author_jid, node
-            )
-            if not cached_node:
-                log.debug(f"node {node!r} at {author_jid} is not found in cache")
-                found_item = None
-            else:
-                cached_items, __ = await self.host.memory.storage.get_items(
-                    cached_node, item_ids=[item_id]
-                )
-                if not cached_items:
-                    log.debug(
-                        f"item {item_id!r} of {node!r} at {author_jid} is not found in "
-                        "cache"
-                    )
-                    found_item = None
-                else:
-                    found_item = cached_items[0].data
-
-            if found_item is None:
-                # the node is not in cache, we have to make a request to retrieve the item
-                # If the node doesn't exist, get_items will raise a NotFound exception
-                found_items, __ = await self._p.get_items(
-                    self.client, author_jid, node, item_ids=[item_id]
-                )
-                try:
-                    found_item = found_items[0]
-                except IndexError:
-                    raise exceptions.NotFound(f"requested item at {url} can't be found")
-
-            if node.startswith(self._events.namespace):
-                # this is an event
-                event_data = self._events.event_elt_2_event_data(found_item)
-                ap_item = await self.ap_events.event_data_2_ap_item(
-                    event_data, author_jid
-                )
-                # the URL must return the object and not the activity
-                ap_item["object"]["@context"] = ap_item["@context"]
-                return ap_item["object"]
-            else:
-                # this is a blog item
-                mb_data = await self._m.item_2_mb_data(
-                    self.client, found_item, author_jid, node
-                )
-                ap_item = await self.mb_data_2_ap_item(self.client, mb_data)
-                # the URL must return the object and not the activity
-                return ap_item["object"]
-        else:
-            raise NotImplementedError(
-                'only object from "item" URLs can be retrieved for now'
-            )
-
-    async def ap_get_list(
-        self,
-        data: dict,
-        key: str,
-        only_ids: bool = False
-    ) -> Optional[List[Dict[str, Any]]]:
-        """Retrieve a list of objects from AP data, dereferencing when necessary
-
-        This method is to be used with non functional vocabularies. Use ``ap_get_object``
-        otherwise.
-        If the value is a dictionary, it will be wrapped in a list
-        @param data: AP object where a list of objects is looked for
-        @param key: key of the list to look for
-        @param only_ids: if Trye, only items IDs are retrieved
-        @return: list of objects, or None if the key is not present
-        """
-        value = data.get(key)
-        if value is None:
-            return None
-        elif isinstance(value, str):
-            if self.is_local_url(value):
-                value = await self.ap_get_local_object(value)
-            else:
-                value = await self.ap_get(value)
-        if isinstance(value, dict):
-            return [value]
-        if not isinstance(value, list):
-            raise ValueError(f"A list was expected, got {type(value)}: {value!r}")
-        if only_ids:
-            return [
-                {"id": v["id"]} if isinstance(v, dict) else {"id": v}
-                for v in value
-            ]
-        else:
-            return [await self.ap_get_object(i) for i in value]
-
-    async def ap_get_actors(
-        self,
-        data: dict,
-        key: str,
-        as_account: bool = True
-    ) -> List[str]:
-        """Retrieve AP actors from data
-
-        @param data: AP object containing a field with actors
-        @param key: field to use to retrieve actors
-        @param as_account: if True returns account handles, otherwise will return actor
-            IDs
-        @raise exceptions.DataError: there is not actor data or it is invalid
-        """
-        value = data.get(key)
-        if value is None:
-            raise exceptions.DataError(
-                f"no actor associated to object {data.get('id')!r}"
-            )
-        elif isinstance(value, dict):
-            actor_id = value.get("id")
-            if actor_id is None:
-                raise exceptions.DataError(
-                    f"invalid actor associated to object {data.get('id')!r}: {value!r}"
-                )
-            value = [actor_id]
-        elif isinstance(value, str):
-            value = [value]
-        elif isinstance(value, list):
-            try:
-                value = [a if isinstance(a, str) else a["id"] for a in value]
-            except (TypeError, KeyError):
-                raise exceptions.DataError(
-                    f"invalid actors list to object {data.get('id')!r}: {value!r}"
-                )
-        if not value:
-            raise exceptions.DataError(
-                f"list of actors is empty"
-            )
-        if as_account:
-            return [await self.get_ap_account_from_id(actor_id) for actor_id in value]
-        else:
-            return value
-
-    async def ap_get_sender_actor(
-        self,
-        data: dict,
-    ) -> str:
-        """Retrieve actor who sent data
-
-        This is done by checking "actor" field first, then "attributedTo" field.
-        Only the first found actor is taken into account
-        @param data: AP object
-        @return: actor id of the sender
-        @raise exceptions.NotFound: no actor has been found in data
-        """
-        try:
-            actors = await self.ap_get_actors(data, "actor", as_account=False)
-        except exceptions.DataError:
-            actors = None
-        if not actors:
-            try:
-                actors = await self.ap_get_actors(data, "attributedTo", as_account=False)
-            except exceptions.DataError:
-                raise exceptions.NotFound(
-                    'actor not specified in "actor" or "attributedTo"'
-                )
-        try:
-            return actors[0]
-        except IndexError:
-            raise exceptions.NotFound("list of actors is empty")
-
-    def must_encode(self, text: str) -> bool:
-        """Indicate if a text must be period encoded"""
-        return (
-            not RE_ALLOWED_UNQUOTED.match(text)
-            or text.startswith("___")
-            or "---" in text
-        )
-
-    def period_encode(self, text: str) -> str:
-        """Period encode a text
-
-        see [get_jid_and_node] for reasons of period encoding
-        """
-        return (
-            parse.quote(text, safe="")
-            .replace("---", "%2d%2d%2d")
-            .replace("___", "%5f%5f%5f")
-            .replace(".", "%2e")
-            .replace("~", "%7e")
-            .replace("%", ".")
-        )
-
-    async def get_ap_account_from_jid_and_node(
-        self,
-        jid_: jid.JID,
-        node: Optional[str]
-    ) -> str:
-        """Construct AP account from JID and node
-
-        The account construction will use escaping when necessary
-        """
-        if not node or node == self._m.namespace:
-            node = None
-
-        if self.client is None:
-            raise exceptions.InternalError("Client is not set yet")
-
-        if self.is_virtual_jid(jid_):
-            # this is an proxy JID to an AP Actor
-            return self._e.unescape(jid_.user)
-
-        if node and not jid_.user and not self.must_encode(node):
-            is_pubsub = await self.is_pubsub(jid_)
-            # when we have a pubsub service, the user part can be used to set the node
-            # this produces more user-friendly AP accounts
-            if is_pubsub:
-                jid_.user = node
-                node = None
-
-        is_local = self.is_local(jid_)
-        user = jid_.user if is_local else jid_.userhost()
-        if user is None:
-            user = ""
-        account_elts = []
-        if node and self.must_encode(node) or self.must_encode(user):
-            account_elts = ["___"]
-            if node:
-                node = self.period_encode(node)
-            user = self.period_encode(user)
-
-        if not user:
-            raise exceptions.InternalError("there should be a user part")
-
-        if node:
-            account_elts.extend((node, "---"))
-
-        account_elts.extend((
-            user, "@", jid_.host if is_local else self.client.jid.userhost()
-        ))
-        return "".join(account_elts)
-
-    def is_local(self, jid_: jid.JID) -> bool:
-        """Returns True if jid_ use a domain or subdomain of gateway's host"""
-        local_host = self.client.host.split(".")
-        assert local_host
-        return jid_.host.split(".")[-len(local_host):] == local_host
-
-    async def is_pubsub(self, jid_: jid.JID) -> bool:
-        """Indicate if a JID is a Pubsub service"""
-        host_disco = await self.host.get_disco_infos(self.client, jid_)
-        return (
-            ("pubsub", "service") in host_disco.identities
-            and not ("pubsub", "pep") in host_disco.identities
-        )
-
-    async def get_jid_and_node(self, ap_account: str) -> Tuple[jid.JID, Optional[str]]:
-        """Decode raw AP account handle to get XMPP JID and Pubsub Node
-
-        Username are case insensitive.
-
-        By default, the username correspond to local username (i.e. username from
-        component's server).
-
-        If local name's domain is a pubsub service (and not PEP), the username is taken as
-        a pubsub node.
-
-        If ``---`` is present in username, the part before is used as pubsub node, and the
-        rest as a JID user part.
-
-        If username starts with ``___``, characters are encoded using period encoding
-        (i.e. percent encoding where a ``.`` is used instead of ``%``).
-
-        This horror is necessary due to limitation in some AP implementation (notably
-        Mastodon), cf. https://github.com/mastodon/mastodon/issues/17222
-
-        examples:
-
-        ``toto@example.org`` => JID = toto@example.org, node = None
-
-        ``___toto.40example.net@example.org`` => JID = toto@example.net (this one is a
-        non-local JID, and will work only if setings ``local_only`` is False), node = None
-
-        ``toto@pubsub.example.org`` (with pubsub.example.org being a pubsub service) =>
-        JID = pubsub.example.org, node = toto
-
-        ``tata---toto@example.org`` => JID = toto@example.org, node = tata
-
-        ``___urn.3axmpp.3amicroblog.3a0@pubsub.example.org`` (with pubsub.example.org
-        being a pubsub service) ==> JID = pubsub.example.org, node = urn:xmpp:microblog:0
-
-        @param ap_account: ActivityPub account handle (``username@domain.tld``)
-        @return: service JID and pubsub node
-            if pubsub node is None, default microblog pubsub node (and possibly other
-            nodes that plugins may hanlde) will be used
-        @raise ValueError: invalid account
-        @raise PermissionError: non local jid is used when gateway doesn't allow them
-        """
-        if ap_account.count("@") != 1:
-            raise ValueError("Invalid AP account")
-        if ap_account.startswith("___"):
-            encoded = True
-            ap_account = ap_account[3:]
-        else:
-            encoded = False
-
-        username, domain = ap_account.split("@")
-
-        if "---" in username:
-            node, username = username.rsplit("---", 1)
-        else:
-            node = None
-
-        if encoded:
-            username = parse.unquote(
-                RE_PERIOD_ENC.sub(r"%\g<hex>", username),
-                errors="strict"
-            )
-            if node:
-                node = parse.unquote(
-                    RE_PERIOD_ENC.sub(r"%\g<hex>", node),
-                    errors="strict"
-                )
-
-        if "@" in username:
-            username, domain = username.rsplit("@", 1)
-
-        if not node:
-            # we need to check host disco, because disco request to user may be
-            # blocked for privacy reason (see
-            # https://xmpp.org/extensions/xep-0030.html#security)
-            is_pubsub = await self.is_pubsub(jid.JID(domain))
-
-            if is_pubsub:
-                # if the host is a pubsub service and not a PEP, we consider that username
-                # is in fact the node name
-                node = username
-                username = None
-
-        jid_s = f"{username}@{domain}" if username else domain
-        try:
-            jid_ = jid.JID(jid_s)
-        except RuntimeError:
-            raise ValueError(f"Invalid jid: {jid_s!r}")
-
-        if self.local_only and not self.is_local(jid_):
-            raise exceptions.PermissionError(
-                "This gateway is configured to map only local entities and services"
-            )
-
-        return jid_, node
-
-    def get_local_jid_from_account(self, account: str) -> jid.JID:
-        """Compute JID linking to an AP account
-
-        The local jid is computer by escaping AP actor handle and using it as local part
-        of JID, where domain part is this gateway own JID
-        """
-        return jid.JID(
-            None,
-            (
-                self._e.escape(account),
-                self.client.jid.host,
-                None
-            )
-        )
-
-    async def get_jid_from_id(self, actor_id: str) -> jid.JID:
-        """Compute JID linking to an AP Actor ID
-
-        The local jid is computer by escaping AP actor handle and using it as local part
-        of JID, where domain part is this gateway own JID
-        If the actor_id comes from local server (checked with self.public_url), it means
-        that we have an XMPP entity, and the original JID is returned
-        """
-        if self.is_local_url(actor_id):
-            request_type, extra_args = self.parse_apurl(actor_id)
-            if request_type != TYPE_ACTOR or len(extra_args) != 1:
-                raise ValueError(f"invalid actor id: {actor_id!r}")
-            actor_jid, __ = await self.get_jid_and_node(extra_args[0])
-            return actor_jid
-
-        account = await self.get_ap_account_from_id(actor_id)
-        return self.get_local_jid_from_account(account)
-
-    def parse_apurl(self, url: str) -> Tuple[str, List[str]]:
-        """Parse an URL leading to an AP endpoint
-
-        @param url: URL to parse (schema is not mandatory)
-        @return: endpoint type and extra arguments
-        """
-        path = parse.urlparse(url).path.lstrip("/")
-        type_, *extra_args = path[len(self.ap_path):].lstrip("/").split("/")
-        return type_, [parse.unquote(a) for a in extra_args]
-
-    def build_apurl(self, type_:str , *args: str) -> str:
-        """Build an AP endpoint URL
-
-        @param type_: type of AP endpoing
-        @param arg: endpoint dependant arguments
-        """
-        return parse.urljoin(
-            self.base_ap_url,
-            str(Path(type_).joinpath(*(parse.quote_plus(a, safe="@") for a in args)))
-        )
-
-    def is_local_url(self, url: str) -> bool:
-        """Tells if an URL link to this component
-
-        ``public_url`` and ``ap_path`` are used to check the URL
-        """
-        return url.startswith(self.base_ap_url)
-
-    def is_virtual_jid(self, jid_: jid.JID) -> bool:
-        """Tell if a JID is an AP actor mapped through this gateway"""
-        return jid_.host == self.client.jid.userhost()
-
-    def build_signature_header(self, values: Dict[str, str]) -> str:
-        """Build key="<value>" signature header from signature data"""
-        fields = []
-        for key, value in values.items():
-            if key not in ("(created)", "(expired)"):
-                if '"' in value:
-                    raise NotImplementedError(
-                        "string escaping is not implemented, double-quote can't be used "
-                        f"in {value!r}"
-                    )
-                value = f'"{value}"'
-            fields.append(f"{key}={value}")
-
-        return ",".join(fields)
-
-    def get_digest(self, body: bytes, algo="SHA-256") -> Tuple[str, str]:
-        """Get digest data to use in header and signature
-
-        @param body: body of the request
-        @return: hash name and digest
-        """
-        if algo != "SHA-256":
-            raise NotImplementedError("only SHA-256 is implemented for now")
-        return algo, base64.b64encode(hashlib.sha256(body).digest()).decode()
-
-    @async_lru(maxsize=LRU_MAX_SIZE)
-    async def get_actor_data(self, actor_id) -> dict:
-        """Retrieve actor data with LRU cache"""
-        return await self.ap_get(actor_id)
-
-    @async_lru(maxsize=LRU_MAX_SIZE)
-    async def get_actor_pub_key_data(
-        self,
-        actor_id: str
-    ) -> Tuple[str, str, rsa.RSAPublicKey]:
-        """Retrieve Public Key data from actor ID
-
-        @param actor_id: actor ID (url)
-        @return: key_id, owner and public_key
-        @raise KeyError: publicKey is missing from actor data
-        """
-        actor_data = await self.get_actor_data(actor_id)
-        pub_key_data = actor_data["publicKey"]
-        key_id = pub_key_data["id"]
-        owner = pub_key_data["owner"]
-        pub_key_pem = pub_key_data["publicKeyPem"]
-        pub_key = serialization.load_pem_public_key(pub_key_pem.encode())
-        return key_id, owner, pub_key
-
-    def create_activity(
-        self,
-        activity: str,
-        actor_id: str,
-        object_: Optional[Union[str, dict]] = None,
-        target: Optional[Union[str, dict]] = None,
-        activity_id: Optional[str] = None,
-        **kwargs,
-    ) -> Dict[str, Any]:
-        """Generate base data for an activity
-
-        @param activity: one of ACTIVITY_TYPES
-        @param actor_id: AP actor ID of the sender
-        @param object_: content of "object" field
-        @param target: content of "target" field
-        @param activity_id: ID to use for the activity
-            if not set it will be automatically generated, but it is usually desirable to
-            set the ID manually so it can be retrieved (e.g. for Undo)
-        """
-        if activity not in ACTIVITY_TYPES:
-            raise exceptions.InternalError(f"invalid activity: {activity!r}")
-        if object_ is None and activity in ACTIVITY_OBJECT_MANDATORY:
-            raise exceptions.InternalError(
-                f'"object_" is mandatory for activity {activity!r}'
-            )
-        if target is None and activity in ACTIVITY_TARGET_MANDATORY:
-            raise exceptions.InternalError(
-                f'"target" is mandatory for activity {activity!r}'
-            )
-        if activity_id is None:
-            activity_id = f"{actor_id}#{activity.lower()}_{shortuuid.uuid()}"
-        data: Dict[str, Any] = {
-            "@context": [NS_AP],
-            "actor": actor_id,
-            "id": activity_id,
-            "type": activity,
-        }
-        data.update(kwargs)
-        if object_ is not None:
-            data["object"] = object_
-        if target is not None:
-            data["target"] = target
-
-        return data
-
-    def get_key_id(self, actor_id: str) -> str:
-        """Get local key ID from actor ID"""
-        return f"{actor_id}#main-key"
-
-    async def check_signature(
-        self,
-        signature: str,
-        key_id: str,
-        headers: Dict[str, str]
-    ) -> str:
-        """Verify that signature matches given headers
-
-        see https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-06#section-3.1.2
-
-        @param signature: Base64 encoded signature
-        @param key_id: ID of the key used to sign the data
-        @param headers: headers and their values, including pseudo-headers
-        @return: id of the signing actor
-
-        @raise InvalidSignature: signature doesn't match headers
-        """
-        to_sign = "\n".join(f"{k.lower()}: {v}" for k,v in headers.items())
-        if key_id.startswith("acct:"):
-            actor = key_id[5:]
-            actor_id = await self.get_ap_actor_id_from_account(actor)
-        else:
-            actor_id = key_id.split("#", 1)[0]
-
-        pub_key_id, pub_key_owner, pub_key = await self.get_actor_pub_key_data(actor_id)
-        if pub_key_id != key_id or pub_key_owner != actor_id:
-            raise exceptions.EncryptionError("Public Key mismatch")
-
-        try:
-            pub_key.verify(
-                base64.b64decode(signature),
-                to_sign.encode(),
-                # we have to use PKCS1v15 padding to be compatible with Mastodon
-                padding.PKCS1v15(),  # type: ignore
-                hashes.SHA256()  # type: ignore
-            )
-        except InvalidSignature:
-            raise exceptions.EncryptionError(
-                "Invalid signature (using PKC0S1 v1.5 and SHA-256)"
-            )
-
-        return actor_id
-
-    def get_signature_data(
-            self,
-            key_id: str,
-            headers: Dict[str, str]
-    ) -> Tuple[Dict[str, str], Dict[str, str]]:
-        """Generate and return signature and corresponding headers
-
-        @param parsed_url: URL where the request is sent/has been received
-        @param key_id: ID of the key (URL linking to the data with public key)
-        @param date: HTTP datetime string of signature generation
-        @param body: body of the HTTP request
-        @param headers: headers to sign and their value:
-            default value will be used if not specified
-
-        @return: headers and signature data
-            ``headers`` is an updated copy of ``headers`` arguments, with pseudo-headers
-            removed, and ``Signature`` added.
-        """
-        # headers must be lower case
-        l_headers: Dict[str, str] = {k.lower(): v for k, v in headers.items()}
-        to_sign = "\n".join(f"{k}: {v}" for k,v in l_headers.items())
-        signature = base64.b64encode(self.private_key.sign(
-            to_sign.encode(),
-            # we have to use PKCS1v15 padding to be compatible with Mastodon
-            padding.PKCS1v15(),  # type: ignore
-            hashes.SHA256()  # type: ignore
-        )).decode()
-        sign_data = {
-            "keyId": key_id,
-            "Algorithm": "rsa-sha256",
-            "headers": " ".join(l_headers.keys()),
-            "signature": signature
-        }
-        new_headers = {k: v for k,v in headers.items() if not k.startswith("(")}
-        new_headers["Signature"] = self.build_signature_header(sign_data)
-        return new_headers, sign_data
-
-    async def convert_and_post_items(
-        self,
-        client: SatXMPPEntity,
-        ap_account: str,
-        service: jid.JID,
-        node: str,
-        items: List[domish.Element],
-        subscribe_extra_nodes: bool = True,
-    ) -> None:
-        """Convert XMPP items to AP items and post them to actor inbox
-
-        @param ap_account: account of ActivityPub actor receiving the item
-        @param service: JID of the (virtual) pubsub service where the item has been
-            published
-        @param node: (virtual) node corresponding where the item has been published
-        @param subscribe_extra_nodes: if True, extra data nodes will be automatically
-            subscribed, that is comment nodes if present and attachments nodes.
-        """
-        actor_id = await self.get_ap_actor_id_from_account(ap_account)
-        inbox = await self.get_ap_inbox_from_id(actor_id)
-        for item in items:
-            if item.name == "item":
-                cached_item = await self.host.memory.storage.search_pubsub_items({
-                    "profiles": [self.client.profile],
-                    "services": [service],
-                    "nodes": [node],
-                    "names": [item["id"]]
-                })
-                is_new = not bool(cached_item)
-                if node.startswith(self._events.namespace):
-                    # event item
-                    event_data = self._events.event_elt_2_event_data(item)
-                    try:
-                        author_jid = jid.JID(item["publisher"]).userhostJID()
-                    except (KeyError, RuntimeWarning):
-                        root_elt = item
-                        while root_elt.parent is not None:
-                            root_elt = root_elt.parent
-                        author_jid = jid.JID(root_elt["from"]).userhostJID()
-                    if subscribe_extra_nodes and not self.is_virtual_jid(author_jid):
-                        # we subscribe automatically to comment nodes if any
-                        recipient_jid = self.get_local_jid_from_account(ap_account)
-                        recipient_client = self.client.get_virtual_client(recipient_jid)
-                        comments_data = event_data.get("comments")
-                        if comments_data:
-                            comment_service = jid.JID(comments_data["jid"])
-                            comment_node = comments_data["node"]
-                            await self._p.subscribe(
-                                recipient_client, comment_service, comment_node
-                            )
-                        try:
-                            await self._pa.subscribe(
-                                recipient_client, service, node, event_data["id"]
-                            )
-                        except exceptions.NotFound:
-                            log.debug(
-                                f"no attachment node found for item {event_data['id']!r} "
-                                f"on {node!r} at {service}"
-                            )
-                    ap_item = await self.ap_events.event_data_2_ap_item(
-                        event_data, author_jid, is_new=is_new
-                    )
-                else:
-                    # blog item
-                    mb_data = await self._m.item_2_mb_data(client, item, service, node)
-                    author_jid = jid.JID(mb_data["author_jid"])
-                    if subscribe_extra_nodes and not self.is_virtual_jid(author_jid):
-                        # we subscribe automatically to comment nodes if any
-                        recipient_jid = self.get_local_jid_from_account(ap_account)
-                        recipient_client = self.client.get_virtual_client(recipient_jid)
-                        for comment_data in mb_data.get("comments", []):
-                            comment_service = jid.JID(comment_data["service"])
-                            if self.is_virtual_jid(comment_service):
-                                log.debug(
-                                    f"ignoring virtual comment service: {comment_data}"
-                                )
-                                continue
-                            comment_node = comment_data["node"]
-                            await self._p.subscribe(
-                                recipient_client, comment_service, comment_node
-                            )
-                        try:
-                            await self._pa.subscribe(
-                                recipient_client, service, node, mb_data["id"]
-                            )
-                        except exceptions.NotFound:
-                            log.debug(
-                                f"no attachment node found for item {mb_data['id']!r} on "
-                                f"{node!r} at {service}"
-                            )
-                    ap_item = await self.mb_data_2_ap_item(client, mb_data, is_new=is_new)
-
-                url_actor = ap_item["actor"]
-            elif item.name == "retract":
-                url_actor, ap_item = await self.ap_delete_item(
-                    client.jid, node, item["id"]
-                )
-            else:
-                raise exceptions.InternalError(f"unexpected element: {item.toXml()}")
-            await self.sign_and_post(inbox, url_actor, ap_item)
-
-    async def convert_and_post_attachments(
-        self,
-        client: SatXMPPEntity,
-        ap_account: str,
-        service: jid.JID,
-        node: str,
-        items: List[domish.Element],
-        publisher: Optional[jid.JID] = None
-    ) -> None:
-        """Convert XMPP item attachments to AP activities and post them to actor inbox
-
-        @param ap_account: account of ActivityPub actor receiving the item
-        @param service: JID of the (virtual) pubsub service where the item has been
-            published
-        @param node: (virtual) node corresponding where the item has been published
-            subscribed, that is comment nodes if present and attachments nodes.
-        @param items: attachments items
-        @param publisher: publisher of the attachments item (it's NOT the PEP/Pubsub
-            service, it's the publisher of the item). To be filled only when the publisher
-            is known for sure, otherwise publisher will be determined either if
-            "publisher" attribute is set by pubsub service, or as a last resort, using
-            item's ID (which MUST be publisher bare JID according to pubsub-attachments
-            specification).
-        """
-        if len(items) != 1:
-            log.warning(
-                "we should get exactly one attachment item for an entity, got "
-                f"{len(items)})"
-            )
-
-        actor_id = await self.get_ap_actor_id_from_account(ap_account)
-        inbox = await self.get_ap_inbox_from_id(actor_id)
-
-        item_elt = items[0]
-        item_id = item_elt["id"]
-
-        if publisher is None:
-            item_pub_s = item_elt.getAttribute("publisher")
-            publisher = jid.JID(item_pub_s) if item_pub_s else jid.JID(item_id)
-
-        if publisher.userhost() != item_id:
-            log.warning(
-                "attachments item ID must be publisher's bare JID, ignoring: "
-                f"{item_elt.toXml()}"
-            )
-            return
-
-        if self.is_virtual_jid(publisher):
-            log.debug(f"ignoring item coming from local virtual JID {publisher}")
-            return
-
-        if publisher is not None:
-            item_elt["publisher"] = publisher.userhost()
-
-        item_service, item_node, item_id = self._pa.attachment_node_2_item(node)
-        item_account = await self.get_ap_account_from_jid_and_node(item_service, item_node)
-        if self.is_virtual_jid(item_service):
-            # it's a virtual JID mapping to an external AP actor, we can use the
-            # item_id directly
-            item_url = item_id
-            if not item_url.startswith("https:"):
-                log.warning(
-                    "item ID of external AP actor is not an https link, ignoring: "
-                    f"{item_id!r}"
-                )
-                return
-        else:
-            item_url = self.build_apurl(TYPE_ITEM, item_account, item_id)
-
-        old_attachment_pubsub_items = await self.host.memory.storage.search_pubsub_items({
-            "profiles": [self.client.profile],
-            "services": [service],
-            "nodes": [node],
-            "names": [item_elt["id"]]
-        })
-        if not old_attachment_pubsub_items:
-            old_attachment = {}
-        else:
-            old_attachment_items = [i.data for i in old_attachment_pubsub_items]
-            old_attachments = self._pa.items_2_attachment_data(client, old_attachment_items)
-            try:
-                old_attachment = old_attachments[0]
-            except IndexError:
-                # no known element was present in attachments
-                old_attachment = {}
-        publisher_account = await self.get_ap_account_from_jid_and_node(
-            publisher,
-            None
-        )
-        publisher_actor_id = self.build_apurl(TYPE_ACTOR, publisher_account)
-        try:
-            attachments = self._pa.items_2_attachment_data(client, [item_elt])[0]
-        except IndexError:
-            # no known element was present in attachments
-            attachments = {}
-
-        # noticed
-        if "noticed" in attachments:
-            if not "noticed" in old_attachment:
-                # new "noticed" attachment, we translate to "Like" activity
-                activity_id = self.build_apurl("like", item_account, item_id)
-                activity = self.create_activity(
-                    TYPE_LIKE, publisher_actor_id, item_url, activity_id=activity_id
-                )
-                activity["to"] = [ap_account]
-                activity["cc"] = [NS_AP_PUBLIC]
-                await self.sign_and_post(inbox, publisher_actor_id, activity)
-        else:
-            if "noticed" in old_attachment:
-                # "noticed" attachment has been removed, we undo the "Like" activity
-                activity_id = self.build_apurl("like", item_account, item_id)
-                activity = self.create_activity(
-                    TYPE_LIKE, publisher_actor_id, item_url, activity_id=activity_id
-                )
-                activity["to"] = [ap_account]
-                activity["cc"] = [NS_AP_PUBLIC]
-                undo = self.create_activity("Undo", publisher_actor_id, activity)
-                await self.sign_and_post(inbox, publisher_actor_id, undo)
-
-        # reactions
-        new_reactions = set(attachments.get("reactions", {}).get("reactions", []))
-        old_reactions = set(old_attachment.get("reactions", {}).get("reactions", []))
-        reactions_remove = old_reactions - new_reactions
-        reactions_add = new_reactions - old_reactions
-        for reactions, undo in ((reactions_remove, True), (reactions_add, False)):
-            for reaction in reactions:
-                activity_id = self.build_apurl(
-                    "reaction", item_account, item_id, reaction.encode().hex()
-                )
-                reaction_activity = self.create_activity(
-                    TYPE_REACTION, publisher_actor_id, item_url,
-                    activity_id=activity_id
-                )
-                reaction_activity["content"] = reaction
-                reaction_activity["to"] = [ap_account]
-                reaction_activity["cc"] = [NS_AP_PUBLIC]
-                if undo:
-                    activy = self.create_activity(
-                        "Undo", publisher_actor_id, reaction_activity
-                    )
-                else:
-                    activy = reaction_activity
-                await self.sign_and_post(inbox, publisher_actor_id, activy)
-
-        # RSVP
-        if "rsvp" in attachments:
-            attending = attachments["rsvp"].get("attending", "no")
-            old_attending = old_attachment.get("rsvp", {}).get("attending", "no")
-            if attending != old_attending:
-                activity_type = TYPE_JOIN if attending == "yes" else TYPE_LEAVE
-                activity_id = self.build_apurl(activity_type.lower(), item_account, item_id)
-                activity = self.create_activity(
-                    activity_type, publisher_actor_id, item_url, activity_id=activity_id
-                )
-                activity["to"] = [ap_account]
-                activity["cc"] = [NS_AP_PUBLIC]
-                await self.sign_and_post(inbox, publisher_actor_id, activity)
-        else:
-            if "rsvp" in old_attachment:
-                old_attending = old_attachment.get("rsvp", {}).get("attending", "no")
-                if old_attending == "yes":
-                    activity_id = self.build_apurl(TYPE_LEAVE.lower(), item_account, item_id)
-                    activity = self.create_activity(
-                        TYPE_LEAVE, publisher_actor_id, item_url, activity_id=activity_id
-                    )
-                    activity["to"] = [ap_account]
-                    activity["cc"] = [NS_AP_PUBLIC]
-                    await self.sign_and_post(inbox, publisher_actor_id, activity)
-
-        if service.user and self.is_virtual_jid(service):
-            # the item is on a virtual service, we need to store it in cache
-            log.debug("storing attachments item in cache")
-            cached_node = await self.host.memory.storage.get_pubsub_node(
-                client, service, node, with_subscriptions=True, create=True
-            )
-            await self.host.memory.storage.cache_pubsub_items(
-                self.client,
-                cached_node,
-                [item_elt],
-                [attachments]
-            )
-
-    async def sign_and_post(self, url: str, actor_id: str, doc: dict) -> TReqResponse:
-        """Sign a documentent and post it to AP server
-
-        @param url: AP server endpoint
-        @param actor_id: originating actor ID (URL)
-        @param doc: document to send
-        """
-        if self.verbose:
-            __, actor_args = self.parse_apurl(actor_id)
-            actor_account = actor_args[0]
-            to_log = [
-                "",
-                f">>> {actor_account} is signing and posting to {url}:\n{pformat(doc)}"
-            ]
-
-        p_url = parse.urlparse(url)
-        body = json.dumps(doc).encode()
-        digest_algo, digest_hash = self.get_digest(body)
-        digest = f"{digest_algo}={digest_hash}"
-
-        headers = {
-            "(request-target)": f"post {p_url.path}",
-            "Host": p_url.hostname,
-            "Date": http.datetimeToString().decode(),
-            "Digest": digest
-        }
-        headers["Content-Type"] = (
-            'application/activity+json'
-        )
-        headers, __ = self.get_signature_data(self.get_key_id(actor_id), headers)
-
-        if self.verbose:
-            if self.verbose>=3:
-                h_to_log = "\n".join(f"    {k}: {v}" for k,v in headers.items())
-                to_log.append(f"  headers:\n{h_to_log}")
-            to_log.append("---")
-            log.info("\n".join(to_log))
-
-        resp = await treq.post(
-            url,
-            body,
-            headers=headers,
-        )
-        if resp.code >= 300:
-            text = await resp.text()
-            log.warning(f"POST request to {url} failed [{resp.code}]: {text}")
-        elif self.verbose:
-            log.info(f"==> response code: {resp.code}")
-        return resp
-
-    def _publish_message(self, mess_data_s: str, service_s: str, profile: str):
-        mess_data: dict = data_format.deserialise(mess_data_s) # type: ignore
-        service = jid.JID(service_s)
-        client = self.host.get_client(profile)
-        return defer.ensureDeferred(self.publish_message(client, mess_data, service))
-
-    @async_lru(maxsize=LRU_MAX_SIZE)
-    async def get_ap_actor_id_from_account(self, account: str) -> str:
-        """Retrieve account ID from it's handle using WebFinger
-
-        Don't use this method to get local actor id from a local account derivated for
-        JID: in this case, the actor ID is retrieve with
-        ``self.build_apurl(TYPE_ACTOR, ap_account)``
-
-        @param account: AP handle (user@domain.tld)
-        @return: Actor ID (which is an URL)
-        """
-        if account.count("@") != 1 or "/" in account:
-            raise ValueError(f"Invalid account: {account!r}")
-        host = account.split("@")[1]
-        try:
-            finger_data = await treq.json_content(await treq.get(
-                f"https://{host}/.well-known/webfinger?"
-                f"resource=acct:{parse.quote_plus(account)}",
-            ))
-        except Exception as e:
-            raise exceptions.DataError(f"Can't get webfinger data for {account!r}: {e}")
-        for link in finger_data.get("links", []):
-            if (
-                link.get("type") == "application/activity+json"
-                and link.get("rel") == "self"
-            ):
-                href = link.get("href", "").strip()
-                if not href:
-                    raise ValueError(
-                        f"Invalid webfinger data for {account:r}: missing href"
-                    )
-                break
-        else:
-            raise ValueError(
-                f"No ActivityPub link found for {account!r}"
-            )
-        return href
-
-    async def get_ap_actor_data_from_account(self, account: str) -> dict:
-        """Retrieve ActivityPub Actor data
-
-        @param account: ActivityPub Actor identifier
-        """
-        href = await self.get_ap_actor_id_from_account(account)
-        return await self.ap_get(href)
-
-    async def get_ap_inbox_from_id(self, actor_id: str, use_shared: bool = True) -> str:
-        """Retrieve inbox of an actor_id
-
-        @param use_shared: if True, and a shared inbox exists, it will be used instead of
-            the user inbox
-        """
-        data = await self.get_actor_data(actor_id)
-        if use_shared:
-            try:
-                return data["endpoints"]["sharedInbox"]
-            except KeyError:
-                pass
-        return data["inbox"]
-
-    @async_lru(maxsize=LRU_MAX_SIZE)
-    async def get_ap_account_from_id(self, actor_id: str) -> str:
-        """Retrieve AP account from the ID URL
-
-        Works with external or local actor IDs.
-        @param actor_id: AP ID of the actor (URL to the actor data)
-        @return: AP handle
-        """
-        if self.is_local_url(actor_id):
-            url_type, url_args = self.parse_apurl(actor_id)
-            if url_type != "actor" or not url_args:
-                raise exceptions.DataError(
-                    f"invalid local actor ID: {actor_id}"
-                )
-            account = url_args[0]
-            try:
-                account_user, account_host = account.split('@')
-            except ValueError:
-                raise exceptions.DataError(
-                    f"invalid account from url: {actor_id}"
-                )
-            if not account_user or account_host != self.public_url:
-                raise exceptions.DataError(
-                    f"{account!r} is not a valid local account (from {actor_id})"
-                )
-            return account
-
-        url_parsed = parse.urlparse(actor_id)
-        actor_data = await self.get_actor_data(actor_id)
-        username = actor_data.get("preferredUsername")
-        if not username:
-            raise exceptions.DataError(
-                'No "preferredUsername" field found, can\'t retrieve actor account'
-            )
-        account = f"{username}@{url_parsed.hostname}"
-        # we try to retrieve the actor ID from the account to check it
-        found_id = await self.get_ap_actor_id_from_account(account)
-        if found_id != actor_id:
-            # cf. https://socialhub.activitypub.rocks/t/how-to-retrieve-user-server-tld-handle-from-actors-url/2196
-            msg = (
-                f"Account ID found on WebFinger {found_id!r} doesn't match our actor ID "
-                f"({actor_id!r}). This AP instance doesn't seems to use "
-                '"preferredUsername" as we expect.'
-            )
-            log.warning(msg)
-            raise exceptions.DataError(msg)
-        return account
-
-    async def get_ap_items(
-        self,
-        collection: dict,
-        max_items: Optional[int] = None,
-        chronological_pagination: bool = True,
-        after_id: Optional[str] = None,
-        start_index: Optional[int] = None,
-        parser: Optional[Callable[[dict], Awaitable[domish.Element]]] = None,
-        only_ids: bool = False,
-    ) -> Tuple[List[domish.Element], rsm.RSMResponse]:
-        """Retrieve AP items and convert them to XMPP items
-
-        @param account: AP account handle to get items from
-        @param max_items: maximum number of items to retrieve
-            retrieve all items by default
-        @param chronological_pagination: get pages in chronological order
-            AP use reversed chronological order for pagination, "first" page returns more
-            recent items. If "chronological_pagination" is True, "last" AP page will be
-            retrieved first.
-        @param after_id: if set, retrieve items starting from given ID
-            Due to ActivityStream Collection Paging limitations, this is inefficient and
-            if ``after_id`` is not already in cache, we have to retrieve every page until
-            we find it.
-            In most common cases, ``after_id`` should be in cache though (client usually
-            use known ID when in-order pagination is used).
-        @param start_index: start retrieving items from the one with given index
-            Due to ActivityStream Collection Paging limitations, this is inefficient and
-            all pages before the requested index will be retrieved to count items.
-        @param parser: method to use to parse AP items and get XMPP item elements
-            if None, use default generic parser
-        @param only_ids: if True, only retrieve items IDs
-            Retrieving only item IDs avoid HTTP requests to retrieve items, it may be
-            sufficient in some use cases (e.g. when retrieving following/followers
-            collections)
-        @return: XMPP Pubsub items and corresponding RSM Response
-            Items are always returned in chronological order in the result
-        """
-        if parser is None:
-            parser = self.ap_item_2_mb_elt
-
-        rsm_resp: Dict[str, Union[bool, int]] = {}
-        try:
-            count = collection["totalItems"]
-        except KeyError:
-            log.warning(
-                f'"totalItems" not found in collection {collection.get("id")}, '
-                "defaulting to 20"
-            )
-            count = 20
-        else:
-            log.info(f"{collection.get('id')} has {count} item(s)")
-
-            rsm_resp["count"] = count
-
-        if start_index is not None:
-            assert chronological_pagination and after_id is None
-            if start_index >= count:
-                return [], rsm_resp
-            elif start_index == 0:
-                # this is the default behaviour
-                pass
-            elif start_index > 5000:
-                raise error.StanzaError(
-                    "feature-not-implemented",
-                    text="Maximum limit for previous_index has been reached, this limit"
-                    "is set to avoid DoS"
-                )
-            else:
-                # we'll convert "start_index" to "after_id", thus we need the item just
-                # before "start_index"
-                previous_index = start_index - 1
-                retrieved_items = 0
-                current_page = collection["last"]
-                while retrieved_items < count:
-                    page_data, items = await self.parse_ap_page(
-                        current_page, parser, only_ids
-                    )
-                    if not items:
-                        log.warning(f"found an empty AP page at {current_page}")
-                        return [], rsm_resp
-                    page_start_idx = retrieved_items
-                    retrieved_items += len(items)
-                    if previous_index <= retrieved_items:
-                        after_id = items[previous_index - page_start_idx]["id"]
-                        break
-                    try:
-                        current_page = page_data["prev"]
-                    except KeyError:
-                        log.warning(
-                            f"missing previous page link at {current_page}: {page_data!r}"
-                        )
-                        raise error.StanzaError(
-                            "service-unavailable",
-                            "Error while retrieving previous page from AP service at "
-                            f"{current_page}"
-                        )
-
-        init_page = "last" if chronological_pagination else "first"
-        page = collection.get(init_page)
-        if not page:
-            raise exceptions.DataError(
-                f"Initial page {init_page!r} not found for collection "
-                f"{collection.get('id')})"
-            )
-        items = []
-        page_items = []
-        retrieved_items = 0
-        found_after_id = False
-
-        while retrieved_items < count:
-            __, page_items = await self.parse_ap_page(page, parser, only_ids)
-            if not page_items:
-                break
-            retrieved_items += len(page_items)
-            if after_id is not None and not found_after_id:
-                # if we have an after_id, we ignore all items until the requested one is
-                # found
-                try:
-                    limit_idx = [i["id"] for i in page_items].index(after_id)
-                except ValueError:
-                    # if "after_id" is not found, we don't add any item from this page
-                    page_id = page.get("id") if isinstance(page, dict) else page
-                    log.debug(f"{after_id!r} not found at {page_id}, skipping")
-                else:
-                    found_after_id = True
-                    if chronological_pagination:
-                        start_index = retrieved_items - len(page_items) + limit_idx + 1
-                        page_items = page_items[limit_idx+1:]
-                    else:
-                        start_index = count - (retrieved_items - len(page_items) +
-                                               limit_idx + 1)
-                        page_items = page_items[:limit_idx]
-                    items.extend(page_items)
-            else:
-                items.extend(page_items)
-            if max_items is not None and len(items) >= max_items:
-                if chronological_pagination:
-                    items = items[:max_items]
-                else:
-                    items = items[-max_items:]
-                break
-            page = collection.get("prev" if chronological_pagination else "next")
-            if not page:
-                break
-
-        if after_id is not None and not found_after_id:
-            raise error.StanzaError("item-not-found")
-
-        if items:
-            if after_id is None:
-                rsm_resp["index"] = 0 if chronological_pagination else count - len(items)
-            if start_index is not None:
-                rsm_resp["index"] = start_index
-            elif after_id is not None:
-                log.warning("Can't determine index of first element")
-            elif chronological_pagination:
-                rsm_resp["index"] = 0
-            else:
-                rsm_resp["index"] = count - len(items)
-            rsm_resp.update({
-                "first": items[0]["id"],
-                "last": items[-1]["id"]
-            })
-
-        return items, rsm.RSMResponse(**rsm_resp)
-
-    async def ap_item_2_mb_data_and_elt(self, ap_item: dict) -> Tuple[dict, domish.Element]:
-        """Convert AP item to parsed microblog data and corresponding item element"""
-        mb_data = await self.ap_item_2_mb_data(ap_item)
-        item_elt = await self._m.mb_data_2_entry_elt(
-            self.client, mb_data, mb_data["id"], None, self._m.namespace
-        )
-        if "repeated" in mb_data["extra"]:
-            item_elt["publisher"] = mb_data["extra"]["repeated"]["by"]
-        else:
-            item_elt["publisher"] = mb_data["author_jid"]
-        return mb_data, item_elt
-
-    async def ap_item_2_mb_elt(self, ap_item: dict) -> domish.Element:
-        """Convert AP item to XMPP item element"""
-        __, item_elt = await self.ap_item_2_mb_data_and_elt(ap_item)
-        return item_elt
-
-    async def parse_ap_page(
-        self,
-        page: Union[str, dict],
-        parser: Callable[[dict], Awaitable[domish.Element]],
-        only_ids: bool = False
-    ) -> Tuple[dict, List[domish.Element]]:
-        """Convert AP objects from an AP page to XMPP items
-
-        @param page: Can be either url linking and AP page, or the page data directly
-        @param parser: method to use to parse AP items and get XMPP item elements
-        @param only_ids: if True, only retrieve items IDs
-        @return: page data, pubsub items
-        """
-        page_data = await self.ap_get_object(page)
-        if page_data is None:
-            log.warning('No data found in collection')
-            return {}, []
-        ap_items = await self.ap_get_list(page_data, "orderedItems", only_ids=only_ids)
-        if ap_items is None:
-            ap_items = await self.ap_get_list(page_data, "items", only_ids=only_ids)
-            if not ap_items:
-                log.warning(f'No item field found in collection: {page_data!r}')
-                return page_data, []
-            else:
-                log.warning(
-                    "Items are not ordered, this is not spec compliant"
-                )
-        items = []
-        # AP Collections are in antichronological order, but we expect chronological in
-        # Pubsub, thus we reverse it
-        for ap_item in reversed(ap_items):
-            try:
-                items.append(await parser(ap_item))
-            except (exceptions.DataError, NotImplementedError, error.StanzaError):
-                continue
-
-        return page_data, items
-
-    async def get_comments_nodes(
-        self,
-        item_id: str,
-        parent_id: Optional[str]
-    ) -> Tuple[Optional[str], Optional[str]]:
-        """Get node where this item is and node to use for comments
-
-        if config option "comments_max_depth" is set, a common node will be used below the
-        given depth
-        @param item_id: ID of the reference item
-        @param parent_id: ID of the parent item if any (the ID set in "inReplyTo")
-        @return: a tuple with parent_node_id, comments_node_id:
-            - parent_node_id is the ID of the node where reference item must be. None is
-              returned when the root node (i.e. not a comments node) must be used.
-            - comments_node_id: is the ID of the node to use for comments. None is
-              returned when no comment node must be used (happens when we have reached
-              "comments_max_depth")
-        """
-        if parent_id is None or not self.comments_max_depth:
-            return (
-                self._m.get_comments_node(parent_id) if parent_id is not None else None,
-                self._m.get_comments_node(item_id)
-            )
-        parent_url = parent_id
-        parents = []
-        for __ in range(COMMENTS_MAX_PARENTS):
-            parent_item = await self.ap_get(parent_url)
-            parents.insert(0, parent_item)
-            parent_url = parent_item.get("inReplyTo")
-            if parent_url is None:
-                break
-        parent_limit = self.comments_max_depth-1
-        if len(parents) <= parent_limit:
-            return (
-                self._m.get_comments_node(parents[-1]["id"]),
-                self._m.get_comments_node(item_id)
-            )
-        else:
-            last_level_item = parents[parent_limit]
-            return (
-                self._m.get_comments_node(last_level_item["id"]),
-                None
-            )
-
-    async def ap_item_2_mb_data(self, ap_item: dict) -> dict:
-        """Convert AP activity or object to microblog data
-
-        @param ap_item: ActivityPub item to convert
-            Can be either an activity of an object
-        @return: AP Item's Object and microblog data
-        @raise exceptions.DataError: something is invalid in the AP item
-        @raise NotImplementedError: some AP data is not handled yet
-        @raise error.StanzaError: error while contacting the AP server
-        """
-        is_activity = self.is_activity(ap_item)
-        if is_activity:
-            ap_object = await self.ap_get_object(ap_item, "object")
-            if not ap_object:
-                log.warning(f'No "object" found in AP item {ap_item!r}')
-                raise exceptions.DataError
-        else:
-            ap_object = ap_item
-        item_id = ap_object.get("id")
-        if not item_id:
-            log.warning(f'No "id" found in AP item: {ap_object!r}')
-            raise exceptions.DataError
-        mb_data = {"id": item_id, "extra": {}}
-
-        # content
-        try:
-            language, content_xhtml = ap_object["contentMap"].popitem()
-        except (KeyError, AttributeError):
-            try:
-                mb_data["content_xhtml"] = ap_object["content"]
-            except KeyError:
-                log.warning(f"no content found:\n{ap_object!r}")
-                raise exceptions.DataError
-        else:
-            mb_data["language"] = language
-            mb_data["content_xhtml"] = content_xhtml
-
-        mb_data["content"] = await self._t.convert(
-            mb_data["content_xhtml"],
-            self._t.SYNTAX_XHTML,
-            self._t.SYNTAX_TEXT,
-            False,
-        )
-
-        if "attachment" in ap_object:
-            attachments = mb_data["extra"][C.KEY_ATTACHMENTS] = []
-            for ap_attachment in ap_object["attachment"]:
-                try:
-                    url = ap_attachment["url"]
-                except KeyError:
-                    log.warning(
-                        f'"url" missing in AP attachment, ignoring: {ap_attachment}'
-                    )
-                    continue
-
-                if not url.startswith("http"):
-                    log.warning(f"non HTTP URL in attachment, ignoring: {ap_attachment}")
-                    continue
-                attachment = {"url": url}
-                for ap_key, key in (
-                    ("mediaType", "media_type"),
-                    # XXX: as weird as it seems, "name" is actually used for description
-                    #   in AP world
-                    ("name", "desc"),
-                ):
-                    value = ap_attachment.get(ap_key)
-                    if value:
-                        attachment[key] = value
-                attachments.append(attachment)
-
-        # author
-        if is_activity:
-            authors = await self.ap_get_actors(ap_item, "actor")
-        else:
-            authors = await self.ap_get_actors(ap_object, "attributedTo")
-        if len(authors) > 1:
-            # we only keep first item as author
-            # TODO: handle multiple actors
-            log.warning("multiple actors are not managed")
-
-        account = authors[0]
-        author_jid = self.get_local_jid_from_account(account).full()
-
-        mb_data["author"] = account.split("@", 1)[0]
-        mb_data["author_jid"] = author_jid
-
-        # published/updated
-        for field in ("published", "updated"):
-            value = ap_object.get(field)
-            if not value and field == "updated":
-                value = ap_object.get("published")
-            if value:
-                try:
-                    mb_data[field] = calendar.timegm(
-                        dateutil.parser.parse(str(value)).utctimetuple()
-                    )
-                except dateutil.parser.ParserError as e:
-                    log.warning(f"Can't parse {field!r} field: {e}")
-
-        # repeat
-        if "_repeated" in ap_item:
-            mb_data["extra"]["repeated"] = ap_item["_repeated"]
-
-        # comments
-        in_reply_to = ap_object.get("inReplyTo")
-        __, comments_node = await self.get_comments_nodes(item_id, in_reply_to)
-        if comments_node is not None:
-            comments_data = {
-                "service": author_jid,
-                "node": comments_node,
-                "uri": uri.build_xmpp_uri(
-                    "pubsub",
-                    path=author_jid,
-                    node=comments_node
-                )
-            }
-            mb_data["comments"] = [comments_data]
-
-        return mb_data
-
-    async def get_reply_to_id_from_xmpp_node(
-        self,
-        client: SatXMPPEntity,
-        ap_account: str,
-        parent_item: str,
-        mb_data: dict
-    ) -> str:
-        """Get URL to use for ``inReplyTo`` field in AP item.
-
-        There is currently no way to know the parent service of a comment with XEP-0277.
-        To work around that, we try to check if we have this item in the cache (we
-        should). If there is more that one item with this ID, we first try to find one
-        with this author_jid. If nothing is found, we use ap_account to build `inReplyTo`.
-
-        @param ap_account: AP account corresponding to the publication author
-        @param parent_item: ID of the node where the publication this item is replying to
-             has been posted
-        @param mb_data: microblog data of the publication
-        @return: URL to use in ``inReplyTo`` field
-        """
-        # FIXME: propose a protoXEP to properly get parent item, node and service
-
-        found_items = await self.host.memory.storage.search_pubsub_items({
-            "profiles": [client.profile],
-            "names": [parent_item]
-        })
-        if not found_items:
-            log.warning(f"parent item {parent_item!r} not found in cache")
-            parent_ap_account = ap_account
-        elif len(found_items) == 1:
-            cached_node = found_items[0].node
-            parent_ap_account = await self.get_ap_account_from_jid_and_node(
-                cached_node.service,
-                cached_node.name
-            )
-        else:
-            # we found several cached item with given ID, we check if there is one
-            # corresponding to this author
-            try:
-                author = jid.JID(mb_data["author_jid"]).userhostJID()
-                cached_item = next(
-                    i for i in found_items
-                    if jid.JID(i.data["publisher"]).userhostJID()
-                    == author
-                )
-            except StopIteration:
-                # no item corresponding to this author, we use ap_account
-                log.warning(
-                    "Can't find a single cached item for parent item "
-                    f"{parent_item!r}"
-                )
-                parent_ap_account = ap_account
-            else:
-                cached_node = cached_item.node
-                parent_ap_account = await self.get_ap_account_from_jid_and_node(
-                    cached_node.service,
-                    cached_node.name
-                )
-
-        return self.build_apurl(
-            TYPE_ITEM, parent_ap_account, parent_item
-        )
-
-    async def repeated_mb_2_ap_item(
-        self,
-        mb_data: dict
-    ) -> dict:
-        """Convert repeated blog item to suitable AP Announce activity
-
-        @param mb_data: microblog metadata of an item repeating an other blog post
-        @return: Announce activity linking to the repeated item
-        """
-        repeated = mb_data["extra"]["repeated"]
-        repeater = jid.JID(repeated["by"])
-        repeater_account = await self.get_ap_account_from_jid_and_node(
-            repeater,
-            None
-        )
-        repeater_id = self.build_apurl(TYPE_ACTOR, repeater_account)
-        repeated_uri = repeated["uri"]
-
-        if not repeated_uri.startswith("xmpp:"):
-            log.warning(
-                "Only xmpp: URL are handled for repeated item at the moment, ignoring "
-                f"item {mb_data}"
-            )
-            raise NotImplementedError
-        parsed_url = uri.parse_xmpp_uri(repeated_uri)
-        if parsed_url["type"] != "pubsub":
-            log.warning(
-                "Only pubsub URL are handled for repeated item at the moment, ignoring "
-                f"item {mb_data}"
-            )
-            raise NotImplementedError
-        rep_service = jid.JID(parsed_url["path"])
-        rep_item = parsed_url["item"]
-        activity_id = self.build_apurl("item", repeater.userhost(), mb_data["id"])
-
-        if self.is_virtual_jid(rep_service):
-            # it's an AP actor linked through this gateway
-            # in this case we can simply use the item ID
-            if not rep_item.startswith("https:"):
-                log.warning(
-                    f"Was expecting an HTTPS url as item ID and got {rep_item!r}\n"
-                    f"{mb_data}"
-                )
-            announced_uri = rep_item
-            repeated_account = self._e.unescape(rep_service.user)
-        else:
-            # the repeated item is an XMPP publication, we build the corresponding ID
-            rep_node = parsed_url["node"]
-            repeated_account = await self.get_ap_account_from_jid_and_node(
-                rep_service, rep_node
-            )
-            announced_uri = self.build_apurl("item", repeated_account, rep_item)
-
-        announce = self.create_activity(
-            "Announce", repeater_id, announced_uri, activity_id=activity_id
-        )
-        announce["to"] = [NS_AP_PUBLIC]
-        announce["cc"] = [
-            self.build_apurl(TYPE_FOLLOWERS, repeater_account),
-            await self.get_ap_actor_id_from_account(repeated_account)
-        ]
-        return announce
-
-    async def mb_data_2_ap_item(
-        self,
-        client: SatXMPPEntity,
-        mb_data: dict,
-        public: bool =True,
-        is_new: bool = True,
-    ) -> dict:
-        """Convert Libervia Microblog Data to ActivityPub item
-
-        @param mb_data: microblog data (as used in plugin XEP-0277) to convert
-            If ``public`` is True, ``service`` and ``node`` keys must be set.
-            If ``published`` is not set, current datetime will be used
-        @param public: True if the message is not a private/direct one
-            if True, the AP Item will be marked as public, and AP followers of target AP
-            account (which retrieve from ``service``) will be put in ``cc``.
-            ``inReplyTo`` will also be set if suitable
-            if False, no destinee will be set (i.e., no ``to`` or ``cc`` or public flag).
-            This is usually used for direct messages.
-        @param is_new: if True, the item is a new one (no instance has been found in
-            cache).
-            If True, a "Create" activity will be generated, otherwise an "Update" one will
-            be.
-        @return: Activity item
-        """
-        extra = mb_data.get("extra", {})
-        if "repeated" in extra:
-            return await self.repeated_mb_2_ap_item(mb_data)
-        if not mb_data.get("id"):
-            mb_data["id"] = shortuuid.uuid()
-        if not mb_data.get("author_jid"):
-            mb_data["author_jid"] = client.jid.userhost()
-        ap_account = await self.get_ap_account_from_jid_and_node(
-            jid.JID(mb_data["author_jid"]),
-            None
-        )
-        url_actor = self.build_apurl(TYPE_ACTOR, ap_account)
-        url_item = self.build_apurl(TYPE_ITEM, ap_account, mb_data["id"])
-        ap_object = {
-            "id": url_item,
-            "type": "Note",
-            "published": utils.xmpp_date(mb_data.get("published")),
-            "attributedTo": url_actor,
-            "content": mb_data.get("content_xhtml") or mb_data["content"],
-        }
-
-        language = mb_data.get("language")
-        if language:
-            ap_object["contentMap"] = {language: ap_object["content"]}
-
-        attachments = extra.get(C.KEY_ATTACHMENTS)
-        if attachments:
-            ap_attachments = ap_object["attachment"] = []
-            for attachment in attachments:
-                try:
-                    url = next(
-                        s['url'] for s in attachment["sources"] if 'url' in s
-                    )
-                except (StopIteration, KeyError):
-                    log.warning(
-                        f"Ignoring attachment without URL: {attachment}"
-                    )
-                    continue
-                ap_attachment = {
-                    "url": url
-                }
-                for key, ap_key in (
-                    ("media_type", "mediaType"),
-                    # XXX: yes "name", cf. [ap_item_2_mb_data]
-                    ("desc", "name"),
-                ):
-                    value = attachment.get(key)
-                    if value:
-                        ap_attachment[ap_key] = value
-                ap_attachments.append(ap_attachment)
-
-        if public:
-            ap_object["to"] = [NS_AP_PUBLIC]
-            if self.auto_mentions:
-                for m in RE_MENTION.finditer(ap_object["content"]):
-                    mention = m.group()
-                    mentioned = mention[1:]
-                    __, m_host = mentioned.split("@", 1)
-                    if m_host in (self.public_url, self.client.jid.host):
-                        # we ignore mention of local users, they should be sent as XMPP
-                        # references
-                        continue
-                    try:
-                        mentioned_id = await self.get_ap_actor_id_from_account(mentioned)
-                    except Exception as e:
-                        log.warning(f"Can't add mention to {mentioned!r}: {e}")
-                    else:
-                        ap_object["to"].append(mentioned_id)
-                        ap_object.setdefault("tag", []).append({
-                            "type": TYPE_MENTION,
-                            "href": mentioned_id,
-                            "name": mention,
-                        })
-            try:
-                node = mb_data["node"]
-                service = jid.JID(mb_data["service"])
-            except KeyError:
-                # node and service must always be specified when this method is used
-                raise exceptions.InternalError(
-                    "node or service is missing in mb_data"
-                )
-            target_ap_account = await self.get_ap_account_from_jid_and_node(
-                service, node
-            )
-            if self.is_virtual_jid(service):
-                # service is a proxy JID for AP account
-                actor_data = await self.get_ap_actor_data_from_account(target_ap_account)
-                followers = actor_data.get("followers")
-            else:
-                # service is a real XMPP entity
-                followers = self.build_apurl(TYPE_FOLLOWERS, target_ap_account)
-            if followers:
-                ap_object["cc"] = [followers]
-            if self._m.is_comment_node(node):
-                parent_item = self._m.get_parent_item(node)
-                if self.is_virtual_jid(service):
-                    # the publication is on a virtual node (i.e. an XMPP node managed by
-                    # this gateway and linking to an ActivityPub actor)
-                    ap_object["inReplyTo"] = parent_item
-                else:
-                    # the publication is from a followed real XMPP node
-                    ap_object["inReplyTo"] = await self.get_reply_to_id_from_xmpp_node(
-                        client,
-                        ap_account,
-                        parent_item,
-                        mb_data
-                    )
-
-        return self.create_activity(
-            "Create" if is_new else "Update", url_actor, ap_object, activity_id=url_item
-        )
-
-    async def publish_message(
-        self,
-        client: SatXMPPEntity,
-        mess_data: dict,
-        service: jid.JID
-    ) -> None:
-        """Send an AP message
-
-        .. note::
-
-            This is a temporary method used for development only
-
-        @param mess_data: message data. Following keys must be set:
-
-            ``node``
-              identifier of message which is being replied (this will
-              correspond to pubsub node in the future)
-
-            ``content_xhtml`` or ``content``
-              message body (respectively in XHTML or plain text)
-
-        @param service: JID corresponding to the AP actor.
-        """
-        if not service.user:
-            raise ValueError("service must have a local part")
-        account = self._e.unescape(service.user)
-        ap_actor_data = await self.get_ap_actor_data_from_account(account)
-
-        try:
-            inbox_url = ap_actor_data["endpoints"]["sharedInbox"]
-        except KeyError:
-            raise exceptions.DataError("Can't get ActivityPub actor inbox")
-
-        item_data = await self.mb_data_2_ap_item(client, mess_data)
-        url_actor = item_data["actor"]
-        resp = await self.sign_and_post(inbox_url, url_actor, item_data)
-
-    async def ap_delete_item(
-        self,
-        jid_: jid.JID,
-        node: Optional[str],
-        item_id: str,
-        public: bool = True
-    ) -> Tuple[str, Dict[str, Any]]:
-        """Build activity to delete an AP item
-
-        @param jid_: JID of the entity deleting an item
-        @param node: node where the item is deleted
-            None if it's microblog or a message
-        @param item_id: ID of the item to delete
-            it's the Pubsub ID or message's origin ID
-        @param public: if True, the activity will be addressed to public namespace
-        @return: actor_id of the entity deleting the item, activity to send
-        """
-        if node is None:
-            node = self._m.namespace
-
-        author_account = await self.get_ap_account_from_jid_and_node(jid_, node)
-        author_actor_id = self.build_apurl(TYPE_ACTOR, author_account)
-
-        items = await self.host.memory.storage.search_pubsub_items({
-            "profiles": [self.client.profile],
-            "services": [jid_],
-            "names": [item_id]
-        })
-        if not items:
-            log.warning(
-                f"Deleting an unknown item at service {jid_}, node {node} and id "
-                f"{item_id}"
-            )
-        else:
-            try:
-                mb_data = await self._m.item_2_mb_data(self.client, items[0].data, jid_, node)
-                if "repeated" in mb_data["extra"]:
-                    # we are deleting a repeated item, we must translate this to an
-                    # "Undo" of the "Announce" activity instead of a "Delete" one
-                    announce = await self.repeated_mb_2_ap_item(mb_data)
-                    undo = self.create_activity("Undo", author_actor_id, announce)
-                    return author_actor_id, undo
-            except Exception as e:
-                log.debug(
-                    f"Can't parse item, maybe it's not a blog item: {e}\n"
-                    f"{items[0].toXml()}"
-                )
-
-        url_item = self.build_apurl(TYPE_ITEM, author_account, item_id)
-        ap_item = self.create_activity(
-            "Delete",
-            author_actor_id,
-            {
-                "id": url_item,
-                "type": TYPE_TOMBSTONE
-            }
-        )
-        if public:
-            ap_item["to"] = [NS_AP_PUBLIC]
-        return author_actor_id, ap_item
-
-    def _message_received_trigger(
-        self,
-        client: SatXMPPEntity,
-        message_elt: domish.Element,
-        post_treat: defer.Deferred
-    ) -> bool:
-        """add the gateway workflow on post treatment"""
-        if self.client is None:
-            log.debug(f"no client set, ignoring message: {message_elt.toXml()}")
-            return True
-        post_treat.addCallback(
-            lambda mess_data: defer.ensureDeferred(self.onMessage(client, mess_data))
-        )
-        return True
-
-    async def onMessage(self, client: SatXMPPEntity, mess_data: dict) -> dict:
-        """Called once message has been parsed
-
-        this method handle the conversion to AP items and posting
-        """
-        if client != self.client:
-            return mess_data
-        if mess_data["type"] not in ("chat", "normal"):
-            log.warning(f"ignoring message with unexpected type: {mess_data}")
-            return mess_data
-        if not self.is_local(mess_data["from"]):
-            log.warning(f"ignoring non local message: {mess_data}")
-            return mess_data
-        if not mess_data["to"].user:
-            log.warning(
-                f"ignoring message addressed to gateway itself: {mess_data}"
-            )
-            return mess_data
-
-        actor_account = self._e.unescape(mess_data["to"].user)
-        actor_id = await self.get_ap_actor_id_from_account(actor_account)
-        inbox = await self.get_ap_inbox_from_id(actor_id, use_shared=False)
-
-        try:
-            language, message = next(iter(mess_data["message"].items()))
-        except (KeyError, StopIteration):
-            log.warning(f"ignoring empty message: {mess_data}")
-            return mess_data
-
-        mb_data = {
-            "content": message,
-        }
-        if language:
-            mb_data["language"] = language
-        origin_id = mess_data["extra"].get("origin_id")
-        if origin_id:
-            # we need to use origin ID when present to be able to retract the message
-            mb_data["id"] = origin_id
-        attachments = mess_data["extra"].get(C.KEY_ATTACHMENTS)
-        if attachments:
-            mb_data["extra"] = {
-                C.KEY_ATTACHMENTS: attachments
-            }
-
-        client = self.client.get_virtual_client(mess_data["from"])
-        ap_item = await self.mb_data_2_ap_item(client, mb_data, public=False)
-        ap_object = ap_item["object"]
-        ap_object["to"] = ap_item["to"] = [actor_id]
-        # we add a mention to direct message, otherwise peer is not notified in some AP
-        # implementations (notably Mastodon), and the message may be missed easily.
-        ap_object.setdefault("tag", []).append({
-            "type": TYPE_MENTION,
-            "href": actor_id,
-            "name": f"@{actor_account}",
-        })
-
-        await self.sign_and_post(inbox, ap_item["actor"], ap_item)
-        return mess_data
-
-    async def _on_message_retract(
-        self,
-        client: SatXMPPEntity,
-        message_elt: domish.Element,
-        retract_elt: domish.Element,
-        fastened_elts
-    ) -> bool:
-        if client != self.client:
-            return True
-        from_jid = jid.JID(message_elt["from"])
-        if not self.is_local(from_jid):
-            log.debug(
-                f"ignoring retract request from non local jid {from_jid}"
-            )
-            return False
-        to_jid = jid.JID(message_elt["to"])
-        if (to_jid.host != self.client.jid.full() or not to_jid.user):
-            # to_jid should be a virtual JID from this gateway
-            raise exceptions.InternalError(
-                f"Invalid destinee's JID: {to_jid.full()}"
-            )
-        ap_account = self._e.unescape(to_jid.user)
-        actor_id = await self.get_ap_actor_id_from_account(ap_account)
-        inbox = await self.get_ap_inbox_from_id(actor_id, use_shared=False)
-        url_actor, ap_item = await self.ap_delete_item(
-            from_jid.userhostJID(), None, fastened_elts.id, public=False
-        )
-        resp = await self.sign_and_post(inbox, url_actor, ap_item)
-        return False
-
-    async def _on_reference_received(
-        self,
-        client: SatXMPPEntity,
-        message_elt: domish.Element,
-        reference_data: Dict[str, Union[str, int]]
-    ) -> bool:
-        parsed_uri: dict = reference_data.get("parsed_uri")
-        if not parsed_uri:
-            log.warning(f"no parsed URI available in reference {reference_data}")
-            return False
-
-        try:
-            mentioned = jid.JID(parsed_uri["path"])
-        except RuntimeError:
-            log.warning(f"invalid target: {reference_data['uri']}")
-            return False
-
-        if mentioned.host != self.client.jid.full() or not mentioned.user:
-            log.warning(
-                f"ignoring mentioned user {mentioned}, it's not a JID mapping an AP "
-                "account"
-            )
-            return False
-
-        ap_account = self._e.unescape(mentioned.user)
-        actor_id = await self.get_ap_actor_id_from_account(ap_account)
-
-        parsed_anchor: dict = reference_data.get("parsed_anchor")
-        if not parsed_anchor:
-            log.warning(f"no XMPP anchor, ignoring reference {reference_data!r}")
-            return False
-
-        if parsed_anchor["type"] != "pubsub":
-            log.warning(
-                f"ignoring reference with non pubsub anchor, this is not supported: "
-                "{reference_data!r}"
-            )
-            return False
-
-        try:
-            pubsub_service = jid.JID(parsed_anchor["path"])
-        except RuntimeError:
-            log.warning(f"invalid anchor: {reference_data['anchor']}")
-            return False
-        pubsub_node = parsed_anchor.get("node")
-        if not pubsub_node:
-            log.warning(f"missing pubsub node in anchor: {reference_data['anchor']}")
-            return False
-        pubsub_item = parsed_anchor.get("item")
-        if not pubsub_item:
-            log.warning(f"missing pubsub item in anchor: {reference_data['anchor']}")
-            return False
-
-        cached_node = await self.host.memory.storage.get_pubsub_node(
-            client, pubsub_service, pubsub_node
-        )
-        if not cached_node:
-            log.warning(f"Anchored node not found in cache: {reference_data['anchor']}")
-            return False
-
-        cached_items, __ = await self.host.memory.storage.get_items(
-            cached_node, item_ids=[pubsub_item]
-        )
-        if not cached_items:
-            log.warning(
-                f"Anchored pubsub item not found in cache: {reference_data['anchor']}"
-            )
-            return False
-
-        cached_item = cached_items[0]
-
-        mb_data = await self._m.item_2_mb_data(
-            client, cached_item.data, pubsub_service, pubsub_node
-        )
-        ap_item = await self.mb_data_2_ap_item(client, mb_data)
-        ap_object = ap_item["object"]
-        ap_object["to"] = [actor_id]
-        ap_object.setdefault("tag", []).append({
-            "type": TYPE_MENTION,
-            "href": actor_id,
-            "name": ap_account,
-        })
-
-        inbox = await self.get_ap_inbox_from_id(actor_id, use_shared=False)
-
-        resp = await self.sign_and_post(inbox, ap_item["actor"], ap_item)
-
-        return False
-
-    async def new_reply_to_xmpp_item(
-        self,
-        client: SatXMPPEntity,
-        ap_item: dict,
-        targets: Dict[str, Set[str]],
-        mentions: List[Dict[str, str]],
-    ) -> None:
-        """We got an AP item which is a reply to an XMPP item"""
-        in_reply_to = ap_item["inReplyTo"]
-        url_type, url_args = self.parse_apurl(in_reply_to)
-        if url_type != "item":
-            log.warning(
-                "Ignoring AP item replying to an XMPP item with an unexpected URL "
-                f"type({url_type!r}):\n{pformat(ap_item)}"
-            )
-            return
-        try:
-            parent_item_account, parent_item_id = url_args[0], '/'.join(url_args[1:])
-        except (IndexError, ValueError):
-            log.warning(
-                "Ignoring AP item replying to an XMPP item with invalid inReplyTo URL "
-                f"({in_reply_to!r}):\n{pformat(ap_item)}"
-            )
-            return
-        parent_item_service, parent_item_node = await self.get_jid_and_node(
-            parent_item_account
-        )
-        if parent_item_node is None:
-            parent_item_node = self._m.namespace
-        items, __ = await self._p.get_items(
-            client, parent_item_service, parent_item_node, item_ids=[parent_item_id]
-        )
-        try:
-            parent_item_elt = items[0]
-        except IndexError:
-            log.warning(
-                f"Can't find parent item at {parent_item_service} (node "
-                f"{parent_item_node!r})\n{pformat(ap_item)}")
-            return
-        parent_item_parsed = await self._m.item_2_mb_data(
-            client, parent_item_elt, parent_item_service, parent_item_node
-        )
-        try:
-            comment_service = jid.JID(parent_item_parsed["comments"][0]["service"])
-            comment_node = parent_item_parsed["comments"][0]["node"]
-        except (KeyError, IndexError):
-            # we don't have a comment node set for this item
-            from sat.tools.xml_tools import pp_elt
-            log.info(f"{pp_elt(parent_item_elt.toXml())}")
-            raise NotImplementedError()
-        else:
-            __, item_elt = await self.ap_item_2_mb_data_and_elt(ap_item)
-            await self._p.publish(client, comment_service, comment_node, [item_elt])
-            await self.notify_mentions(
-                targets, mentions, comment_service, comment_node, item_elt["id"]
-            )
-
-    def get_ap_item_targets(
-        self,
-        item: Dict[str, Any]
-    ) -> Tuple[bool, Dict[str, Set[str]], List[Dict[str, str]]]:
-        """Retrieve targets of an AP item, and indicate if it's a public one
-
-        @param item: AP object payload
-        @return: Are returned:
-            - is_public flag, indicating if the item is world-readable
-            - a dict mapping target type to targets
-        """
-        targets: Dict[str, Set[str]] = {}
-        is_public = False
-        # TODO: handle "audience"
-        for key in ("to", "bto", "cc", "bcc"):
-            values = item.get(key)
-            if not values:
-                continue
-            if isinstance(values, str):
-                values = [values]
-            for value in values:
-                if value in PUBLIC_TUPLE:
-                    is_public = True
-                    continue
-                if not value:
-                    continue
-                if not self.is_local_url(value):
-                    continue
-                target_type = self.parse_apurl(value)[0]
-                if target_type != TYPE_ACTOR:
-                    log.debug(f"ignoring non actor type as a target: {href}")
-                else:
-                    targets.setdefault(target_type, set()).add(value)
-
-        mentions = []
-        tags = item.get("tag")
-        if tags:
-            for tag in tags:
-                if tag.get("type") != TYPE_MENTION:
-                    continue
-                href = tag.get("href")
-                if not href:
-                    log.warning('Missing "href" field from mention object: {tag!r}')
-                    continue
-                if not self.is_local_url(href):
-                    continue
-                uri_type = self.parse_apurl(href)[0]
-                if uri_type != TYPE_ACTOR:
-                    log.debug(f"ignoring non actor URI as a target: {href}")
-                    continue
-                mention = {"uri": href}
-                mentions.append(mention)
-                name = tag.get("name")
-                if name:
-                    mention["content"] = name
-
-        return is_public, targets, mentions
-
-    async def new_ap_item(
-        self,
-        client: SatXMPPEntity,
-        destinee: Optional[jid.JID],
-        node: str,
-        item: dict,
-    ) -> None:
-        """Analyse, cache and send notification for received AP item
-
-        @param destinee: jid of the destinee,
-        @param node: XMPP pubsub node
-        @param item: AP object payload
-        """
-        is_public, targets, mentions = self.get_ap_item_targets(item)
-        if not is_public and targets.keys() == {TYPE_ACTOR}:
-            # this is a direct message
-            await self.handle_message_ap_item(
-                client, targets, mentions, destinee, item
-            )
-        else:
-            await self.handle_pubsub_ap_item(
-                client, targets, mentions, destinee, node, item, is_public
-            )
-
-    async def handle_message_ap_item(
-        self,
-        client: SatXMPPEntity,
-        targets: Dict[str, Set[str]],
-        mentions: List[Dict[str, str]],
-        destinee: Optional[jid.JID],
-        item: dict,
-    ) -> None:
-        """Parse and deliver direct AP items translating to XMPP messages
-
-        @param targets: actors where the item must be delivered
-        @param destinee: jid of the destinee,
-        @param item: AP object payload
-        """
-        targets_jids = {
-            await self.get_jid_from_id(t)
-            for t_set in targets.values()
-            for t in t_set
-        }
-        if destinee is not None:
-            targets_jids.add(destinee)
-        mb_data = await self.ap_item_2_mb_data(item)
-        extra = {
-            "origin_id": mb_data["id"]
-        }
-        attachments = mb_data["extra"].get(C.KEY_ATTACHMENTS)
-        if attachments:
-            extra[C.KEY_ATTACHMENTS] = attachments
-
-        defer_l = []
-        for target_jid in targets_jids:
-            defer_l.append(
-                client.sendMessage(
-                    target_jid,
-                    {'': mb_data.get("content", "")},
-                    mb_data.get("title"),
-                    extra=extra
-                )
-            )
-        await defer.DeferredList(defer_l)
-
-    async def notify_mentions(
-        self,
-        targets: Dict[str, Set[str]],
-        mentions: List[Dict[str, str]],
-        service: jid.JID,
-        node: str,
-        item_id: str,
-    ) -> None:
-        """Send mention notifications to recipients and mentioned entities
-
-        XEP-0372 (References) is used.
-
-        Mentions are also sent to recipients as they are primary audience (see
-        https://www.w3.org/TR/activitystreams-vocabulary/#microsyntaxes).
-
-        """
-        anchor = uri.build_xmpp_uri("pubsub", path=service.full(), node=node, item=item_id)
-        seen = set()
-        # we start with explicit mentions because mentions' content will be used in the
-        # future to fill "begin" and "end" reference attributes (we can't do it at the
-        # moment as there is no way to specify the XML element to use in the blog item).
-        for mention in mentions:
-            mentioned_jid = await self.get_jid_from_id(mention["uri"])
-            self._refs.send_reference(
-                self.client,
-                to_jid=mentioned_jid,
-                anchor=anchor
-            )
-            seen.add(mentioned_jid)
-
-        remaining = {
-            await self.get_jid_from_id(t)
-            for t_set in targets.values()
-            for t in t_set
-        } - seen
-        for target in remaining:
-            self._refs.send_reference(
-                self.client,
-                to_jid=target,
-                anchor=anchor
-            )
-
-    async def handle_pubsub_ap_item(
-        self,
-        client: SatXMPPEntity,
-        targets: Dict[str, Set[str]],
-        mentions: List[Dict[str, str]],
-        destinee: Optional[jid.JID],
-        node: str,
-        item: dict,
-        public: bool
-    ) -> None:
-        """Analyse, cache and deliver AP items translating to Pubsub
-
-        @param targets: actors/collections where the item must be delivered
-        @param destinee: jid of the destinee,
-        @param node: XMPP pubsub node
-        @param item: AP object payload
-        @param public: True if the item is public
-        """
-        # XXX: "public" is not used for now
-        service = client.jid
-        in_reply_to = item.get("inReplyTo")
-
-        if in_reply_to and isinstance(in_reply_to, list):
-            in_reply_to = in_reply_to[0]
-        if in_reply_to and isinstance(in_reply_to, str):
-            if self.is_local_url(in_reply_to):
-                # this is a reply to an XMPP item
-                await self.new_reply_to_xmpp_item(client, item, targets, mentions)
-                return
-
-            # this item is a reply to an AP item, we use or create a corresponding node
-            # for comments
-            parent_node, __ = await self.get_comments_nodes(item["id"], in_reply_to)
-            node = parent_node or node
-            cached_node = await self.host.memory.storage.get_pubsub_node(
-                client, service, node, with_subscriptions=True, create=True,
-                create_kwargs={"subscribed": True}
-            )
-        else:
-            # it is a root item (i.e. not a reply to an other item)
-            create = node == self._events.namespace
-            cached_node = await self.host.memory.storage.get_pubsub_node(
-                client, service, node, with_subscriptions=True, create=create
-            )
-            if cached_node is None:
-                log.warning(
-                    f"Received item in unknown node {node!r} at {service}. This may be "
-                    f"due to a cache purge. We synchronise the node\n{item}"
-
-                )
-                return
-        if item.get("type") == TYPE_EVENT:
-            data, item_elt = await self.ap_events.ap_item_2_event_data_and_elt(item)
-        else:
-            data, item_elt = await self.ap_item_2_mb_data_and_elt(item)
-        await self.host.memory.storage.cache_pubsub_items(
-            client,
-            cached_node,
-            [item_elt],
-            [data]
-        )
-
-        for subscription in cached_node.subscriptions:
-            if subscription.state != SubscriptionState.SUBSCRIBED:
-                continue
-            self.pubsub_service.notifyPublish(
-                service,
-                node,
-                [(subscription.subscriber, None, [item_elt])]
-            )
-
-        await self.notify_mentions(targets, mentions, service, node, item_elt["id"])
-
-    async def new_ap_delete_item(
-        self,
-        client: SatXMPPEntity,
-        destinee: Optional[jid.JID],
-        node: str,
-        item: dict,
-    ) -> None:
-        """Analyse, cache and send notification for received AP item
-
-        @param destinee: jid of the destinee,
-        @param node: XMPP pubsub node
-        @param activity: parent AP activity
-        @param item: AP object payload
-            only the "id" field is used
-        """
-        item_id = item.get("id")
-        if not item_id:
-            raise exceptions.DataError('"id" attribute is missing in item')
-        if not item_id.startswith("http"):
-            raise exceptions.DataError(f"invalid id: {item_id!r}")
-        if self.is_local_url(item_id):
-            raise ValueError("Local IDs should not be used")
-
-        # we have no way to know if a deleted item is a direct one (thus a message) or one
-        # converted to pubsub. We check if the id is in message history to decide what to
-        # do.
-        history = await self.host.memory.storage.get(
-            client,
-            History,
-            History.origin_id,
-            item_id,
-            (History.messages, History.subjects)
-        )
-
-        if history is not None:
-            # it's a direct message
-            if history.source_jid != client.jid:
-                log.warning(
-                    f"retraction received from an entity ''{client.jid}) which is "
-                    f"not the original sender of the message ({history.source_jid}), "
-                    "hack attemps?"
-                )
-                raise exceptions.PermissionError("forbidden")
-
-            await self._r.retract_by_history(client, history)
-        else:
-            # no history in cache with this ID, it's probably a pubsub item
-            cached_node = await self.host.memory.storage.get_pubsub_node(
-                client, client.jid, node, with_subscriptions=True
-            )
-            if cached_node is None:
-                log.warning(
-                    f"Received an item retract for node {node!r} at {client.jid} "
-                    "which is not cached"
-                )
-                raise exceptions.NotFound
-            await self.host.memory.storage.delete_pubsub_items(cached_node, [item_id])
-            # notifyRetract is expecting domish.Element instances
-            item_elt = domish.Element((None, "item"))
-            item_elt["id"] = item_id
-            for subscription in cached_node.subscriptions:
-                if subscription.state != SubscriptionState.SUBSCRIBED:
-                    continue
-                self.pubsub_service.notifyRetract(
-                    client.jid,
-                    node,
-                    [(subscription.subscriber, None, [item_elt])]
-                )
--- a/sat/plugins/plugin_comp_ap_gateway/ad_hoc.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,89 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia ActivityPub Gateway
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from twisted.words.protocols.jabber import jid
-from twisted.words.xish import domish
-from wokkel import data_form
-
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-
-log = getLogger(__name__)
-NS_XMPP_JID_NODE_2_AP = "https://libervia.org/ap_gateway/xmpp_jid_node_2_ap_actor"
-
-class APAdHocService:
-    """Ad-Hoc commands for AP Gateway"""
-
-    def __init__(self, apg):
-        self.host = apg.host
-        self.apg = apg
-        self._c = self.host.plugins["XEP-0050"]
-
-    def init(self, client: SatXMPPEntity) -> None:
-        self._c.add_ad_hoc_command(
-            client,
-            self.xmpp_jid_node_2_ap_actor,
-            "Convert XMPP JID/Node to AP actor",
-            node=NS_XMPP_JID_NODE_2_AP,
-            allowed_magics=C.ENTITY_ALL,
-        )
-
-    async def xmpp_jid_node_2_ap_actor(
-        self,
-        client: SatXMPPEntity,
-        command_elt: domish.Element,
-        session_data: dict,
-        action: str,
-        node: str
-    ):
-        try:
-            x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
-            command_form = data_form.Form.fromElement(x_elt)
-        except StopIteration:
-            command_form = None
-        if command_form is None or len(command_form.fields) == 0:
-            # root request
-            status = self._c.STATUS.EXECUTING
-            form = data_form.Form(
-                "form", title="XMPP JID/node to AP actor conversion",
-                formNamespace=NS_XMPP_JID_NODE_2_AP
-            )
-
-            field = data_form.Field(
-                "text-single", "jid", required=True
-            )
-            form.addField(field)
-
-            field = data_form.Field(
-                "text-single", "node", required=False
-            )
-            form.addField(field)
-
-            payload = form.toElement()
-            return payload, status, None, None
-        else:
-            xmpp_jid = jid.JID(command_form["jid"])
-            xmpp_node = command_form.get("node")
-            actor = await self.apg.get_ap_account_from_jid_and_node(xmpp_jid, xmpp_node)
-            note = (self._c.NOTE.INFO, actor)
-            status = self._c.STATUS.COMPLETED
-            payload = None
-            return (payload, status, None, note)
--- a/sat/plugins/plugin_comp_ap_gateway/constants.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,90 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia ActivityPub Gateway
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-IMPORT_NAME = "ap-gateway"
-CONF_SECTION = f"component {IMPORT_NAME}"
-CONTENT_TYPE_AP = "application/activity+json; charset=utf-8"
-TYPE_ACTOR = "actor"
-TYPE_INBOX = "inbox"
-TYPE_SHARED_INBOX = "shared_inbox"
-TYPE_OUTBOX = "outbox"
-TYPE_FOLLOWERS = "followers"
-TYPE_FOLLOWING = "following"
-TYPE_ITEM = "item"
-TYPE_TOMBSTONE = "Tombstone"
-TYPE_MENTION = "Mention"
-TYPE_LIKE = "Like"
-TYPE_REACTION = "EmojiReact"
-TYPE_EVENT = "Event"
-TYPE_JOIN = "Join"
-TYPE_LEAVE = "Leave"
-MEDIA_TYPE_AP = "application/activity+json"
-NS_AP = "https://www.w3.org/ns/activitystreams"
-NS_AP_PUBLIC = f"{NS_AP}#Public"
-# 3 values can be used, see https://www.w3.org/TR/activitypub/#public-addressing
-PUBLIC_TUPLE = (NS_AP_PUBLIC, "as:Public", "Public")
-AP_REQUEST_TYPES = {
-    "GET": {TYPE_ACTOR, TYPE_OUTBOX, TYPE_FOLLOWERS, TYPE_FOLLOWING},
-    "POST": {"inbox"},
-}
-AP_REQUEST_TYPES["HEAD"] = AP_REQUEST_TYPES["GET"]
-# headers to check for signature
-SIGN_HEADERS = {
-    # headers needed for all HTTP methods
-    None: [
-        # tuples are equivalent headers/pseudo headers, one of them must be present
-        ("date", "(created)"),
-        ("digest", "(request-target)"),
-    ],
-    b"GET": ["host"],
-    b"POST": ["digest"]
-}
-PAGE_SIZE = 10
-HS2019 = "hs2019"
-# delay after which a signed request is not accepted anymore
-SIGN_EXP = 12*60*60  # 12 hours (same value as for Mastodon)
-
-LRU_MAX_SIZE = 200
-ACTIVITY_TYPES = (
-    "Accept", "Add", "Announce", "Arrive", "Block", "Create", "Delete", "Dislike", "Flag",
-    "Follow", "Ignore", "Invite", "Join", "Leave", "Like", "Listen", "Move", "Offer",
-    "Question", "Reject", "Read", "Remove", "TentativeReject", "TentativeAccept",
-    "Travel", "Undo", "Update", "View",
-    # non-standard activities
-    "EmojiReact"
-)
-ACTIVITY_TYPES_LOWER = [a.lower() for a in ACTIVITY_TYPES]
-ACTIVITY_OBJECT_MANDATORY = (
-    "Create", "Update", "Delete", "Follow", "Add", "Remove", "Like", "Block", "Undo"
-)
-ACTIVITY_TARGET_MANDATORY = ("Add", "Remove")
-# activities which can be used with Shared Inbox (i.e. with no account specified)
-# must be lowercase
-ACTIVIY_NO_ACCOUNT_ALLOWED = (
-    "create", "update", "delete", "announce", "undo", "like", "emojireact", "join",
-    "leave"
-)
-# maximum number of parents to retrieve when comments_max_depth option is set
-COMMENTS_MAX_PARENTS = 100
-# maximum size of avatar, in bytes
-MAX_AVATAR_SIZE = 1024 * 1024 * 5
-
-# storage prefixes
-ST_AVATAR = "[avatar]"
-ST_AP_CACHE = "[AP_item_cache]"
--- a/sat/plugins/plugin_comp_ap_gateway/events.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,407 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia ActivityPub Gateway
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Tuple
-
-import mimetypes
-import html
-
-import shortuuid
-from twisted.words.xish import domish
-from twisted.words.protocols.jabber import jid
-
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.tools.common import date_utils, uri
-
-from .constants import NS_AP_PUBLIC, TYPE_ACTOR, TYPE_EVENT, TYPE_ITEM
-
-
-log = getLogger(__name__)
-
-# direct copy of what Mobilizon uses
-AP_EVENTS_CONTEXT = {
-    "@language": "und",
-    "Hashtag": "as:Hashtag",
-    "PostalAddress": "sc:PostalAddress",
-    "PropertyValue": "sc:PropertyValue",
-    "address": {"@id": "sc:address", "@type": "sc:PostalAddress"},
-    "addressCountry": "sc:addressCountry",
-    "addressLocality": "sc:addressLocality",
-    "addressRegion": "sc:addressRegion",
-    "anonymousParticipationEnabled": {"@id": "mz:anonymousParticipationEnabled",
-                                      "@type": "sc:Boolean"},
-    "category": "sc:category",
-    "commentsEnabled": {"@id": "pt:commentsEnabled",
-                        "@type": "sc:Boolean"},
-    "discoverable": "toot:discoverable",
-    "discussions": {"@id": "mz:discussions", "@type": "@id"},
-    "events": {"@id": "mz:events", "@type": "@id"},
-    "ical": "http://www.w3.org/2002/12/cal/ical#",
-    "inLanguage": "sc:inLanguage",
-    "isOnline": {"@id": "mz:isOnline", "@type": "sc:Boolean"},
-    "joinMode": {"@id": "mz:joinMode", "@type": "mz:joinModeType"},
-    "joinModeType": {"@id": "mz:joinModeType",
-                     "@type": "rdfs:Class"},
-    "location": {"@id": "sc:location", "@type": "sc:Place"},
-    "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
-    "maximumAttendeeCapacity": "sc:maximumAttendeeCapacity",
-    "memberCount": {"@id": "mz:memberCount", "@type": "sc:Integer"},
-    "members": {"@id": "mz:members", "@type": "@id"},
-    "mz": "https://joinmobilizon.org/ns#",
-    "openness": {"@id": "mz:openness", "@type": "@id"},
-    "participantCount": {"@id": "mz:participantCount",
-                         "@type": "sc:Integer"},
-    "participationMessage": {"@id": "mz:participationMessage",
-                             "@type": "sc:Text"},
-    "postalCode": "sc:postalCode",
-    "posts": {"@id": "mz:posts", "@type": "@id"},
-    "propertyID": "sc:propertyID",
-    "pt": "https://joinpeertube.org/ns#",
-    "remainingAttendeeCapacity": "sc:remainingAttendeeCapacity",
-    "repliesModerationOption": {"@id": "mz:repliesModerationOption",
-                                "@type": "mz:repliesModerationOptionType"},
-    "repliesModerationOptionType": {"@id": "mz:repliesModerationOptionType",
-                                    "@type": "rdfs:Class"},
-    "resources": {"@id": "mz:resources", "@type": "@id"},
-    "sc": "http://schema.org#",
-    "streetAddress": "sc:streetAddress",
-    "timezone": {"@id": "mz:timezone", "@type": "sc:Text"},
-    "todos": {"@id": "mz:todos", "@type": "@id"},
-    "toot": "http://joinmastodon.org/ns#",
-    "uuid": "sc:identifier",
-    "value": "sc:value"
-}
-
-
-class APEvents:
-    """XMPP Events <=> AP Events conversion"""
-
-    def __init__(self, apg):
-        self.host = apg.host
-        self.apg = apg
-        self._events = self.host.plugins["XEP-0471"]
-
-    async def event_data_2_ap_item(
-        self, event_data: dict, author_jid: jid.JID, is_new: bool=True
-    ) -> dict:
-        """Convert event data to AP activity
-
-        @param event_data: event data as used in [plugin_exp_events]
-        @param author_jid: jid of the published of the event
-        @param is_new: if True, the item is a new one (no instance has been found in
-            cache).
-            If True, a "Create" activity will be generated, otherwise an "Update" one will
-            be
-        @return: AP activity wrapping an Event object
-        """
-        if not event_data.get("id"):
-            event_data["id"] = shortuuid.uuid()
-        ap_account = await self.apg.get_ap_account_from_jid_and_node(
-            author_jid,
-            self._events.namespace
-        )
-        url_actor = self.apg.build_apurl(TYPE_ACTOR, ap_account)
-        url_item = self.apg.build_apurl(TYPE_ITEM, ap_account, event_data["id"])
-        ap_object = {
-            "actor": url_actor,
-            "attributedTo": url_actor,
-            "to": [NS_AP_PUBLIC],
-            "id": url_item,
-            "type": TYPE_EVENT,
-            "name": next(iter(event_data["name"].values())),
-            "startTime": date_utils.date_fmt(event_data["start"], "iso"),
-            "endTime": date_utils.date_fmt(event_data["end"], "iso"),
-            "url": url_item,
-        }
-
-        attachment = ap_object["attachment"] = []
-
-        # FIXME: we only handle URL head-picture for now
-        # TODO: handle jingle and use file metadata
-        try:
-            head_picture_url = event_data["head-picture"]["sources"][0]["url"]
-        except (KeyError, IndexError, TypeError):
-            pass
-        else:
-            media_type = mimetypes.guess_type(head_picture_url, False)[0] or "image/jpeg"
-            attachment.append({
-                "name": "Banner",
-                "type": "Document",
-                "mediaType": media_type,
-                "url": head_picture_url,
-            })
-
-        descriptions = event_data.get("descriptions")
-        if descriptions:
-            for description in descriptions:
-                content = description["description"]
-                if description["type"] == "xhtml":
-                    break
-            else:
-                content = f"<p>{html.escape(content)}</p>"  # type: ignore
-            ap_object["content"] = content
-
-        categories = event_data.get("categories")
-        if categories:
-            tag = ap_object["tag"] = []
-            for category in categories:
-                tag.append({
-                    "name": f"#{category['term']}",
-                    "type": "Hashtag",
-                })
-
-        locations = event_data.get("locations")
-        if locations:
-            ap_loc = ap_object["location"] = {}
-            # we only use the first found location
-            location = locations[0]
-            for source, dest in (
-                ("description", "name"),
-                ("lat", "latitude"),
-                ("lon", "longitude"),
-            ):
-                value = location.get(source)
-                if value is not None:
-                    ap_loc[dest] = value
-            for source, dest in (
-                ("country", "addressCountry"),
-                ("locality", "addressLocality"),
-                ("region", "addressRegion"),
-                ("postalcode", "postalCode"),
-                ("street", "streetAddress"),
-            ):
-                value = location.get(source)
-                if value is not None:
-                    ap_loc.setdefault("address", {})[dest] = value
-
-        if event_data.get("comments"):
-            ap_object["commentsEnabled"] = True
-
-        extra = event_data.get("extra")
-
-        if extra:
-            status = extra.get("status")
-            if status:
-                ap_object["ical:status"] = status.upper()
-
-            website = extra.get("website")
-            if website:
-                attachment.append({
-                    "href": website,
-                    "mediaType": "text/html",
-                    "name": "Website",
-                    "type": "Link"
-                })
-
-            accessibility = extra.get("accessibility")
-            if accessibility:
-                wheelchair = accessibility.get("wheelchair")
-                if wheelchair:
-                    if wheelchair == "full":
-                        ap_wc_value = "fully"
-                    elif wheelchair == "partial":
-                        ap_wc_value = "partially"
-                    elif wheelchair == "no":
-                        ap_wc_value = "no"
-                    else:
-                        log.error(f"unexpected wheelchair value: {wheelchair}")
-                        ap_wc_value = None
-                    if ap_wc_value is not None:
-                        attachment.append({
-                            "propertyID": "mz:accessibility:wheelchairAccessible",
-                            "type": "PropertyValue",
-                            "value": ap_wc_value
-                        })
-
-        activity = self.apg.create_activity(
-            "Create" if is_new else "Update", url_actor, ap_object, activity_id=url_item
-        )
-        activity["@context"].append(AP_EVENTS_CONTEXT)
-        return activity
-
-    async def ap_item_2_event_data(self, ap_item: dict) -> dict:
-        """Convert AP activity or object to event data
-
-        @param ap_item: ActivityPub item to convert
-            Can be either an activity of an object
-        @return: AP Item's Object and event data
-        @raise exceptions.DataError: something is invalid in the AP item
-        """
-        is_activity = self.apg.is_activity(ap_item)
-        if is_activity:
-            ap_object = await self.apg.ap_get_object(ap_item, "object")
-            if not ap_object:
-                log.warning(f'No "object" found in AP item {ap_item!r}')
-                raise exceptions.DataError
-        else:
-            ap_object = ap_item
-
-        # id
-        if "_repeated" in ap_item:
-            # if the event is repeated, we use the original one ID
-            repeated_uri = ap_item["_repeated"]["uri"]
-            parsed_uri = uri.parse_xmpp_uri(repeated_uri)
-            object_id = parsed_uri["item"]
-        else:
-            object_id = ap_object.get("id")
-            if not object_id:
-                raise exceptions.DataError('"id" is missing in AP object')
-
-        if ap_item["type"] != TYPE_EVENT:
-            raise exceptions.DataError("AP Object is not an event")
-
-        # author
-        actor = await self.apg.ap_get_sender_actor(ap_object)
-
-        account = await self.apg.get_ap_account_from_id(actor)
-        author_jid = self.apg.get_local_jid_from_account(account).full()
-
-        # name, start, end
-        event_data = {
-            "id": object_id,
-            "name": {"": ap_object.get("name") or "unnamed"},
-            "start": date_utils.date_parse(ap_object["startTime"]),
-            "end": date_utils.date_parse(ap_object["endTime"]),
-        }
-
-        # attachments/extra
-        event_data["extra"] = extra = {}
-        attachments = ap_object.get("attachment") or []
-        for attachment in attachments:
-            name = attachment.get("name")
-            if name == "Banner":
-                try:
-                    url = attachment["url"]
-                except KeyError:
-                    log.warning(f"invalid attachment: {attachment}")
-                    continue
-                event_data["head-picture"] = {"sources": [{"url": url}]}
-            elif name == "Website":
-                try:
-                    url = attachment["href"]
-                except KeyError:
-                    log.warning(f"invalid attachment: {attachment}")
-                    continue
-                extra["website"] = url
-            else:
-                log.debug(f"unmanaged attachment: {attachment}")
-
-        # description
-        content = ap_object.get("content")
-        if content:
-            event_data["descriptions"] = [{
-                "type": "xhtml",
-                "description": content
-            }]
-
-        # categories
-        tags = ap_object.get("tag")
-        if tags:
-            categories = event_data["categories"] = []
-            for tag in tags:
-                if tag.get("type") == "Hashtag":
-                    try:
-                        term = tag["name"][1:]
-                    except KeyError:
-                        log.warning(f"invalid tag: {tag}")
-                        continue
-                    categories.append({"term": term})
-
-        #location
-        ap_location = ap_object.get("location")
-        if ap_location:
-            location = {}
-            for source, dest in (
-                ("name", "description"),
-                ("latitude", "lat"),
-                ("longitude", "lon"),
-            ):
-                value = ap_location.get(source)
-                if value is not None:
-                    location[dest] = value
-            address = ap_location.get("address")
-            if address:
-                for source, dest in (
-                    ("addressCountry", "country"),
-                    ("addressLocality", "locality"),
-                    ("addressRegion", "region"),
-                    ("postalCode", "postalcode"),
-                    ("streetAddress", "street"),
-                ):
-                    value = address.get(source)
-                    if value is not None:
-                        location[dest] = value
-            if location:
-                event_data["locations"] = [location]
-
-        # rsvp
-        # So far Mobilizon seems to only handle participate/don't participate, thus we use
-        # a simple "yes"/"no" form.
-        rsvp_data = {"fields": []}
-        event_data["rsvp"] = [rsvp_data]
-        rsvp_data["fields"].append({
-            "type": "list-single",
-            "name": "attending",
-            "label": "Attending",
-            "options": [
-                {"label": "yes", "value": "yes"},
-                {"label": "no", "value": "no"}
-            ],
-            "required": True
-        })
-
-        # comments
-
-        if ap_object.get("commentsEnabled"):
-            __, comments_node = await self.apg.get_comments_nodes(object_id, None)
-            event_data["comments"] = {
-                "service": author_jid,
-                "node": comments_node,
-            }
-
-        # extra
-        # part of extra come from "attachment" above
-
-        status = ap_object.get("ical:status")
-        if status is None:
-            pass
-        elif status in ("CONFIRMED", "CANCELLED", "TENTATIVE"):
-            extra["status"] = status.lower()
-        else:
-            log.warning(f"unknown event status: {status}")
-
-        return event_data
-
-    async def ap_item_2_event_data_and_elt(
-        self,
-        ap_item: dict
-    ) -> Tuple[dict, domish.Element]:
-        """Convert AP item to parsed event data and corresponding item element"""
-        event_data = await self.ap_item_2_event_data(ap_item)
-        event_elt = self._events.event_data_2_event_elt(event_data)
-        item_elt = domish.Element((None, "item"))
-        item_elt["id"] = event_data["id"]
-        item_elt.addChild(event_elt)
-        return event_data, item_elt
-
-    async def ap_item_2_event_elt(self, ap_item: dict) -> domish.Element:
-        """Convert AP item to XMPP item element"""
-        __, item_elt = await self.ap_item_2_event_data_and_elt(ap_item)
-        return item_elt
--- a/sat/plugins/plugin_comp_ap_gateway/http_server.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1328 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia ActivityPub Gateway
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import time
-import html
-from typing import Optional, Dict, List, Any
-import json
-from urllib import parse
-from collections import deque
-import unicodedata
-
-from twisted.web import http, resource as web_resource, server
-from twisted.web import static
-from twisted.web import util as web_util
-from twisted.python import failure
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid, error
-from wokkel import pubsub, rsm
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.core_types import SatXMPPEntity
-from sat.core.log import getLogger
-from sat.tools.common import date_utils, uri
-from sat.memory.sqla_mapping import SubscriptionState
-
-from .constants import (
-    NS_AP, MEDIA_TYPE_AP, CONTENT_TYPE_AP, TYPE_ACTOR, TYPE_INBOX, TYPE_SHARED_INBOX,
-    TYPE_OUTBOX, TYPE_EVENT, AP_REQUEST_TYPES, PAGE_SIZE, ACTIVITY_TYPES_LOWER,
-    ACTIVIY_NO_ACCOUNT_ALLOWED, SIGN_HEADERS, HS2019, SIGN_EXP, TYPE_FOLLOWERS,
-    TYPE_FOLLOWING, TYPE_ITEM, TYPE_LIKE, TYPE_REACTION, ST_AP_CACHE
-)
-from .regex import RE_SIG_PARAM
-
-
-log = getLogger(__name__)
-
-VERSION = unicodedata.normalize(
-    'NFKD',
-    f"{C.APP_NAME} ActivityPub Gateway {C.APP_VERSION}"
-)
-
-
-class HTTPAPGServer(web_resource.Resource):
-    """HTTP Server handling ActivityPub S2S protocol"""
-    isLeaf = True
-
-    def __init__(self, ap_gateway):
-        self.apg = ap_gateway
-        self._seen_digest = deque(maxlen=50)
-        super().__init__()
-
-    def response_code(
-        self,
-        request: "HTTPRequest",
-        http_code: int,
-        msg: Optional[str] = None
-    ) -> None:
-        """Log and set HTTP return code and associated message"""
-        if msg is not None:
-            log.warning(msg)
-        request.setResponseCode(http_code, None if msg is None else msg.encode())
-
-    def _on_request_error(self, failure_: failure.Failure, request: "HTTPRequest") -> None:
-        exc = failure_.value
-        if isinstance(exc, exceptions.NotFound):
-            self.response_code(
-                request,
-                http.NOT_FOUND,
-                str(exc)
-            )
-        else:
-            log.exception(f"Internal error: {failure_.value}")
-            self.response_code(
-                request,
-                http.INTERNAL_SERVER_ERROR,
-                f"internal error: {failure_.value}"
-            )
-            request.finish()
-            raise failure_
-
-        request.finish()
-
-    async def webfinger(self, request):
-        url_parsed = parse.urlparse(request.uri.decode())
-        query = parse.parse_qs(url_parsed.query)
-        resource = query.get("resource", [""])[0]
-        account = resource[5:].strip()
-        if not resource.startswith("acct:") or not account:
-            return web_resource.ErrorPage(
-                http.BAD_REQUEST, "Bad Request" , "Invalid webfinger resource"
-            ).render(request)
-
-        actor_url = self.apg.build_apurl(TYPE_ACTOR, account)
-
-        resp = {
-            "aliases": [actor_url],
-            "subject": resource,
-            "links": [
-                {
-                    "rel": "self",
-                    "type": "application/activity+json",
-                    "href": actor_url
-                }
-            ]
-        }
-        request.setHeader("content-type", CONTENT_TYPE_AP)
-        request.write(json.dumps(resp).encode())
-        request.finish()
-
-    async def handle_undo_activity(
-        self,
-        request: "HTTPRequest",
-        data: dict,
-        account_jid: jid.JID,
-        node: Optional[str],
-        ap_account: str,
-        ap_url: str,
-        signing_actor: str
-    ) -> None:
-        if node is None:
-            node = self.apg._m.namespace
-        client = await self.apg.get_virtual_client(signing_actor)
-        object_ = data.get("object")
-        if isinstance(object_, str):
-            # we check first if it's not a cached object
-            ap_cache_key = f"{ST_AP_CACHE}{object_}"
-            value = await self.apg.client._ap_storage.get(ap_cache_key)
-        else:
-            value = None
-        if value is not None:
-            objects = [value]
-            # because we'll undo the activity, we can remove it from cache
-            await self.apg.client._ap_storage.remove(ap_cache_key)
-        else:
-            objects = await self.apg.ap_get_list(data, "object")
-        for obj in objects:
-            type_ = obj.get("type")
-            actor = await self.apg.ap_get_sender_actor(obj)
-            if actor != signing_actor:
-                log.warning(f"ignoring object not attributed to signing actor: {data}")
-                continue
-
-            if type_ == "Follow":
-                try:
-                    target_account = obj["object"]
-                except KeyError:
-                    log.warning(f'ignoring invalid object, missing "object" key: {data}')
-                    continue
-                if not self.apg.is_local_url(target_account):
-                    log.warning(f"ignoring unfollow request to non local actor: {data}")
-                    continue
-                await self.apg._p.unsubscribe(
-                    client,
-                    account_jid,
-                    node,
-                    sender=client.jid,
-                )
-            elif type_ == "Announce":
-                # we can use directly the Announce object, as only the "id" field is
-                # needed
-                await self.apg.new_ap_delete_item(client, None, node, obj)
-            elif type_ == TYPE_LIKE:
-                await self.handle_attachment_item(client, obj, {"noticed": False})
-            elif type_ == TYPE_REACTION:
-                await self.handle_attachment_item(client, obj, {
-                    "reactions": {"operation": "update", "remove": [obj["content"]]}
-                })
-            else:
-                log.warning(f"Unmanaged undo type: {type_!r}")
-
-    async def handle_follow_activity(
-        self,
-        request: "HTTPRequest",
-        data: dict,
-        account_jid: jid.JID,
-        node: Optional[str],
-        ap_account: str,
-        ap_url: str,
-        signing_actor: str
-    ) -> None:
-        if node is None:
-            node = self.apg._m.namespace
-        client = await self.apg.get_virtual_client(signing_actor)
-        try:
-            subscription = await self.apg._p.subscribe(
-                client,
-                account_jid,
-                node,
-                # subscriptions from AP are always public
-                options=self.apg._pps.set_public_opt()
-            )
-        except pubsub.SubscriptionPending:
-            log.info(f"subscription to node {node!r} of {account_jid} is pending")
-        # TODO: manage SubscriptionUnconfigured
-        else:
-            if subscription.state != "subscribed":
-                # other states should raise an Exception
-                raise exceptions.InternalError('"subscribed" state was expected')
-            inbox = await self.apg.get_ap_inbox_from_id(signing_actor, use_shared=False)
-            actor_id = self.apg.build_apurl(TYPE_ACTOR, ap_account)
-            accept_data = self.apg.create_activity(
-                "Accept", actor_id, object_=data
-            )
-            await self.apg.sign_and_post(inbox, actor_id, accept_data)
-        await self.apg._c.synchronise(client, account_jid, node, resync=False)
-
-    async def handle_accept_activity(
-        self,
-        request: "HTTPRequest",
-        data: dict,
-        account_jid: jid.JID,
-        node: Optional[str],
-        ap_account: str,
-        ap_url: str,
-        signing_actor: str
-    ) -> None:
-        if node is None:
-            node = self.apg._m.namespace
-        client = await self.apg.get_virtual_client(signing_actor)
-        objects = await self.apg.ap_get_list(data, "object")
-        for obj in objects:
-            type_ = obj.get("type")
-            if type_ == "Follow":
-                follow_node = await self.apg.host.memory.storage.get_pubsub_node(
-                    client, client.jid, node, with_subscriptions=True
-                )
-                if follow_node is None:
-                    log.warning(
-                        f"Received a follow accept on an unknown node: {node!r} at "
-                        f"{client.jid}. Ignoring it"
-                    )
-                    continue
-                try:
-                    sub = next(
-                        s for s in follow_node.subscriptions if s.subscriber==account_jid
-                    )
-                except StopIteration:
-                    log.warning(
-                        "Received a follow accept on a node without subscription: "
-                        f"{node!r} at {client.jid}. Ignoring it"
-                    )
-                else:
-                    if sub.state == SubscriptionState.SUBSCRIBED:
-                        log.warning(f"Already subscribed to {node!r} at {client.jid}")
-                    elif sub.state == SubscriptionState.PENDING:
-                        follow_node.subscribed = True
-                        sub.state = SubscriptionState.SUBSCRIBED
-                        await self.apg.host.memory.storage.add(follow_node)
-                    else:
-                        raise exceptions.InternalError(
-                            f"Unhandled subscription state {sub.state!r}"
-                        )
-            else:
-                log.warning(f"Unmanaged accept type: {type_!r}")
-
-    async def handle_delete_activity(
-        self,
-        request: "HTTPRequest",
-        data: dict,
-        account_jid: Optional[jid.JID],
-        node: Optional[str],
-        ap_account: Optional[str],
-        ap_url: str,
-        signing_actor: str
-    ):
-        if node is None:
-            node = self.apg._m.namespace
-        client = await self.apg.get_virtual_client(signing_actor)
-        objects = await self.apg.ap_get_list(data, "object")
-        for obj in objects:
-            await self.apg.new_ap_delete_item(client, account_jid, node, obj)
-
-    async def handle_new_ap_items(
-        self,
-        request: "HTTPRequest",
-        data: dict,
-        account_jid: Optional[jid.JID],
-        node: Optional[str],
-        signing_actor: str,
-        repeated: bool = False,
-    ):
-        """Helper method to handle workflow for new AP items
-
-        accept globally the same parameter as for handle_create_activity
-        @param repeated: if True, the item is an item republished from somewhere else
-        """
-        if "_repeated" in data:
-            log.error(
-                '"_repeated" field already present in given AP item, this should not '
-                f"happen. Ignoring object from {signing_actor}\n{data}"
-            )
-            raise exceptions.DataError("unexpected field in item")
-        client = await self.apg.get_virtual_client(signing_actor)
-        objects = await self.apg.ap_get_list(data, "object")
-        for obj in objects:
-            if node is None:
-                if obj.get("type") == TYPE_EVENT:
-                    node = self.apg._events.namespace
-                else:
-                    node = self.apg._m.namespace
-            sender = await self.apg.ap_get_sender_actor(obj)
-            if repeated:
-                # we don't check sender when item is repeated, as it should be different
-                # from post author in this case
-                sender_jid = await self.apg.get_jid_from_id(sender)
-                repeater_jid = await self.apg.get_jid_from_id(signing_actor)
-                repeated_item_id = obj["id"]
-                if self.apg.is_local_url(repeated_item_id):
-                    # the repeated object is from XMPP, we need to parse the URL to find
-                    # the right ID
-                    url_type, url_args = self.apg.parse_apurl(repeated_item_id)
-                    if url_type != "item":
-                        raise exceptions.DataError(
-                            "local URI is not an item: {repeated_id}"
-                        )
-                    try:
-                        url_account, url_item_id = url_args
-                        if not url_account or not url_item_id:
-                            raise ValueError
-                    except (RuntimeError, ValueError):
-                        raise exceptions.DataError(
-                            "local URI is invalid: {repeated_id}"
-                        )
-                    else:
-                        url_jid, url_node = await self.apg.get_jid_and_node(url_account)
-                        if ((url_jid != sender_jid
-                             or url_node and url_node != self.apg._m.namespace)):
-                            raise exceptions.DataError(
-                                "announced ID doesn't match sender ({sender}): "
-                                f"[repeated_item_id]"
-                            )
-
-                    repeated_item_id = url_item_id
-
-                obj["_repeated"] = {
-                    "by": repeater_jid.full(),
-                    "at": data.get("published"),
-                    "uri": uri.build_xmpp_uri(
-                        "pubsub",
-                        path=sender_jid.full(),
-                        node=self.apg._m.namespace,
-                        item=repeated_item_id
-                    )
-                }
-                # we must use activity's id and targets, not the original item ones
-                for field in ("id", "to", "bto", "cc", "bcc"):
-                    obj[field] = data.get(field)
-            else:
-                if sender != signing_actor:
-                    log.warning(
-                        "Ignoring object not attributed to signing actor: {obj}"
-                    )
-                    continue
-
-            await self.apg.new_ap_item(client, account_jid, node, obj)
-
-    async def handle_create_activity(
-        self,
-        request: "HTTPRequest",
-        data: dict,
-        account_jid: Optional[jid.JID],
-        node: Optional[str],
-        ap_account: Optional[str],
-        ap_url: str,
-        signing_actor: str
-    ):
-        await self.handle_new_ap_items(request, data, account_jid, node, signing_actor)
-
-    async def handle_update_activity(
-        self,
-        request: "HTTPRequest",
-        data: dict,
-        account_jid: Optional[jid.JID],
-        node: Optional[str],
-        ap_account: Optional[str],
-        ap_url: str,
-        signing_actor: str
-    ):
-        # Update is the same as create: the item ID stays the same, thus the item will be
-        # overwritten
-        await self.handle_new_ap_items(request, data, account_jid, node, signing_actor)
-
-    async def handle_announce_activity(
-        self,
-        request: "HTTPRequest",
-        data: dict,
-        account_jid: Optional[jid.JID],
-        node: Optional[str],
-        ap_account: Optional[str],
-        ap_url: str,
-        signing_actor: str
-    ):
-        # we create a new item
-        await self.handle_new_ap_items(
-            request,
-            data,
-            account_jid,
-            node,
-            signing_actor,
-            repeated=True
-        )
-
-    async def handle_attachment_item(
-        self,
-        client: SatXMPPEntity,
-        data: dict,
-        attachment_data: dict
-    ) -> None:
-        target_ids = data.get("object")
-        if not target_ids:
-            raise exceptions.DataError("object should be set")
-        elif isinstance(target_ids, list):
-            try:
-                target_ids = [o["id"] for o in target_ids]
-            except (KeyError, TypeError):
-                raise exceptions.DataError(f"invalid object: {target_ids!r}")
-        elif isinstance(target_ids, dict):
-            obj_id = target_ids.get("id")
-            if not obj_id or not isinstance(obj_id, str):
-                raise exceptions.DataError(f"invalid object: {target_ids!r}")
-            target_ids = [obj_id]
-        elif isinstance(target_ids, str):
-            target_ids = [target_ids]
-
-        # XXX: we have to cache AP items because some implementation (Pleroma notably)
-        #   don't keep object accessible, and we need to be able to retrieve them for
-        #   UNDO. Current implementation will grow, we need to add a way to flush it after
-        #   a while.
-        # TODO: add a way to flush old cached AP items.
-        await client._ap_storage.aset(f"{ST_AP_CACHE}{data['id']}", data)
-
-        for target_id in target_ids:
-            if not self.apg.is_local_url(target_id):
-                log.debug(f"ignoring non local target ID: {target_id}")
-                continue
-            url_type, url_args = self.apg.parse_apurl(target_id)
-            if url_type != TYPE_ITEM:
-                log.warning(f"unexpected local URL for attachment on item {target_id}")
-                continue
-            try:
-                account, item_id = url_args
-            except ValueError:
-                raise ValueError(f"invalid URL: {target_id}")
-            author_jid, item_node = await self.apg.get_jid_and_node(account)
-            if item_node is None:
-                item_node = self.apg._m.namespace
-            attachment_node = self.apg._pa.get_attachment_node_name(
-                author_jid, item_node, item_id
-            )
-            cached_node = await self.apg.host.memory.storage.get_pubsub_node(
-                client,
-                author_jid,
-                attachment_node,
-                with_subscriptions=True,
-                create=True
-            )
-            found_items, __ = await self.apg.host.memory.storage.get_items(
-                cached_node, item_ids=[client.jid.userhost()]
-            )
-            if not found_items:
-                old_item_elt = None
-            else:
-                found_item = found_items[0]
-                old_item_elt = found_item.data
-
-            item_elt = await self.apg._pa.apply_set_handler(
-                client,
-                {"extra": attachment_data},
-                old_item_elt,
-                None
-            )
-            # we reparse the element, as there can be other attachments
-            attachments_data = self.apg._pa.items_2_attachment_data(client, [item_elt])
-            # and we update the cache
-            await self.apg.host.memory.storage.cache_pubsub_items(
-                client,
-                cached_node,
-                [item_elt],
-                attachments_data or [{}]
-            )
-
-            if self.apg.is_virtual_jid(author_jid):
-                # the attachment is on t a virtual pubsub service (linking to an AP item),
-                # we notify all subscribers
-                for subscription in cached_node.subscriptions:
-                    if subscription.state != SubscriptionState.SUBSCRIBED:
-                        continue
-                    self.apg.pubsub_service.notifyPublish(
-                        author_jid,
-                        attachment_node,
-                        [(subscription.subscriber, None, [item_elt])]
-                    )
-            else:
-                # the attachment is on an XMPP item, we publish it to the attachment node
-                await self.apg._p.send_items(
-                    client, author_jid, attachment_node, [item_elt]
-                )
-
-    async def handle_like_activity(
-        self,
-        request: "HTTPRequest",
-        data: dict,
-        account_jid: Optional[jid.JID],
-        node: Optional[str],
-        ap_account: Optional[str],
-        ap_url: str,
-        signing_actor: str
-    ) -> None:
-        client = await self.apg.get_virtual_client(signing_actor)
-        await self.handle_attachment_item(client, data, {"noticed": True})
-
-    async def handle_emojireact_activity(
-        self,
-        request: "HTTPRequest",
-        data: dict,
-        account_jid: Optional[jid.JID],
-        node: Optional[str],
-        ap_account: Optional[str],
-        ap_url: str,
-        signing_actor: str
-    ) -> None:
-        client = await self.apg.get_virtual_client(signing_actor)
-        await self.handle_attachment_item(client, data, {
-            "reactions": {"operation": "update", "add": [data["content"]]}
-        })
-
-    async def handle_join_activity(
-        self,
-        request: "HTTPRequest",
-        data: dict,
-        account_jid: Optional[jid.JID],
-        node: Optional[str],
-        ap_account: Optional[str],
-        ap_url: str,
-        signing_actor: str
-    ) -> None:
-        client = await self.apg.get_virtual_client(signing_actor)
-        await self.handle_attachment_item(client, data, {"rsvp": {"attending": "yes"}})
-
-    async def handle_leave_activity(
-        self,
-        request: "HTTPRequest",
-        data: dict,
-        account_jid: Optional[jid.JID],
-        node: Optional[str],
-        ap_account: Optional[str],
-        ap_url: str,
-        signing_actor: str
-    ) -> None:
-        client = await self.apg.get_virtual_client(signing_actor)
-        await self.handle_attachment_item(client, data, {"rsvp": {"attending": "no"}})
-
-    async def ap_actor_request(
-        self,
-        request: "HTTPRequest",
-        data: Optional[dict],
-        account_jid: jid.JID,
-        node: Optional[str],
-        ap_account: str,
-        ap_url: str,
-        signing_actor: Optional[str]
-    ) -> dict:
-        inbox = self.apg.build_apurl(TYPE_INBOX, ap_account)
-        shared_inbox = self.apg.build_apurl(TYPE_SHARED_INBOX)
-        outbox = self.apg.build_apurl(TYPE_OUTBOX, ap_account)
-        followers = self.apg.build_apurl(TYPE_FOLLOWERS, ap_account)
-        following = self.apg.build_apurl(TYPE_FOLLOWING, ap_account)
-
-        # we have to use AP account as preferredUsername because it is used to retrieve
-        # actor handle (see https://socialhub.activitypub.rocks/t/how-to-retrieve-user-server-tld-handle-from-actors-url/2196)
-        preferred_username = ap_account.split("@", 1)[0]
-
-        identity_data = await self.apg._i.get_identity(self.apg.client, account_jid)
-        if node and node.startswith(self.apg._events.namespace):
-            events = outbox
-        else:
-            events_account = await self.apg.get_ap_account_from_jid_and_node(
-                account_jid, self.apg._events.namespace
-            )
-            events = self.apg.build_apurl(TYPE_OUTBOX, events_account)
-
-        actor_data = {
-            "@context": [
-                "https://www.w3.org/ns/activitystreams",
-                "https://w3id.org/security/v1"
-            ],
-
-            # XXX: Mastodon doesn't like percent-encode arobas, so we have to unescape it
-            #   if it is escaped
-            "id": ap_url.replace("%40", "@"),
-            "type": "Person",
-            "preferredUsername": preferred_username,
-            "inbox": inbox,
-            "outbox": outbox,
-            "events": events,
-            "followers": followers,
-            "following": following,
-            "publicKey": {
-                "id": f"{ap_url}#main-key",
-                "owner": ap_url,
-                "publicKeyPem": self.apg.public_key_pem
-            },
-            "endpoints": {
-                "sharedInbox": shared_inbox,
-                "events": events,
-            },
-        }
-
-        if identity_data.get("nicknames"):
-            actor_data["name"] = identity_data["nicknames"][0]
-        if identity_data.get("description"):
-            # description is plain text while summary expects HTML
-            actor_data["summary"] = html.escape(identity_data["description"])
-        if identity_data.get("avatar"):
-            avatar_data = identity_data["avatar"]
-            try:
-                filename = avatar_data["filename"]
-                media_type = avatar_data["media_type"]
-            except KeyError:
-                log.error(f"incomplete avatar data: {identity_data!r}")
-            else:
-                avatar_url = self.apg.build_apurl("avatar", filename)
-                actor_data["icon"] = {
-                    "type": "Image",
-                    "url": avatar_url,
-                    "mediaType": media_type
-                }
-
-        return actor_data
-
-    def get_canonical_url(self, request: "HTTPRequest") -> str:
-        return parse.urljoin(
-            f"https://{self.apg.public_url}",
-            request.path.decode().rstrip("/")
-        # we unescape "@" for the same reason as in [ap_actor_request]
-        ).replace("%40", "@")
-
-    def query_data_2_rsm_request(
-        self,
-        query_data: Dict[str, List[str]]
-    ) -> rsm.RSMRequest:
-        """Get RSM kwargs to use with RSMRequest from query data"""
-        page = query_data.get("page")
-
-        if page == ["first"]:
-            return rsm.RSMRequest(max_=PAGE_SIZE, before="")
-        elif page == ["last"]:
-            return rsm.RSMRequest(max_=PAGE_SIZE)
-        else:
-            for query_key in ("index", "before", "after"):
-                try:
-                    kwargs={query_key: query_data[query_key][0], "max_": PAGE_SIZE}
-                except (KeyError, IndexError, ValueError):
-                    pass
-                else:
-                    return rsm.RSMRequest(**kwargs)
-        raise ValueError(f"Invalid query data: {query_data!r}")
-
-    async def ap_outbox_page_request(
-        self,
-        request: "HTTPRequest",
-        data: Optional[dict],
-        account_jid: jid.JID,
-        node: Optional[str],
-        ap_account: str,
-        ap_url: str,
-        query_data: Dict[str, List[str]]
-    ) -> dict:
-        if node is None:
-            node = self.apg._m.namespace
-        # we only keep useful keys, and sort to have consistent URL which can
-        # be used as ID
-        url_keys = sorted(set(query_data) & {"page", "index", "before", "after"})
-        query_data = {k: query_data[k] for k in url_keys}
-        try:
-            items, metadata = await self.apg._p.get_items(
-                client=self.apg.client,
-                service=account_jid,
-                node=node,
-                rsm_request=self.query_data_2_rsm_request(query_data),
-                extra = {C.KEY_USE_CACHE: False}
-            )
-        except error.StanzaError as e:
-            log.warning(f"Can't get data from pubsub node {node} at {account_jid}: {e}")
-            return {}
-
-        base_url = self.get_canonical_url(request)
-        url = f"{base_url}?{parse.urlencode(query_data, True)}"
-        if node and node.startswith(self.apg._events.namespace):
-            ordered_items = [
-                await self.apg.ap_events.event_data_2_ap_item(
-                    self.apg._events.event_elt_2_event_data(item),
-                    account_jid
-                )
-                for item in reversed(items)
-            ]
-        else:
-            ordered_items = [
-                await self.apg.mb_data_2_ap_item(
-                    self.apg.client,
-                    await self.apg._m.item_2_mb_data(
-                        self.apg.client,
-                        item,
-                        account_jid,
-                        node
-                    )
-                )
-                for item in reversed(items)
-            ]
-        ret_data = {
-            "@context": ["https://www.w3.org/ns/activitystreams"],
-            "id": url,
-            "type": "OrderedCollectionPage",
-            "partOf": base_url,
-            "orderedItems": ordered_items
-        }
-
-        if "rsm" not in metadata:
-            # no RSM available, we return what we have
-            return ret_data
-
-        # AP OrderedCollection must be in reversed chronological order, thus the opposite
-        # of what we get with RSM (at least with Libervia Pubsub)
-        if not metadata["complete"]:
-            try:
-                last= metadata["rsm"]["last"]
-            except KeyError:
-                last = None
-            ret_data["prev"] = f"{base_url}?{parse.urlencode({'after': last})}"
-        if metadata["rsm"]["index"] != 0:
-            try:
-                first= metadata["rsm"]["first"]
-            except KeyError:
-                first = None
-            ret_data["next"] = f"{base_url}?{parse.urlencode({'before': first})}"
-
-        return ret_data
-
-    async def ap_outbox_request(
-        self,
-        request: "HTTPRequest",
-        data: Optional[dict],
-        account_jid: jid.JID,
-        node: Optional[str],
-        ap_account: str,
-        ap_url: str,
-        signing_actor: Optional[str]
-    ) -> dict:
-        if node is None:
-            node = self.apg._m.namespace
-
-        parsed_url = parse.urlparse(request.uri.decode())
-        query_data = parse.parse_qs(parsed_url.query)
-        if query_data:
-            return await self.ap_outbox_page_request(
-                request, data, account_jid, node, ap_account, ap_url, query_data
-            )
-
-        # XXX: we can't use disco#info here because this request won't work on a bare jid
-        # due to security considerations of XEP-0030 (we don't have presence
-        # subscription).
-        # The current workaround is to do a request as if RSM was available, and actually
-        # check its availability according to result.
-        try:
-            __, metadata = await self.apg._p.get_items(
-                client=self.apg.client,
-                service=account_jid,
-                node=node,
-                max_items=0,
-                rsm_request=rsm.RSMRequest(max_=0),
-                extra = {C.KEY_USE_CACHE: False}
-            )
-        except error.StanzaError as e:
-            log.warning(f"Can't get data from pubsub node {node} at {account_jid}: {e}")
-            return {}
-        try:
-            items_count = metadata["rsm"]["count"]
-        except KeyError:
-            log.warning(
-                f"No RSM metadata found when requesting pubsub node {node} at "
-                f"{account_jid}, defaulting to items_count=20"
-            )
-            items_count = 20
-
-        url = self.get_canonical_url(request)
-        url_first_page = f"{url}?{parse.urlencode({'page': 'first'})}"
-        url_last_page = f"{url}?{parse.urlencode({'page': 'last'})}"
-        return {
-            "@context": ["https://www.w3.org/ns/activitystreams"],
-            "id": url,
-            "totalItems": items_count,
-            "type": "OrderedCollection",
-            "first": url_first_page,
-            "last": url_last_page,
-        }
-
-    async def ap_inbox_request(
-        self,
-        request: "HTTPRequest",
-        data: Optional[dict],
-        account_jid: Optional[jid.JID],
-        node: Optional[str],
-        ap_account: Optional[str],
-        ap_url: str,
-        signing_actor: Optional[str]
-    ) -> None:
-        assert data is not None
-        if signing_actor is None:
-            raise exceptions.InternalError("signing_actor must be set for inbox requests")
-        await self.check_signing_actor(data, signing_actor)
-        activity_type = (data.get("type") or "").lower()
-        if not activity_type in ACTIVITY_TYPES_LOWER:
-            return self.response_code(
-                request,
-                http.UNSUPPORTED_MEDIA_TYPE,
-                f"request is not an activity, ignoring"
-            )
-
-        if account_jid is None and activity_type not in ACTIVIY_NO_ACCOUNT_ALLOWED:
-            return self.response_code(
-                request,
-                http.UNSUPPORTED_MEDIA_TYPE,
-                f"{activity_type.title()!r} activity must target an account"
-            )
-
-        try:
-            method = getattr(self, f"handle_{activity_type}_activity")
-        except AttributeError:
-            return self.response_code(
-                request,
-                http.UNSUPPORTED_MEDIA_TYPE,
-                f"{activity_type.title()} activity is not yet supported"
-            )
-        else:
-            await method(
-                request, data, account_jid, node, ap_account, ap_url, signing_actor
-            )
-
-    async def ap_followers_request(
-        self,
-        request: "HTTPRequest",
-        data: Optional[dict],
-        account_jid: jid.JID,
-        node: Optional[str],
-        ap_account: Optional[str],
-        ap_url: str,
-        signing_actor: Optional[str]
-    ) -> dict:
-        if node is None:
-            node = self.apg._m.namespace
-        client = self.apg.client
-        subscribers = await self.apg._pps.get_public_node_subscriptions(
-            client, account_jid, node
-        )
-        followers = []
-        for subscriber in subscribers.keys():
-            if self.apg.is_virtual_jid(subscriber):
-                # the subscriber is an AP user subscribed with this gateway
-                ap_account = self.apg._e.unescape(subscriber.user)
-            else:
-                # regular XMPP user
-                ap_account = await self.apg.get_ap_account_from_jid_and_node(subscriber, node)
-            followers.append(ap_account)
-
-        url = self.get_canonical_url(request)
-        return {
-          "@context": ["https://www.w3.org/ns/activitystreams"],
-          "type": "OrderedCollection",
-          "id": url,
-          "totalItems": len(subscribers),
-          "first": {
-            "type": "OrderedCollectionPage",
-            "id": url,
-            "orderedItems": followers
-          }
-        }
-
-    async def ap_following_request(
-        self,
-        request: "HTTPRequest",
-        data: Optional[dict],
-        account_jid: jid.JID,
-        node: Optional[str],
-        ap_account: Optional[str],
-        ap_url: str,
-        signing_actor: Optional[str]
-    ) -> dict[str, Any]:
-        client = self.apg.client
-        subscriptions = await self.apg._pps.subscriptions(
-            client, account_jid, node
-        )
-        following = []
-        for sub_dict in subscriptions:
-            service = jid.JID(sub_dict["service"])
-            if self.apg.is_virtual_jid(service):
-                # the subscription is to an AP actor with this gateway
-                ap_account = self.apg._e.unescape(service.user)
-            else:
-                # regular XMPP user
-                ap_account = await self.apg.get_ap_account_from_jid_and_node(
-                    service, sub_dict["node"]
-                )
-            following.append(ap_account)
-
-        url = self.get_canonical_url(request)
-        return {
-          "@context": ["https://www.w3.org/ns/activitystreams"],
-          "type": "OrderedCollection",
-          "id": url,
-          "totalItems": len(subscriptions),
-          "first": {
-            "type": "OrderedCollectionPage",
-            "id": url,
-            "orderedItems": following
-          }
-        }
-
-    def _get_to_log(
-        self,
-        request: "HTTPRequest",
-        data: Optional[dict] = None,
-    ) -> List[str]:
-        """Get base data to logs in verbose mode"""
-        from pprint import pformat
-        to_log = [
-            "",
-            f"<<< got {request.method.decode()} request - {request.uri.decode()}"
-        ]
-        if data is not None:
-            to_log.append(pformat(data))
-        if self.apg.verbose>=3:
-            headers = "\n".join(
-                f"    {k.decode()}: {v.decode()}"
-                for k,v in request.getAllHeaders().items()
-            )
-            to_log.append(f"  headers:\n{headers}")
-        return to_log
-
-    async def ap_request(
-        self,
-        request: "HTTPRequest",
-        data: Optional[dict] = None,
-        signing_actor: Optional[str] = None
-    ) -> None:
-        if self.apg.verbose:
-            to_log = self._get_to_log(request, data)
-
-        path = request.path.decode()
-        ap_url = parse.urljoin(
-            f"https://{self.apg.public_url}",
-            path
-        )
-        request_type, extra_args = self.apg.parse_apurl(ap_url)
-        if ((MEDIA_TYPE_AP not in (request.getHeader("accept") or "")
-             and request_type in self.apg.html_redirect)):
-            # this is not a AP request, and we have a redirections for it
-            kw = {}
-            if extra_args:
-                kw["jid"], kw["node"] = await self.apg.get_jid_and_node(extra_args[0])
-                kw["jid_user"] = kw["jid"].user
-                if kw["node"] is None:
-                    kw["node"] = self.apg._m.namespace
-                if len(extra_args) > 1:
-                    kw["item"] = extra_args[1]
-                else:
-                    kw["item"] = ""
-            else:
-                kw["jid"], kw["jid_user"], kw["node"], kw["item"] = "", "", "", ""
-
-            redirections = self.apg.html_redirect[request_type]
-            for redirection in redirections:
-                filters = redirection["filters"]
-                if not filters:
-                    break
-                # if we have filter, they must all match
-                elif all(v in kw[k] for k,v in filters.items()):
-                    break
-            else:
-                # no redirection is matching
-                redirection = None
-
-            if redirection is not None:
-                kw = {k: parse.quote(str(v), safe="") for k,v in kw.items()}
-                target_url = redirection["url"].format(**kw)
-                content = web_util.redirectTo(target_url.encode(), request)
-                request.write(content)
-                request.finish()
-                return
-
-        if len(extra_args) == 0:
-            if request_type != "shared_inbox":
-                raise exceptions.DataError(f"Invalid request type: {request_type!r}")
-            ret_data = await self.ap_inbox_request(
-                request, data, None, None, None, ap_url, signing_actor
-            )
-        elif request_type == "avatar":
-            if len(extra_args) != 1:
-                raise exceptions.DataError("avatar argument expected in URL")
-            avatar_filename = extra_args[0]
-            avatar_path = self.apg.host.common_cache.getPath(avatar_filename)
-            return static.File(str(avatar_path)).render(request)
-        elif request_type == "item":
-            ret_data = await self.apg.ap_get_local_object(ap_url)
-            if "@context" not in ret_data:
-                ret_data["@context"] = [NS_AP]
-        else:
-            if len(extra_args) > 1:
-                log.warning(f"unexpected extra arguments: {extra_args!r}")
-            ap_account = extra_args[0]
-            account_jid, node = await self.apg.get_jid_and_node(ap_account)
-            if request_type not in AP_REQUEST_TYPES.get(
-                    request.method.decode().upper(), []
-            ):
-                raise exceptions.DataError(f"Invalid request type: {request_type!r}")
-            method = getattr(self, f"AP{request_type.title()}Request")
-            ret_data = await method(
-                request, data, account_jid, node, ap_account, ap_url, signing_actor
-            )
-        if ret_data is not None:
-            request.setHeader("content-type", CONTENT_TYPE_AP)
-            request.write(json.dumps(ret_data).encode())
-        if self.apg.verbose:
-            to_log.append(f"--- RET (code: {request.code})---")
-            if self.apg.verbose>=2:
-                if ret_data is not None:
-                    from pprint import pformat
-                    to_log.append(f"{pformat(ret_data)}")
-                    to_log.append("---")
-            log.info("\n".join(to_log))
-        request.finish()
-
-    async def ap_post_request(self, request: "HTTPRequest") -> None:
-        try:
-            data = json.load(request.content)
-            if not isinstance(data, dict):
-                log.warning(f"JSON data should be an object (uri={request.uri.decode()})")
-                self.response_code(
-                    request,
-                    http.BAD_REQUEST,
-                    f"invalid body, was expecting a JSON object"
-                )
-                request.finish()
-                return
-        except (json.JSONDecodeError, ValueError) as e:
-            self.response_code(
-                request,
-                http.BAD_REQUEST,
-                f"invalid json in inbox request: {e}"
-            )
-            request.finish()
-            return
-        else:
-            request.content.seek(0)
-
-        try:
-            if data["type"] == "Delete" and data["actor"] == data["object"]:
-                # we don't handle actor deletion
-                request.setResponseCode(http.ACCEPTED)
-                log.debug(f"ignoring actor deletion ({data['actor']})")
-                # TODO: clean data in cache coming from this actor, maybe with a tombstone
-                request.finish()
-                return
-        except KeyError:
-            pass
-
-        try:
-            signing_actor = await self.check_signature(request)
-        except exceptions.EncryptionError as e:
-            if self.apg.verbose:
-                to_log = self._get_to_log(request)
-                to_log.append(f"  body: {request.content.read()!r}")
-                request.content.seek(0)
-                log.info("\n".join(to_log))
-            self.response_code(
-                request,
-                http.FORBIDDEN,
-                f"invalid signature: {e}"
-            )
-            request.finish()
-            return
-        except Exception as e:
-            self.response_code(
-                request,
-                http.INTERNAL_SERVER_ERROR,
-                f"Can't check signature: {e}"
-            )
-            request.finish()
-            return
-
-        request.setResponseCode(http.ACCEPTED)
-
-        digest = request.getHeader("digest")
-        if digest in self._seen_digest:
-            log.debug(f"Ignoring duplicated request (digest: {digest!r})")
-            request.finish()
-            return
-        self._seen_digest.append(digest)
-
-        # default response code, may be changed, e.g. in case of exception
-        try:
-            return await self.ap_request(request, data, signing_actor)
-        except Exception as e:
-            self._on_request_error(failure.Failure(e), request)
-
-    async def check_signing_actor(self, data: dict, signing_actor: str) -> None:
-        """That that signing actor correspond to actor declared in data
-
-        @param data: request payload
-        @param signing_actor: actor ID of the signing entity, as returned by
-            check_signature
-        @raise exceptions.NotFound: no actor found in data
-        @raise exceptions.EncryptionError: signing actor doesn't match actor in data
-        """
-        actor = await self.apg.ap_get_sender_actor(data)
-
-        if signing_actor != actor:
-            raise exceptions.EncryptionError(
-                f"signing actor ({signing_actor}) doesn't match actor in data ({actor})"
-            )
-
-    async def check_signature(self, request: "HTTPRequest") -> str:
-        """Check and validate HTTP signature
-
-        @return: id of the signing actor
-
-        @raise exceptions.EncryptionError: signature is not present or doesn't match
-        """
-        signature = request.getHeader("Signature")
-        if signature is None:
-            raise exceptions.EncryptionError("No signature found")
-        sign_data = {
-            m["key"]: m["uq_value"] or m["quoted_value"][1:-1]
-            for m in RE_SIG_PARAM.finditer(signature)
-        }
-        try:
-            key_id = sign_data["keyId"]
-        except KeyError:
-            raise exceptions.EncryptionError('"keyId" is missing from signature')
-        algorithm = sign_data.get("algorithm", HS2019)
-        signed_headers = sign_data.get(
-            "headers",
-            "(created)" if algorithm==HS2019 else "date"
-        ).lower().split()
-        try:
-            headers_to_check = SIGN_HEADERS[None] + SIGN_HEADERS[request.method]
-        except KeyError:
-            raise exceptions.InternalError(
-                f"there should be a list of headers for {request.method} method"
-            )
-        if not headers_to_check:
-            raise exceptions.InternalError("headers_to_check must not be empty")
-
-        for header in headers_to_check:
-            if isinstance(header, tuple):
-                if len(set(header).intersection(signed_headers)) == 0:
-                    raise exceptions.EncryptionError(
-                        f"at least one of following header must be signed: {header}"
-                    )
-            elif header not in signed_headers:
-                raise exceptions.EncryptionError(
-                    f"the {header!r} header must be signed"
-                )
-
-        body = request.content.read()
-        request.content.seek(0)
-        headers = {}
-        for to_sign in signed_headers:
-            if to_sign == "(request-target)":
-                method = request.method.decode().lower()
-                uri = request.uri.decode()
-                headers[to_sign] = f"{method} /{uri.lstrip('/')}"
-            elif to_sign in ("(created)", "(expires)"):
-                if algorithm != HS2019:
-                    raise exceptions.EncryptionError(
-                        f"{to_sign!r} pseudo-header can only be used with {HS2019} "
-                        "algorithm"
-                    )
-                key = to_sign[1:-1]
-                value = sign_data.get(key)
-                if not value:
-                    raise exceptions.EncryptionError(
-                        "{key!r} parameter is missing from signature"
-                    )
-                try:
-                    if float(value) < 0:
-                        raise ValueError
-                except ValueError:
-                    raise exceptions.EncryptionError(
-                        f"{to_sign} must be a Unix timestamp"
-                    )
-                headers[to_sign] = value
-            else:
-                value = request.getHeader(to_sign)
-                if not value:
-                    raise exceptions.EncryptionError(
-                        f"value of header {to_sign!r} is missing!"
-                    )
-                elif to_sign == "host":
-                    # we check Forwarded/X-Forwarded-Host headers
-                    # as we need original host if a proxy has modified the header
-                    forwarded = request.getHeader("forwarded")
-                    if forwarded is not None:
-                        try:
-                            host = [
-                                f[5:] for f in forwarded.split(";")
-                                if f.startswith("host=")
-                            ][0] or None
-                        except IndexError:
-                            host = None
-                    else:
-                        host = None
-                    if host is None:
-                        host = request.getHeader("x-forwarded-host")
-                    if host:
-                        value = host
-                elif to_sign == "digest":
-                    hashes = {
-                        algo.lower(): hash_ for algo, hash_ in (
-                            digest.split("=", 1) for digest in value.split(",")
-                        )
-                    }
-                    try:
-                        given_digest = hashes["sha-256"]
-                    except KeyError:
-                        raise exceptions.EncryptionError(
-                            "Only SHA-256 algorithm is currently supported for digest"
-                        )
-                    __, computed_digest = self.apg.get_digest(body)
-                    if given_digest != computed_digest:
-                        raise exceptions.EncryptionError(
-                            f"SHA-256 given and computed digest differ:\n"
-                            f"given: {given_digest!r}\ncomputed: {computed_digest!r}"
-                        )
-                headers[to_sign] = value
-
-        # date check
-        limit_ts = time.time() + SIGN_EXP
-        if "(created)" in headers:
-            created = float(headers["created"])
-        else:
-            created = date_utils.date_parse(headers["date"])
-
-
-        try:
-            expires = float(headers["expires"])
-        except KeyError:
-            pass
-        else:
-            if expires < created:
-                log.warning(
-                    f"(expires) [{expires}] set in the past of (created) [{created}] "
-                    "ignoring it according to specs"
-                )
-            else:
-                limit_ts = min(limit_ts, expires)
-
-        if created > limit_ts:
-            raise exceptions.EncryptionError("Signature has expired")
-
-        try:
-            return await self.apg.check_signature(
-                sign_data["signature"],
-                key_id,
-                headers
-            )
-        except exceptions.EncryptionError:
-            method, url = headers["(request-target)"].rsplit(' ', 1)
-            headers["(request-target)"] = f"{method} {parse.unquote(url)}"
-            log.debug(
-                "Using workaround for (request-target) encoding bug in signature, "
-                "see https://github.com/mastodon/mastodon/issues/18871"
-            )
-            return await self.apg.check_signature(
-                sign_data["signature"],
-                key_id,
-                headers
-            )
-
-    def render(self, request):
-        request.setHeader("server", VERSION)
-        return super().render(request)
-
-    def render_GET(self, request):
-        path = request.path.decode().lstrip("/")
-        if path.startswith(".well-known/webfinger"):
-            defer.ensureDeferred(self.webfinger(request))
-            return server.NOT_DONE_YET
-        elif path.startswith(self.apg.ap_path):
-            d = defer.ensureDeferred(self.ap_request(request))
-            d.addErrback(self._on_request_error, request)
-            return server.NOT_DONE_YET
-
-        return web_resource.NoResource().render(request)
-
-    def render_POST(self, request):
-        path = request.path.decode().lstrip("/")
-        if not path.startswith(self.apg.ap_path):
-            return web_resource.NoResource().render(request)
-        defer.ensureDeferred(self.ap_post_request(request))
-        return server.NOT_DONE_YET
-
-
-class HTTPRequest(server.Request):
-    pass
-
-
-class HTTPServer(server.Site):
-    requestFactory = HTTPRequest
-
-    def __init__(self, ap_gateway):
-        super().__init__(HTTPAPGServer(ap_gateway))
--- a/sat/plugins/plugin_comp_ap_gateway/pubsub_service.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,570 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia ActivityPub Gateway
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional, Tuple, List, Dict, Any, Union
-from urllib.parse import urlparse
-from pathlib import Path
-from base64 import b64encode
-import tempfile
-
-from twisted.internet import defer, threads
-from twisted.words.protocols.jabber import jid, error
-from twisted.words.xish import domish
-from wokkel import rsm, pubsub, disco
-
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.core.core_types import SatXMPPEntity
-from sat.core.log import getLogger
-from sat.core.constants import Const as C
-from sat.tools import image
-from sat.tools.utils import ensure_deferred
-from sat.tools.web import download_file
-from sat.memory.sqla_mapping import PubsubSub, SubscriptionState
-
-from .constants import (
-    TYPE_ACTOR,
-    ST_AVATAR,
-    MAX_AVATAR_SIZE
-)
-
-
-log = getLogger(__name__)
-
-# all nodes have the same config
-NODE_CONFIG = [
-    {"var": "pubsub#persist_items", "type": "boolean", "value": True},
-    {"var": "pubsub#max_items", "value": "max"},
-    {"var": "pubsub#access_model", "type": "list-single", "value": "open"},
-    {"var": "pubsub#publish_model", "type": "list-single", "value": "open"},
-
-]
-
-NODE_CONFIG_VALUES = {c["var"]: c["value"] for c in NODE_CONFIG}
-NODE_OPTIONS = {c["var"]: {} for c in NODE_CONFIG}
-for c in NODE_CONFIG:
-    NODE_OPTIONS[c["var"]].update({k:v for k,v in c.items() if k not in ("var", "value")})
-
-
-class APPubsubService(rsm.PubSubService):
-    """Pubsub service for XMPP requests"""
-
-    def __init__(self, apg):
-        super(APPubsubService, self).__init__()
-        self.host = apg.host
-        self.apg = apg
-        self.discoIdentity = {
-            "category": "pubsub",
-            "type": "service",
-            "name": "Libervia ActivityPub Gateway",
-        }
-
-    async def get_ap_actor_ids_and_inbox(
-        self,
-        requestor: jid.JID,
-        recipient: jid.JID,
-    ) -> Tuple[str, str, str]:
-        """Get AP actor IDs from requestor and destinee JIDs
-
-        @param requestor: XMPP entity doing a request to an AP actor via the gateway
-        @param recipient: JID mapping an AP actor via the gateway
-        @return: requestor actor ID, recipient actor ID and recipient inbox
-        @raise error.StanzaError: "item-not-found" is raised if not user part is specified
-            in requestor
-        """
-        if not recipient.user:
-            raise error.StanzaError(
-                "item-not-found",
-                text="No user part specified"
-            )
-        requestor_actor_id = self.apg.build_apurl(TYPE_ACTOR, requestor.userhost())
-        recipient_account = self.apg._e.unescape(recipient.user)
-        recipient_actor_id = await self.apg.get_ap_actor_id_from_account(recipient_account)
-        inbox = await self.apg.get_ap_inbox_from_id(recipient_actor_id, use_shared=False)
-        return requestor_actor_id, recipient_actor_id, inbox
-
-
-    @ensure_deferred
-    async def publish(self, requestor, service, nodeIdentifier, items):
-        if self.apg.local_only and not self.apg.is_local(requestor):
-            raise error.StanzaError(
-                "forbidden",
-                "Only local users can publish on this gateway."
-            )
-        if not service.user:
-            raise error.StanzaError(
-                "bad-request",
-                "You must specify an ActivityPub actor account in JID user part."
-            )
-        ap_account = self.apg._e.unescape(service.user)
-        if ap_account.count("@") != 1:
-            raise error.StanzaError(
-                "bad-request",
-                f"{ap_account!r} is not a valid ActivityPub actor account."
-            )
-
-        client = self.apg.client.get_virtual_client(requestor)
-        if self.apg._pa.is_attachment_node(nodeIdentifier):
-            await self.apg.convert_and_post_attachments(
-                client, ap_account, service, nodeIdentifier, items, publisher=requestor
-            )
-        else:
-            await self.apg.convert_and_post_items(
-                client, ap_account, service, nodeIdentifier, items
-            )
-            cached_node = await self.host.memory.storage.get_pubsub_node(
-                client, service, nodeIdentifier, with_subscriptions=True, create=True
-            )
-            await self.host.memory.storage.cache_pubsub_items(
-                client,
-                cached_node,
-                items
-            )
-            for subscription in cached_node.subscriptions:
-                if subscription.state != SubscriptionState.SUBSCRIBED:
-                    continue
-                self.notifyPublish(
-                    service,
-                    nodeIdentifier,
-                    [(subscription.subscriber, None, items)]
-                )
-
-    async def ap_following_2_elt(self, ap_item: dict) -> domish.Element:
-        """Convert actor ID from following collection to XMPP item"""
-        actor_id = ap_item["id"]
-        actor_jid = await self.apg.get_jid_from_id(actor_id)
-        subscription_elt = self.apg._pps.build_subscription_elt(
-            self.apg._m.namespace, actor_jid
-        )
-        item_elt = pubsub.Item(id=actor_id, payload=subscription_elt)
-        return item_elt
-
-    async def ap_follower_2_elt(self, ap_item: dict) -> domish.Element:
-        """Convert actor ID from followers collection to XMPP item"""
-        actor_id = ap_item["id"]
-        actor_jid = await self.apg.get_jid_from_id(actor_id)
-        subscriber_elt = self.apg._pps.build_subscriber_elt(actor_jid)
-        item_elt = pubsub.Item(id=actor_id, payload=subscriber_elt)
-        return item_elt
-
-    async def generate_v_card(self, ap_account: str) -> domish.Element:
-        """Generate vCard4 (XEP-0292) item element from ap_account's metadata"""
-        actor_data = await self.apg.get_ap_actor_data_from_account(ap_account)
-        identity_data = {}
-
-        summary = actor_data.get("summary")
-        # summary is HTML, we have to convert it to text
-        if summary:
-            identity_data["description"] = await self.apg._t.convert(
-                summary,
-                self.apg._t.SYNTAX_XHTML,
-                self.apg._t.SYNTAX_TEXT,
-                False,
-            )
-
-        for field in ("name", "preferredUsername"):
-            value = actor_data.get(field)
-            if value:
-                identity_data.setdefault("nicknames", []).append(value)
-        vcard_elt = self.apg._v.dict_2_v_card(identity_data)
-        item_elt = domish.Element((pubsub.NS_PUBSUB, "item"))
-        item_elt.addChild(vcard_elt)
-        item_elt["id"] = self.apg._p.ID_SINGLETON
-        return item_elt
-
-    async def get_avatar_data(
-        self,
-        client: SatXMPPEntity,
-        ap_account: str
-    ) -> Dict[str, Any]:
-        """Retrieve actor's avatar if any, cache it and file actor_data
-
-        ``cache_uid``, `path``` and ``media_type`` keys are always files
-        ``base64`` key is only filled if the file was not already in cache
-        """
-        actor_data = await self.apg.get_ap_actor_data_from_account(ap_account)
-
-        for icon in await self.apg.ap_get_list(actor_data, "icon"):
-            url = icon.get("url")
-            if icon["type"] != "Image" or not url:
-                continue
-            parsed_url = urlparse(url)
-            if not parsed_url.scheme in ("http", "https"):
-                log.warning(f"unexpected URL scheme: {url!r}")
-                continue
-            filename = Path(parsed_url.path).name
-            if not filename:
-                log.warning(f"ignoring URL with invald path: {url!r}")
-                continue
-            break
-        else:
-            raise error.StanzaError("item-not-found")
-
-        key = f"{ST_AVATAR}{url}"
-        cache_uid = await client._ap_storage.get(key)
-
-        if cache_uid is None:
-            cache = None
-        else:
-            cache = self.apg.host.common_cache.get_metadata(cache_uid)
-
-        if cache is None:
-            with tempfile.TemporaryDirectory() as dir_name:
-                dest_path = Path(dir_name, filename)
-                await download_file(url, dest_path, max_size=MAX_AVATAR_SIZE)
-                avatar_data = {
-                    "path": dest_path,
-                    "filename": filename,
-                    'media_type': image.guess_type(dest_path),
-                }
-
-                await self.apg._i.cache_avatar(
-                    self.apg.IMPORT_NAME,
-                    avatar_data
-                )
-        else:
-            avatar_data = {
-            "cache_uid": cache["uid"],
-            "path": cache["path"],
-            "media_type": cache["mime_type"]
-        }
-
-        return avatar_data
-
-    async def generate_avatar_metadata(
-        self,
-        client: SatXMPPEntity,
-        ap_account: str
-    ) -> domish.Element:
-        """Generate the metadata element for user avatar
-
-        @raise StanzaError("item-not-found"): no avatar is present in actor data (in
-            ``icon`` field)
-        """
-        avatar_data = await self.get_avatar_data(client, ap_account)
-        return self.apg._a.build_item_metadata_elt(avatar_data)
-
-    def _blocking_b_6_4_encode_avatar(self, avatar_data: Dict[str, Any]) -> None:
-        with avatar_data["path"].open("rb") as f:
-            avatar_data["base64"] = b64encode(f.read()).decode()
-
-    async def generate_avatar_data(
-        self,
-        client: SatXMPPEntity,
-        ap_account: str,
-        itemIdentifiers: Optional[List[str]],
-    ) -> domish.Element:
-        """Generate the data element for user avatar
-
-        @raise StanzaError("item-not-found"): no avatar cached with requested ID
-        """
-        if not itemIdentifiers:
-            avatar_data = await self.get_avatar_data(client, ap_account)
-            if "base64" not in avatar_data:
-                await threads.deferToThread(self._blocking_b_6_4_encode_avatar, avatar_data)
-        else:
-            if len(itemIdentifiers) > 1:
-                # only a single item ID is supported
-                raise error.StanzaError("item-not-found")
-            item_id = itemIdentifiers[0]
-            # just to be sure that that we don't have an empty string
-            assert item_id
-            cache_data = self.apg.host.common_cache.get_metadata(item_id)
-            if cache_data is None:
-                raise error.StanzaError("item-not-found")
-            avatar_data = {
-                "cache_uid": item_id,
-                "path": cache_data["path"]
-            }
-            await threads.deferToThread(self._blocking_b_6_4_encode_avatar, avatar_data)
-
-        return self.apg._a.build_item_data_elt(avatar_data)
-
-    @ensure_deferred
-    async def items(
-        self,
-        requestor: jid.JID,
-        service: jid.JID,
-        node: str,
-        maxItems: Optional[int],
-        itemIdentifiers: Optional[List[str]],
-        rsm_req: Optional[rsm.RSMRequest]
-    ) -> Tuple[List[domish.Element], Optional[rsm.RSMResponse]]:
-        if not service.user:
-            return [], None
-        ap_account = self.host.plugins["XEP-0106"].unescape(service.user)
-        if ap_account.count("@") != 1:
-            log.warning(f"Invalid AP account used by {requestor}: {ap_account!r}")
-            return [], None
-
-        # cached_node may be pre-filled with some nodes (e.g. attachments nodes),
-        # otherwise it is filled when suitable
-        cached_node = None
-        client = self.apg.client
-        kwargs = {}
-
-        if node == self.apg._pps.subscriptions_node:
-            collection_name = "following"
-            parser = self.ap_following_2_elt
-            kwargs["only_ids"] = True
-            use_cache = False
-        elif node.startswith(self.apg._pps.subscribers_node_prefix):
-            collection_name = "followers"
-            parser = self.ap_follower_2_elt
-            kwargs["only_ids"] = True
-            use_cache = False
-        elif node == self.apg._v.node:
-            # vCard4 request
-            item_elt = await self.generate_v_card(ap_account)
-            return [item_elt], None
-        elif node == self.apg._a.namespace_metadata:
-            item_elt = await self.generate_avatar_metadata(self.apg.client, ap_account)
-            return [item_elt], None
-        elif node == self.apg._a.namespace_data:
-            item_elt = await self.generate_avatar_data(
-                self.apg.client, ap_account, itemIdentifiers
-            )
-            return [item_elt], None
-        elif self.apg._pa.is_attachment_node(node):
-            use_cache = True
-            # we check cache here because we emit an item-not-found error if the node is
-            # not in cache, as we are not dealing with real AP items
-            cached_node = await self.host.memory.storage.get_pubsub_node(
-                client, service, node
-            )
-            if cached_node is None:
-                raise error.StanzaError("item-not-found")
-        else:
-            if node.startswith(self.apg._m.namespace):
-                parser = self.apg.ap_item_2_mb_elt
-            elif node.startswith(self.apg._events.namespace):
-                parser = self.apg.ap_events.ap_item_2_event_elt
-            else:
-                raise error.StanzaError(
-                    "feature-not-implemented",
-                    text=f"AP Gateway {C.APP_VERSION} only supports "
-                    f"{self.apg._m.namespace} node for now"
-                )
-            collection_name = "outbox"
-            use_cache = True
-
-        if use_cache:
-            if cached_node is None:
-                cached_node = await self.host.memory.storage.get_pubsub_node(
-                    client, service, node
-                )
-            # TODO: check if node is synchronised
-            if cached_node is not None:
-                # the node is cached, we return items from cache
-                log.debug(f"node {node!r} from {service} is in cache")
-                pubsub_items, metadata = await self.apg._c.get_items_from_cache(
-                    client, cached_node, maxItems, itemIdentifiers, rsm_request=rsm_req
-                )
-                try:
-                    rsm_resp = rsm.RSMResponse(**metadata["rsm"])
-                except KeyError:
-                    rsm_resp = None
-                return [i.data for i in pubsub_items], rsm_resp
-
-        if itemIdentifiers:
-            items = []
-            for item_id in itemIdentifiers:
-                item_data = await self.apg.ap_get(item_id)
-                item_elt = await parser(item_data)
-                items.append(item_elt)
-            return items, None
-        else:
-            if rsm_req is None:
-                if maxItems is None:
-                    maxItems = 20
-                kwargs.update({
-                    "max_items": maxItems,
-                    "chronological_pagination": False,
-                })
-            else:
-                if len(
-                    [v for v in (rsm_req.after, rsm_req.before, rsm_req.index)
-                     if v is not None]
-                ) > 1:
-                    raise error.StanzaError(
-                        "bad-request",
-                        text="You can't use after, before and index at the same time"
-                    )
-                kwargs.update({"max_items": rsm_req.max})
-                if rsm_req.after is not None:
-                    kwargs["after_id"] = rsm_req.after
-                elif rsm_req.before is not None:
-                    kwargs["chronological_pagination"] = False
-                    if rsm_req.before != "":
-                        kwargs["after_id"] = rsm_req.before
-                elif rsm_req.index is not None:
-                    kwargs["start_index"] = rsm_req.index
-
-            log.info(
-                f"No cache found for node {node} at {service} (AP account {ap_account}), "
-                "using Collection Paging to RSM translation"
-            )
-            if self.apg._m.is_comment_node(node):
-                parent_item = self.apg._m.get_parent_item(node)
-                try:
-                    parent_data = await self.apg.ap_get(parent_item)
-                    collection = await self.apg.ap_get_object(
-                        parent_data.get("object", {}),
-                        "replies"
-                    )
-                except Exception as e:
-                    raise error.StanzaError(
-                        "item-not-found",
-                        text=e
-                    )
-            else:
-                actor_data = await self.apg.get_ap_actor_data_from_account(ap_account)
-                collection = await self.apg.ap_get_object(actor_data, collection_name)
-            if not collection:
-                raise error.StanzaError(
-                    "item-not-found",
-                    text=f"No collection found for node {node!r} (account: {ap_account})"
-                )
-
-            kwargs["parser"] = parser
-            return await self.apg.get_ap_items(collection, **kwargs)
-
-    @ensure_deferred
-    async def retract(self, requestor, service, nodeIdentifier, itemIdentifiers):
-        raise error.StanzaError("forbidden")
-
-    @ensure_deferred
-    async def subscribe(self, requestor, service, nodeIdentifier, subscriber):
-        # TODO: handle comments nodes
-        client = self.apg.client
-        # we use PENDING state for microblog, it will be set to SUBSCRIBED once the Follow
-        # is accepted. Other nodes are directly set to subscribed, their subscriptions
-        # being internal.
-        if nodeIdentifier == self.apg._m.namespace:
-            sub_state = SubscriptionState.PENDING
-        else:
-            sub_state = SubscriptionState.SUBSCRIBED
-        node = await self.host.memory.storage.get_pubsub_node(
-            client, service, nodeIdentifier, with_subscriptions=True
-        )
-        if node is None:
-            node = await self.host.memory.storage.set_pubsub_node(
-                client,
-                service,
-                nodeIdentifier,
-            )
-            subscription = None
-        else:
-            try:
-                subscription = next(
-                    s for s in node.subscriptions
-                    if s.subscriber == requestor.userhostJID()
-                )
-            except StopIteration:
-                subscription = None
-
-        if subscription is None:
-            subscription = PubsubSub(
-                subscriber=requestor.userhostJID(),
-                state=sub_state
-            )
-            node.subscriptions.append(subscription)
-            await self.host.memory.storage.add(node)
-        else:
-            if subscription.state is None:
-                subscription.state = sub_state
-                await self.host.memory.storage.add(node)
-            elif subscription.state == SubscriptionState.SUBSCRIBED:
-                log.info(
-                    f"{requestor.userhostJID()} has already a subscription to {node!r} "
-                    f"at {service}. Doing the request anyway."
-                )
-            elif subscription.state == SubscriptionState.PENDING:
-                log.info(
-                    f"{requestor.userhostJID()} has already a pending subscription to "
-                    f"{node!r} at {service}. Doing the request anyway."
-                )
-                if sub_state != SubscriptionState.PENDING:
-                    subscription.state = sub_state
-                    await self.host.memory.storage.add(node)
-            else:
-                raise exceptions.InternalError(
-                    f"unmanaged subscription state: {subscription.state}"
-                )
-
-        if nodeIdentifier in (self.apg._m.namespace, self.apg._events.namespace):
-            # if we subscribe to microblog or events node, we follow the corresponding
-            # account
-            req_actor_id, recip_actor_id, inbox = await self.get_ap_actor_ids_and_inbox(
-                requestor, service
-            )
-
-            data = self.apg.create_activity("Follow", req_actor_id, recip_actor_id)
-
-            resp = await self.apg.sign_and_post(inbox, req_actor_id, data)
-            if resp.code >= 300:
-                text = await resp.text()
-                raise error.StanzaError("service-unavailable", text=text)
-        return pubsub.Subscription(nodeIdentifier, requestor, "subscribed")
-
-    @ensure_deferred
-    async def unsubscribe(self, requestor, service, nodeIdentifier, subscriber):
-        req_actor_id, recip_actor_id, inbox = await self.get_ap_actor_ids_and_inbox(
-            requestor, service
-        )
-        data = self.apg.create_activity(
-            "Undo",
-            req_actor_id,
-            self.apg.create_activity(
-                "Follow",
-                req_actor_id,
-                recip_actor_id
-            )
-        )
-
-        resp = await self.apg.sign_and_post(inbox, req_actor_id, data)
-        if resp.code >= 300:
-            text = await resp.text()
-            raise error.StanzaError("service-unavailable", text=text)
-
-    def getConfigurationOptions(self):
-        return NODE_OPTIONS
-
-    def getConfiguration(
-        self,
-        requestor: jid.JID,
-        service: jid.JID,
-        nodeIdentifier: str
-    ) -> defer.Deferred:
-        return defer.succeed(NODE_CONFIG_VALUES)
-
-    def getNodeInfo(
-        self,
-        requestor: jid.JID,
-        service: jid.JID,
-        nodeIdentifier: str,
-        pep: bool = False,
-        recipient: Optional[jid.JID] = None
-    ) -> Optional[dict]:
-        if not nodeIdentifier:
-            return None
-        info = {
-            "type": "leaf",
-            "meta-data": NODE_CONFIG
-        }
-        return info
--- a/sat/plugins/plugin_comp_ap_gateway/regex.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,64 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia ActivityPub Gateway
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""Various Regular Expression for AP gateway"""
-
-import re
-
-## "Signature" header parsing
-
-# those expression have been generated with abnf-to-regex
-# (https://github.com/aas-core-works/abnf-to-regexp)
-
-# the base RFC 7320 ABNF rules come from https://github.com/EricGT/ABNF
-
-# here is the ABNF file used:
-# ---
-# BWS = OWS
-# OWS = *( SP / HTAB )
-# tchar = "!" / "#" / "$" / "%" / "&" / "`" / "*" / "+" / "-" / "." / "^" / "_" / "\'" / "|" / "~" / DIGIT / ALPHA
-# token = 1*tchar
-# sig-param = token BWS "=" BWS ( token / quoted-string )
-# quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
-# qdtext = HTAB / SP / "!" / %x23-5B ; '#'-'['
-#  / %x5D-7E ; ']'-'~'
-#  / obs-text
-# quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
-# obs-text = %x80-FF
-# ---
-
-ows = '[ \t]*'
-bws = f'{ows}'
-obs_text = '[\\x80-\\xff]'
-qdtext = f'([\t !#-\\[\\]-~]|{obs_text})'
-quoted_pair = f'\\\\([\t !-~]|{obs_text})'
-quoted_string = f'"({qdtext}|{quoted_pair})*"'
-tchar = "([!#$%&`*+\\-.^_]|\\\\'|[|~0-9a-zA-Z])"
-token = f'({tchar})+'
-RE_SIG_PARAM = re.compile(
-    f'(?P<key>{token}{bws})={bws}'
-    f'((?P<uq_value>{token})|(?P<quoted_value>{quoted_string}))'
-)
-
-
-## Account/Mention
-
-# FIXME: naive regex, should be approved following webfinger, but popular implementations
-#   such as Mastodon use a very restricted subset
-RE_ACCOUNT = re.compile(r"[a-zA-Z0-9._-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-]+")
-RE_MENTION = re.compile(rf"(?<!\w)@{RE_ACCOUNT.pattern}\b")
--- a/sat/plugins/plugin_comp_file_sharing.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,884 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia File Sharing component
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import os
-import os.path
-import mimetypes
-import tempfile
-from functools import partial
-import shortuuid
-import unicodedata
-from urllib.parse import urljoin, urlparse, quote, unquote
-from pathlib import Path
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-from sat.tools import stream
-from sat.tools import video
-from sat.tools.utils import ensure_deferred
-from sat.tools.common import regex
-from sat.tools.common import uri
-from sat.tools.common import files_utils
-from sat.tools.common import utils
-from sat.tools.common import tls
-from twisted.internet import defer, reactor
-from twisted.words.protocols.jabber import error
-from twisted.web import server, resource, static, http
-from wokkel import pubsub
-from wokkel import generic
-
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "File sharing component",
-    C.PI_IMPORT_NAME: "file-sharing",
-    C.PI_MODES: [C.PLUG_MODE_COMPONENT],
-    C.PI_TYPE: C.PLUG_TYPE_ENTRY_POINT,
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [
-        "FILE",
-        "FILE_SHARING_MANAGEMENT",
-        "XEP-0106",
-        "XEP-0234",
-        "XEP-0260",
-        "XEP-0261",
-        "XEP-0264",
-        "XEP-0329",
-        "XEP-0363",
-    ],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "FileSharing",
-    C.PI_HANDLER: C.BOOL_TRUE,
-    C.PI_DESCRIPTION: _("""Component hosting and sharing files"""),
-}
-
-HASH_ALGO = "sha-256"
-NS_COMMENTS = "org.salut-a-toi.comments"
-NS_FS_AFFILIATION = "org.salut-a-toi.file-sharing-affiliation"
-COMMENT_NODE_PREFIX = "org.salut-a-toi.file_comments/"
-# Directory used to buffer request body (i.e. file in case of PUT) we use more than one @
-# there, to be sure than it's not conflicting with a JID
-TMP_BUFFER_DIR = "@@tmp@@"
-OVER_QUOTA_TXT = D_(
-    "You are over quota, your maximum allowed size is {quota} and you are already using "
-    "{used_space}, you can't upload {file_size} more."
-)
-
-HTTP_VERSION = unicodedata.normalize(
-    'NFKD',
-    f"{C.APP_NAME} file sharing {C.APP_VERSION}"
-)
-
-
-class HTTPFileServer(resource.Resource):
-    isLeaf = True
-
-    def errorPage(self, request, code):
-        request.setResponseCode(code)
-        if code == http.BAD_REQUEST:
-            brief = 'Bad Request'
-            details = "Your request is invalid"
-        elif code == http.FORBIDDEN:
-            brief = 'Forbidden'
-            details = "You're not allowed to use this resource"
-        elif code == http.NOT_FOUND:
-            brief = 'Not Found'
-            details = "No resource found at this URL"
-        else:
-            brief = 'Error'
-            details = "This resource can't be used"
-            log.error(f"Unexpected return code used: {code}")
-        log.warning(
-            f'Error returned while trying to access url {request.uri.decode()}: '
-            f'"{brief}" ({code}): {details}'
-        )
-
-        return resource.ErrorPage(code, brief, details).render(request)
-
-    def get_disposition_type(self, media_type, media_subtype):
-        if media_type in ('image', 'video'):
-            return 'inline'
-        elif media_type == 'application' and media_subtype == 'pdf':
-            return 'inline'
-        else:
-            return 'attachment'
-
-    def render(self, request):
-        request.setHeader("server", HTTP_VERSION)
-        request.setHeader("Access-Control-Allow-Origin", "*")
-        request.setHeader("Access-Control-Allow-Methods", "OPTIONS, HEAD, GET, PUT")
-        request.setHeader(
-            "Access-Control-Allow-Headers",
-            "Content-Type, Range, Xmpp-File-Path, Xmpp-File-No-Http")
-        request.setHeader("Access-Control-Allow-Credentials", "true")
-        request.setHeader("Accept-Ranges", "bytes")
-
-        request.setHeader(
-            "Access-Control-Expose-Headers",
-            "Date, Content-Length, Content-Range")
-        return super().render(request)
-
-    def render_options(self, request):
-        request.setResponseCode(http.OK)
-        return b""
-
-    def render_GET(self, request):
-        try:
-            request.upload_data
-        except exceptions.DataError:
-            return self.errorPage(request, http.NOT_FOUND)
-
-        defer.ensureDeferred(self.render_get(request))
-        return server.NOT_DONE_YET
-
-    async def render_get(self, request):
-        try:
-            upload_id, filename = request.upload_data
-        except exceptions.DataError:
-            request.write(self.errorPage(request, http.FORBIDDEN))
-            request.finish()
-            return
-        found_files = await request.file_sharing.host.memory.get_files(
-            client=None, peer_jid=None, perms_to_check=None, public_id=upload_id)
-        if not found_files:
-            request.write(self.errorPage(request, http.NOT_FOUND))
-            request.finish()
-            return
-        if len(found_files) > 1:
-            log.error(f"more that one files found for public id {upload_id!r}")
-
-        found_file = found_files[0]
-        file_path = request.file_sharing.files_path/found_file['file_hash']
-        file_res = static.File(file_path)
-        file_res.type = f'{found_file["media_type"]}/{found_file["media_subtype"]}'
-        file_res.encoding = file_res.contentEncodings.get(Path(found_file['name']).suffix)
-        disp_type = self.get_disposition_type(
-            found_file['media_type'], found_file['media_subtype'])
-        # the URL is percent encoded, and not all browsers/tools unquote the file name,
-        # thus we add a content disposition header
-        request.setHeader(
-            'Content-Disposition',
-            f"{disp_type}; filename*=UTF-8''{quote(found_file['name'])}"
-        )
-        # cf. https://xmpp.org/extensions/xep-0363.html#server
-        request.setHeader(
-            'Content-Security-Policy',
-            "default-src 'none'; frame-ancestors 'none';"
-        )
-        ret = file_res.render(request)
-        if ret != server.NOT_DONE_YET:
-            # HEAD returns directly the result (while GET use a produced)
-            request.write(ret)
-            request.finish()
-
-    def render_PUT(self, request):
-        defer.ensureDeferred(self.render_put(request))
-        return server.NOT_DONE_YET
-
-    async def render_put(self, request):
-        try:
-            client, upload_request = request.upload_request_data
-            upload_id, filename = request.upload_data
-        except AttributeError:
-            request.write(self.errorPage(request, http.BAD_REQUEST))
-            request.finish()
-            return
-
-        # at this point request is checked and file is buffered, we can store it
-        # we close the content here, before registering the file
-        request.content.close()
-        tmp_file_path = Path(request.content.name)
-        request.content = None
-
-        # the 2 following headers are not standard, but useful in the context of file
-        # sharing with HTTP Upload: first one allow uploader to specify the path
-        # and second one will disable public exposure of the file through HTTP
-        path = request.getHeader("Xmpp-File-Path")
-        if path:
-            path = unquote(path)
-        else:
-            path =  "/uploads"
-        if request.getHeader("Xmpp-File-No-Http") is not None:
-            public_id = None
-        else:
-            public_id = upload_id
-
-        file_data = {
-            "name": unquote(upload_request.filename),
-            "mime_type": upload_request.content_type,
-            "size": upload_request.size,
-            "path": path
-        }
-
-        await request.file_sharing.register_received_file(
-            client, upload_request.from_, file_data, tmp_file_path,
-            public_id=public_id,
-        )
-
-        request.setResponseCode(http.CREATED)
-        request.finish()
-
-
-class FileSharingRequest(server.Request):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self._upload_data = None
-
-    @property
-    def upload_data(self):
-        """A tuple with upload_id and filename retrieved from requested path"""
-        if self._upload_data is not None:
-            return self._upload_data
-
-        # self.path is not available if we are early in the request (e.g. when gotLength
-        # is called), in which case channel._path must be used. On the other hand, when
-        # render_[VERB] is called, only self.path is available
-        path = self.channel._path if self.path is None else self.path
-        # we normalise the path
-        path = urlparse(path.decode()).path
-        try:
-            __, upload_id, filename = path.split('/')
-        except ValueError:
-            raise exceptions.DataError("no enought path elements")
-        if len(upload_id) < 10:
-            raise exceptions.DataError(f"invalid upload ID received for a PUT: {upload_id!r}")
-
-        self._upload_data = (upload_id, filename)
-        return self._upload_data
-
-    @property
-    def file_sharing(self):
-        return self.channel.site.file_sharing
-
-    @property
-    def file_tmp_dir(self):
-        return self.channel.site.file_tmp_dir
-
-    def refuse_request(self):
-        if self.content is not None:
-            self.content.close()
-        self.content = open(os.devnull, 'w+b')
-        self.channel._respondToBadRequestAndDisconnect()
-
-    def gotLength(self, length):
-        if self.channel._command.decode().upper() == 'PUT':
-            # for PUT we check early if upload_id is fine, to avoid buffering a file we'll refuse
-            # we buffer the file in component's TMP_BUFFER_DIR, so we just have to rename it at the end
-            try:
-                upload_id, filename = self.upload_data
-            except exceptions.DataError as e:
-                log.warning(f"Invalid PUT request, we stop here: {e}")
-                return self.refuse_request()
-            try:
-                client, upload_request, timer = self.file_sharing.expected_uploads.pop(upload_id)
-            except KeyError:
-                log.warning(f"unknown (expired?) upload ID received for a PUT: {upload_id!r}")
-                return self.refuse_request()
-
-            if not timer.active:
-                log.warning(f"upload id {upload_id!r} used for a PUT, but it is expired")
-                return self.refuse_request()
-
-            timer.cancel()
-
-            if upload_request.filename != filename:
-                log.warning(
-                    f"invalid filename for PUT (upload id: {upload_id!r}, URL: {self.channel._path.decode()}). Original "
-                    f"{upload_request.filename!r} doesn't match {filename!r}"
-                )
-                return self.refuse_request()
-
-            self.upload_request_data = (client, upload_request)
-
-            file_tmp_path = files_utils.get_unique_name(
-                self.file_tmp_dir/upload_id)
-
-            self.content = open(file_tmp_path, 'w+b')
-        else:
-            return super().gotLength(length)
-
-
-class FileSharingSite(server.Site):
-    requestFactory = FileSharingRequest
-
-    def __init__(self, file_sharing):
-        self.file_sharing = file_sharing
-        self.file_tmp_dir = file_sharing.host.get_local_path(
-            None, C.FILES_TMP_DIR, TMP_BUFFER_DIR, component=True
-        )
-        for old_file in self.file_tmp_dir.iterdir():
-            log.debug(f"purging old buffer file at {old_file}")
-            old_file.unlink()
-        super().__init__(HTTPFileServer())
-
-    def getContentFile(self, length):
-        file_tmp_path = self.file_tmp_dir/shortuuid.uuid()
-        return open(file_tmp_path, 'w+b')
-
-
-class FileSharing:
-
-    def __init__(self, host):
-        self.host = host
-        self.initialised = False
-
-    def init(self):
-        # we init once on first component connection,
-        # there is not need to init this plugin if not component use it
-        # TODO: this plugin should not be loaded at all if no component uses it
-        #   and should be loaded dynamically as soon as a suitable profile is created
-        if self.initialised:
-            return
-        self.initialised = True
-        log.info(_("File Sharing initialization"))
-        self._f = self.host.plugins["FILE"]
-        self._jf = self.host.plugins["XEP-0234"]
-        self._h = self.host.plugins["XEP-0300"]
-        self._t = self.host.plugins["XEP-0264"]
-        self._hu = self.host.plugins["XEP-0363"]
-        self._hu.register_handler(self._on_http_upload)
-        self.host.trigger.add("FILE_getDestDir", self._get_dest_dir_trigger)
-        self.host.trigger.add(
-            "XEP-0234_fileSendingRequest", self._file_sending_request_trigger, priority=1000
-        )
-        self.host.trigger.add("XEP-0234_buildFileElement", self._add_file_metadata_elts)
-        self.host.trigger.add("XEP-0234_parseFileElement", self._get_file_metadata_elts)
-        self.host.trigger.add("XEP-0329_compGetFilesFromNode", self._add_file_metadata)
-        self.host.trigger.add(
-            "XEP-0329_compGetFilesFromNode_build_directory",
-            self._add_directory_metadata_elts)
-        self.host.trigger.add(
-            "XEP-0329_parseResult_directory",
-            self._get_directory_metadata_elts)
-        self.files_path = self.host.get_local_path(None, C.FILES_DIR)
-        self.http_port = int(self.host.memory.config_get(
-            'component file-sharing', 'http_upload_port', 8888))
-        connection_type = self.host.memory.config_get(
-            'component file-sharing', 'http_upload_connection_type', 'https')
-        if connection_type not in ('http', 'https'):
-            raise exceptions.ConfigError(
-                'bad http_upload_connection_type, you must use one of "http" or "https"'
-            )
-        self.server = FileSharingSite(self)
-        self.expected_uploads = {}
-        if connection_type == 'http':
-            reactor.listenTCP(self.http_port, self.server)
-        else:
-            options = tls.get_options_from_config(
-                self.host.memory.config, "component file-sharing")
-            tls.tls_options_check(options)
-            context_factory = tls.get_tls_context_factory(options)
-            reactor.listenSSL(self.http_port, self.server, context_factory)
-
-    def get_handler(self, client):
-        return Comments_handler(self)
-
-    def profile_connecting(self, client):
-        # we activate HTTP upload
-        client.enabled_features.add("XEP-0363")
-
-        self.init()
-        public_base_url = self.host.memory.config_get(
-            'component file-sharing', 'http_upload_public_facing_url')
-        if public_base_url is None:
-            client._file_sharing_base_url = f"https://{client.host}:{self.http_port}"
-        else:
-            client._file_sharing_base_url = public_base_url
-        path = client.file_tmp_dir = os.path.join(
-            self.host.memory.config_get("", "local_dir"),
-            C.FILES_TMP_DIR,
-            regex.path_escape(client.profile),
-        )
-        if not os.path.exists(path):
-            os.makedirs(path)
-
-    def get_quota(self, client, entity):
-        """Return maximum size allowed for all files for entity"""
-        quotas = self.host.memory.config_get("component file-sharing", "quotas_json", {})
-        if self.host.memory.is_admin_jid(entity):
-            quota = quotas.get("admins")
-        else:
-            try:
-                quota = quotas["jids"][entity.userhost()]
-            except KeyError:
-                quota = quotas.get("users")
-        return None if quota is None else utils.parse_size(quota)
-
-    async def generate_thumbnails(self, extra: dict, image_path: Path):
-        thumbnails = extra.setdefault(C.KEY_THUMBNAILS, [])
-        for max_thumb_size in self._t.SIZES:
-            try:
-                thumb_size, thumb_id = await self._t.generate_thumbnail(
-                    image_path,
-                    max_thumb_size,
-                    #  we keep thumbnails for 6 months
-                    60 * 60 * 24 * 31 * 6,
-                )
-            except Exception as e:
-                log.warning(_("Can't create thumbnail: {reason}").format(reason=e))
-                break
-            thumbnails.append({"id": thumb_id, "size": thumb_size})
-
-    async def register_received_file(
-            self, client, peer_jid, file_data, file_path, public_id=None, extra=None):
-        """Post file reception tasks
-
-        once file is received, this method create hash/thumbnails if necessary
-        move the file to the right location, and create metadata entry in database
-        """
-        name = file_data["name"]
-        if extra is None:
-            extra = {}
-
-        mime_type = file_data.get("mime_type")
-        if not mime_type or mime_type == "application/octet-stream":
-            mime_type = mimetypes.guess_type(name)[0]
-
-        is_image = mime_type is not None and mime_type.startswith("image")
-        is_video = mime_type is not None and mime_type.startswith("video")
-
-        if file_data.get("hash_algo") == HASH_ALGO:
-            log.debug(_("Reusing already generated hash"))
-            file_hash = file_data["hash_hasher"].hexdigest()
-        else:
-            hasher = self._h.get_hasher(HASH_ALGO)
-            with file_path.open('rb') as f:
-                file_hash = await self._h.calculate_hash(f, hasher)
-        final_path = self.files_path/file_hash
-
-        if final_path.is_file():
-            log.debug(
-                "file [{file_hash}] already exists, we can remove temporary one".format(
-                    file_hash=file_hash
-                )
-            )
-            file_path.unlink()
-        else:
-            file_path.rename(final_path)
-            log.debug(
-                "file [{file_hash}] moved to {files_path}".format(
-                    file_hash=file_hash, files_path=self.files_path
-                )
-            )
-
-        if is_image:
-            await self.generate_thumbnails(extra, final_path)
-        elif is_video:
-            with tempfile.TemporaryDirectory() as tmp_dir:
-                thumb_path = Path(tmp_dir) / "thumbnail.jpg"
-                try:
-                    await video.get_thumbnail(final_path, thumb_path)
-                except Exception as e:
-                    log.warning(_("Can't get thumbnail for {final_path}: {e}").format(
-                        final_path=final_path, e=e))
-                else:
-                    await self.generate_thumbnails(extra, thumb_path)
-
-        await self.host.memory.set_file(
-            client,
-            name=name,
-            version="",
-            file_hash=file_hash,
-            hash_algo=HASH_ALGO,
-            size=file_data["size"],
-            path=file_data.get("path"),
-            namespace=file_data.get("namespace"),
-            mime_type=mime_type,
-            public_id=public_id,
-            owner=peer_jid,
-            extra=extra,
-        )
-
-    async def _get_dest_dir_trigger(
-        self, client, peer_jid, transfer_data, file_data, stream_object
-    ):
-        """This trigger accept file sending request, and store file locally"""
-        if not client.is_component:
-            return True, None
-        # client._file_sharing_allowed_hosts is set in plugin XEP-0329
-        if peer_jid.host not in client._file_sharing_allowed_hosts:
-            raise error.StanzaError("forbidden")
-        assert stream_object
-        assert "stream_object" not in transfer_data
-        assert C.KEY_PROGRESS_ID in file_data
-        filename = file_data["name"]
-        assert filename and not "/" in filename
-        quota = self.get_quota(client, peer_jid)
-        if quota is not None:
-            used_space = await self.host.memory.file_get_used_space(client, peer_jid)
-
-            if (used_space + file_data["size"]) > quota:
-                raise error.StanzaError(
-                    "not-acceptable",
-                    text=OVER_QUOTA_TXT.format(
-                        quota=utils.get_human_size(quota),
-                        used_space=utils.get_human_size(used_space),
-                        file_size=utils.get_human_size(file_data['size'])
-                    )
-                )
-        file_tmp_dir = self.host.get_local_path(
-            None, C.FILES_TMP_DIR, peer_jid.userhost(), component=True
-        )
-        file_tmp_path = file_data['file_path'] = files_utils.get_unique_name(
-            file_tmp_dir/filename)
-
-        transfer_data["finished_d"].addCallback(
-            lambda __: defer.ensureDeferred(
-                self.register_received_file(client, peer_jid, file_data, file_tmp_path)
-            )
-        )
-
-        self._f.open_file_write(
-            client, file_tmp_path, transfer_data, file_data, stream_object
-        )
-        return False, True
-
-    async def _retrieve_files(
-        self, client, session, content_data, content_name, file_data, file_elt
-    ):
-        """This method retrieve a file on request, and send if after checking permissions"""
-        peer_jid = session["peer_jid"]
-        if session['local_jid'].user:
-            owner = client.get_owner_from_jid(session['local_jid'])
-        else:
-            owner = peer_jid
-        try:
-            found_files = await self.host.memory.get_files(
-                client,
-                peer_jid=peer_jid,
-                name=file_data.get("name"),
-                file_hash=file_data.get("file_hash"),
-                hash_algo=file_data.get("hash_algo"),
-                path=file_data.get("path"),
-                namespace=file_data.get("namespace"),
-                owner=owner,
-            )
-        except exceptions.NotFound:
-            found_files = None
-        except exceptions.PermissionError:
-            log.warning(
-                _("{peer_jid} is trying to access an unauthorized file: {name}").format(
-                    peer_jid=peer_jid, name=file_data.get("name")
-                )
-            )
-            return False
-
-        if not found_files:
-            log.warning(
-                _("no matching file found ({file_data})").format(file_data=file_data)
-            )
-            return False
-
-        # we only use the first found file
-        found_file = found_files[0]
-        if found_file['type'] != C.FILE_TYPE_FILE:
-            raise TypeError("a file was expected, type is {type_}".format(
-                type_=found_file['type']))
-        file_hash = found_file["file_hash"]
-        file_path = self.files_path / file_hash
-        file_data["hash_hasher"] = hasher = self._h.get_hasher(found_file["hash_algo"])
-        size = file_data["size"] = found_file["size"]
-        file_data["file_hash"] = file_hash
-        file_data["hash_algo"] = found_file["hash_algo"]
-
-        # we complete file_elt so peer can have some details on the file
-        if "name" not in file_data:
-            file_elt.addElement("name", content=found_file["name"])
-        file_elt.addElement("size", content=str(size))
-        content_data["stream_object"] = stream.FileStreamObject(
-            self.host,
-            client,
-            file_path,
-            uid=self._jf.get_progress_id(session, content_name),
-            size=size,
-            data_cb=lambda data: hasher.update(data),
-        )
-        return True
-
-    def _file_sending_request_trigger(
-        self, client, session, content_data, content_name, file_data, file_elt
-    ):
-        if not client.is_component:
-            return True, None
-        else:
-            return (
-                False,
-                defer.ensureDeferred(self._retrieve_files(
-                    client, session, content_data, content_name, file_data, file_elt
-                )),
-            )
-
-    ## HTTP Upload ##
-
-    def _purge_slot(self, upload_id):
-        try:
-            del self.expected_uploads[upload_id]
-        except KeyError:
-            log.error(f"trying to purge an inexisting upload slot ({upload_id})")
-
-    async def _on_http_upload(self, client, request):
-        # filename should be already cleaned, but it's better to double check
-        assert '/' not in request.filename
-        # client._file_sharing_allowed_hosts is set in plugin XEP-0329
-        if request.from_.host not in client._file_sharing_allowed_hosts:
-            raise error.StanzaError("forbidden")
-
-        quota = self.get_quota(client, request.from_)
-        if quota is not None:
-            used_space = await self.host.memory.file_get_used_space(client, request.from_)
-
-            if (used_space + request.size) > quota:
-                raise error.StanzaError(
-                    "not-acceptable",
-                    text=OVER_QUOTA_TXT.format(
-                        quota=utils.get_human_size(quota),
-                        used_space=utils.get_human_size(used_space),
-                        file_size=utils.get_human_size(request.size)
-                    ),
-                    appCondition = self._hu.get_file_too_large_elt(max(quota - used_space, 0))
-                )
-
-        upload_id = shortuuid.ShortUUID().random(length=30)
-        assert '/' not in upload_id
-        timer = reactor.callLater(30, self._purge_slot, upload_id)
-        self.expected_uploads[upload_id] = (client, request, timer)
-        url = urljoin(client._file_sharing_base_url, f"{upload_id}/{request.filename}")
-        slot = self._hu.Slot(
-            put=url,
-            get=url,
-            headers=[],
-        )
-        return slot
-
-    ## metadata triggers ##
-
-    def _add_file_metadata_elts(self, client, file_elt, extra_args):
-        # affiliation
-        affiliation = extra_args.get('affiliation')
-        if affiliation is not None:
-            file_elt.addElement((NS_FS_AFFILIATION, "affiliation"), content=affiliation)
-
-        # comments
-        try:
-            comments_url = extra_args.pop("comments_url")
-        except KeyError:
-            return
-
-        comment_elt = file_elt.addElement((NS_COMMENTS, "comments"), content=comments_url)
-
-        try:
-            count = len(extra_args["extra"]["comments"])
-        except KeyError:
-            count = 0
-
-        comment_elt["count"] = str(count)
-        return True
-
-    def _get_file_metadata_elts(self, client, file_elt, file_data):
-        # affiliation
-        try:
-            affiliation_elt = next(file_elt.elements(NS_FS_AFFILIATION, "affiliation"))
-        except StopIteration:
-            pass
-        else:
-            file_data["affiliation"] = str(affiliation_elt)
-
-        # comments
-        try:
-            comments_elt = next(file_elt.elements(NS_COMMENTS, "comments"))
-        except StopIteration:
-            pass
-        else:
-            file_data["comments_url"] = str(comments_elt)
-            file_data["comments_count"] = comments_elt["count"]
-        return True
-
-    def _add_file_metadata(
-            self, client, iq_elt, iq_result_elt, owner, node_path, files_data):
-        for file_data in files_data:
-            file_data["comments_url"] = uri.build_xmpp_uri(
-                "pubsub",
-                path=client.jid.full(),
-                node=COMMENT_NODE_PREFIX + file_data["id"],
-            )
-        return True
-
-    def _add_directory_metadata_elts(
-            self, client, file_data, directory_elt, owner, node_path):
-        affiliation = file_data.get('affiliation')
-        if affiliation is not None:
-            directory_elt.addElement(
-                (NS_FS_AFFILIATION, "affiliation"),
-                content=affiliation
-            )
-
-    def _get_directory_metadata_elts(
-            self, client, elt, file_data):
-        try:
-            affiliation_elt = next(elt.elements(NS_FS_AFFILIATION, "affiliation"))
-        except StopIteration:
-            pass
-        else:
-            file_data['affiliation'] = str(affiliation_elt)
-
-
-class Comments_handler(pubsub.PubSubService):
-    """This class is a minimal Pubsub service handling virtual nodes for comments"""
-
-    def __init__(self, plugin_parent):
-        super(Comments_handler, self).__init__()
-        self.host = plugin_parent.host
-        self.plugin_parent = plugin_parent
-        self.discoIdentity = {
-            "category": "pubsub",
-            "type": "virtual",  # FIXME: non standard, here to avoid this service being considered as main pubsub one
-            "name": "files commenting service",
-        }
-
-    def _get_file_id(self, nodeIdentifier):
-        if not nodeIdentifier.startswith(COMMENT_NODE_PREFIX):
-            raise error.StanzaError("item-not-found")
-        file_id = nodeIdentifier[len(COMMENT_NODE_PREFIX) :]
-        if not file_id:
-            raise error.StanzaError("item-not-found")
-        return file_id
-
-    async def get_file_data(self, requestor, nodeIdentifier):
-        file_id = self._get_file_id(nodeIdentifier)
-        try:
-            files = await self.host.memory.get_files(self.parent, requestor, file_id)
-        except (exceptions.NotFound, exceptions.PermissionError):
-            # we don't differenciate between NotFound and PermissionError
-            # to avoid leaking information on existing files
-            raise error.StanzaError("item-not-found")
-        if not files:
-            raise error.StanzaError("item-not-found")
-        if len(files) > 1:
-            raise error.InternalError("there should be only one file")
-        return files[0]
-
-    def comments_update(self, extra, new_comments, peer_jid):
-        """update comments (replace or insert new_comments)
-
-        @param extra(dict): extra data to update
-        @param new_comments(list[tuple(unicode, unicode, unicode)]): comments to update or insert
-        @param peer_jid(unicode, None): bare jid of the requestor, or None if request is done by owner
-        """
-        current_comments = extra.setdefault("comments", [])
-        new_comments_by_id = {c[0]: c for c in new_comments}
-        updated = []
-        # we now check every current comment, to see if one id in new ones
-        # exist, in which case we must update
-        for idx, comment in enumerate(current_comments):
-            comment_id = comment[0]
-            if comment_id in new_comments_by_id:
-                # a new comment has an existing id, update is requested
-                if peer_jid and comment[1] != peer_jid:
-                    # requestor has not the right to modify the comment
-                    raise exceptions.PermissionError
-                # we replace old_comment with updated one
-                new_comment = new_comments_by_id[comment_id]
-                current_comments[idx] = new_comment
-                updated.append(new_comment)
-
-        # we now remove every updated comments, to only keep
-        # the ones to insert
-        for comment in updated:
-            new_comments.remove(comment)
-
-        current_comments.extend(new_comments)
-
-    def comments_delete(self, extra, comments):
-        try:
-            comments_dict = extra["comments"]
-        except KeyError:
-            return
-        for comment in comments:
-            try:
-                comments_dict.remove(comment)
-            except ValueError:
-                continue
-
-    def _get_from(self, item_elt):
-        """retrieve publisher of an item
-
-        @param item_elt(domish.element): <item> element
-        @return (unicode): full jid as string
-        """
-        iq_elt = item_elt
-        while iq_elt.parent != None:
-            iq_elt = iq_elt.parent
-        return iq_elt["from"]
-
-    @ensure_deferred
-    async def publish(self, requestor, service, nodeIdentifier, items):
-        #  we retrieve file a first time to check authorisations
-        file_data = await self.get_file_data(requestor, nodeIdentifier)
-        file_id = file_data["id"]
-        comments = [(item["id"], self._get_from(item), item.toXml()) for item in items]
-        if requestor.userhostJID() == file_data["owner"]:
-            peer_jid = None
-        else:
-            peer_jid = requestor.userhost()
-        update_cb = partial(self.comments_update, new_comments=comments, peer_jid=peer_jid)
-        try:
-            await self.host.memory.file_update(file_id, "extra", update_cb)
-        except exceptions.PermissionError:
-            raise error.StanzaError("not-authorized")
-
-    @ensure_deferred
-    async def items(self, requestor, service, nodeIdentifier, maxItems, itemIdentifiers):
-        file_data = await self.get_file_data(requestor, nodeIdentifier)
-        comments = file_data["extra"].get("comments", [])
-        if itemIdentifiers:
-            return [generic.parseXml(c[2]) for c in comments if c[0] in itemIdentifiers]
-        else:
-            return [generic.parseXml(c[2]) for c in comments]
-
-    @ensure_deferred
-    async def retract(self, requestor, service, nodeIdentifier, itemIdentifiers):
-        file_data = await self.get_file_data(requestor, nodeIdentifier)
-        file_id = file_data["id"]
-        try:
-            comments = file_data["extra"]["comments"]
-        except KeyError:
-            raise error.StanzaError("item-not-found")
-
-        to_remove = []
-        for comment in comments:
-            comment_id = comment[0]
-            if comment_id in itemIdentifiers:
-                to_remove.append(comment)
-                itemIdentifiers.remove(comment_id)
-                if not itemIdentifiers:
-                    break
-
-        if itemIdentifiers:
-            # not all items have been to_remove, we can't continue
-            raise error.StanzaError("item-not-found")
-
-        if requestor.userhostJID() != file_data["owner"]:
-            if not all([c[1] == requestor.userhost() for c in to_remove]):
-                raise error.StanzaError("not-authorized")
-
-        remove_cb = partial(self.comments_delete, comments=to_remove)
-        await self.host.memory.file_update(file_id, "extra", remove_cb)
--- a/sat/plugins/plugin_comp_file_sharing_management.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,483 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin to manage file sharing component through ad-hoc commands
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import os.path
-from functools import partial
-from wokkel import data_form
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from sat.core.i18n import _, D_
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.tools.common import utils
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "File Sharing Management",
-    C.PI_IMPORT_NAME: "FILE_SHARING_MANAGEMENT",
-    C.PI_MODES: [C.PLUG_MODE_COMPONENT],
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0050", "XEP-0264"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "FileSharingManagement",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _(
-        "Experimental handling of file management for file sharing. This plugins allows "
-        "to change permissions of stored files/directories or remove them."
-    ),
-}
-
-NS_FILE_MANAGEMENT = "https://salut-a-toi.org/protocol/file-management:0"
-NS_FILE_MANAGEMENT_PERM = "https://salut-a-toi.org/protocol/file-management:0#perm"
-NS_FILE_MANAGEMENT_DELETE = "https://salut-a-toi.org/protocol/file-management:0#delete"
-NS_FILE_MANAGEMENT_THUMB = "https://salut-a-toi.org/protocol/file-management:0#thumb"
-NS_FILE_MANAGEMENT_QUOTA = "https://salut-a-toi.org/protocol/file-management:0#quota"
-
-
-class WorkflowError(Exception):
-    """Raised when workflow can't be completed"""
-
-    def __init__(self, err_args):
-        """
-        @param err_args(tuple): arguments to return to finish the command workflow
-        """
-        Exception.__init__(self)
-        self.err_args = err_args
-
-
-class FileSharingManagement(object):
-    # This is a temporary way (Q&D) to handle stored files, a better way (using pubsub
-    # syntax?) should be elaborated and proposed as a standard.
-
-    def __init__(self, host):
-        log.info(_("File Sharing Management plugin initialization"))
-        self.host = host
-        self._c = host.plugins["XEP-0050"]
-        self._t = host.plugins["XEP-0264"]
-        self.files_path = host.get_local_path(None, C.FILES_DIR)
-        host.bridge.add_method(
-            "file_sharing_delete",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="",
-            method=self._delete,
-            async_=True,
-        )
-
-    def profile_connected(self, client):
-        self._c.add_ad_hoc_command(
-            client, self._on_change_file, "Change Permissions of File(s)",
-            node=NS_FILE_MANAGEMENT_PERM,
-            allowed_magics=C.ENTITY_ALL,
-        )
-        self._c.add_ad_hoc_command(
-            client, self._on_delete_file, "Delete File(s)",
-            node=NS_FILE_MANAGEMENT_DELETE,
-            allowed_magics=C.ENTITY_ALL,
-        )
-        self._c.add_ad_hoc_command(
-            client, self._on_gen_thumbnails, "Generate Thumbnails",
-            node=NS_FILE_MANAGEMENT_THUMB,
-            allowed_magics=C.ENTITY_ALL,
-        )
-        self._c.add_ad_hoc_command(
-            client, self._on_quota, "Get Quota",
-            node=NS_FILE_MANAGEMENT_QUOTA,
-            allowed_magics=C.ENTITY_ALL,
-        )
-
-    def _delete(self, service_jid_s, path, namespace, profile):
-        client = self.host.get_client(profile)
-        service_jid = jid.JID(service_jid_s) if service_jid_s else None
-        return defer.ensureDeferred(self._c.sequence(
-            client,
-            [{"path": path, "namespace": namespace}, {"confirm": True}],
-            NS_FILE_MANAGEMENT_DELETE,
-            service_jid,
-        ))
-
-    def _err(self, reason):
-        """Helper method to get argument to return for error
-
-        workflow will be interrupted with an error note
-        @param reason(unicode): reason of the error
-        @return (tuple): arguments to use in defer.returnValue
-        """
-        status = self._c.STATUS.COMPLETED
-        payload = None
-        note = (self._c.NOTE.ERROR, reason)
-        return payload, status, None, note
-
-    def _get_root_args(self):
-        """Create the form to select the file to use
-
-        @return (tuple): arguments to use in defer.returnValue
-        """
-        status = self._c.STATUS.EXECUTING
-        form = data_form.Form("form", title="File Management",
-                              formNamespace=NS_FILE_MANAGEMENT)
-
-        field = data_form.Field(
-            "text-single", "path", required=True
-        )
-        form.addField(field)
-
-        field = data_form.Field(
-            "text-single", "namespace", required=False
-        )
-        form.addField(field)
-
-        payload = form.toElement()
-        return payload, status, None, None
-
-    async def _get_file_data(self, client, session_data, command_form):
-        """Retrieve field requested in root form
-
-        "found_file" will also be set in session_data
-        @param command_form(data_form.Form): response to root form
-        @return (D(dict)): found file data
-        @raise WorkflowError: something is wrong
-        """
-        fields = command_form.fields
-        try:
-            path = fields['path'].value.strip()
-            namespace = fields['namespace'].value or None
-        except KeyError:
-            self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
-
-        if not path:
-            self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
-
-        requestor = session_data['requestor']
-        requestor_bare = requestor.userhostJID()
-        path = path.rstrip('/')
-        parent_path, basename = os.path.split(path)
-
-        # TODO: if parent_path and basename are empty, we ask for root directory
-        #       this must be managed
-
-        try:
-            found_files = await self.host.memory.get_files(
-                client, requestor_bare, path=parent_path, name=basename,
-                namespace=namespace)
-            found_file = found_files[0]
-        except (exceptions.NotFound, IndexError):
-            raise WorkflowError(self._err(_("file not found")))
-        except exceptions.PermissionError:
-            raise WorkflowError(self._err(_("forbidden")))
-
-        if found_file['owner'] != requestor_bare:
-            # only owner can manage files
-            log.warning(_("Only owner can manage files"))
-            raise WorkflowError(self._err(_("forbidden")))
-
-        session_data['found_file'] = found_file
-        session_data['namespace'] = namespace
-        return found_file
-
-    def _update_read_permission(self, access, allowed_jids):
-        if not allowed_jids:
-            if C.ACCESS_PERM_READ in access:
-                del access[C.ACCESS_PERM_READ]
-        elif allowed_jids == 'PUBLIC':
-            access[C.ACCESS_PERM_READ] = {
-                "type": C.ACCESS_TYPE_PUBLIC
-            }
-        else:
-            access[C.ACCESS_PERM_READ] = {
-                "type": C.ACCESS_TYPE_WHITELIST,
-                "jids": [j.full() for j in allowed_jids]
-            }
-
-    async def _update_dir(self, client, requestor, namespace, file_data, allowed_jids):
-        """Recursively update permission of a directory and all subdirectories
-
-        @param file_data(dict): metadata of the file
-        @param allowed_jids(list[jid.JID]): list of entities allowed to read the file
-        """
-        assert file_data['type'] == C.FILE_TYPE_DIRECTORY
-        files_data = await self.host.memory.get_files(
-            client, requestor, parent=file_data['id'], namespace=namespace)
-
-        for file_data in files_data:
-            if not file_data['access'].get(C.ACCESS_PERM_READ, {}):
-                log.debug("setting {perm} read permission for {name}".format(
-                    perm=allowed_jids, name=file_data['name']))
-                await self.host.memory.file_update(
-                    file_data['id'], 'access',
-                    partial(self._update_read_permission, allowed_jids=allowed_jids))
-            if file_data['type'] == C.FILE_TYPE_DIRECTORY:
-                await self._update_dir(client, requestor, namespace, file_data, 'PUBLIC')
-
-    async def _on_change_file(self, client, command_elt, session_data, action, node):
-        try:
-            x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
-            command_form = data_form.Form.fromElement(x_elt)
-        except StopIteration:
-            command_form = None
-
-        found_file = session_data.get('found_file')
-        requestor = session_data['requestor']
-        requestor_bare = requestor.userhostJID()
-
-        if command_form is None or len(command_form.fields) == 0:
-            # root request
-            return self._get_root_args()
-
-        elif found_file is None:
-            # file selected, we retrieve it and ask for permissions
-            try:
-                found_file = await self._get_file_data(client, session_data, command_form)
-            except WorkflowError as e:
-                return e.err_args
-
-            # management request
-            if found_file['type'] == C.FILE_TYPE_DIRECTORY:
-                instructions = D_("Please select permissions for this directory")
-            else:
-                instructions = D_("Please select permissions for this file")
-
-            form = data_form.Form("form", title="File Management",
-                                  instructions=[instructions],
-                                  formNamespace=NS_FILE_MANAGEMENT)
-            field = data_form.Field(
-                "text-multi", "read_allowed", required=False,
-                desc='list of jids allowed to read this file (beside yourself), or '
-                     '"PUBLIC" to let a public access'
-            )
-            read_access = found_file["access"].get(C.ACCESS_PERM_READ, {})
-            access_type = read_access.get('type', C.ACCESS_TYPE_WHITELIST)
-            if access_type == C.ACCESS_TYPE_PUBLIC:
-                field.values = ['PUBLIC']
-            else:
-                field.values = read_access.get('jids', [])
-            form.addField(field)
-            if found_file['type'] == C.FILE_TYPE_DIRECTORY:
-                field = data_form.Field(
-                    "boolean", "recursive", value=False, required=False,
-                    desc="Files under it will be made public to follow this dir "
-                         "permission (only if they don't have already a permission set)."
-                )
-                form.addField(field)
-
-            status = self._c.STATUS.EXECUTING
-            payload = form.toElement()
-            return (payload, status, None, None)
-
-        else:
-            # final phase, we'll do permission change here
-            try:
-                read_allowed = command_form.fields['read_allowed']
-            except KeyError:
-                self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
-
-            if read_allowed.value == 'PUBLIC':
-                allowed_jids = 'PUBLIC'
-            elif read_allowed.value.strip() == '':
-                allowed_jids = None
-            else:
-                try:
-                    allowed_jids = [jid.JID(v.strip()) for v in read_allowed.values
-                                    if v.strip()]
-                except RuntimeError as e:
-                    log.warning(_("Can't use read_allowed values: {reason}").format(
-                        reason=e))
-                    self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
-
-            if found_file['type'] == C.FILE_TYPE_FILE:
-                await self.host.memory.file_update(
-                    found_file['id'], 'access',
-                    partial(self._update_read_permission, allowed_jids=allowed_jids))
-            else:
-                try:
-                    recursive = command_form.fields['recursive']
-                except KeyError:
-                    self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
-                await self.host.memory.file_update(
-                    found_file['id'], 'access',
-                    partial(self._update_read_permission, allowed_jids=allowed_jids))
-                if recursive:
-                    # we set all file under the directory as public (if they haven't
-                    # already a permission set), so allowed entities of root directory
-                    # can read them.
-                    namespace = session_data['namespace']
-                    await self._update_dir(
-                        client, requestor_bare, namespace, found_file, 'PUBLIC')
-
-            # job done, we can end the session
-            status = self._c.STATUS.COMPLETED
-            payload = None
-            note = (self._c.NOTE.INFO, _("management session done"))
-            return (payload, status, None, note)
-
-    async def _on_delete_file(self, client, command_elt, session_data, action, node):
-        try:
-            x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
-            command_form = data_form.Form.fromElement(x_elt)
-        except StopIteration:
-            command_form = None
-
-        found_file = session_data.get('found_file')
-        requestor = session_data['requestor']
-        requestor_bare = requestor.userhostJID()
-
-        if command_form is None or len(command_form.fields) == 0:
-            # root request
-            return self._get_root_args()
-
-        elif found_file is None:
-            # file selected, we need confirmation before actually deleting
-            try:
-                found_file = await self._get_file_data(client, session_data, command_form)
-            except WorkflowError as e:
-                return e.err_args
-            if found_file['type'] == C.FILE_TYPE_DIRECTORY:
-                msg = D_("Are you sure to delete directory {name} and all files and "
-                         "directories under it?").format(name=found_file['name'])
-            else:
-                msg = D_("Are you sure to delete file {name}?"
-                    .format(name=found_file['name']))
-            form = data_form.Form("form", title="File Management",
-                                  instructions = [msg],
-                                  formNamespace=NS_FILE_MANAGEMENT)
-            field = data_form.Field(
-                "boolean", "confirm", value=False, required=True,
-                desc="check this box to confirm"
-            )
-            form.addField(field)
-            status = self._c.STATUS.EXECUTING
-            payload = form.toElement()
-            return (payload, status, None, None)
-
-        else:
-            # final phase, we'll do deletion here
-            try:
-                confirmed = C.bool(command_form.fields['confirm'].value)
-            except KeyError:
-                self._c.ad_hoc_error(self._c.ERROR.BAD_PAYLOAD)
-            if not confirmed:
-                note = None
-            else:
-                recursive = found_file['type'] == C.FILE_TYPE_DIRECTORY
-                await self.host.memory.file_delete(
-                    client, requestor_bare, found_file['id'], recursive)
-                note = (self._c.NOTE.INFO, _("file deleted"))
-            status = self._c.STATUS.COMPLETED
-            payload = None
-            return (payload, status, None, note)
-
-    def _update_thumbs(self, extra, thumbnails):
-        extra[C.KEY_THUMBNAILS] = thumbnails
-
-    async def _gen_thumbs(self, client, requestor, namespace, file_data):
-        """Recursively generate thumbnails
-
-        @param file_data(dict): metadata of the file
-        """
-        if file_data['type'] == C.FILE_TYPE_DIRECTORY:
-            sub_files_data = await self.host.memory.get_files(
-                client, requestor, parent=file_data['id'], namespace=namespace)
-            for sub_file_data in sub_files_data:
-                await self._gen_thumbs(client, requestor, namespace, sub_file_data)
-
-        elif file_data['type'] == C.FILE_TYPE_FILE:
-            media_type = file_data['media_type']
-            file_path = os.path.join(self.files_path, file_data['file_hash'])
-            if media_type == 'image':
-                thumbnails = []
-
-                for max_thumb_size in self._t.SIZES:
-                    try:
-                        thumb_size, thumb_id = await self._t.generate_thumbnail(
-                            file_path,
-                            max_thumb_size,
-                            #  we keep thumbnails for 6 months
-                            60 * 60 * 24 * 31 * 6,
-                        )
-                    except Exception as e:
-                        log.warning(_("Can't create thumbnail: {reason}")
-                            .format(reason=e))
-                        break
-                    thumbnails.append({"id": thumb_id, "size": thumb_size})
-
-                await self.host.memory.file_update(
-                    file_data['id'], 'extra',
-                    partial(self._update_thumbs, thumbnails=thumbnails))
-
-                log.info("thumbnails for [{file_name}] generated"
-                    .format(file_name=file_data['name']))
-
-        else:
-            log.warning("unmanaged file type: {type_}".format(type_=file_data['type']))
-
-    async def _on_gen_thumbnails(self, client, command_elt, session_data, action, node):
-        try:
-            x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
-            command_form = data_form.Form.fromElement(x_elt)
-        except StopIteration:
-            command_form = None
-
-        found_file = session_data.get('found_file')
-        requestor = session_data['requestor']
-
-        if command_form is None or len(command_form.fields) == 0:
-            # root request
-            return self._get_root_args()
-
-        elif found_file is None:
-            # file selected, we retrieve it and ask for permissions
-            try:
-                found_file = await self._get_file_data(client, session_data, command_form)
-            except WorkflowError as e:
-                return e.err_args
-
-            log.info("Generating thumbnails as requested")
-            await self._gen_thumbs(client, requestor, found_file['namespace'], found_file)
-
-            # job done, we can end the session
-            status = self._c.STATUS.COMPLETED
-            payload = None
-            note = (self._c.NOTE.INFO, _("thumbnails generated"))
-            return (payload, status, None, note)
-
-    async def _on_quota(self, client, command_elt, session_data, action, node):
-        requestor = session_data['requestor']
-        quota = self.host.plugins["file_sharing"].get_quota(client, requestor)
-        try:
-            size_used = await self.host.memory.file_get_used_space(client, requestor)
-        except exceptions.PermissionError:
-            raise WorkflowError(self._err(_("forbidden")))
-        status = self._c.STATUS.COMPLETED
-        form = data_form.Form("result")
-        form.makeFields({"quota": quota, "user": size_used})
-        payload = form.toElement()
-        note = (
-            self._c.NOTE.INFO,
-            _("You are currently using {size_used} on {size_quota}").format(
-                size_used = utils.get_human_size(size_used),
-                size_quota = (
-                    _("unlimited quota") if quota is None
-                    else utils.get_human_size(quota)
-                )
-            )
-        )
-        return (payload, status, None, note)
--- a/sat/plugins/plugin_dbg_manhole.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,69 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for debugging, using a manhole
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from twisted.conch.insults import insults
-from twisted.conch.telnet import TelnetTransport, TelnetBootstrapProtocol
-from twisted.internet import reactor, protocol, defer
-from twisted.words.protocols.jabber import jid
-from twisted.conch.manhole import ColoredManhole
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Manhole debug plugin",
-    C.PI_IMPORT_NAME: "manhole",
-    C.PI_TYPE: "DEBUG",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "Manhole",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Debug plugin to have a telnet server"""),
-}
-
-
-
-class Manhole(object):
-
-    def __init__(self, host):
-        self.host = host
-        port = int(host.memory.config_get(None, "manhole_debug_dangerous_port_int", 0))
-        if port:
-            self.start_manhole(port)
-
-    def start_manhole(self, port):
-        log.warning(_("/!\\ Manhole debug server activated, be sure to not use it in "
-                      "production, this is dangerous /!\\"))
-        log.info(_("You can connect to manhole server using telnet on port {port}")
-            .format(port=port))
-        f = protocol.ServerFactory()
-        namespace = {
-            "host": self.host,
-            "C": C,
-            "jid": jid,
-            "d": defer.ensureDeferred,
-        }
-        f.protocol = lambda: TelnetTransport(TelnetBootstrapProtocol,
-                                             insults.ServerProtocol,
-                                             ColoredManhole,
-                                             namespace=namespace,
-                                             )
-        reactor.listenTCP(port, f)
--- a/sat/plugins/plugin_exp_command_export.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,167 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin to export commands (experimental)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.words.protocols.jabber import jid
-from twisted.internet import reactor, protocol
-
-from sat.tools import trigger
-from sat.tools.utils import clean_ustr
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Command export plugin",
-    C.PI_IMPORT_NAME: "EXP-COMMANS-EXPORT",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "CommandExport",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of command export"""),
-}
-
-
-class ExportCommandProtocol(protocol.ProcessProtocol):
-    """ Try to register an account with prosody """
-
-    def __init__(self, parent, client, target, options):
-        self.parent = parent
-        self.target = target
-        self.options = options
-        self.client = client
-
-    def _clean(self, data):
-        if not data:
-            log.error("data should not be empty !")
-            return ""
-        decoded = data.decode("utf-8", "ignore")[: -1 if data[-1] == "\n" else None]
-        return clean_ustr(decoded)
-
-    def connectionMade(self):
-        log.info("connectionMade :)")
-
-    def outReceived(self, data):
-        self.client.sendMessage(self.target, {"": self._clean(data)}, no_trigger=True)
-
-    def errReceived(self, data):
-        self.client.sendMessage(self.target, {"": self._clean(data)}, no_trigger=True)
-
-    def processEnded(self, reason):
-        log.info("process finished: %d" % (reason.value.exitCode,))
-        self.parent.removeProcess(self.target, self)
-
-    def write(self, message):
-        self.transport.write(message.encode("utf-8"))
-
-    def bool_option(self, key):
-        """ Get boolean value from options
-        @param key: name of the option
-        @return: True if key exists and set to "true" (case insensitive),
-                 False in all other cases """
-        value = self.options.get(key, "")
-        return value.lower() == "true"
-
-
-class CommandExport(object):
-    """Command export plugin: export a command to an entity"""
-
-    # XXX: This plugin can be potentially dangerous if we don't trust entities linked
-    #      this is specially true if we have other triggers.
-    # FIXME: spawned should be a client attribute, not a class one
-
-    def __init__(self, host):
-        log.info(_("Plugin command export initialization"))
-        self.host = host
-        self.spawned = {}  # key = entity
-        host.trigger.add("message_received", self.message_received_trigger, priority=10000)
-        host.bridge.add_method(
-            "command_export",
-            ".plugin",
-            in_sign="sasasa{ss}s",
-            out_sign="",
-            method=self._export_command,
-        )
-
-    def removeProcess(self, entity, process):
-        """ Called when the process is finished
-        @param entity: jid.JID attached to the process
-        @param process: process to remove"""
-        try:
-            processes_set = self.spawned[(entity, process.client.profile)]
-            processes_set.discard(process)
-            if not processes_set:
-                del (self.spawned[(entity, process.client.profile)])
-        except ValueError:
-            pass
-
-    def message_received_trigger(self, client, message_elt, post_treat):
-        """ Check if source is linked and repeat message, else do nothing  """
-        from_jid = jid.JID(message_elt["from"])
-        spawned_key = (from_jid.userhostJID(), client.profile)
-
-        if spawned_key in self.spawned:
-            try:
-                body = next(message_elt.elements(C.NS_CLIENT, "body"))
-            except StopIteration:
-                # do not block message without body (chat state notification...)
-                return True
-
-            mess_data = str(body) + "\n"
-            processes_set = self.spawned[spawned_key]
-            _continue = False
-            exclusive = False
-            for process in processes_set:
-                process.write(mess_data)
-                _continue &= process.bool_option("continue")
-                exclusive |= process.bool_option("exclusive")
-            if exclusive:
-                raise trigger.SkipOtherTriggers
-            return _continue
-
-        return True
-
-    def _export_command(self, command, args, targets, options, profile_key):
-        """ Export a commands to authorised targets
-        @param command: full path of the command to execute
-        @param args: list of arguments, with command name as first one
-        @param targets: list of allowed entities
-        @param options: export options, a dict which can have the following keys ("true" to set booleans):
-                        - exclusive: if set, skip all other triggers
-                        - loop: if set, restart the command once terminated #TODO
-                        - pty: if set, launch in a pseudo terminal
-                        - continue: continue normal message_received handling
-        """
-        client = self.host.get_client(profile_key)
-        for target in targets:
-            try:
-                _jid = jid.JID(target)
-                if not _jid.user or not _jid.host:
-                    raise jid.InvalidFormat
-                _jid = _jid.userhostJID()
-            except (RuntimeError, jid.InvalidFormat, AttributeError):
-                log.info("invalid target ignored: %s" % (target,))
-                continue
-            process_prot = ExportCommandProtocol(self, client, _jid, options)
-            self.spawned.setdefault((_jid, client.profile), set()).add(process_prot)
-            reactor.spawnProcess(
-                process_prot, command, args, usePTY=process_prot.bool_option("pty")
-            )
--- a/sat/plugins/plugin_exp_invitation.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,350 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin to manage invitations
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional
-from zope.interface import implementer
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from wokkel import disco, iwokkel
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.core.xmpp import SatXMPPEntity
-from sat.tools import utils
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Invitation",
-    C.PI_IMPORT_NAME: "INVITATION",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0329", "XEP-0334", "LIST_INTEREST"],
-    C.PI_RECOMMENDATIONS: ["EMAIL_INVITATION"],
-    C.PI_MAIN: "Invitation",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("Experimental handling of invitations"),
-}
-
-NS_INVITATION = "https://salut-a-toi/protocol/invitation:0"
-INVITATION = '/message/invitation[@xmlns="{ns_invit}"]'.format(
-    ns_invit=NS_INVITATION
-)
-NS_INVITATION_LIST = NS_INVITATION + "#list"
-
-
-class Invitation(object):
-
-    def __init__(self, host):
-        log.info(_("Invitation plugin initialization"))
-        self.host = host
-        self._p = self.host.plugins["XEP-0060"]
-        self._h = self.host.plugins["XEP-0334"]
-        # map from namespace of the invitation to callback handling it
-        self._ns_cb = {}
-
-    def get_handler(self, client):
-        return PubsubInvitationHandler(self)
-
-    def register_namespace(self, namespace, callback):
-        """Register a callback for a namespace
-
-        @param namespace(unicode): namespace handled
-        @param callback(callbable): method handling the invitation
-            For pubsub invitation, it will be called with following arguments:
-                - client
-                - name(unicode, None): name of the event
-                - extra(dict): extra data
-                - service(jid.JID): pubsub service jid
-                - node(unicode): pubsub node
-                - item_id(unicode, None): pubsub item id
-                - item_elt(domish.Element): item of the invitation
-            For file sharing invitation, it will be called with following arguments:
-                - client
-                - name(unicode, None): name of the repository
-                - extra(dict): extra data
-                - service(jid.JID): service jid of the file repository
-                - repos_type(unicode): type of the repository, can be:
-                    - files: generic file sharing
-                    - photos: photos album
-                - namespace(unicode, None): namespace of the repository
-                - path(unicode, None): path of the repository
-        @raise exceptions.ConflictError: this namespace is already registered
-        """
-        if namespace in self._ns_cb:
-            raise exceptions.ConflictError(
-                "invitation namespace {namespace} is already register with {callback}"
-                .format(namespace=namespace, callback=self._ns_cb[namespace]))
-        self._ns_cb[namespace] = callback
-
-    def _generate_base_invitation(self, client, invitee_jid, name, extra):
-        """Generate common mess_data end invitation_elt
-
-        @param invitee_jid(jid.JID): entitee to send invitation to
-        @param name(unicode, None): name of the shared repository
-        @param extra(dict, None): extra data, where key can be:
-            - thumb_url: URL of a thumbnail
-        @return (tuple[dict, domish.Element): mess_data and invitation_elt
-        """
-        mess_data = {
-            "from": client.jid,
-            "to": invitee_jid,
-            "uid": "",
-            "message": {},
-            "type": C.MESS_TYPE_CHAT,
-            "subject": {},
-            "extra": {},
-        }
-        client.generate_message_xml(mess_data)
-        self._h.add_hint_elements(mess_data["xml"], [self._h.HINT_STORE])
-        invitation_elt = mess_data["xml"].addElement("invitation", NS_INVITATION)
-        if name is not None:
-            invitation_elt["name"] = name
-        thumb_url = extra.get('thumb_url')
-        if thumb_url:
-            if not thumb_url.startswith('http'):
-                log.warning(
-                    "only http URLs are allowed for thumbnails, got {url}, ignoring"
-                    .format(url=thumb_url))
-            else:
-                invitation_elt['thumb_url'] = thumb_url
-        return mess_data, invitation_elt
-
-    def send_pubsub_invitation(
-        self,
-        client: SatXMPPEntity,
-        invitee_jid: jid.JID,
-        service: jid.JID,
-        node: str,
-        item_id: Optional[str],
-        name: Optional[str],
-        extra: Optional[dict]
-    ) -> None:
-        """Send an pubsub invitation in a <message> stanza
-
-        @param invitee_jid: entitee to send invitation to
-        @param service: pubsub service
-        @param node: pubsub node
-        @param item_id: pubsub id
-            None when the invitation is for a whole node
-        @param name: see [_generate_base_invitation]
-        @param extra: see [_generate_base_invitation]
-        """
-        if extra is None:
-            extra = {}
-        mess_data, invitation_elt = self._generate_base_invitation(
-            client, invitee_jid, name, extra)
-        pubsub_elt = invitation_elt.addElement("pubsub")
-        pubsub_elt["service"] = service.full()
-        pubsub_elt["node"] = node
-        if item_id is None:
-            try:
-                namespace = extra.pop("namespace")
-            except KeyError:
-                raise exceptions.DataError('"namespace" key is missing in "extra" data')
-            node_data_elt = pubsub_elt.addElement("node_data")
-            node_data_elt["namespace"] = namespace
-            try:
-                node_data_elt.addChild(extra["element"])
-            except KeyError:
-                pass
-        else:
-            pubsub_elt["item"] = item_id
-        if "element" in extra:
-            invitation_elt.addChild(extra.pop("element"))
-        client.send(mess_data["xml"])
-
-    async def send_file_sharing_invitation(
-        self, client, invitee_jid, service, repos_type=None, namespace=None, path=None,
-        name=None, extra=None
-    ):
-        """Send a file sharing invitation in a <message> stanza
-
-        @param invitee_jid(jid.JID): entitee to send invitation to
-        @param service(jid.JID): file sharing service
-        @param repos_type(unicode, None): type of files repository, can be:
-            - None, "files": files sharing
-            - "photos": photos album
-        @param namespace(unicode, None): namespace of the shared repository
-        @param path(unicode, None): path of the shared repository
-        @param name(unicode, None): see [_generate_base_invitation]
-        @param extra(dict, None): see [_generate_base_invitation]
-        """
-        if extra is None:
-            extra = {}
-        li_plg = self.host.plugins["LIST_INTEREST"]
-        li_plg.normalise_file_sharing_service(client, service)
-
-        # FIXME: not the best place to adapt permission, but it's necessary to check them
-        #   for UX
-        try:
-            await self.host.plugins['XEP-0329'].affiliationsSet(
-                client, service, namespace, path, {invitee_jid: "member"}
-            )
-        except Exception as e:
-            log.warning(f"Can't set affiliation: {e}")
-
-        if "thumb_url" not in extra:
-            # we have no thumbnail, we check in our own list of interests if there is one
-            try:
-                item_id = li_plg.get_file_sharing_id(service, namespace, path)
-                own_interest = await li_plg.get(client, item_id)
-            except exceptions.NotFound:
-                log.debug(
-                    "no thumbnail found for file sharing interest at "
-                    f"[{service}/{namespace}]{path}"
-                )
-            else:
-                try:
-                    extra['thumb_url'] = own_interest['thumb_url']
-                except KeyError:
-                    pass
-
-        mess_data, invitation_elt = self._generate_base_invitation(
-            client, invitee_jid, name, extra)
-        file_sharing_elt = invitation_elt.addElement("file_sharing")
-        file_sharing_elt["service"] = service.full()
-        if repos_type is not None:
-            if repos_type not in ("files", "photos"):
-                msg = "unknown repository type: {repos_type}".format(
-                    repos_type=repos_type)
-                log.warning(msg)
-                raise exceptions.DateError(msg)
-            file_sharing_elt["type"] = repos_type
-        if namespace is not None:
-            file_sharing_elt["namespace"] = namespace
-        if path is not None:
-            file_sharing_elt["path"] = path
-        client.send(mess_data["xml"])
-
-    async def _parse_pubsub_elt(self, client, pubsub_elt):
-        try:
-            service = jid.JID(pubsub_elt["service"])
-            node = pubsub_elt["node"]
-        except (RuntimeError, KeyError):
-            raise exceptions.DataError("Bad invitation, ignoring")
-
-        item_id = pubsub_elt.getAttribute("item")
-
-        if item_id is not None:
-            try:
-                items, metadata = await self._p.get_items(
-                    client, service, node, item_ids=[item_id]
-                )
-            except Exception as e:
-                log.warning(_("Can't get item linked with invitation: {reason}").format(
-                            reason=e))
-            try:
-                item_elt = items[0]
-            except IndexError:
-                log.warning(_("Invitation was linking to a non existing item"))
-                raise exceptions.DataError
-
-            try:
-                namespace = item_elt.firstChildElement().uri
-            except Exception as e:
-                log.warning(_("Can't retrieve namespace of invitation: {reason}").format(
-                    reason = e))
-                raise exceptions.DataError
-
-            args = [service, node, item_id, item_elt]
-        else:
-            try:
-                node_data_elt = next(pubsub_elt.elements(NS_INVITATION, "node_data"))
-            except StopIteration:
-                raise exceptions.DataError("Bad invitation, ignoring")
-            namespace = node_data_elt['namespace']
-            args = [service, node, None, node_data_elt]
-
-        return namespace, args
-
-    async def _parse_file_sharing_elt(self, client, file_sharing_elt):
-        try:
-            service = jid.JID(file_sharing_elt["service"])
-        except (RuntimeError, KeyError):
-            log.warning(_("Bad invitation, ignoring"))
-            raise exceptions.DataError
-        repos_type = file_sharing_elt.getAttribute("type", "files")
-        sharing_ns = file_sharing_elt.getAttribute("namespace")
-        path = file_sharing_elt.getAttribute("path")
-        args = [service, repos_type, sharing_ns, path]
-        ns_fis = self.host.get_namespace("fis")
-        return ns_fis, args
-
-    async def on_invitation(self, message_elt, client):
-        log.debug("invitation received [{profile}]".format(profile=client.profile))
-        invitation_elt = message_elt.invitation
-
-        name = invitation_elt.getAttribute("name")
-        extra = {}
-        if invitation_elt.hasAttribute("thumb_url"):
-            extra['thumb_url'] = invitation_elt['thumb_url']
-
-        for elt in invitation_elt.elements():
-            if elt.uri != NS_INVITATION:
-                log.warning("unexpected element: {xml}".format(xml=elt.toXml()))
-                continue
-            if elt.name == "pubsub":
-                method = self._parse_pubsub_elt
-            elif elt.name == "file_sharing":
-                method = self._parse_file_sharing_elt
-            else:
-                log.warning("not implemented invitation element: {xml}".format(
-                    xml = elt.toXml()))
-                continue
-            try:
-                namespace, args = await method(client, elt)
-            except exceptions.DataError:
-                log.warning("Can't parse invitation element: {xml}".format(
-                            xml = elt.toXml()))
-                continue
-
-            try:
-                cb = self._ns_cb[namespace]
-            except KeyError:
-                log.warning(_(
-                    'No handler for namespace "{namespace}", invitation ignored')
-                    .format(namespace=namespace))
-            else:
-                await utils.as_deferred(cb, client, namespace, name, extra, *args)
-
-
-@implementer(iwokkel.IDisco)
-class PubsubInvitationHandler(XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(
-            INVITATION,
-            lambda message_elt: defer.ensureDeferred(
-                self.plugin_parent.on_invitation(message_elt, client=self.parent)
-            ),
-        )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [
-            disco.DiscoFeature(NS_INVITATION),
-        ]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_exp_invitation_file.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,103 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin to send invitations for file sharing
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.core.xmpp import SatXMPPEntity
-from sat.tools.common import data_format
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "File Sharing Invitation",
-    C.PI_IMPORT_NAME: "FILE_SHARING_INVITATION",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0329", "INVITATION"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "FileSharingInvitation",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("Experimental handling of invitations for file sharing"),
-}
-
-
-class FileSharingInvitation:
-
-    def __init__(self, host):
-        log.info(_("File Sharing Invitation plugin initialization"))
-        self.host = host
-        ns_fis = host.get_namespace("fis")
-        host.plugins["INVITATION"].register_namespace(ns_fis, self.on_invitation)
-        host.bridge.add_method(
-            "fis_invite",
-            ".plugin",
-            in_sign="ssssssss",
-            out_sign="",
-            method=self._send_file_sharing_invitation,
-            async_=True
-        )
-
-    def _send_file_sharing_invitation(
-            self, invitee_jid_s, service_s, repos_type=None, namespace=None, path=None,
-            name=None, extra_s='', profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        invitee_jid = jid.JID(invitee_jid_s)
-        service = jid.JID(service_s)
-        extra = data_format.deserialise(extra_s)
-        return defer.ensureDeferred(
-            self.host.plugins["INVITATION"].send_file_sharing_invitation(
-                client, invitee_jid, service, repos_type=repos_type or None,
-                namespace=namespace or None, path=path or None, name=name or None,
-                extra=extra)
-        )
-
-    def on_invitation(
-        self,
-        client: SatXMPPEntity,
-        namespace: str,
-        name: str,
-        extra: dict,
-        service: jid.JID,
-        repos_type: str,
-        sharing_ns: str,
-        path: str
-    ):
-        if repos_type == "files":
-            type_human = _("file sharing")
-        elif repos_type == "photos":
-            type_human = _("photo album")
-        else:
-            log.warning("Unknown repository type: {repos_type}".format(
-                repos_type=repos_type))
-            repos_type = "file"
-            type_human = _("file sharing")
-        log.info(_(
-            '{profile} has received an invitation for a files repository ({type_human}) '
-            'with namespace {sharing_ns!r} at path [{path}]').format(
-            profile=client.profile, type_human=type_human, sharing_ns=sharing_ns,
-                path=path)
-            )
-        return defer.ensureDeferred(
-            self.host.plugins['LIST_INTEREST'].register_file_sharing(
-                client, service, repos_type, sharing_ns, path, name, extra
-            )
-        )
--- a/sat/plugins/plugin_exp_invitation_pubsub.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,169 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin to send invitations for Pubsub
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from twisted.words.xish import domish
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.core.xmpp import SatXMPPEntity
-from sat.tools import utils
-from sat.tools.common import data_format
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Pubsub Invitation",
-    C.PI_IMPORT_NAME: "PUBSUB_INVITATION",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "INVITATION"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "PubsubInvitation",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("Invitations for pubsub based features"),
-}
-
-
-class PubsubInvitation:
-
-    def __init__(self, host):
-        log.info(_("Pubsub Invitation plugin initialization"))
-        self.host = host
-        self._p = host.plugins["XEP-0060"]
-        # namespace to handler map
-        self._ns_handler = {}
-        host.bridge.add_method(
-            "ps_invite",
-            ".plugin",
-            in_sign="sssssss",
-            out_sign="",
-            method=self._send_pubsub_invitation,
-            async_=True
-        )
-
-    def register(
-        self,
-        namespace: str,
-        handler
-    ) -> None:
-        self._ns_handler[namespace] = handler
-        self.host.plugins["INVITATION"].register_namespace(namespace, self.on_invitation)
-
-    def _send_pubsub_invitation(
-            self, invitee_jid_s, service_s, node, item_id=None,
-            name=None, extra_s='', profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        invitee_jid = jid.JID(invitee_jid_s)
-        service = jid.JID(service_s)
-        extra = data_format.deserialise(extra_s)
-        return defer.ensureDeferred(
-            self.invite(
-                client,
-                invitee_jid,
-                service,
-                node,
-                item_id or None,
-                name=name or None,
-                extra=extra
-            )
-        )
-
-    async def invite(
-        self,
-        client: SatXMPPEntity,
-        invitee_jid: jid.JID,
-        service: jid.JID,
-        node: str,
-        item_id: Optional[str] = None,
-        name: str = '',
-        extra: Optional[dict] = None,
-    ) -> None:
-        if extra is None:
-            extra = {}
-        else:
-            namespace = extra.get("namespace")
-            if namespace:
-                try:
-                    handler = self._ns_handler[namespace]
-                    preflight = handler.invite_preflight
-                except KeyError:
-                    pass
-                except AttributeError:
-                    log.debug(f"no invite_preflight method found for {namespace!r}")
-                else:
-                    await utils.as_deferred(
-                        preflight,
-                        client, invitee_jid, service, node, item_id, name, extra
-                    )
-            if item_id is None:
-                item_id = extra.pop("default_item_id", None)
-
-        # we authorize our invitee to see the nodes of interest
-        await self._p.set_node_affiliations(client, service, node, {invitee_jid: "member"})
-        log.debug(f"affiliation set on {service}'s {node!r} node")
-
-        # now we send the invitation
-        self.host.plugins["INVITATION"].send_pubsub_invitation(
-            client,
-            invitee_jid,
-            service,
-            node,
-            item_id,
-            name=name or None,
-            extra=extra
-        )
-
-    async def on_invitation(
-        self,
-        client: SatXMPPEntity,
-        namespace: str,
-        name: str,
-        extra: dict,
-        service: jid.JID,
-        node: str,
-        item_id: Optional[str],
-        item_elt: domish.Element
-    ) -> None:
-        if extra is None:
-            extra = {}
-        try:
-            handler = self._ns_handler[namespace]
-            preflight = handler.on_invitation_preflight
-        except KeyError:
-            pass
-        except AttributeError:
-            log.debug(f"no on_invitation_preflight method found for {namespace!r}")
-        else:
-            await utils.as_deferred(
-                preflight,
-                client, namespace, name, extra, service, node, item_id, item_elt
-            )
-            if item_id is None:
-                item_id = extra.pop("default_item_id", None)
-        creator = extra.pop("creator", False)
-        element = extra.pop("element", None)
-        if not name:
-            name = extra.pop("name", "")
-
-        return await self.host.plugins['LIST_INTEREST'].register_pubsub(
-            client, namespace, service, node, item_id, creator,
-            name, element, extra)
--- a/sat/plugins/plugin_exp_jingle_stream.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,305 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing pipes (experimental)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import errno
-from zope import interface
-from twisted.words.xish import domish
-from twisted.words.protocols.jabber import jid
-from twisted.internet import defer
-from twisted.internet import protocol
-from twisted.internet import endpoints
-from twisted.internet import reactor
-from twisted.internet import error
-from twisted.internet import interfaces
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-from sat.tools import xml_tools
-from sat.tools import stream
-
-
-log = getLogger(__name__)
-
-NS_STREAM = "http://salut-a-toi.org/protocol/stream"
-SECURITY_LIMIT = 30
-START_PORT = 8888
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Jingle Stream Plugin",
-    C.PI_IMPORT_NAME: "STREAM",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0166"],
-    C.PI_MAIN: "JingleStream",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Jingle Stream plugin"""),
-}
-
-CONFIRM = D_("{peer} wants to send you a stream, do you accept ?")
-CONFIRM_TITLE = D_("Stream Request")
-
-
-class StreamProtocol(protocol.Protocol):
-    def __init__(self):
-        self.pause = False
-
-    def set_pause(self, paused):
-        # in Python 2.x, Twisted classes are old style
-        # so we can use property and setter
-        if paused:
-            if not self.pause:
-                self.transport.pauseProducing()
-                self.pause = True
-        else:
-            if self.pause:
-                self.transport.resumeProducing()
-                self.pause = False
-
-    def disconnect(self):
-        self.transport.loseConnection()
-
-    def connectionMade(self):
-        if self.factory.client_conn is not None:
-            self.transport.loseConnection()
-        self.factory.set_client_conn(self)
-
-    def dataReceived(self, data):
-        self.factory.write_to_consumer(data)
-
-    def sendData(self, data):
-        self.transport.write(data)
-
-    def connectionLost(self, reason):
-        if self.factory.client_conn != self:
-            # only the first connected client_conn is relevant
-            return
-
-        if reason.type == error.ConnectionDone:
-            self.factory.stream_finished()
-        else:
-            self.factory.stream_failed(reason)
-
-
-@interface.implementer(stream.IStreamProducer)
-@interface.implementer(interfaces.IPushProducer)
-@interface.implementer(interfaces.IConsumer)
-class StreamFactory(protocol.Factory):
-    protocol = StreamProtocol
-    consumer = None
-    producer = None
-    deferred = None
-
-    def __init__(self):
-        self.client_conn = None
-
-    def set_client_conn(self, stream_protocol):
-        # in Python 2.x, Twisted classes are old style
-        # so we can use property and setter
-        assert self.client_conn is None
-        self.client_conn = stream_protocol
-        if self.consumer is None:
-            self.client_conn.set_pause(True)
-
-    def start_stream(self, consumer):
-        if self.consumer is not None:
-            raise exceptions.InternalError(
-                _("stream can't be used with multiple consumers")
-            )
-        assert self.deferred is None
-        self.consumer = consumer
-        consumer.registerProducer(self, True)
-        self.deferred = defer.Deferred()
-        if self.client_conn is not None:
-            self.client_conn.set_pause(False)
-        return self.deferred
-
-    def stream_finished(self):
-        self.client_conn = None
-        if self.consumer:
-            self.consumer.unregisterProducer()
-            self.port_listening.stopListening()
-        self.deferred.callback(None)
-
-    def stream_failed(self, failure_):
-        self.client_conn = None
-        if self.consumer:
-            self.consumer.unregisterProducer()
-            self.port_listening.stopListening()
-            self.deferred.errback(failure_)
-        elif self.producer:
-            self.producer.stopProducing()
-
-    def stop_stream(self):
-        if self.client_conn is not None:
-            self.client_conn.disconnect()
-
-    def registerProducer(self, producer, streaming):
-        self.producer = producer
-
-    def pauseProducing(self):
-        self.client_conn.set_pause(True)
-
-    def resumeProducing(self):
-        self.client_conn.set_pause(False)
-
-    def stopProducing(self):
-        if self.client_conn:
-            self.client_conn.disconnect()
-
-    def write(self, data):
-        try:
-            self.client_conn.sendData(data)
-        except AttributeError:
-            log.warning(_("No client connected, can't send data"))
-
-    def write_to_consumer(self, data):
-        self.consumer.write(data)
-
-
-class JingleStream(object):
-    """This non standard jingle application send byte stream"""
-
-    def __init__(self, host):
-        log.info(_("Plugin Stream initialization"))
-        self.host = host
-        self._j = host.plugins["XEP-0166"]  # shortcut to access jingle
-        self._j.register_application(NS_STREAM, self)
-        host.bridge.add_method(
-            "stream_out",
-            ".plugin",
-            in_sign="ss",
-            out_sign="s",
-            method=self._stream_out,
-            async_=True,
-        )
-
-    # jingle callbacks
-
-    def _stream_out(self, to_jid_s, profile_key):
-        client = self.host.get_client(profile_key)
-        return defer.ensureDeferred(self.stream_out(client, jid.JID(to_jid_s)))
-
-    async def stream_out(self, client, to_jid):
-        """send a stream
-
-        @param peer_jid(jid.JID): recipient
-        @return: an unique id to identify the transfer
-        """
-        port = START_PORT
-        factory = StreamFactory()
-        while True:
-            endpoint = endpoints.TCP4ServerEndpoint(reactor, port)
-            try:
-                port_listening = await endpoint.listen(factory)
-            except error.CannotListenError as e:
-                if e.socketError.errno == errno.EADDRINUSE:
-                    port += 1
-                else:
-                    raise e
-            else:
-                factory.port_listening = port_listening
-                break
-        # we don't want to wait for IQ result of initiate
-        defer.ensureDeferred(self._j.initiate(
-            client,
-            to_jid,
-            [
-                {
-                    "app_ns": NS_STREAM,
-                    "senders": self._j.ROLE_INITIATOR,
-                    "app_kwargs": {"stream_object": factory},
-                }
-            ],
-        ))
-        return str(port)
-
-    def jingle_session_init(self, client, session, content_name, stream_object):
-        content_data = session["contents"][content_name]
-        application_data = content_data["application_data"]
-        assert "stream_object" not in application_data
-        application_data["stream_object"] = stream_object
-        desc_elt = domish.Element((NS_STREAM, "description"))
-        return desc_elt
-
-    @defer.inlineCallbacks
-    def jingle_request_confirmation(self, client, action, session, content_name, desc_elt):
-        """This method request confirmation for a jingle session"""
-        content_data = session["contents"][content_name]
-        if content_data["senders"] not in (
-            self._j.ROLE_INITIATOR,
-            self._j.ROLE_RESPONDER,
-        ):
-            log.warning("Bad sender, assuming initiator")
-            content_data["senders"] = self._j.ROLE_INITIATOR
-
-        confirm_data = yield xml_tools.defer_dialog(
-            self.host,
-            _(CONFIRM).format(peer=session["peer_jid"].full()),
-            _(CONFIRM_TITLE),
-            type_=C.XMLUI_DIALOG_CONFIRM,
-            action_extra={
-                "from_jid": session["peer_jid"].full(),
-                "type": "STREAM",
-            },
-            security_limit=SECURITY_LIMIT,
-            profile=client.profile,
-        )
-
-        if not C.bool(confirm_data["answer"]):
-            defer.returnValue(False)
-        try:
-            port = int(confirm_data["port"])
-        except (ValueError, KeyError):
-            raise exceptions.DataError(_("given port is invalid"))
-        endpoint = endpoints.TCP4ClientEndpoint(reactor, "localhost", port)
-        factory = StreamFactory()
-        yield endpoint.connect(factory)
-        content_data["stream_object"] = factory
-        finished_d = content_data["finished_d"] = defer.Deferred()
-        args = [client, session, content_name, content_data]
-        finished_d.addCallbacks(self._finished_cb, self._finished_eb, args, None, args)
-        defer.returnValue(True)
-
-    def jingle_handler(self, client, action, session, content_name, desc_elt):
-        content_data = session["contents"][content_name]
-        application_data = content_data["application_data"]
-        if action in (self._j.A_ACCEPTED_ACK, self._j.A_SESSION_INITIATE):
-            pass
-        elif action == self._j.A_SESSION_ACCEPT:
-            assert not "stream_object" in content_data
-            content_data["stream_object"] = application_data["stream_object"]
-            finished_d = content_data["finished_d"] = defer.Deferred()
-            args = [client, session, content_name, content_data]
-            finished_d.addCallbacks(self._finished_cb, self._finished_eb, args, None, args)
-        else:
-            log.warning("FIXME: unmanaged action {}".format(action))
-        return desc_elt
-
-    def _finished_cb(self, __, client, session, content_name, content_data):
-        log.info("Pipe transfer completed")
-        self._j.content_terminate(client, session, content_name)
-        content_data["stream_object"].stop_stream()
-
-    def _finished_eb(self, failure, client, session, content_name, content_data):
-        log.warning("Error while streaming pipe: {}".format(failure))
-        self._j.content_terminate(
-            client, session, content_name, reason=self._j.REASON_FAILED_TRANSPORT
-        )
-        content_data["stream_object"].stop_stream()
--- a/sat/plugins/plugin_exp_lang_detect.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,97 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin to detect language (experimental)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core import exceptions
-
-try:
-    from langid.langid import LanguageIdentifier, model
-except ImportError:
-    raise exceptions.MissingModule(
-        'Missing module langid, please download/install it with "pip install langid")'
-    )
-
-identifier = LanguageIdentifier.from_modelstring(model, norm_probs=False)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Language detection plugin",
-    C.PI_IMPORT_NAME: "EXP-LANG-DETECT",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "LangDetect",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Detect and set message language when unknown"""),
-}
-
-CATEGORY = D_("Misc")
-NAME = "lang_detect"
-LABEL = D_("language detection")
-PARAMS = """
-    <params>
-    <individual>
-    <category name="{category_name}">
-        <param name="{name}" label="{label}" type="bool" value="true" />
-    </category>
-    </individual>
-    </params>
-    """.format(
-    category_name=CATEGORY, name=NAME, label=_(LABEL)
-)
-
-
-class LangDetect(object):
-    def __init__(self, host):
-        log.info(_("Language detection plugin initialization"))
-        self.host = host
-        host.memory.update_params(PARAMS)
-        host.trigger.add("message_received", self.message_received_trigger)
-        host.trigger.add("sendMessage", self.message_send_trigger)
-
-    def add_language(self, mess_data):
-        message = mess_data["message"]
-        if len(message) == 1 and list(message.keys())[0] == "":
-            msg = list(message.values())[0].strip()
-            if msg:
-                lang = identifier.classify(msg)[0]
-                mess_data["message"] = {lang: msg}
-        return mess_data
-
-    def message_received_trigger(self, client, message_elt, post_treat):
-        """ Check if source is linked and repeat message, else do nothing  """
-
-        lang_detect = self.host.memory.param_get_a(
-            NAME, CATEGORY, profile_key=client.profile
-        )
-        if lang_detect:
-            post_treat.addCallback(self.add_language)
-        return True
-
-    def message_send_trigger(self, client, data, pre_xml_treatments, post_xml_treatments):
-        lang_detect = self.host.memory.param_get_a(
-            NAME, CATEGORY, profile_key=client.profile
-        )
-        if lang_detect:
-            self.add_language(data)
-        return True
--- a/sat/plugins/plugin_exp_list_of_interest.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,321 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin to detect language (experimental)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.xmpp import SatXMPPEntity
-from sat.core import exceptions
-from sat.core.log import getLogger
-from sat.tools.common import data_format
-from sat.tools.common import uri
-from wokkel import disco, iwokkel, pubsub
-from zope.interface import implementer
-from twisted.internet import defer
-from twisted.words.protocols.jabber import error as jabber_error, jid
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "List of Interest",
-    C.PI_IMPORT_NAME: "LIST_INTEREST",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0329", "XEP-0106"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "ListInterest",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("Experimental handling of interesting XMPP locations"),
-}
-
-NS_LIST_INTEREST = "https://salut-a-toi/protocol/list-interest:0"
-
-
-class ListInterest(object):
-    namespace = NS_LIST_INTEREST
-
-    def __init__(self, host):
-        log.info(_("List of Interest plugin initialization"))
-        self.host = host
-        self._p = self.host.plugins["XEP-0060"]
-        host.bridge.add_method(
-            "interests_list",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="aa{ss}",
-            method=self._list_interests,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "interests_file_sharing_register",
-            ".plugin",
-            in_sign="sssssss",
-            out_sign="",
-            method=self._register_file_sharing,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "interest_retract",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._interest_retract,
-            async_=True,
-        )
-
-    def get_handler(self, client):
-        return ListInterestHandler(self)
-
-    @defer.inlineCallbacks
-    def createNode(self, client):
-        try:
-            # TODO: check auto-create, no need to create node first if available
-            options = {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST}
-            yield self._p.createNode(
-                client,
-                client.jid.userhostJID(),
-                nodeIdentifier=NS_LIST_INTEREST,
-                options=options,
-            )
-        except jabber_error.StanzaError as e:
-            if e.condition == "conflict":
-                log.debug(_("requested node already exists"))
-
-    async def register_pubsub(self, client, namespace, service, node, item_id=None,
-                       creator=False, name=None, element=None, extra=None):
-        """Register an interesting element in personal list
-
-        @param namespace(unicode): namespace of the interest
-            this is used as a cache, to avoid the need to retrieve the item only to get
-            its namespace
-        @param service(jid.JID): target pubsub service
-        @param node(unicode): target pubsub node
-        @param item_id(unicode, None): target pubsub id
-        @param creator(bool): True if client's profile is the creator of the node
-            This is used a cache, to avoid the need to retrieve affiliations
-        @param name(unicode, None): name of the interest
-        @param element(domish.Element, None): element to attach
-            may be used to cache some extra data
-        @param extra(dict, None): extra data, key can be:
-            - thumb_url: http(s) URL of a thumbnail
-        """
-        if extra is None:
-            extra = {}
-        await self.createNode(client)
-        interest_elt = domish.Element((NS_LIST_INTEREST, "interest"))
-        interest_elt["namespace"] = namespace
-        if name is not None:
-            interest_elt['name'] = name
-        thumb_url = extra.get('thumb_url')
-        if thumb_url:
-            interest_elt['thumb_url'] = thumb_url
-        pubsub_elt = interest_elt.addElement("pubsub")
-        pubsub_elt["service"] = service.full()
-        pubsub_elt["node"] = node
-        if item_id is not None:
-            pubsub_elt["item"] = item_id
-        if creator:
-            pubsub_elt["creator"] = C.BOOL_TRUE
-        if element is not None:
-            pubsub_elt.addChild(element)
-        uri_kwargs = {
-            "path": service.full(),
-            "node": node
-        }
-        if item_id:
-            uri_kwargs['id'] = item_id
-        interest_uri = uri.build_xmpp_uri("pubsub", **uri_kwargs)
-        # we use URI of the interest as item id to avoid duplicates
-        item_elt = pubsub.Item(interest_uri, payload=interest_elt)
-        await self._p.publish(
-            client, client.jid.userhostJID(), NS_LIST_INTEREST, items=[item_elt]
-        )
-
-    def _register_file_sharing(
-        self, service, repos_type, namespace, path, name, extra_raw,
-        profile
-    ):
-        client = self.host.get_client(profile)
-        extra = data_format.deserialise(extra_raw)
-
-        return defer.ensureDeferred(self.register_file_sharing(
-            client, jid.JID(service), repos_type or None, namespace or None, path or None,
-            name or None, extra
-        ))
-
-    def normalise_file_sharing_service(self, client, service):
-        # FIXME: Q&D fix as the bare file sharing service JID will lead to user own
-        #   repository, which thus would not be the same for the host and the guest.
-        #   By specifying the user part, we for the use of the host repository.
-        #   A cleaner way should be implemented
-        if service.user is None:
-            service.user = self.host.plugins['XEP-0106'].escape(client.jid.user)
-
-    def get_file_sharing_id(self, service, namespace, path):
-        return f"{service}_{namespace or ''}_{path or ''}"
-
-    async def register_file_sharing(
-            self, client, service, repos_type=None, namespace=None, path=None, name=None,
-            extra=None):
-        """Register an interesting file repository in personal list
-
-        @param service(jid.JID): service of the file repository
-        @param repos_type(unicode): type of the repository
-        @param namespace(unicode, None): namespace of the repository
-        @param path(unicode, None): path of the repository
-        @param name(unicode, None): name of the repository
-        @param extra(dict, None): same as [register_pubsub]
-        """
-        if extra is None:
-            extra = {}
-        self.normalise_file_sharing_service(client, service)
-        await self.createNode(client)
-        item_id = self.get_file_sharing_id(service, namespace, path)
-        interest_elt = domish.Element((NS_LIST_INTEREST, "interest"))
-        interest_elt["namespace"] = self.host.get_namespace("fis")
-        if name is not None:
-            interest_elt['name'] = name
-        thumb_url = extra.get('thumb_url')
-        if thumb_url:
-            interest_elt['thumb_url'] = thumb_url
-
-        file_sharing_elt = interest_elt.addElement("file_sharing")
-        file_sharing_elt["service"] = service.full()
-        if repos_type is not None:
-            file_sharing_elt["type"] = repos_type
-        if namespace is not None:
-            file_sharing_elt["namespace"] = namespace
-        if path is not None:
-            file_sharing_elt["path"] = path
-        item_elt = pubsub.Item(item_id, payload=interest_elt)
-        await self._p.publish(
-            client, client.jid.userhostJID(), NS_LIST_INTEREST, items=[item_elt]
-        )
-
-    def _list_interests_serialise(self, interests_data):
-        interests = []
-        for item_elt in interests_data[0]:
-            interest_data = {"id": item_elt['id']}
-            interest_elt = item_elt.interest
-            if interest_elt.hasAttribute('namespace'):
-                interest_data['namespace'] = interest_elt.getAttribute('namespace')
-            if interest_elt.hasAttribute('name'):
-                interest_data['name'] = interest_elt.getAttribute('name')
-            if interest_elt.hasAttribute('thumb_url'):
-                interest_data['thumb_url'] = interest_elt.getAttribute('thumb_url')
-            elt = interest_elt.firstChildElement()
-            if elt.uri != NS_LIST_INTEREST:
-                log.warning("unexpected child element, ignoring: {xml}".format(
-                    xml = elt.toXml()))
-                continue
-            if elt.name == 'pubsub':
-                interest_data.update({
-                    "type": "pubsub",
-                    "service": elt['service'],
-                    "node": elt['node'],
-                })
-                for attr in ('item', 'creator'):
-                    if elt.hasAttribute(attr):
-                        interest_data[attr] = elt[attr]
-            elif elt.name == 'file_sharing':
-                interest_data.update({
-                    "type": "file_sharing",
-                    "service": elt['service'],
-                })
-                if elt.hasAttribute('type'):
-                    interest_data['subtype'] = elt['type']
-                for attr in ('files_namespace', 'path'):
-                    if elt.hasAttribute(attr):
-                        interest_data[attr] = elt[attr]
-            else:
-                log.warning("unknown element, ignoring: {xml}".format(xml=elt.toXml()))
-                continue
-            interests.append(interest_data)
-
-        return interests
-
-    def _list_interests(self, service, node, namespace, profile):
-        service = jid.JID(service) if service else None
-        node = node or None
-        namespace = namespace or None
-        client = self.host.get_client(profile)
-        d = defer.ensureDeferred(self.list_interests(client, service, node, namespace))
-        d.addCallback(self._list_interests_serialise)
-        return d
-
-    async def list_interests(self, client, service=None, node=None, namespace=None):
-        """Retrieve list of interests
-
-        @param service(jid.JID, None): service to use
-            None to use own PEP
-        @param node(unicode, None): node to use
-            None to use default node
-        @param namespace(unicode, None): filter interests of this namespace
-            None to retrieve all interests
-        @return: same as [XEP_0060.get_items]
-        """
-        # TODO: if a MAM filter were available, it would improve performances
-        if not node:
-            node = NS_LIST_INTEREST
-        items, metadata = await self._p.get_items(client, service, node)
-        if namespace is not None:
-            filtered_items = []
-            for item in items:
-                try:
-                    interest_elt = next(item.elements(NS_LIST_INTEREST, "interest"))
-                except StopIteration:
-                    log.warning(_("Missing interest element: {xml}").format(
-                        xml=item.toXml()))
-                    continue
-                if interest_elt.getAttribute("namespace") == namespace:
-                    filtered_items.append(item)
-            items = filtered_items
-
-        return (items, metadata)
-
-    def _interest_retract(self, service_s, item_id, profile_key):
-        d = self._p._retract_item(
-            service_s, NS_LIST_INTEREST, item_id, True, profile_key)
-        d.addCallback(lambda __: None)
-        return d
-
-    async def get(self, client: SatXMPPEntity, item_id: str) -> dict:
-        """Retrieve a specific interest in profile's list"""
-        items_data = await self._p.get_items(client, None, NS_LIST_INTEREST, item_ids=[item_id])
-        try:
-            return self._list_interests_serialise(items_data)[0]
-        except IndexError:
-            raise exceptions.NotFound
-
-
-@implementer(iwokkel.IDisco)
-class ListInterestHandler(XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [
-            disco.DiscoFeature(NS_LIST_INTEREST),
-        ]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_exp_parrot.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,201 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for parrot mode (experimental)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.words.protocols.jabber import jid
-
-from sat.core.exceptions import UnknownEntityError
-
-# from sat.tools import trigger
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Parrot Plugin",
-    C.PI_IMPORT_NAME: "EXP-PARROT",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0045"],
-    C.PI_RECOMMENDATIONS: [C.TEXT_CMDS],
-    C.PI_MAIN: "Exp_Parrot",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _(
-        """Implementation of parrot mode (repeat messages between 2 entities)"""
-    ),
-}
-
-
-class Exp_Parrot(object):
-    """Parrot mode plugin: repeat messages from one entity or MUC room to another one"""
-
-    # XXX: This plugin can be potentially dangerous if we don't trust entities linked
-    #      this is specially true if we have other triggers.
-    #      send_message_trigger avoid other triggers execution, it's deactivated to allow
-    #      /unparrot command in text commands plugin.
-    # FIXME: potentially unsecure, specially with e2e encryption
-
-    def __init__(self, host):
-        log.info(_("Plugin Parrot initialization"))
-        self.host = host
-        host.trigger.add("message_received", self.message_received_trigger, priority=100)
-        # host.trigger.add("sendMessage", self.send_message_trigger, priority=100)
-        try:
-            self.host.plugins[C.TEXT_CMDS].register_text_commands(self)
-        except KeyError:
-            log.info(_("Text commands not available"))
-
-    # def send_message_trigger(self, client, mess_data, treatments):
-    #    """ Deactivate other triggers if recipient is in parrot links """
-    #    try:
-    #        _links = client.parrot_links
-    #    except AttributeError:
-    #        return True
-    #
-    #    if mess_data['to'].userhostJID() in _links.values():
-    #        log.debug("Parrot link detected, skipping other triggers")
-    #        raise trigger.SkipOtherTriggers
-
-    def message_received_trigger(self, client, message_elt, post_treat):
-        """ Check if source is linked and repeat message, else do nothing  """
-        # TODO: many things are not repeated (subject, thread, etc)
-        from_jid = message_elt["from"]
-
-        try:
-            _links = client.parrot_links
-        except AttributeError:
-            return True
-
-        if not from_jid.userhostJID() in _links:
-            return True
-
-        message = {}
-        for e in message_elt.elements(C.NS_CLIENT, "body"):
-            body = str(e)
-            lang = e.getAttribute("lang") or ""
-
-            try:
-                entity_type = self.host.memory.entity_data_get(
-                    client, from_jid, [C.ENTITY_TYPE])[C.ENTITY_TYPE]
-            except (UnknownEntityError, KeyError):
-                entity_type = "contact"
-            if entity_type == C.ENTITY_TYPE_MUC:
-                src_txt = from_jid.resource
-                if src_txt == self.host.plugins["XEP-0045"].get_room_nick(
-                    client, from_jid.userhostJID()
-                ):
-                    # we won't repeat our own messages
-                    return True
-            else:
-                src_txt = from_jid.user
-            message[lang] = "[{}] {}".format(src_txt, body)
-
-            linked = _links[from_jid.userhostJID()]
-
-            client.sendMessage(
-                jid.JID(str(linked)), message, None, "auto", no_trigger=True
-            )
-
-        return True
-
-    def add_parrot(self, client, source_jid, dest_jid):
-        """Add a parrot link from one entity to another one
-
-        @param source_jid: entity from who messages will be repeated
-        @param dest_jid: entity where the messages will be repeated
-        """
-        try:
-            _links = client.parrot_links
-        except AttributeError:
-            _links = client.parrot_links = {}
-
-        _links[source_jid.userhostJID()] = dest_jid
-        log.info(
-            "Parrot mode: %s will be repeated to %s"
-            % (source_jid.userhost(), str(dest_jid))
-        )
-
-    def remove_parrot(self, client, source_jid):
-        """Remove parrot link
-
-        @param source_jid: this entity will no more be repeated
-        """
-        try:
-            del client.parrot_links[source_jid.userhostJID()]
-        except (AttributeError, KeyError):
-            pass
-
-    def cmd_parrot(self, client, mess_data):
-        """activate Parrot mode between 2 entities, in both directions."""
-        log.debug("Catched parrot command")
-        txt_cmd = self.host.plugins[C.TEXT_CMDS]
-
-        try:
-            link_left_jid = jid.JID(mess_data["unparsed"].strip())
-            if not link_left_jid.user or not link_left_jid.host:
-                raise jid.InvalidFormat
-        except (RuntimeError, jid.InvalidFormat, AttributeError):
-            txt_cmd.feed_back(
-                client, "Can't activate Parrot mode for invalid jid", mess_data
-            )
-            return False
-
-        link_right_jid = mess_data["to"]
-
-        self.add_parrot(client, link_left_jid, link_right_jid)
-        self.add_parrot(client, link_right_jid, link_left_jid)
-
-        txt_cmd.feed_back(
-            client,
-            "Parrot mode activated for {}".format(str(link_left_jid)),
-            mess_data,
-        )
-
-        return False
-
-    def cmd_unparrot(self, client, mess_data):
-        """remove Parrot mode between 2 entities, in both directions."""
-        log.debug("Catched unparrot command")
-        txt_cmd = self.host.plugins[C.TEXT_CMDS]
-
-        try:
-            link_left_jid = jid.JID(mess_data["unparsed"].strip())
-            if not link_left_jid.user or not link_left_jid.host:
-                raise jid.InvalidFormat
-        except jid.InvalidFormat:
-            txt_cmd.feed_back(
-                client, "Can't deactivate Parrot mode for invalid jid", mess_data
-            )
-            return False
-
-        link_right_jid = mess_data["to"]
-
-        self.remove_parrot(client, link_left_jid)
-        self.remove_parrot(client, link_right_jid)
-
-        txt_cmd.feed_back(
-            client,
-            "Parrot mode deactivated for {} and {}".format(
-                str(link_left_jid), str(link_right_jid)
-            ),
-            mess_data,
-        )
-
-        return False
--- a/sat/plugins/plugin_exp_pubsub_admin.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,94 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin to send pubsub requests with administrator privilege
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.tools.common import data_format
-from twisted.words.protocols.jabber import jid
-from wokkel import pubsub
-from wokkel import generic
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Pubsub Administrator",
-    C.PI_IMPORT_NAME: "PUBSUB_ADMIN",
-    C.PI_TYPE: C.PLUG_TYPE_EXP,
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "PubsubAdmin",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""\Implementation of Pubsub Administrator
-This allows a pubsub administrator to overwrite completly items, including publisher.
-Specially useful when importing a node."""),
-}
-
-NS_PUBSUB_ADMIN = "https://salut-a-toi.org/spec/pubsub_admin:0"
-
-
-class PubsubAdmin(object):
-
-    def __init__(self, host):
-        self.host = host
-        host.bridge.add_method(
-            "ps_admin_items_send",
-            ".plugin",
-            in_sign="ssasss",
-            out_sign="as",
-            method=self._publish,
-            async_=True,
-        )
-
-    def _publish(self, service, nodeIdentifier, items, extra=None,
-                 profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        service = None if not service else jid.JID(service)
-        extra = data_format.deserialise(extra)
-        items = [generic.parseXml(i.encode('utf-8')) for i in items]
-        return self.publish(
-            client, service, nodeIdentifier, items, extra
-        )
-
-    def _send_cb(self, iq_result):
-        publish_elt = iq_result.admin.pubsub.publish
-        ids = []
-        for item_elt in publish_elt.elements(pubsub.NS_PUBSUB, 'item'):
-            ids.append(item_elt['id'])
-        return ids
-
-    def publish(self, client, service, nodeIdentifier, items, extra=None):
-        for item in items:
-            if item.name != 'item' or item.uri != pubsub.NS_PUBSUB:
-                raise exceptions.DataError(
-                    'Invalid element, a pubsub item is expected: {xml}'.format(
-                    xml=item.toXml()))
-        iq_elt = client.IQ()
-        iq_elt['to'] = service.full() if service else client.jid.userhost()
-        admin_elt = iq_elt.addElement((NS_PUBSUB_ADMIN, 'admin'))
-        pubsub_elt = admin_elt.addElement((pubsub.NS_PUBSUB, 'pubsub'))
-        publish_elt = pubsub_elt.addElement('publish')
-        publish_elt['node'] = nodeIdentifier
-        for item in items:
-            publish_elt.addChild(item)
-        d = iq_elt.send()
-        d.addCallback(self._send_cb)
-        return d
--- a/sat/plugins/plugin_exp_pubsub_hook.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,286 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Pubsub Hooks
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-from sat.memory import persistent
-from twisted.words.protocols.jabber import jid
-from twisted.internet import defer
-
-log = getLogger(__name__)
-
-NS_PUBSUB_HOOK = "PUBSUB_HOOK"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "PubSub Hook",
-    C.PI_IMPORT_NAME: NS_PUBSUB_HOOK,
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060"],
-    C.PI_MAIN: "PubsubHook",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _(
-        """Experimental plugin to launch on action on Pubsub notifications"""
-    ),
-}
-
-#  python module
-HOOK_TYPE_PYTHON = "python"
-# python file path
-HOOK_TYPE_PYTHON_FILE = "python_file"
-# python code directly
-HOOK_TYPE_PYTHON_CODE = "python_code"
-HOOK_TYPES = (HOOK_TYPE_PYTHON, HOOK_TYPE_PYTHON_FILE, HOOK_TYPE_PYTHON_CODE)
-
-
-class PubsubHook(object):
-    def __init__(self, host):
-        log.info(_("PubSub Hook initialization"))
-        self.host = host
-        self.node_hooks = {}  # keep track of the number of hooks per node (for all profiles)
-        host.bridge.add_method(
-            "ps_hook_add", ".plugin", in_sign="ssssbs", out_sign="", method=self._addHook
-        )
-        host.bridge.add_method(
-            "ps_hook_remove",
-            ".plugin",
-            in_sign="sssss",
-            out_sign="i",
-            method=self._removeHook,
-        )
-        host.bridge.add_method(
-            "ps_hook_list",
-            ".plugin",
-            in_sign="s",
-            out_sign="aa{ss}",
-            method=self._list_hooks,
-        )
-
-    @defer.inlineCallbacks
-    def profile_connected(self, client):
-        hooks = client._hooks = persistent.PersistentBinaryDict(
-            NS_PUBSUB_HOOK, client.profile
-        )
-        client._hooks_temporary = {}
-        yield hooks.load()
-        for node in hooks:
-            self._install_node_manager(client, node)
-
-    def profile_disconnected(self, client):
-        for node in client._hooks:
-            self._remove_node_manager(client, node)
-
-    def _install_node_manager(self, client, node):
-        if node in self.node_hooks:
-            log.debug(_("node manager already set for {node}").format(node=node))
-            self.node_hooks[node] += 1
-        else:
-            # first hook on this node
-            self.host.plugins["XEP-0060"].add_managed_node(
-                node, items_cb=self._items_received
-            )
-            self.node_hooks[node] = 0
-            log.info(_("node manager installed on {node}").format(node=node))
-
-    def _remove_node_manager(self, client, node):
-        try:
-            self.node_hooks[node] -= 1
-        except KeyError:
-            log.error(_("trying to remove a {node} without hook").format(node=node))
-        else:
-            if self.node_hooks[node] == 0:
-                del self.node_hooks[node]
-                self.host.plugins["XEP-0060"].remove_managed_node(node, self._items_received)
-                log.debug(_("hook removed"))
-            else:
-                log.debug(_("node still needed for an other hook"))
-
-    def install_hook(self, client, service, node, hook_type, hook_arg, persistent):
-        if hook_type not in HOOK_TYPES:
-            raise exceptions.DataError(
-                _("{hook_type} is not handled").format(hook_type=hook_type)
-            )
-        if hook_type != HOOK_TYPE_PYTHON_FILE:
-            raise NotImplementedError(
-                _("{hook_type} hook type not implemented yet").format(
-                    hook_type=hook_type
-                )
-            )
-        self._install_node_manager(client, node)
-        hook_data = {"service": service, "type": hook_type, "arg": hook_arg}
-
-        if persistent:
-            hooks_list = client._hooks.setdefault(node, [])
-            hooks_list.append(hook_data)
-            client._hooks.force(node)
-        else:
-            hooks_list = client._hooks_temporary.setdefault(node, [])
-            hooks_list.append(hook_data)
-
-        log.info(
-            _("{persistent} hook installed on {node} for {profile}").format(
-                persistent=_("persistent") if persistent else _("temporary"),
-                node=node,
-                profile=client.profile,
-            )
-        )
-
-    def _items_received(self, client, itemsEvent):
-        node = itemsEvent.nodeIdentifier
-        for hooks in (client._hooks, client._hooks_temporary):
-            if node not in hooks:
-                continue
-            hooks_list = hooks[node]
-            for hook_data in hooks_list[:]:
-                if hook_data["service"] != itemsEvent.sender.userhostJID():
-                    continue
-                try:
-                    callback = hook_data["callback"]
-                except KeyError:
-                    # first time we get this hook, we create the callback
-                    hook_type = hook_data["type"]
-                    try:
-                        if hook_type == HOOK_TYPE_PYTHON_FILE:
-                            hook_globals = {}
-                            exec(compile(open(hook_data["arg"], "rb").read(), hook_data["arg"], 'exec'), hook_globals)
-                            callback = hook_globals["hook"]
-                        else:
-                            raise NotImplementedError(
-                                _("{hook_type} hook type not implemented yet").format(
-                                    hook_type=hook_type
-                                )
-                            )
-                    except Exception as e:
-                        log.warning(
-                            _(
-                                "Can't load Pubsub hook at node {node}, it will be removed: {reason}"
-                            ).format(node=node, reason=e)
-                        )
-                        hooks_list.remove(hook_data)
-                        continue
-
-                for item in itemsEvent.items:
-                    try:
-                        callback(self.host, client, item)
-                    except Exception as e:
-                        log.warning(
-                            _(
-                                "Error while running Pubsub hook for node {node}: {msg}"
-                            ).format(node=node, msg=e)
-                        )
-
-    def _addHook(self, service, node, hook_type, hook_arg, persistent, profile):
-        client = self.host.get_client(profile)
-        service = jid.JID(service) if service else client.jid.userhostJID()
-        return self.add_hook(
-            client,
-            service,
-            str(node),
-            str(hook_type),
-            str(hook_arg),
-            persistent,
-        )
-
-    def add_hook(self, client, service, node, hook_type, hook_arg, persistent):
-        r"""Add a hook which will be triggered on a pubsub notification
-
-        @param service(jid.JID): service of the node
-        @param node(unicode): Pubsub node
-        @param hook_type(unicode): type of the hook, one of:
-            - HOOK_TYPE_PYTHON: a python module (must be in path)
-                module must have a "hook" method which will be called
-            - HOOK_TYPE_PYTHON_FILE: a python file
-                file must have a "hook" method which will be called
-            - HOOK_TYPE_PYTHON_CODE: direct python code
-                /!\ Python hooks will be executed in SàT context,
-                with host, client and item as arguments, it means that:
-                    - they can do whatever they wants, so don't run untrusted hooks
-                    - they MUST NOT BLOCK, they are run in Twisted async environment and blocking would block whole SàT process
-                    - item are domish.Element
-        @param hook_arg(unicode): argument of the hook, depending on the hook_type
-            can be a module path, file path, python code
-        """
-        assert service is not None
-        return self.install_hook(client, service, node, hook_type, hook_arg, persistent)
-
-    def _removeHook(self, service, node, hook_type, hook_arg, profile):
-        client = self.host.get_client(profile)
-        service = jid.JID(service) if service else client.jid.userhostJID()
-        return self.remove_hook(client, service, node, hook_type or None, hook_arg or None)
-
-    def remove_hook(self, client, service, node, hook_type=None, hook_arg=None):
-        """Remove a persistent or temporaty root
-
-        @param service(jid.JID): service of the node
-        @param node(unicode): Pubsub node
-        @param hook_type(unicode, None): same as for [add_hook]
-            match all if None
-        @param hook_arg(unicode, None): same as for [add_hook]
-            match all if None
-        @return(int): number of hooks removed
-        """
-        removed = 0
-        for hooks in (client._hooks, client._hooks_temporary):
-            if node in hooks:
-                for hook_data in hooks[node]:
-                    if (
-                        service != hook_data["service"]
-                        or hook_type is not None
-                        and hook_type != hook_data["type"]
-                        or hook_arg is not None
-                        and hook_arg != hook_data["arg"]
-                    ):
-                        continue
-                    hooks[node].remove(hook_data)
-                    removed += 1
-                    if not hooks[node]:
-                        #  no more hooks, we can remove the node
-                        del hooks[node]
-                        self._remove_node_manager(client, node)
-                    else:
-                        if hooks == client._hooks:
-                            hooks.force(node)
-        return removed
-
-    def _list_hooks(self, profile):
-        hooks_list = self.list_hooks(self.host.get_client(profile))
-        for hook in hooks_list:
-            hook["service"] = hook["service"].full()
-            hook["persistent"] = C.bool_const(hook["persistent"])
-        return hooks_list
-
-    def list_hooks(self, client):
-        """return list of registered hooks"""
-        hooks_list = []
-        for hooks in (client._hooks, client._hooks_temporary):
-            persistent = hooks is client._hooks
-            for node, hooks_data in hooks.items():
-                for hook_data in hooks_data:
-                    hooks_list.append(
-                        {
-                            "service": hook_data["service"],
-                            "node": node,
-                            "type": hook_data["type"],
-                            "arg": hook_data["arg"],
-                            "persistent": persistent,
-                        }
-                    )
-        return hooks_list
--- a/sat/plugins/plugin_import.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,334 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT plugin for generic data import handling
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.internet import defer
-from sat.core import exceptions
-from twisted.words.protocols.jabber import jid
-from functools import partial
-import collections
-import uuid
-import json
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "import",
-    C.PI_IMPORT_NAME: "IMPORT",
-    C.PI_TYPE: C.PLUG_TYPE_IMPORT,
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "ImportPlugin",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Generic import plugin, base for specialized importers"""),
-}
-
-Importer = collections.namedtuple("Importer", ("callback", "short_desc", "long_desc"))
-
-
-class ImportPlugin(object):
-    def __init__(self, host):
-        log.info(_("plugin import initialization"))
-        self.host = host
-
-    def initialize(self, import_handler, name):
-        """Initialize a specialized import handler
-
-        @param import_handler(object): specialized import handler instance
-            must have the following methods:
-                - import_item: import a single main item (i.e. prepare data for publishing)
-                - importSubitems: import sub items (i.e. items linked to main item, e.g. comments).
-                    Must return a dict with kwargs for recursive_import if items are to be imported recursively.
-                    At least "items_import_data", "service" and "node" keys must be provided.
-                    if None is returned, no recursion will be done to import subitems, but import can still be done directly by the method.
-                - publish_item: actualy publish an item
-                - item_filters: modify item according to options
-        @param name(unicode): import handler name
-        """
-        assert name == name.lower().strip()
-        log.info(_("initializing {name} import handler").format(name=name))
-        import_handler.name = name
-        import_handler.register = partial(self.register, import_handler)
-        import_handler.unregister = partial(self.unregister, import_handler)
-        import_handler.importers = {}
-
-        def _import(name, location, options, pubsub_service, pubsub_node, profile):
-            return self._do_import(
-                import_handler,
-                name,
-                location,
-                options,
-                pubsub_service,
-                pubsub_node,
-                profile,
-            )
-
-        def _import_list():
-            return self.list_importers(import_handler)
-
-        def _import_desc(name):
-            return self.getDescription(import_handler, name)
-
-        self.host.bridge.add_method(
-            name + "import",
-            ".plugin",
-            in_sign="ssa{ss}sss",
-            out_sign="s",
-            method=_import,
-            async_=True,
-        )
-        self.host.bridge.add_method(
-            name + "ImportList",
-            ".plugin",
-            in_sign="",
-            out_sign="a(ss)",
-            method=_import_list,
-        )
-        self.host.bridge.add_method(
-            name + "ImportDesc",
-            ".plugin",
-            in_sign="s",
-            out_sign="(ss)",
-            method=_import_desc,
-        )
-
-    def get_progress(self, import_handler, progress_id, profile):
-        client = self.host.get_client(profile)
-        return client._import[import_handler.name][progress_id]
-
-    def list_importers(self, import_handler):
-        importers = list(import_handler.importers.keys())
-        importers.sort()
-        return [
-            (name, import_handler.importers[name].short_desc)
-            for name in import_handler.importers
-        ]
-
-    def getDescription(self, import_handler, name):
-        """Return import short and long descriptions
-
-        @param name(unicode): importer name
-        @return (tuple[unicode,unicode]): short and long description
-        """
-        try:
-            importer = import_handler.importers[name]
-        except KeyError:
-            raise exceptions.NotFound(
-                "{handler_name} importer not found [{name}]".format(
-                    handler_name=import_handler.name, name=name
-                )
-            )
-        else:
-            return importer.short_desc, importer.long_desc
-
-    def _do_import(self, import_handler, name, location, options, pubsub_service="",
-                  pubsub_node="", profile=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile)
-        options = {key: str(value) for key, value in options.items()}
-        for option in import_handler.BOOL_OPTIONS:
-            try:
-                options[option] = C.bool(options[option])
-            except KeyError:
-                pass
-        for option in import_handler.JSON_OPTIONS:
-            try:
-                options[option] = json.loads(options[option])
-            except KeyError:
-                pass
-            except ValueError:
-                raise exceptions.DataError(
-                    _("invalid json option: {option}").format(option=option)
-                )
-        pubsub_service = jid.JID(pubsub_service) if pubsub_service else None
-        return self.do_import(
-            client,
-            import_handler,
-            str(name),
-            str(location),
-            options,
-            pubsub_service,
-            pubsub_node or None,
-        )
-
-    @defer.inlineCallbacks
-    def do_import(self, client, import_handler, name, location, options=None,
-                 pubsub_service=None, pubsub_node=None,):
-        """import data
-
-        @param import_handler(object): instance of the import handler
-        @param name(unicode): name of the importer
-        @param location(unicode): location of the data to import
-            can be an url, a file path, or anything which make sense
-            check importer description for more details
-        @param options(dict, None): extra options.
-        @param pubsub_service(jid.JID, None): jid of the PubSub service where data must be
-            imported.
-            None to use profile's server
-        @param pubsub_node(unicode, None): PubSub node to use
-            None to use importer's default node
-        @return (unicode): progress id
-        """
-        if options is None:
-            options = {}
-        else:
-            for opt_name, opt_default in import_handler.OPT_DEFAULTS.items():
-                # we want a filled options dict, with all empty or False values removed
-                try:
-                    value = options[opt_name]
-                except KeyError:
-                    if opt_default:
-                        options[opt_name] = opt_default
-                else:
-                    if not value:
-                        del options[opt_name]
-
-        try:
-            importer = import_handler.importers[name]
-        except KeyError:
-            raise exceptions.NotFound("Importer [{}] not found".format(name))
-        items_import_data, items_count = yield importer.callback(
-            client, location, options
-        )
-        progress_id = str(uuid.uuid4())
-        try:
-            _import = client._import
-        except AttributeError:
-            _import = client._import = {}
-        progress_data = _import.setdefault(import_handler.name, {})
-        progress_data[progress_id] = {"position": "0"}
-        if items_count is not None:
-            progress_data[progress_id]["size"] = str(items_count)
-        metadata = {
-            "name": "{}: {}".format(name, location),
-            "direction": "out",
-            "type": import_handler.name.upper() + "_IMPORT",
-        }
-        self.host.register_progress_cb(
-            progress_id,
-            partial(self.get_progress, import_handler),
-            metadata,
-            profile=client.profile,
-        )
-        self.host.bridge.progress_started(progress_id, metadata, client.profile)
-        session = {  #  session data, can be used by importers
-            "root_service": pubsub_service,
-            "root_node": pubsub_node,
-        }
-        self.recursive_import(
-            client,
-            import_handler,
-            items_import_data,
-            progress_id,
-            session,
-            options,
-            None,
-            pubsub_service,
-            pubsub_node,
-        )
-        defer.returnValue(progress_id)
-
-    @defer.inlineCallbacks
-    def recursive_import(
-        self,
-        client,
-        import_handler,
-        items_import_data,
-        progress_id,
-        session,
-        options,
-        return_data=None,
-        service=None,
-        node=None,
-        depth=0,
-    ):
-        """Do the import recursively
-
-        @param import_handler(object): instance of the import handler
-        @param items_import_data(iterable): iterable of data as specified in [register]
-        @param progress_id(unicode): id of progression
-        @param session(dict): data for this import session
-            can be used by importer so store any useful data
-            "root_service" and "root_node" are set to the main pubsub service and node of the import
-        @param options(dict): import options
-        @param return_data(dict): data to return on progress_finished
-        @param service(jid.JID, None): PubSub service to use
-        @param node(unicode, None): PubSub node to use
-        @param depth(int): level of recursion
-        """
-        if return_data is None:
-            return_data = {}
-        for idx, item_import_data in enumerate(items_import_data):
-            item_data = yield import_handler.import_item(
-                client, item_import_data, session, options, return_data, service, node
-            )
-            yield import_handler.item_filters(client, item_data, session, options)
-            recurse_kwargs = yield import_handler.import_sub_items(
-                client, item_import_data, item_data, session, options
-            )
-            yield import_handler.publish_item(client, item_data, service, node, session)
-
-            if recurse_kwargs is not None:
-                recurse_kwargs["client"] = client
-                recurse_kwargs["import_handler"] = import_handler
-                recurse_kwargs["progress_id"] = progress_id
-                recurse_kwargs["session"] = session
-                recurse_kwargs.setdefault("options", options)
-                recurse_kwargs["return_data"] = return_data
-                recurse_kwargs["depth"] = depth + 1
-                log.debug(_("uploading subitems"))
-                yield self.recursive_import(**recurse_kwargs)
-
-            if depth == 0:
-                client._import[import_handler.name][progress_id]["position"] = str(
-                    idx + 1
-                )
-
-        if depth == 0:
-            self.host.bridge.progress_finished(progress_id, return_data, client.profile)
-            self.host.remove_progress_cb(progress_id, client.profile)
-            del client._import[import_handler.name][progress_id]
-
-    def register(self, import_handler, name, callback, short_desc="", long_desc=""):
-        """Register an Importer method
-
-        @param name(unicode): unique importer name, should indicate the software it can import and always lowercase
-        @param callback(callable): method to call:
-            the signature must be (client, location, options) (cf. [do_import])
-            the importer must return a tuple with (items_import_data, items_count)
-            items_import_data(iterable[dict]) data specific to specialized importer
-                cf. import_item docstring of specialized importer for details
-            items_count (int, None) indicate the total number of items (without subitems)
-                useful to display a progress indicator when the iterator is a generator
-                use None if you can't guess the total number of items
-        @param short_desc(unicode): one line description of the importer
-        @param long_desc(unicode): long description of the importer, its options, etc.
-        """
-        name = name.lower()
-        if name in import_handler.importers:
-            raise exceptions.ConflictError(
-                _(
-                    "An {handler_name} importer with the name {name} already exist"
-                ).format(handler_name=import_handler.name, name=name)
-            )
-        import_handler.importers[name] = Importer(callback, short_desc, long_desc)
-
-    def unregister(self, import_handler, name):
-        del import_handler.importers[name]
--- a/sat/plugins/plugin_merge_req_mercurial.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,171 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin managing Mercurial VCS
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import re
-from twisted.python.procutils import which
-from sat.tools.common import async_process
-from sat.tools import utils
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Mercurial Merge Request handler",
-    C.PI_IMPORT_NAME: "MERGE_REQUEST_MERCURIAL",
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_DEPENDENCIES: ["MERGE_REQUESTS"],
-    C.PI_MAIN: "MercurialHandler",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Merge request handler for Mercurial""")
-}
-
-SHORT_DESC = D_("handle Mercurial repository")
-CLEAN_RE = re.compile(r'[^\w -._]', flags=re.UNICODE)
-
-
-class MercurialProtocol(async_process.CommandProtocol):
-    """handle hg commands"""
-    name = "Mercurial"
-    command = None
-
-    @classmethod
-    def run(cls, path, command, *args, **kwargs):
-        """Create a new MercurialRegisterProtocol and execute the given mercurial command.
-
-        @param path(unicode): path to the repository
-        @param command(unicode): hg command to run
-        @return D(bytes): stdout of the command
-        """
-        assert "path" not in kwargs
-        kwargs["path"] = path
-        # FIXME: we have to use this workaround because Twisted's protocol.ProcessProtocol
-        #        is not using new style classes. This can be removed once moved to
-        #        Python 3 (super can be used normally then).
-        d = async_process.CommandProtocol.run.__func__(cls, command, *args, **kwargs)
-        d.addErrback(utils.logError)
-        return d
-
-
-class MercurialHandler(object):
-    data_types = ('mercurial_changeset',)
-
-    def __init__(self, host):
-        log.info(_("Mercurial merge request handler initialization"))
-        try:
-            MercurialProtocol.command = which('hg')[0]
-        except IndexError:
-            raise exceptions.NotFound(_("Mercurial executable (hg) not found, "
-                                        "can't use Mercurial handler"))
-        self.host = host
-        self._m = host.plugins['MERGE_REQUESTS']
-        self._m.register('mercurial', self, self.data_types, SHORT_DESC)
-
-
-    def check(self, repository):
-        d = MercurialProtocol.run(repository, 'identify')
-        d.addCallback(lambda __: True)
-        d.addErrback(lambda __: False)
-        return d
-
-    def export(self, repository):
-        d = MercurialProtocol.run(
-            repository, 'export', '-g', '-r', 'outgoing() and ancestors(.)',
-            '--encoding=utf-8'
-        )
-        d.addCallback(lambda data: data.decode('utf-8'))
-        return d
-
-    def import_(self, repository, data, data_type, item_id, service, node, extra):
-        parsed_data = self.parse(data)
-        try:
-            parsed_name = parsed_data[0]['commit_msg'].split('\n')[0]
-            parsed_name = CLEAN_RE.sub('', parsed_name)[:40]
-        except Exception:
-            parsed_name = ''
-        name = 'mr_{item_id}_{parsed_name}'.format(item_id=CLEAN_RE.sub('', item_id),
-                                                   parsed_name=parsed_name)
-        return MercurialProtocol.run(repository, 'qimport', '-g', '--name', name,
-                                     '--encoding=utf-8', '-', stdin=data)
-
-    def parse(self, data, data_type=None):
-        lines = data.splitlines()
-        total_lines = len(lines)
-        patches = []
-        while lines:
-            patch = {}
-            commit_msg = []
-            diff = []
-            state = 'init'
-            if lines[0] != '# HG changeset patch':
-                raise exceptions.DataError(_('invalid changeset signature'))
-            # line index of this patch in the whole data
-            patch_idx = total_lines - len(lines)
-            del lines[0]
-
-            for idx, line in enumerate(lines):
-                if state == 'init':
-                    if line.startswith('# '):
-                        if line.startswith('# User '):
-                            elems = line[7:].split()
-                            if not elems:
-                                continue
-                            last = elems[-1]
-                            if (last.startswith('<') and last.endswith('>')
-                                and '@' in last):
-                                patch[self._m.META_EMAIL] = elems.pop()[1:-1]
-                            patch[self._m.META_AUTHOR] = ' '.join(elems)
-                        elif line.startswith('# Date '):
-                            time_data = line[7:].split()
-                            if len(time_data) != 2:
-                                log.warning(_('unexpected time data: {data}')
-                                            .format(data=line[7:]))
-                                continue
-                            patch[self._m.META_TIMESTAMP] = (int(time_data[0])
-                                                             + int(time_data[1]))
-                        elif line.startswith('# Node ID '):
-                            patch[self._m.META_HASH] = line[10:]
-                        elif line.startswith('# Parent  '):
-                            patch[self._m.META_PARENT_HASH] = line[10:]
-                    else:
-                        state = 'commit_msg'
-                if state == 'commit_msg':
-                    if line.startswith('diff --git a/'):
-                        state = 'diff'
-                        patch[self._m.META_DIFF_IDX] = patch_idx + idx + 1
-                    else:
-                        commit_msg.append(line)
-                if state == 'diff':
-                    if line.startswith('# ') or idx == len(lines)-1:
-                        # a new patch is starting or we have reached end of patches
-                        if idx == len(lines)-1:
-                            # end of patches, we need to keep the line
-                            diff.append(line)
-                        patch[self._m.META_COMMIT_MSG] = '\n'.join(commit_msg)
-                        patch[self._m.META_DIFF] = '\n'.join(diff)
-                        patches.append(patch)
-                        if idx == len(lines)-1:
-                            del lines[:]
-                        else:
-                            del lines[:idx]
-                        break
-                    else:
-                        diff.append(line)
-        return patches
--- a/sat/plugins/plugin_misc_account.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,766 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for account creation
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger
-
-from sat.core import exceptions
-from sat.tools import xml_tools
-from sat.memory.memory import Sessions
-from sat.memory.crypto import PasswordHasher
-from sat.core.constants import Const as C
-import configparser
-from twisted.internet import defer
-from twisted.python.failure import Failure
-from twisted.words.protocols.jabber import jid
-from sat.tools.common import email as sat_email
-
-
-log = getLogger(__name__)
-
-
-#  FIXME: this plugin code is old and need a cleaning
-# TODO: account deletion/password change need testing
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Account Plugin",
-    C.PI_IMPORT_NAME: "MISC-ACCOUNT",
-    C.PI_TYPE: "MISC",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0077"],
-    C.PI_RECOMMENDATIONS: ["GROUPBLOG"],
-    C.PI_MAIN: "MiscAccount",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Libervia account creation"""),
-}
-
-CONFIG_SECTION = "plugin account"
-
-# You need do adapt the following consts to your server
-# all theses values (key=option name, value=default) can (and should) be overriden
-# in libervia.conf in section CONFIG_SECTION
-
-default_conf = {
-    "email_from": "NOREPLY@example.net",
-    "email_server": "localhost",
-    "email_sender_domain": "",
-    "email_port": 25,
-    "email_username": "",
-    "email_password": "",
-    "email_starttls": "false",
-    "email_auth": "false",
-    "email_admins_list": [],
-    "admin_email": "",
-    "new_account_server": "localhost",
-    "new_account_domain": "",  #  use xmpp_domain if not found
-    "reserved_list": ["libervia"],  # profiles which can't be used
-}
-
-WELCOME_MSG = D_(
-    """Welcome to Libervia, the web interface of Salut à Toi.
-
-Your account on {domain} has been successfully created.
-This is a demonstration version to show you the current status of the project.
-It is still under development, please keep it in mind!
-
-Here is your connection information:
-
-Login on {domain}: {profile}
-Jabber ID (JID): {jid}
-Your password has been chosen by yourself during registration.
-
-In the beginning, you have nobody to talk to. To find some contacts, you may use the users' directory:
-    - make yourself visible in "Service / Directory subscription".
-    - search for people with "Contacts" / Search directory".
-
-Any feedback welcome. Thank you!
-
-Salut à Toi association
-https://www.salut-a-toi.org
-"""
-)
-
-DEFAULT_DOMAIN = "example.net"
-
-
-class MiscAccount(object):
-    """Account plugin: create a SàT + XMPP account, used by Libervia"""
-
-    # XXX: This plugin was initialy a Q&D one used for the demo.
-    # TODO: cleaning, separate email handling, more configuration/tests, fixes
-
-    def __init__(self, host):
-        log.info(_("Plugin Account initialization"))
-        self.host = host
-        host.bridge.add_method(
-            "libervia_account_register",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._register_account,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "account_domain_new_get",
-            ".plugin",
-            in_sign="",
-            out_sign="s",
-            method=self.account_domain_new_get,
-            async_=False,
-        )
-        host.bridge.add_method(
-            "account_dialog_ui_get",
-            ".plugin",
-            in_sign="s",
-            out_sign="s",
-            method=self._get_account_dialog_ui,
-            async_=False,
-        )
-        host.bridge.add_method(
-            "credentials_xmpp_connect",
-            ".plugin",
-            in_sign="ss",
-            out_sign="b",
-            method=self.credentials_xmpp_connect,
-            async_=True,
-        )
-
-        self.fix_email_admins()
-        self._sessions = Sessions()
-
-        self.__account_cb_id = host.register_callback(
-            self._account_dialog_cb, with_data=True
-        )
-        self.__change_password_id = host.register_callback(
-            self.__change_password_cb, with_data=True
-        )
-
-        def delete_blog_callback(posts, comments):
-            return lambda data, profile: self.__delete_blog_posts_cb(
-                posts, comments, data, profile
-            )
-
-        self.__delete_posts_id = host.register_callback(
-            delete_blog_callback(True, False), with_data=True
-        )
-        self.__delete_comments_id = host.register_callback(
-            delete_blog_callback(False, True), with_data=True
-        )
-        self.__delete_posts_comments_id = host.register_callback(
-            delete_blog_callback(True, True), with_data=True
-        )
-
-        self.__delete_account_id = host.register_callback(
-            self.__delete_account_cb, with_data=True
-        )
-
-    # FIXME: remove this after some time, when the deprecated parameter is really abandoned
-    def fix_email_admins(self):
-        """Handle deprecated config option "admin_email" to fix the admin emails list"""
-        admin_email = self.config_get("admin_email")
-        if not admin_email:
-            return
-        log.warning(
-            "admin_email parameter is deprecated, please use email_admins_list instead"
-        )
-        param_name = "email_admins_list"
-        try:
-            section = ""
-            value = self.host.memory.config_get(section, param_name, Exception)
-        except (configparser.NoOptionError, configparser.NoSectionError):
-            section = CONFIG_SECTION
-            value = self.host.memory.config_get(
-                section, param_name, default_conf[param_name]
-            )
-
-        value = set(value)
-        value.add(admin_email)
-        self.host.memory.config.set(section, param_name, ",".join(value))
-
-    def config_get(self, name, section=CONFIG_SECTION):
-        if name.startswith("email_"):
-            # XXX: email_ parameters were first in [plugin account] section
-            #      but as it make more sense to have them in common with other plugins,
-            #      they can now be in [DEFAULT] section
-            try:
-                value = self.host.memory.config_get(None, name, Exception)
-            except (configparser.NoOptionError, configparser.NoSectionError):
-                pass
-            else:
-                return value
-
-        if section == CONFIG_SECTION:
-            default = default_conf[name]
-        else:
-            default = None
-        return self.host.memory.config_get(section, name, default)
-
-    def _register_account(self, email, password, profile):
-        return self.registerAccount(email, password, None, profile)
-
-    def registerAccount(self, email, password, jid_s, profile):
-        """Register a new profile, its associated XMPP account, send the confirmation emails.
-
-        @param email (unicode): where to send to confirmation email to
-        @param password (unicode): password chosen by the user
-            while be used for profile *and* XMPP account
-        @param jid_s (unicode): JID to re-use or to register:
-            - non empty value: bind this JID to the new sat profile
-            - None or "": register a new JID on the local XMPP server
-        @param profile
-        @return Deferred
-        """
-        d = self.create_profile(password, jid_s, profile)
-        d.addCallback(lambda __: self.send_emails(email, profile))
-        return d
-
-    def create_profile(self, password, jid_s, profile):
-        """Register a new profile and its associated XMPP account.
-
-        @param password (unicode): password chosen by the user
-            while be used for profile *and* XMPP account
-        @param jid_s (unicode): JID to re-use or to register:
-            - non empty value: bind this JID to the new sat profile
-            - None or "": register a new JID on the local XMPP server
-        @param profile
-        @return Deferred
-        """
-        if not password or not profile:
-            raise exceptions.DataError
-
-        if profile.lower() in self.config_get("reserved_list"):
-            return defer.fail(Failure(exceptions.ConflictError))
-
-        d = self.host.memory.create_profile(profile, password)
-        d.addCallback(lambda __: self.profile_created(password, jid_s, profile))
-        return d
-
-    def profile_created(self, password, jid_s, profile):
-        """Create the XMPP account and set the profile connection parameters.
-
-        @param password (unicode): password chosen by the user
-        @param jid_s (unicode): JID to re-use or to register:
-            - non empty value: bind this JID to the new sat profile
-            - None or empty: register a new JID on the local XMPP server
-        @param profile
-        @return: Deferred
-        """
-        if jid_s:
-            d = defer.succeed(None)
-            jid_ = jid.JID(jid_s)
-        else:
-            jid_s = profile + "@" + self.account_domain_new_get()
-            jid_ = jid.JID(jid_s)
-            d = self.host.plugins["XEP-0077"].register_new_account(jid_, password)
-
-        def setParams(__):
-            self.host.memory.param_set(
-                "JabberID", jid_s, "Connection", profile_key=profile
-            )
-            d = self.host.memory.param_set(
-                "Password", password, "Connection", profile_key=profile
-            )
-            return d
-
-        def remove_profile(failure):
-            self.host.memory.profile_delete_async(profile)
-            return failure
-
-        d.addCallback(lambda __: self.host.memory.start_session(password, profile))
-        d.addCallback(setParams)
-        d.addCallback(lambda __: self.host.memory.stop_session(profile))
-        d.addErrback(remove_profile)
-        return d
-
-    def _send_email_eb(self, failure_, email):
-        # TODO: return error code to user
-        log.error(
-            _("Failed to send account creation confirmation to {email}: {msg}").format(
-                email=email, msg=failure_
-            )
-        )
-
-    def send_emails(self, email, profile):
-        # time to send the email
-
-        domain = self.account_domain_new_get()
-
-        # email to the administrators
-        admins_emails = self.config_get("email_admins_list")
-        if not admins_emails:
-            log.warning(
-                "No known admin email, we can't send email to administrator(s).\n"
-                "Please fill email_admins_list parameter"
-            )
-            d_admin = defer.fail(exceptions.DataError("no admin email"))
-        else:
-            subject = _("New Libervia account created")
-            # there is no email when an existing XMPP account is used
-            body = f"New account created on {domain}: {profile} [{email or '<no email>'}]"
-            d_admin = sat_email.send_email(
-                self.host.memory.config, admins_emails, subject, body)
-
-        admins_emails_txt = ", ".join(["<" + addr + ">" for addr in admins_emails])
-        d_admin.addCallbacks(
-            lambda __: log.debug(
-                "Account creation notification sent to admin(s) {}".format(
-                    admins_emails_txt
-                )
-            ),
-            lambda __: log.error(
-                "Failed to send account creation notification to admin {}".format(
-                    admins_emails_txt
-                )
-            ),
-        )
-        if not email:
-            # TODO: if use register with an existing account, an XMPP message should be sent
-            return d_admin
-
-        jid_s = self.host.memory.param_get_a(
-            "JabberID", "Connection", profile_key=profile
-        )
-        subject = _("Your Libervia account has been created")
-        body = _(WELCOME_MSG).format(profile=profile, jid=jid_s, domain=domain)
-
-        # XXX: this will not fail when the email address doesn't exist
-        # FIXME: check email reception to validate email given by the user
-        # FIXME: delete the profile if the email could not been sent?
-        d_user = sat_email.send_email(self.host.memory.config, [email], subject, body)
-        d_user.addCallbacks(
-            lambda __: log.debug(
-                "Account creation confirmation sent to <{}>".format(email)
-            ),
-            self._send_email_eb,
-            errbackArgs=[email]
-        )
-        return defer.DeferredList([d_user, d_admin])
-
-    def account_domain_new_get(self):
-        """get the domain that will be set to new account"""
-
-        domain = self.config_get("new_account_domain") or self.config_get(
-            "xmpp_domain", None
-        )
-        if not domain:
-            log.warning(
-                _(
-                    'xmpp_domain needs to be set in sat.conf. Using "{default}" meanwhile'
-                ).format(default=DEFAULT_DOMAIN)
-            )
-            return DEFAULT_DOMAIN
-        return domain
-
-    def _get_account_dialog_ui(self, profile):
-        """Get the main dialog to manage your account
-        @param menu_data
-        @param profile: %(doc_profile)s
-        @return: XML of the dialog
-        """
-        form_ui = xml_tools.XMLUI(
-            "form",
-            "tabs",
-            title=D_("Manage your account"),
-            submit_id=self.__account_cb_id,
-        )
-        tab_container = form_ui.current_container
-
-        tab_container.add_tab(
-            "update", D_("Change your password"), container=xml_tools.PairsContainer
-        )
-        form_ui.addLabel(D_("Current profile password"))
-        form_ui.addPassword("current_passwd", value="")
-        form_ui.addLabel(D_("New password"))
-        form_ui.addPassword("new_passwd1", value="")
-        form_ui.addLabel(D_("New password (again)"))
-        form_ui.addPassword("new_passwd2", value="")
-
-        # FIXME: uncomment and fix these features
-        """
-        if 'GROUPBLOG' in self.host.plugins:
-            tab_container.add_tab("delete_posts", D_("Delete your posts"), container=xml_tools.PairsContainer)
-            form_ui.addLabel(D_("Current profile password"))
-            form_ui.addPassword("delete_posts_passwd", value="")
-            form_ui.addLabel(D_("Delete all your posts and their comments"))
-            form_ui.addBool("delete_posts_checkbox", "false")
-            form_ui.addLabel(D_("Delete all your comments on other's posts"))
-            form_ui.addBool("delete_comments_checkbox", "false")
-
-        tab_container.add_tab("delete", D_("Delete your account"), container=xml_tools.PairsContainer)
-        form_ui.addLabel(D_("Current profile password"))
-        form_ui.addPassword("delete_passwd", value="")
-        form_ui.addLabel(D_("Delete your account"))
-        form_ui.addBool("delete_checkbox", "false")
-        """
-
-        return form_ui.toXml()
-
-    @defer.inlineCallbacks
-    def _account_dialog_cb(self, data, profile):
-        """Called when the user submits the main account dialog
-        @param data
-        @param profile
-        """
-        sat_cipher = yield self.host.memory.param_get_a_async(
-            C.PROFILE_PASS_PATH[1], C.PROFILE_PASS_PATH[0], profile_key=profile
-        )
-
-        @defer.inlineCallbacks
-        def verify(attempt):
-            auth = yield PasswordHasher.verify(attempt, sat_cipher)
-            defer.returnValue(auth)
-
-        def error_ui(message=None):
-            if not message:
-                message = D_("The provided profile password doesn't match.")
-            error_ui = xml_tools.XMLUI("popup", title=D_("Attempt failure"))
-            error_ui.addText(message)
-            return {"xmlui": error_ui.toXml()}
-
-        # check for account deletion
-        # FIXME: uncomment and fix these features
-        """
-        delete_passwd = data[xml_tools.SAT_FORM_PREFIX + 'delete_passwd']
-        delete_checkbox = data[xml_tools.SAT_FORM_PREFIX + 'delete_checkbox']
-        if delete_checkbox == 'true':
-            verified = yield verify(delete_passwd)
-            assert isinstance(verified, bool)
-            if verified:
-                defer.returnValue(self.__delete_account(profile))
-            defer.returnValue(error_ui())
-
-        # check for blog posts deletion
-        if 'GROUPBLOG' in self.host.plugins:
-            delete_posts_passwd = data[xml_tools.SAT_FORM_PREFIX + 'delete_posts_passwd']
-            delete_posts_checkbox = data[xml_tools.SAT_FORM_PREFIX + 'delete_posts_checkbox']
-            delete_comments_checkbox = data[xml_tools.SAT_FORM_PREFIX + 'delete_comments_checkbox']
-            posts = delete_posts_checkbox == 'true'
-            comments = delete_comments_checkbox == 'true'
-            if posts or comments:
-                verified = yield verify(delete_posts_passwd)
-                assert isinstance(verified, bool)
-                if verified:
-                    defer.returnValue(self.__delete_blog_posts(posts, comments, profile))
-                defer.returnValue(error_ui())
-        """
-
-        # check for password modification
-        current_passwd = data[xml_tools.SAT_FORM_PREFIX + "current_passwd"]
-        new_passwd1 = data[xml_tools.SAT_FORM_PREFIX + "new_passwd1"]
-        new_passwd2 = data[xml_tools.SAT_FORM_PREFIX + "new_passwd2"]
-        if new_passwd1 or new_passwd2:
-            verified = yield verify(current_passwd)
-            assert isinstance(verified, bool)
-            if verified:
-                if new_passwd1 == new_passwd2:
-                    data = yield self.__change_password(new_passwd1, profile=profile)
-                    defer.returnValue(data)
-                else:
-                    defer.returnValue(
-                        error_ui(
-                            D_("The values entered for the new password are not equal.")
-                        )
-                    )
-            defer.returnValue(error_ui())
-
-        defer.returnValue({})
-
-    def __change_password(self, password, profile):
-        """Ask for a confirmation before changing the XMPP account and SàT profile passwords.
-
-        @param password (str): the new password
-        @param profile (str): %(doc_profile)s
-        """
-        session_id, __ = self._sessions.new_session(
-            {"new_password": password}, profile=profile
-        )
-        form_ui = xml_tools.XMLUI(
-            "form",
-            title=D_("Change your password?"),
-            submit_id=self.__change_password_id,
-            session_id=session_id,
-        )
-        form_ui.addText(
-            D_(
-                "Note for advanced users: this will actually change both your SàT profile password AND your XMPP account password."
-            )
-        )
-        form_ui.addText(D_("Continue with changing the password?"))
-        return {"xmlui": form_ui.toXml()}
-
-    def __change_password_cb(self, data, profile):
-        """Actually change the user XMPP account and SàT profile password
-        @param data (dict)
-        @profile (str): %(doc_profile)s
-        """
-        client = self.host.get_client(profile)
-        password = self._sessions.profile_get(data["session_id"], profile)["new_password"]
-        del self._sessions[data["session_id"]]
-
-        def password_changed(__):
-            d = self.host.memory.param_set(
-                C.PROFILE_PASS_PATH[1],
-                password,
-                C.PROFILE_PASS_PATH[0],
-                profile_key=profile,
-            )
-            d.addCallback(
-                lambda __: self.host.memory.param_set(
-                    "Password", password, "Connection", profile_key=profile
-                )
-            )
-            confirm_ui = xml_tools.XMLUI("popup", title=D_("Confirmation"))
-            confirm_ui.addText(D_("Your password has been changed."))
-            return defer.succeed({"xmlui": confirm_ui.toXml()})
-
-        def errback(failure):
-            error_ui = xml_tools.XMLUI("popup", title=D_("Error"))
-            error_ui.addText(
-                D_("Your password could not be changed: %s") % failure.getErrorMessage()
-            )
-            return defer.succeed({"xmlui": error_ui.toXml()})
-
-        d = self.host.plugins["XEP-0077"].change_password(client, password)
-        d.addCallbacks(password_changed, errback)
-        return d
-
-    def __delete_account(self, profile):
-        """Ask for a confirmation before deleting the XMPP account and SàT profile
-        @param profile
-        """
-        form_ui = xml_tools.XMLUI(
-            "form", title=D_("Delete your account?"), submit_id=self.__delete_account_id
-        )
-        form_ui.addText(
-            D_(
-                "If you confirm this dialog, you will be disconnected and then your XMPP account AND your SàT profile will both be DELETED."
-            )
-        )
-        target = D_(
-            "contact list, messages history, blog posts and comments"
-            if "GROUPBLOG" in self.host.plugins
-            else D_("contact list and messages history")
-        )
-        form_ui.addText(
-            D_(
-                "All your data stored on %(server)s, including your %(target)s will be erased."
-            )
-            % {"server": self.account_domain_new_get(), "target": target}
-        )
-        form_ui.addText(
-            D_(
-                "There is no other confirmation dialog, this is the very last one! Are you sure?"
-            )
-        )
-        return {"xmlui": form_ui.toXml()}
-
-    def __delete_account_cb(self, data, profile):
-        """Actually delete the XMPP account and SàT profile
-
-        @param data
-        @param profile
-        """
-        client = self.host.get_client(profile)
-
-        def user_deleted(__):
-
-            # FIXME: client should be disconnected at this point, so 2 next loop should be removed (to be confirmed)
-            for jid_ in client.roster._jids:  # empty roster
-                client.presence.unsubscribe(jid_)
-
-            for jid_ in self.host.memory.sub_waiting_get(
-                profile
-            ):  # delete waiting subscriptions
-                self.host.memory.del_waiting_sub(jid_)
-
-            delete_profile = lambda: self.host.memory.profile_delete_async(
-                profile, force=True
-            )
-            if "GROUPBLOG" in self.host.plugins:
-                d = self.host.plugins["GROUPBLOG"].deleteAllGroupBlogsAndComments(
-                    profile_key=profile
-                )
-                d.addCallback(lambda __: delete_profile())
-            else:
-                delete_profile()
-
-            return defer.succeed({})
-
-        def errback(failure):
-            error_ui = xml_tools.XMLUI("popup", title=D_("Error"))
-            error_ui.addText(
-                D_("Your XMPP account could not be deleted: %s")
-                % failure.getErrorMessage()
-            )
-            return defer.succeed({"xmlui": error_ui.toXml()})
-
-        d = self.host.plugins["XEP-0077"].unregister(client, jid.JID(client.jid.host))
-        d.addCallbacks(user_deleted, errback)
-        return d
-
-    def __delete_blog_posts(self, posts, comments, profile):
-        """Ask for a confirmation before deleting the blog posts
-        @param posts: delete all posts of the user (and their comments)
-        @param comments: delete all the comments of the user on other's posts
-        @param data
-        @param profile
-        """
-        if posts:
-            if comments:  # delete everything
-                form_ui = xml_tools.XMLUI(
-                    "form",
-                    title=D_("Delete all your (micro-)blog posts and comments?"),
-                    submit_id=self.__delete_posts_comments_id,
-                )
-                form_ui.addText(
-                    D_(
-                        "If you confirm this dialog, all the (micro-)blog data you submitted will be erased."
-                    )
-                )
-                form_ui.addText(
-                    D_(
-                        "These are the public and private posts and comments you sent to any group."
-                    )
-                )
-                form_ui.addText(
-                    D_(
-                        "There is no other confirmation dialog, this is the very last one! Are you sure?"
-                    )
-                )
-            else:  # delete only the posts
-                form_ui = xml_tools.XMLUI(
-                    "form",
-                    title=D_("Delete all your (micro-)blog posts?"),
-                    submit_id=self.__delete_posts_id,
-                )
-                form_ui.addText(
-                    D_(
-                        "If you confirm this dialog, all the public and private posts you sent to any group will be erased."
-                    )
-                )
-                form_ui.addText(
-                    D_(
-                        "There is no other confirmation dialog, this is the very last one! Are you sure?"
-                    )
-                )
-        elif comments:  # delete only the comments
-            form_ui = xml_tools.XMLUI(
-                "form",
-                title=D_("Delete all your (micro-)blog comments?"),
-                submit_id=self.__delete_comments_id,
-            )
-            form_ui.addText(
-                D_(
-                    "If you confirm this dialog, all the public and private comments you made on other people's posts will be erased."
-                )
-            )
-            form_ui.addText(
-                D_(
-                    "There is no other confirmation dialog, this is the very last one! Are you sure?"
-                )
-            )
-
-        return {"xmlui": form_ui.toXml()}
-
-    def __delete_blog_posts_cb(self, posts, comments, data, profile):
-        """Actually delete the XMPP account and SàT profile
-        @param posts: delete all posts of the user (and their comments)
-        @param comments: delete all the comments of the user on other's posts
-        @param profile
-        """
-        if posts:
-            if comments:
-                target = D_("blog posts and comments")
-                d = self.host.plugins["GROUPBLOG"].deleteAllGroupBlogsAndComments(
-                    profile_key=profile
-                )
-            else:
-                target = D_("blog posts")
-                d = self.host.plugins["GROUPBLOG"].deleteAllGroupBlogs(
-                    profile_key=profile
-                )
-        elif comments:
-            target = D_("comments")
-            d = self.host.plugins["GROUPBLOG"].deleteAllGroupBlogsComments(
-                profile_key=profile
-            )
-
-        def deleted(result):
-            ui = xml_tools.XMLUI("popup", title=D_("Deletion confirmation"))
-            # TODO: change the message when delete/retract notifications are done with XEP-0060
-            ui.addText(D_("Your %(target)s have been deleted.") % {"target": target})
-            ui.addText(
-                D_(
-                    "Known issue of the demo version: you need to refresh the page to make the deleted posts actually disappear."
-                )
-            )
-            return defer.succeed({"xmlui": ui.toXml()})
-
-        def errback(failure):
-            error_ui = xml_tools.XMLUI("popup", title=D_("Error"))
-            error_ui.addText(
-                D_("Your %(target)s could not be deleted: %(message)s")
-                % {"target": target, "message": failure.getErrorMessage()}
-            )
-            return defer.succeed({"xmlui": error_ui.toXml()})
-
-        d.addCallbacks(deleted, errback)
-        return d
-
-    def credentials_xmpp_connect(self, jid_s, password):
-        """Create and connect a new SàT profile using the given XMPP credentials.
-
-        Re-use given JID and XMPP password for the profile name and profile password.
-        @param jid_s (unicode): JID
-        @param password (unicode): XMPP password
-        @return Deferred(bool)
-        @raise exceptions.PasswordError, exceptions.ConflictError
-        """
-        try:  # be sure that the profile doesn't exist yet
-            self.host.memory.get_profile_name(jid_s)
-        except exceptions.ProfileUnknownError:
-            pass
-        else:
-            raise exceptions.ConflictError
-
-        d = self.create_profile(password, jid_s, jid_s)
-        d.addCallback(
-            lambda __: self.host.memory.get_profile_name(jid_s)
-        )  # checks if the profile has been successfuly created
-        d.addCallback(lambda profile: defer.ensureDeferred(
-            self.host.connect(profile, password, {}, 0)))
-
-        def connected(result):
-            self.send_emails(None, profile=jid_s)
-            return result
-
-        def remove_profile(
-            failure
-        ):  # profile has been successfully created but the XMPP credentials are wrong!
-            log.debug(
-                "Removing previously auto-created profile: %s" % failure.getErrorMessage()
-            )
-            self.host.memory.profile_delete_async(jid_s)
-            raise failure
-
-        # FIXME: we don't catch the case where the JID host is not an XMPP server, and the user
-        # has to wait until the DBUS timeout ; as a consequence, emails are sent to the admins
-        # and the profile is not deleted. When the host exists, remove_profile is well called.
-        d.addCallbacks(connected, remove_profile)
-        return d
--- a/sat/plugins/plugin_misc_android.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,566 +0,0 @@
-#!/usr/bin/env python3
-
-# SAT plugin for file tansfer
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import sys
-import os
-import os.path
-import json
-from pathlib import Path
-from zope.interface import implementer
-from twisted.names import client as dns_client
-from twisted.python.procutils import which
-from twisted.internet import defer
-from twisted.internet import reactor
-from twisted.internet import protocol
-from twisted.internet import abstract
-from twisted.internet import error as int_error
-from twisted.internet import _sslverify
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.tools.common import async_process
-from sat.memory import params
-
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Android",
-    C.PI_IMPORT_NAME: "android",
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_RECOMMENDATIONS: ["XEP-0352"],
-    C.PI_MAIN: "AndroidPlugin",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: D_(
-        """Manage Android platform specificities, like pause or notifications"""
-    ),
-}
-
-if sys.platform != "android":
-    raise exceptions.CancelError("this module is not needed on this platform")
-
-
-import re
-import certifi
-from plyer import vibrator
-from android import api_version
-from plyer.platforms.android import activity
-from plyer.platforms.android.notification import AndroidNotification
-from jnius import autoclass
-from android.broadcast import BroadcastReceiver
-from android import python_act
-
-
-Context = autoclass('android.content.Context')
-ConnectivityManager = autoclass('android.net.ConnectivityManager')
-MediaPlayer = autoclass('android.media.MediaPlayer')
-AudioManager = autoclass('android.media.AudioManager')
-
-# notifications
-AndroidString = autoclass('java.lang.String')
-PendingIntent = autoclass('android.app.PendingIntent')
-Intent = autoclass('android.content.Intent')
-
-# DNS
-# regex to find dns server prop with "getprop"
-RE_DNS = re.compile(r"^\[net\.[a-z0-9]+\.dns[0-4]\]: \[(.*)\]$", re.MULTILINE)
-SystemProperties = autoclass('android.os.SystemProperties')
-
-#: delay between a pause event and sending the inactive indication to server, in seconds
-#: we don't send the indication immediately because user can be just checking something
-#: quickly on an other app.
-CSI_DELAY = 30
-
-PARAM_RING_CATEGORY = "Notifications"
-PARAM_RING_NAME = "sound"
-PARAM_RING_LABEL = D_("sound on notifications")
-RING_OPTS = {
-    "normal": D_("Normal"),
-    "never": D_("Never"),
-}
-PARAM_VIBRATE_CATEGORY = "Notifications"
-PARAM_VIBRATE_NAME = "vibrate"
-PARAM_VIBRATE_LABEL = D_("Vibrate on notifications")
-VIBRATION_OPTS = {
-    "always": D_("Always"),
-    "vibrate": D_("In vibrate mode"),
-    "never": D_("Never"),
-}
-SOCKET_DIR = "/data/data/org.libervia.cagou/"
-SOCKET_FILE = ".socket"
-STATE_RUNNING = b"running"
-STATE_PAUSED = b"paused"
-STATE_STOPPED = b"stopped"
-STATES = (STATE_RUNNING, STATE_PAUSED, STATE_STOPPED)
-NET_TYPE_NONE = "no network"
-NET_TYPE_WIFI = "wifi"
-NET_TYPE_MOBILE = "mobile"
-NET_TYPE_OTHER = "other"
-INTENT_EXTRA_ACTION = AndroidString("org.salut-a-toi.IntentAction")
-
-
-@implementer(_sslverify.IOpenSSLTrustRoot)
-class AndroidTrustPaths:
-
-    def _addCACertsToContext(self, context):
-        # twisted doesn't have access to Android root certificates
-        # we use certifi to work around that (same thing is done in Kivy)
-        context.load_verify_locations(certifi.where())
-
-
-def platformTrust():
-    return AndroidTrustPaths()
-
-
-class Notification(AndroidNotification):
-    # We extend plyer's AndroidNotification instead of creating directly with jnius
-    # because it already handles issues like backward compatibility, and we just want to
-    # slightly modify the behaviour.
-
-    @staticmethod
-    def _set_open_behavior(notification, sat_action):
-        # we reproduce plyer's AndroidNotification._set_open_behavior
-        # bu we add SàT specific extra action data
-
-        app_context = activity.getApplication().getApplicationContext()
-        notification_intent = Intent(app_context, python_act)
-
-        notification_intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
-        notification_intent.setAction(Intent.ACTION_MAIN)
-        notification_intent.add_category(Intent.CATEGORY_LAUNCHER)
-        if sat_action is not None:
-            action_data = AndroidString(json.dumps(sat_action).encode())
-            log.debug(f"adding extra {INTENT_EXTRA_ACTION} ==> {action_data}")
-            notification_intent = notification_intent.putExtra(
-                INTENT_EXTRA_ACTION, action_data)
-
-        # we use PendingIntent.FLAG_UPDATE_CURRENT here, otherwise extra won't be set
-        # in the new intent (the old ACTION_MAIN intent will be reused). This differs
-        # from plyers original behaviour which set no flag here
-        pending_intent = PendingIntent.getActivity(
-            app_context, 0, notification_intent, PendingIntent.FLAG_UPDATE_CURRENT
-        )
-
-        notification.setContentIntent(pending_intent)
-        notification.setAutoCancel(True)
-
-    def _notify(self, **kwargs):
-        # we reproduce plyer's AndroidNotification._notify behaviour here
-        # and we add handling of "sat_action" attribute (SàT specific).
-        # we also set, where suitable, default values to empty string instead of
-        # original None, as a string is expected (in plyer the empty string is used
-        # in the generic "notify" method).
-        sat_action = kwargs.pop("sat_action", None)
-        noti = None
-        message = kwargs.get('message', '').encode('utf-8')
-        ticker = kwargs.get('ticker', '').encode('utf-8')
-        title = AndroidString(
-            kwargs.get('title', '').encode('utf-8')
-        )
-        icon = kwargs.get('app_icon', '')
-
-        if kwargs.get('toast', False):
-            self._toast(message)
-            return
-        else:
-            noti = self._build_notification(title)
-
-        noti.setContentTitle(title)
-        noti.setContentText(AndroidString(message))
-        noti.setTicker(AndroidString(ticker))
-
-        self._set_icons(noti, icon=icon)
-        self._set_open_behavior(noti, sat_action)
-
-        self._open_notification(noti)
-
-
-class FrontendStateProtocol(protocol.Protocol):
-
-    def __init__(self, android_plugin):
-        self.android_plugin = android_plugin
-
-    def dataReceived(self, data):
-        if data in STATES:
-            self.android_plugin.state = data
-        else:
-            log.warning("Unexpected data: {data}".format(data=data))
-
-
-class FrontendStateFactory(protocol.Factory):
-
-    def __init__(self, android_plugin):
-        self.android_plugin = android_plugin
-
-    def buildProtocol(self, addr):
-        return FrontendStateProtocol(self.android_plugin)
-
-
-
-class AndroidPlugin(object):
-
-    params = """
-    <params>
-    <individual>
-    <category name="{category_name}" label="{category_label}">
-        <param name="{ring_param_name}" label="{ring_param_label}" type="list" security="0">
-            {ring_options}
-        </param>
-        <param name="{vibrate_param_name}" label="{vibrate_param_label}" type="list" security="0">
-            {vibrate_options}
-        </param>
-     </category>
-    </individual>
-    </params>
-    """.format(
-        category_name=PARAM_VIBRATE_CATEGORY,
-        category_label=D_(PARAM_VIBRATE_CATEGORY),
-        vibrate_param_name=PARAM_VIBRATE_NAME,
-        vibrate_param_label=PARAM_VIBRATE_LABEL,
-        vibrate_options=params.make_options(VIBRATION_OPTS, "always"),
-        ring_param_name=PARAM_RING_NAME,
-        ring_param_label=PARAM_RING_LABEL,
-        ring_options=params.make_options(RING_OPTS, "normal"),
-    )
-
-    def __init__(self, host):
-        log.info(_("plugin Android initialization"))
-        log.info(f"using Android API {api_version}")
-        self.host = host
-        self._csi = host.plugins.get('XEP-0352')
-        self._csi_timer = None
-        host.memory.update_params(self.params)
-        try:
-            os.mkdir(SOCKET_DIR, 0o700)
-        except OSError as e:
-            if e.errno == 17:
-                # dir already exists
-                pass
-            else:
-                raise e
-        self._state = None
-        factory = FrontendStateFactory(self)
-        socket_path = os.path.join(SOCKET_DIR, SOCKET_FILE)
-        try:
-            reactor.listenUNIX(socket_path, factory)
-        except int_error.CannotListenError as e:
-            if e.socketError.errno == 98:
-                # the address is already in use, we need to remove it
-                os.unlink(socket_path)
-                reactor.listenUNIX(socket_path, factory)
-            else:
-                raise e
-        # we set a low priority because we want the notification to be sent after all
-        # plugins have done their job
-        host.trigger.add("message_received", self.message_received_trigger, priority=-1000)
-
-        # profiles autoconnection
-        host.bridge.add_method(
-            "profile_autoconnect_get",
-            ".plugin",
-            in_sign="",
-            out_sign="s",
-            method=self._profile_autoconnect_get,
-            async_=True,
-        )
-
-        # audio manager, to get ring status
-        self.am = activity.getSystemService(Context.AUDIO_SERVICE)
-
-        # sound notification
-        media_dir = Path(host.memory.config_get("", "media_dir"))
-        assert media_dir is not None
-        notif_path = media_dir / "sounds" / "notifications" / "music-box.mp3"
-        self.notif_player = MediaPlayer()
-        self.notif_player.setDataSource(str(notif_path))
-        self.notif_player.setAudioStreamType(AudioManager.STREAM_NOTIFICATION)
-        self.notif_player.prepare()
-
-        # SSL fix
-        _sslverify.platformTrust = platformTrust
-        log.info("SSL Android patch applied")
-
-        # DNS fix
-        defer.ensureDeferred(self.update_resolver())
-
-        # Connectivity handling
-        self.cm = activity.getSystemService(Context.CONNECTIVITY_SERVICE)
-        self._net_type = None
-        d = defer.ensureDeferred(self._check_connectivity())
-        d.addErrback(host.log_errback)
-
-        # XXX: we need to keep a reference to BroadcastReceiver to avoid
-        #     "XXX has no attribute 'invoke'" error (looks like the same issue as
-        #     https://github.com/kivy/pyjnius/issues/59)
-        self.br = BroadcastReceiver(
-            callback=lambda *args, **kwargs: reactor.callFromThread(
-                self.on_connectivity_change
-            ),
-            actions=["android.net.conn.CONNECTIVITY_CHANGE"]
-        )
-        self.br.start()
-
-    @property
-    def state(self):
-        return self._state
-
-    @state.setter
-    def state(self, new_state):
-        log.debug(f"frontend state has changed: {new_state.decode()}")
-        previous_state = self._state
-        self._state = new_state
-        if new_state == STATE_RUNNING:
-            self._on_running(previous_state)
-        elif new_state == STATE_PAUSED:
-            self._on_paused(previous_state)
-        elif new_state == STATE_STOPPED:
-            self._on_stopped(previous_state)
-
-    @property
-    def cagou_active(self):
-        return self._state == STATE_RUNNING
-
-    def _on_running(self, previous_state):
-        if previous_state is not None:
-            self.host.bridge.bridge_reactivate_signals()
-        self.set_active()
-
-    def _on_paused(self, previous_state):
-        self.host.bridge.bridge_deactivate_signals()
-        self.set_inactive()
-
-    def _on_stopped(self, previous_state):
-        self.set_inactive()
-
-    def _notify_message(self, mess_data, client):
-        """Send notification when suitable
-
-        notification is sent if:
-            - there is a message and it is not a groupchat
-            - message is not coming from ourself
-        """
-        if (mess_data["message"] and mess_data["type"] != C.MESS_TYPE_GROUPCHAT
-            and not mess_data["from"].userhostJID() == client.jid.userhostJID()):
-            message = next(iter(mess_data["message"].values()))
-            try:
-                subject = next(iter(mess_data["subject"].values()))
-            except StopIteration:
-                subject = D_("new message from {contact}").format(
-                    contact = mess_data['from'])
-
-            notification = Notification()
-            notification._notify(
-                title=subject,
-                message=message,
-                sat_action={
-                    "type": "open",
-                    "widget": "chat",
-                    "target": mess_data["from"].userhost(),
-                },
-            )
-
-            ringer_mode = self.am.getRingerMode()
-            vibrate_mode = ringer_mode == AudioManager.RINGER_MODE_VIBRATE
-
-            ring_setting = self.host.memory.param_get_a(
-                PARAM_RING_NAME,
-                PARAM_RING_CATEGORY,
-                profile_key=client.profile
-            )
-
-            if ring_setting != 'never' and ringer_mode == AudioManager.RINGER_MODE_NORMAL:
-                self.notif_player.start()
-
-            vibration_setting = self.host.memory.param_get_a(
-                PARAM_VIBRATE_NAME,
-                PARAM_VIBRATE_CATEGORY,
-                profile_key=client.profile
-            )
-            if (vibration_setting == 'always'
-                or vibration_setting == 'vibrate' and vibrate_mode):
-                    try:
-                        vibrator.vibrate()
-                    except Exception as e:
-                        log.warning("Can't use vibrator: {e}".format(e=e))
-        return mess_data
-
-    def message_received_trigger(self, client, message_elt, post_treat):
-        if not self.cagou_active:
-            # we only send notification is the frontend is not displayed
-            post_treat.addCallback(self._notify_message, client)
-
-        return True
-
-    # Profile autoconnection
-
-    def _profile_autoconnect_get(self):
-        return defer.ensureDeferred(self.profile_autoconnect_get())
-
-    async def _get_profiles_autoconnect(self):
-        autoconnect_dict = await self.host.memory.storage.get_ind_param_values(
-            category='Connection', name='autoconnect_backend',
-        )
-        return [p for p, v in autoconnect_dict.items() if C.bool(v)]
-
-    async def profile_autoconnect_get(self):
-        """Return profile to connect automatically by frontend, if any"""
-        profiles_autoconnect = await self._get_profiles_autoconnect()
-        if not profiles_autoconnect:
-            return None
-        if len(profiles_autoconnect) > 1:
-            log.warning(
-                f"More that one profiles with backend autoconnection set found, picking "
-                f"up first one (full list: {profiles_autoconnect!r})")
-        return profiles_autoconnect[0]
-
-    # CSI
-
-    def _set_inactive(self):
-        self._csi_timer = None
-        for client in self.host.get_clients(C.PROF_KEY_ALL):
-            self._csi.set_inactive(client)
-
-    def set_inactive(self):
-        if self._csi is None or self._csi_timer is not None:
-            return
-        self._csi_timer = reactor.callLater(CSI_DELAY, self._set_inactive)
-
-    def set_active(self):
-        if self._csi is None:
-            return
-        if self._csi_timer is not None:
-            self._csi_timer.cancel()
-            self._csi_timer = None
-        for client in self.host.get_clients(C.PROF_KEY_ALL):
-            self._csi.set_active(client)
-
-    # Connectivity
-
-    async def _handle_network_change(self, net_type):
-        """Notify the clients about network changes.
-
-        This way the client can disconnect/reconnect transport, or change delays
-        """
-        log.debug(f"handling network change ({net_type})")
-        if net_type == NET_TYPE_NONE:
-            for client in self.host.get_clients(C.PROF_KEY_ALL):
-                client.network_disabled()
-        else:
-            # DNS servers may have changed
-            await self.update_resolver()
-            # client may be there but disabled (e.g. with stream management)
-            for client in self.host.get_clients(C.PROF_KEY_ALL):
-                log.debug(f"enabling network for {client.profile}")
-                client.network_enabled()
-
-            # profiles may have been disconnected and then purged, we try
-            # to reconnect them in case
-            profiles_autoconnect = await self._get_profiles_autoconnect()
-            for profile in profiles_autoconnect:
-                if not self.host.is_connected(profile):
-                    log.info(f"{profile} is not connected, reconnecting it")
-                    try:
-                        await self.host.connect(profile)
-                    except Exception as e:
-                        log.error(f"Can't connect profile {profile}: {e}")
-
-    async def _check_connectivity(self):
-        active_network = self.cm.getActiveNetworkInfo()
-        if active_network is None:
-            net_type = NET_TYPE_NONE
-        else:
-            net_type_android = active_network.getType()
-            if net_type_android == ConnectivityManager.TYPE_WIFI:
-                net_type = NET_TYPE_WIFI
-            elif net_type_android == ConnectivityManager.TYPE_MOBILE:
-                net_type = NET_TYPE_MOBILE
-            else:
-                net_type = NET_TYPE_OTHER
-
-        if net_type != self._net_type:
-            log.info("connectivity has changed")
-            self._net_type = net_type
-            if net_type == NET_TYPE_NONE:
-                log.info("no network active")
-            elif net_type == NET_TYPE_WIFI:
-                log.info("WIFI activated")
-            elif net_type == NET_TYPE_MOBILE:
-                log.info("mobile data activated")
-            else:
-                log.info("network activated (type={net_type_android})"
-                    .format(net_type_android=net_type_android))
-        else:
-            log.debug("_check_connectivity called without network change ({net_type})"
-                .format(net_type = net_type))
-
-        # we always call _handle_network_change even if there is not connectivity change
-        # to be sure to reconnect when necessary
-        await self._handle_network_change(net_type)
-
-
-    def on_connectivity_change(self):
-        log.debug("on_connectivity_change called")
-        d = defer.ensureDeferred(self._check_connectivity())
-        d.addErrback(self.host.log_errback)
-
-    async def update_resolver(self):
-        # There is no "/etc/resolv.conf" on Android, which confuse Twisted and makes
-        # SRV record checking unusable. We fixe that by checking DNS server used, and
-        # updating Twisted's resolver accordingly
-        dns_servers = await self.get_dns_servers()
-
-        log.info(
-            "Patching Twisted to use Android DNS resolver ({dns_servers})".format(
-            dns_servers=', '.join([s[0] for s in dns_servers]))
-        )
-        dns_client.theResolver = dns_client.createResolver(servers=dns_servers)
-
-    async def get_dns_servers(self):
-        servers = []
-
-        if api_version < 26:
-            # thanks to A-IV at https://stackoverflow.com/a/11362271 for the way to go
-            log.debug("Old API, using SystemProperties to find DNS")
-            for idx in range(1, 5):
-                addr = SystemProperties.get(f'net.dns{idx}')
-                if abstract.isIPAddress(addr):
-                    servers.append((addr, 53))
-        else:
-            log.debug(f"API {api_version} >= 26, using getprop to find DNS")
-            # use of getprop inspired by various solutions at
-            # https://stackoverflow.com/q/3070144
-            # it's the most simple option, and it fit wells with async_process
-            getprop_paths = which('getprop')
-            if getprop_paths:
-                try:
-                    getprop_path = getprop_paths[0]
-                    props = await async_process.run(getprop_path)
-                    servers = [(ip, 53) for ip in RE_DNS.findall(props.decode())
-                               if abstract.isIPAddress(ip)]
-                except Exception as e:
-                    log.warning(f"Can't use \"getprop\" to find DNS server: {e}")
-        if not servers:
-            # FIXME: Cloudflare's 1.1.1.1 seems to have a better privacy policy, to be
-            #   checked.
-            log.warning(
-                "no server found, we have to use factory Google DNS, this is not ideal "
-                "for privacy"
-            )
-            servers.append(('8.8.8.8', 53), ('8.8.4.4', 53))
-        return servers
--- a/sat/plugins/plugin_misc_app_manager.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,636 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin to manage external applications
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from pathlib import Path
-from typing import Optional, List, Callable
-from functools import partial, reduce
-import tempfile
-import secrets
-import string
-import shortuuid
-from twisted.internet import defer
-from twisted.python.procutils import which
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.tools.common import data_format
-from sat.tools.common import async_process
-
-log = getLogger(__name__)
-
-try:
-    import yaml
-except ImportError:
-    raise exceptions.MissingModule(
-        'Missing module PyYAML, please download/install it. You can use '
-        '"pip install pyyaml"'
-    )
-
-try:
-    from yaml import CLoader as Loader, CDumper as Dumper
-except ImportError:
-    log.warning(
-        "Can't use LibYAML binding (is libyaml installed?), pure Python version will be "
-        "used, but it is slower"
-    )
-    from yaml import Loader, Dumper
-
-from yaml.constructor import ConstructorError
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Applications Manager",
-    C.PI_IMPORT_NAME: "APP_MANAGER",
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_MAIN: "AppManager",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _(
-        """Applications Manager
-
-Manage external applications using packagers, OS virtualization/containers or other
-software management tools.
-"""),
-}
-
-APP_FILE_PREFIX = "sat_app_"
-
-
-class AppManager:
-    load = partial(yaml.load, Loader=Loader)
-    dump = partial(yaml.dump, Dumper=Dumper)
-
-    def __init__(self, host):
-        log.info(_("plugin Applications Manager initialization"))
-        self.host = host
-        self._managers = {}
-        self._apps = {}
-        self._started = {}
-        # instance id to app data map
-        self._instances = {}
-        host.bridge.add_method(
-            "applications_list",
-            ".plugin",
-            in_sign="as",
-            out_sign="as",
-            method=self.list_applications,
-        )
-        host.bridge.add_method(
-            "application_start",
-            ".plugin",
-            in_sign="ss",
-            out_sign="s",
-            method=self._start,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "application_stop",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._stop,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "application_exposed_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=self._get_exposed,
-            async_=True,
-        )
-        # application has been started succeesfully,
-        # args: name, instance_id, extra
-        host.bridge.add_signal(
-            "application_started", ".plugin", signature="sss"
-        )
-        # application went wrong with the application
-        # args: name, instance_id, extra
-        host.bridge.add_signal(
-            "application_error", ".plugin", signature="sss"
-        )
-        yaml.add_constructor(
-            "!sat_conf", self._sat_conf_constr, Loader=Loader)
-        yaml.add_constructor(
-            "!sat_generate_pwd", self._sat_generate_pwd_constr, Loader=Loader)
-        yaml.add_constructor(
-            "!sat_param", self._sat_param_constr, Loader=Loader)
-
-    def unload(self):
-        log.debug("unloading applications manager")
-        for instances in self._started.values():
-            for instance in instances:
-                data = instance['data']
-                if not data['single_instance']:
-                    log.debug(
-                        f"cleaning temporary directory at {data['_instance_dir_path']}")
-                    data['_instance_dir_obj'].cleanup()
-
-    def _sat_conf_constr(self, loader, node):
-        """Get a value from Libervia configuration
-
-        A list is expected with either "name" of a config parameter, a one or more of
-        those parameters:
-            - section
-            - name
-            - default value
-            - filter
-        filter can be:
-            - "first": get the first item of the value
-        """
-        config_data = loader.construct_sequence(node)
-        if len(config_data) == 1:
-            section, name, default, filter_ = "", config_data[0], None, None
-        if len(config_data) == 2:
-            (section, name), default, filter_ = config_data, None, None
-        elif len(config_data) == 3:
-            (section, name, default), filter_ = config_data, None
-        elif len(config_data) == 4:
-            section, name, default, filter_ = config_data
-        else:
-            raise ValueError(
-                f"invalid !sat_conf value ({config_data!r}), a list of 1 to 4 items is "
-                "expected"
-            )
-
-        value = self.host.memory.config_get(section, name, default)
-        # FIXME: "public_url" is used only here and doesn't take multi-sites into account
-        if name == "public_url" and (not value or value.startswith('http')):
-            if not value:
-                log.warning(_(
-                    'No value found for "public_url", using "example.org" for '
-                    'now, please set the proper value in libervia.conf'))
-            else:
-                log.warning(_(
-                    'invalid value for "public_url" ({value}), it musts not start with '
-                    'schema ("http"), ignoring it and using "example.org" '
-                    'instead')
-                        .format(value=value))
-            value = "example.org"
-
-        if filter_ is None:
-            pass
-        elif filter_ == 'first':
-            value = value[0]
-        else:
-            raise ValueError(f"unmanaged filter: {filter_}")
-
-        return value
-
-    def _sat_generate_pwd_constr(self, loader, node):
-        alphabet = string.ascii_letters + string.digits
-        return ''.join(secrets.choice(alphabet) for i in range(30))
-
-    def _sat_param_constr(self, loader, node):
-        """Get a parameter specified when starting the application
-
-        The value can be either the name of the parameter to get, or a list as
-        [name, default_value]
-        """
-        try:
-            name, default = loader.construct_sequence(node)
-        except ConstructorError:
-            name, default = loader.construct_scalar(node), None
-        return self._params.get(name, default)
-
-    def register(self, manager):
-        name = manager.name
-        if name in self._managers:
-            raise exceptions.ConflictError(
-                f"There is already a manager with the name {name}")
-        self._managers[manager.name] = manager
-        if hasattr(manager, "discover_path"):
-            self.discover(manager.discover_path, manager)
-
-    def get_manager(self, app_data: dict) -> object:
-        """Get manager instance needed for this app
-
-        @raise exceptions.DataError: something is wrong with the type
-        @raise exceptions.NotFound: manager is not registered
-        """
-        try:
-            app_type = app_data["type"]
-        except KeyError:
-            raise exceptions.DataError(
-                "app file doesn't have the mandatory \"type\" key"
-            )
-        if not isinstance(app_type, str):
-            raise exceptions.DataError(
-                f"invalid app data type: {app_type!r}"
-            )
-        app_type = app_type.strip()
-        try:
-            return self._managers[app_type]
-        except KeyError:
-            raise exceptions.NotFound(
-                f"No manager found to manage app of type {app_type!r}")
-
-    def get_app_data(
-        self,
-        id_type: Optional[str],
-        identifier: str
-    ) -> dict:
-        """Retrieve instance's app_data from identifier
-
-        @param id_type: type of the identifier, can be:
-            - "name": identifier is a canonical application name
-                the first found instance of this application is returned
-            - "instance": identifier is an instance id
-        @param identifier: identifier according to id_type
-        @return: instance application data
-        @raise exceptions.NotFound: no instance with this id can be found
-        @raise ValueError: id_type is invalid
-        """
-        if not id_type:
-            id_type = 'name'
-        if id_type == 'name':
-            identifier = identifier.lower().strip()
-            try:
-                return next(iter(self._started[identifier]))
-            except (KeyError, StopIteration):
-                raise exceptions.NotFound(
-                    f"No instance of {identifier!r} is currently running"
-                )
-        elif id_type == 'instance':
-            instance_id = identifier
-            try:
-                return self._instances[instance_id]
-            except KeyError:
-                raise exceptions.NotFound(
-                    f"There is no application instance running with id {instance_id!r}"
-                )
-        else:
-            raise ValueError(f"invalid id_type: {id_type!r}")
-
-    def discover(
-            self,
-            dir_path: Path,
-            manager: Optional = None
-    ) -> None:
-        for file_path in dir_path.glob(f"{APP_FILE_PREFIX}*.yaml"):
-            if manager is None:
-                try:
-                    app_data = self.parse(file_path)
-                    manager = self.get_manager(app_data)
-                except (exceptions.DataError, exceptions.NotFound) as e:
-                    log.warning(
-                        f"Can't parse {file_path}, skipping: {e}")
-            app_name = file_path.stem[len(APP_FILE_PREFIX):].strip().lower()
-            if not app_name:
-                log.warning(
-                    f"invalid app file name at {file_path}")
-                continue
-            app_dict = self._apps.setdefault(app_name, {})
-            manager_set = app_dict.setdefault(manager, set())
-            manager_set.add(file_path)
-            log.debug(
-                f"{app_name!r} {manager.name} application found"
-            )
-
-    def parse(self, file_path: Path, params: Optional[dict] = None) -> dict:
-        """Parse Libervia application file
-
-        @param params: parameters for running this instance
-        @raise exceptions.DataError: something is wrong in the file
-        """
-        if params is None:
-            params = {}
-        with file_path.open() as f:
-            # we set parameters to be used only with this instance
-            # no async method must used between this assignation and `load`
-            self._params = params
-            app_data = self.load(f)
-            self._params = None
-        if "name" not in app_data:
-            # note that we don't use lower() here as we want human readable name and
-            # uppercase may be set on purpose
-            app_data['name'] = file_path.stem[len(APP_FILE_PREFIX):].strip()
-        single_instance = app_data.setdefault("single_instance", True)
-        if not isinstance(single_instance, bool):
-            raise ValueError(
-                f'"single_instance" must be a boolean, but it is {type(single_instance)}'
-            )
-        return app_data
-
-    def list_applications(self, filters: Optional[List[str]]) -> List[str]:
-        """List available application
-
-        @param filters: only show applications matching those filters.
-            using None will list all known applications
-            a filter can be:
-                - available: applications available locally
-                - running: only show launched applications
-        """
-        if not filters:
-            return list(self.apps)
-        found = set()
-        for filter_ in filters:
-            if filter_ == "available":
-                found.update(self._apps)
-            elif filter_ == "running":
-                found.update(self._started)
-            else:
-                raise ValueError(f"Unknown filter: {filter_}")
-        return list(found)
-
-    def _start(self, app_name, extra):
-        extra = data_format.deserialise(extra)
-        d = defer.ensureDeferred(self.start(str(app_name), extra))
-        d.addCallback(data_format.serialise)
-        return d
-
-    async def start(
-        self,
-        app_name: str,
-        extra: Optional[dict] = None,
-    ) -> dict:
-        """Start an application
-
-        @param app_name: name of the application to start
-        @param extra: extra parameters
-        @return: data with following keys:
-            - name (str): canonical application name
-            - instance (str): instance ID
-            - started (bool): True if the application is already started
-                if False, the "application_started" signal should be used to get notificed
-                when the application is actually started
-            - expose (dict): exposed data as given by [self.get_exposed]
-                exposed data which need to be computed are NOT returned, they will
-                available when the app will be fully started, throught the
-                [self.get_exposed] method.
-        """
-        # FIXME: for now we use the first app manager available for the requested app_name
-        # TODO: implement running multiple instance of the same app if some metadata
-        #   to be defined in app_data allows explicitly it.
-        app_name = app_name.lower().strip()
-        try:
-            app_file_path = next(iter(next(iter(self._apps[app_name].values()))))
-        except KeyError:
-            raise exceptions.NotFound(
-                f"No application found with the name {app_name!r}"
-            )
-        log.info(f"starting {app_name!r}")
-        started_data = self._started.setdefault(app_name, [])
-        app_data = self.parse(app_file_path, extra)
-        app_data["_started"] = False
-        app_data['_file_path'] = app_file_path
-        app_data['_name_canonical'] = app_name
-        single_instance = app_data['single_instance']
-        ret_data = {
-            "name": app_name,
-            "started": False
-        }
-        if single_instance:
-            if started_data:
-                instance_data = started_data[0]
-                instance_id = instance_data["_instance_id"]
-                ret_data["instance"] = instance_id
-                ret_data["started"] = instance_data["_started"]
-                ret_data["expose"] = await self.get_exposed(
-                    instance_id, "instance", {"skip_compute": True}
-                )
-                log.info(f"{app_name!r} is already started or being started")
-                return ret_data
-            else:
-                cache_path = self.host.memory.get_cache_path(
-                    PLUGIN_INFO[C.PI_IMPORT_NAME], app_name
-                )
-                cache_path.mkdir(0o700, parents=True, exist_ok=True)
-                app_data['_instance_dir_path'] = cache_path
-        else:
-            dest_dir_obj = tempfile.TemporaryDirectory(prefix="sat_app_")
-            app_data['_instance_dir_obj'] = dest_dir_obj
-            app_data['_instance_dir_path'] = Path(dest_dir_obj.name)
-        instance_id = ret_data["instance"] = app_data['_instance_id'] = shortuuid.uuid()
-        manager = self.get_manager(app_data)
-        app_data['_manager'] = manager
-        started_data.append(app_data)
-        self._instances[instance_id] = app_data
-        # we retrieve exposed data such as url_prefix which can be useful computed exposed
-        # data must wait for the app to be started, so we skip them for now
-        ret_data["expose"] = await self.get_exposed(
-            instance_id, "instance", {"skip_compute": True}
-        )
-
-        try:
-            start = manager.start
-        except AttributeError:
-            raise exceptions.InternalError(
-                f"{manager.name} doesn't have the mandatory \"start\" method"
-            )
-        else:
-            defer.ensureDeferred(self.start_app(start, app_data))
-        return ret_data
-
-    async def start_app(self, start_cb: Callable, app_data: dict) -> None:
-        app_name = app_data["_name_canonical"]
-        instance_id = app_data["_instance_id"]
-        try:
-            await start_cb(app_data)
-        except Exception as e:
-            log.exception(f"Can't start libervia app {app_name!r}")
-            self.host.bridge.application_error(
-                app_name,
-                instance_id,
-                data_format.serialise(
-                    {
-                        "class": str(type(e)),
-                        "msg": str(e)
-                    }
-                ))
-        else:
-            app_data["_started"] = True
-            self.host.bridge.application_started(app_name, instance_id, "")
-            log.info(f"{app_name!r} started")
-
-    def _stop(self, identifier, id_type, extra):
-        extra = data_format.deserialise(extra)
-        return defer.ensureDeferred(
-            self.stop(str(identifier), str(id_type) or None, extra))
-
-    async def stop(
-        self,
-        identifier: str,
-        id_type: Optional[str] = None,
-        extra: Optional[dict] = None,
-    ) -> None:
-        if extra is None:
-            extra = {}
-
-        app_data = self.get_app_data(id_type, identifier)
-
-        log.info(f"stopping {app_data['name']!r}")
-
-        app_name = app_data['_name_canonical']
-        instance_id = app_data['_instance_id']
-        manager = app_data['_manager']
-
-        try:
-            stop = manager.stop
-        except AttributeError:
-            raise exceptions.InternalError(
-                f"{manager.name} doesn't have the mandatory \"stop\" method"
-            )
-        else:
-            try:
-                await stop(app_data)
-            except Exception as e:
-                log.warning(
-                    f"Instance {instance_id} of application {app_name} can't be stopped "
-                    f"properly: {e}"
-                )
-                return
-
-        try:
-            del self._instances[instance_id]
-        except KeyError:
-            log.error(
-                f"INTERNAL ERROR: {instance_id!r} is not present in self._instances")
-
-        try:
-            self._started[app_name].remove(app_data)
-        except ValueError:
-            log.error(
-                "INTERNAL ERROR: there is no app data in self._started with id "
-                f"{instance_id!r}"
-            )
-
-        log.info(f"{app_name!r} stopped")
-
-    def _get_exposed(self, identifier, id_type, extra):
-        extra = data_format.deserialise(extra)
-        d = defer.ensureDeferred(self.get_exposed(identifier, id_type, extra))
-        d.addCallback(lambda d: data_format.serialise(d))
-        return d
-
-    async def get_exposed(
-        self,
-        identifier: str,
-        id_type: Optional[str] = None,
-        extra: Optional[dict] = None,
-    ) -> dict:
-        """Get data exposed by the application
-
-        The manager's "compute_expose" method will be called if it exists. It can be used
-        to handle manager specific conventions.
-        """
-        app_data = self.get_app_data(id_type, identifier)
-        if app_data.get('_exposed_computed', False):
-            return app_data['expose']
-        if extra is None:
-            extra = {}
-        expose = app_data.setdefault("expose", {})
-        if "passwords" in expose:
-            passwords = expose['passwords']
-            for name, value in list(passwords.items()):
-                if isinstance(value, list):
-                    # if we have a list, is the sequence of keys leading to the value
-                    # to expose. We use "reduce" to retrieve the desired value
-                    try:
-                        passwords[name] = reduce(lambda l, k: l[k], value, app_data)
-                    except Exception as e:
-                        log.warning(
-                            f"Can't retrieve exposed value for password {name!r}: {e}")
-                        del passwords[name]
-
-        url_prefix = expose.get("url_prefix")
-        if isinstance(url_prefix, list):
-            try:
-                expose["url_prefix"] = reduce(lambda l, k: l[k], url_prefix, app_data)
-            except Exception as e:
-                log.warning(
-                    f"Can't retrieve exposed value for url_prefix: {e}")
-                del expose["url_prefix"]
-
-        if extra.get("skip_compute", False):
-            return expose
-
-        try:
-            compute_expose = app_data['_manager'].compute_expose
-        except AttributeError:
-            pass
-        else:
-            await compute_expose(app_data)
-
-        app_data['_exposed_computed'] = True
-        return expose
-
-    async def _do_prepare(
-        self,
-        app_data: dict,
-    ) -> None:
-        name = app_data['name']
-        dest_path = app_data['_instance_dir_path']
-        if next(dest_path.iterdir(), None) != None:
-            log.debug(f"There is already a prepared dir at {dest_path}, nothing to do")
-            return
-        try:
-            prepare = app_data['prepare'].copy()
-        except KeyError:
-            prepare = {}
-
-        if not prepare:
-            log.debug("Nothing to prepare for {name!r}")
-            return
-
-        for action, value in list(prepare.items()):
-            log.debug(f"[{name}] [prepare] running {action!r} action")
-            if action == "git":
-                try:
-                    git_path = which('git')[0]
-                except IndexError:
-                    raise exceptions.NotFound(
-                        "Can't find \"git\" executable, {name} can't be started without it"
-                    )
-                await async_process.run(git_path, "clone", value, str(dest_path))
-                log.debug(f"{value!r} git repository cloned at {dest_path}")
-            else:
-                raise NotImplementedError(
-                    f"{action!r} is not managed, can't start {name}"
-                )
-            del prepare[action]
-
-        if prepare:
-            raise exceptions.InternalError('"prepare" should be empty')
-
-    async def _do_create_files(
-        self,
-        app_data: dict,
-    ) -> None:
-        dest_path = app_data['_instance_dir_path']
-        files = app_data.get('files')
-        if not files:
-            return
-        if not isinstance(files, dict):
-            raise ValueError('"files" must be a dictionary')
-        for filename, data in files.items():
-            path = dest_path / filename
-            if path.is_file():
-                log.info(f"{path} already exists, skipping")
-            with path.open("w") as f:
-                f.write(data.get("content", ""))
-            log.debug(f"{path} created")
-
-    async def start_common(self, app_data: dict) -> None:
-        """Method running common action when starting a manager
-
-        It should be called by managers in "start" method.
-        """
-        await self._do_prepare(app_data)
-        await self._do_create_files(app_data)
--- a/sat/plugins/plugin_misc_attach.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,278 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin for attaching files
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from collections import namedtuple
-import mimetypes
-from pathlib import Path
-import shutil
-import tempfile
-from typing import Callable, Optional
-
-from twisted.internet import defer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools import utils
-from sat.tools import image
-
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "File Attach",
-    C.PI_IMPORT_NAME: "ATTACH",
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_DEPENDENCIES: ["UPLOAD"],
-    C.PI_MAIN: "AttachPlugin",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Attachments handler"""),
-}
-
-
-AttachmentHandler = namedtuple('AttachmentHandler', ['can_handle', 'attach', 'priority'])
-
-
-class AttachPlugin:
-
-    def __init__(self, host):
-        log.info(_("plugin Attach initialization"))
-        self.host = host
-        self._u = host.plugins["UPLOAD"]
-        host.trigger.add("sendMessage", self._send_message_trigger)
-        host.trigger.add("sendMessageComponent", self._send_message_trigger)
-        self._attachments_handlers = {'clear': [], 'encrypted': []}
-        self.register(self.default_can_handle, self.default_attach, False, -1000)
-
-    def register(self, can_handle, attach, encrypted=False, priority=0):
-        """Register an attachments handler
-
-        @param can_handle(callable, coroutine, Deferred): a method which must return True
-            if this plugin can handle the upload, otherwise next ones will be tried.
-            This method will get client and mess_data as arguments, before the XML is
-            generated
-        @param attach(callable, coroutine, Deferred): attach the file
-            this method will get client and mess_data as arguments, after XML is
-            generated. Upload operation must be handled
-            hint: "UPLOAD" plugin can be used
-        @param encrypted(bool): True if the handler manages encrypted files
-            A handler can be registered twice if it handle both encrypted and clear
-            attachments
-        @param priority(int): priority of this handler, handler with higher priority will
-            be tried first
-        """
-        handler = AttachmentHandler(can_handle, attach, priority)
-        handlers = (
-            self._attachments_handlers['encrypted']
-            if encrypted else self._attachments_handlers['clear']
-        )
-        if handler in handlers:
-            raise exceptions.InternalError(
-                'Attachment handler has been registered twice, this should never happen'
-            )
-
-        handlers.append(handler)
-        handlers.sort(key=lambda h: h.priority, reverse=True)
-        log.debug(f"new attachments handler: {handler}")
-
-    async def attach_files(self, client, data):
-        """Main method to attach file
-
-        It will do generic pre-treatment, and call the suitable attachments handler
-        """
-        # we check attachment for pre-treatment like large image resizing
-        # media_type will be added if missing (and if it can be guessed from path)
-        attachments = data["extra"][C.KEY_ATTACHMENTS]
-        tmp_dirs_to_clean = []
-        for attachment in attachments:
-            if attachment.get(C.KEY_ATTACHMENTS_RESIZE, False):
-                path = Path(attachment["path"])
-                try:
-                    media_type = attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE]
-                except KeyError:
-                    media_type = mimetypes.guess_type(path, strict=False)[0]
-                    if media_type is None:
-                        log.warning(
-                            _("Can't resize attachment of unknown type: {attachment}")
-                            .format(attachment=attachment))
-                        continue
-                    attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE] = media_type
-
-                main_type = media_type.split('/')[0]
-                if main_type == "image":
-                    report = image.check(self.host, path)
-                    if report['too_large']:
-                        tmp_dir = Path(tempfile.mkdtemp())
-                        tmp_dirs_to_clean.append(tmp_dir)
-                        new_path = tmp_dir / path.name
-                        await image.resize(
-                            path, report["recommended_size"], dest=new_path)
-                        attachment["path"] = new_path
-                        log.info(
-                            _("Attachment {path!r} has been resized at {new_path!r}")
-                            .format(path=str(path), new_path=str(new_path)))
-                else:
-                    log.warning(
-                        _("Can't resize attachment of type {main_type!r}: {attachment}")
-                        .format(main_type=main_type, attachment=attachment))
-
-        if client.encryption.is_encryption_requested(data):
-            handlers = self._attachments_handlers['encrypted']
-        else:
-            handlers = self._attachments_handlers['clear']
-
-        for handler in handlers:
-            can_handle = await utils.as_deferred(handler.can_handle, client, data)
-            if can_handle:
-                break
-        else:
-            raise exceptions.NotFound(
-                _("No plugin can handle attachment with {destinee}").format(
-                destinee = data['to']
-            ))
-
-        await utils.as_deferred(handler.attach, client, data)
-
-        for dir_path in tmp_dirs_to_clean:
-            log.debug(f"Cleaning temporary directory at {dir_path}")
-            shutil.rmtree(dir_path)
-
-        return data
-
-    async def upload_files(
-        self,
-        client: SatXMPPEntity,
-        data: dict,
-        upload_cb: Optional[Callable] = None
-    ):
-        """Upload file, and update attachments
-
-        invalid attachments will be removed
-        @param client:
-        @param data(dict): message data
-        @param upload_cb(coroutine, Deferred, None): method to use for upload
-            if None, upload method from UPLOAD plugin will be used.
-            Otherwise, following kwargs will be used with the cb:
-                - client
-                - filepath
-                - filename
-                - options
-            the method must return a tuple similar to UPLOAD plugin's upload method,
-            it must contain:
-                - progress_id
-                - a deferred which fire download URL
-        """
-        if upload_cb is None:
-            upload_cb = self._u.upload
-
-        uploads_d = []
-        to_delete = []
-        attachments = data["extra"]["attachments"]
-
-        for attachment in attachments:
-            if "url" in attachment and not "path" in attachment:
-                log.debug(f"attachment is external, we don't upload it: {attachment}")
-                continue
-            try:
-                # we pop path because we don't want it to be stored, as the file can be
-                # only in a temporary location
-                path = Path(attachment.pop("path"))
-            except KeyError:
-                log.warning("no path in attachment: {attachment}")
-                to_delete.append(attachment)
-                continue
-
-            if "url" in attachment:
-                url = attachment.pop('url')
-                log.warning(
-                    f"unexpected URL in attachment: {url!r}\nattachment: {attachment}"
-                )
-
-            try:
-                name = attachment["name"]
-            except KeyError:
-                name = attachment["name"] = path.name
-
-            attachment["size"] = path.stat().st_size
-
-            extra = {
-                "attachment": attachment
-            }
-            progress_id = attachment.pop("progress_id", None)
-            if progress_id:
-                extra["progress_id"] = progress_id
-            check_certificate = self.host.memory.param_get_a(
-                "check_certificate", "Connection", profile_key=client.profile)
-            if not check_certificate:
-                extra['ignore_tls_errors'] = True
-                log.warning(
-                    _("certificate check disabled for upload, this is dangerous!"))
-
-            __, upload_d = await upload_cb(
-                client=client,
-                filepath=path,
-                filename=name,
-                extra=extra,
-            )
-            uploads_d.append(upload_d)
-
-        for attachment in to_delete:
-            attachments.remove(attachment)
-
-        upload_results = await defer.DeferredList(uploads_d)
-        for idx, (success, ret) in enumerate(upload_results):
-            attachment = attachments[idx]
-
-            if not success:
-                # ret is a failure here
-                log.warning(f"error while uploading {attachment}: {ret}")
-                continue
-
-            attachment["url"] = ret
-
-        return data
-
-    def _attach_files(self, data, client):
-        return defer.ensureDeferred(self.attach_files(client, data))
-
-    def _send_message_trigger(
-        self, client, mess_data, pre_xml_treatments, post_xml_treatments):
-        if mess_data['extra'].get(C.KEY_ATTACHMENTS):
-            post_xml_treatments.addCallback(self._attach_files, client=client)
-        return True
-
-    async def default_can_handle(self, client, data):
-        return True
-
-    async def default_attach(self, client, data):
-        await self.upload_files(client, data)
-        # TODO: handle xhtml-im
-        body_elt = data["xml"].body
-        if body_elt is None:
-            body_elt = data["xml"].addElement("body")
-        attachments = data["extra"][C.KEY_ATTACHMENTS]
-        if attachments:
-            body_links = '\n'.join(a['url'] for a in attachments)
-            if str(body_elt).strip():
-                # if there is already a body, we add a line feed before the first link
-                body_elt.addContent('\n')
-            body_elt.addContent(body_links)
--- a/sat/plugins/plugin_misc_debug.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,63 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin for managing raw XML log
-# Copyright (C) 2009-2016  Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import json
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.constants import Const as C
-
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Debug Plugin",
-    C.PI_IMPORT_NAME: "DEBUG",
-    C.PI_TYPE: "Misc",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "Debug",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Set of method to make development and debugging easier"""),
-}
-
-
-class Debug(object):
-    def __init__(self, host):
-        log.info(_("Plugin Debug initialization"))
-        self.host = host
-        host.bridge.add_method(
-            "debug_signal_fake",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._fake_signal,
-        )
-
-    def _fake_signal(self, signal, arguments, profile_key):
-        """send a signal from backend
-
-        @param signal(str): name of the signal
-        @param arguments(unicode): json encoded list of arguments
-        @parm profile_key(unicode): profile_key to use or C.PROF_KEY_NONE if profile is not needed
-        """
-        args = json.loads(arguments)
-        method = getattr(self.host.bridge, signal)
-        if profile_key != C.PROF_KEY_NONE:
-            profile = self.host.memory.get_profile_name(profile_key)
-            args.append(profile)
-        method(*args)
--- a/sat/plugins/plugin_misc_download.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,368 +0,0 @@
-#!/usr/bin/env python3
-
-# SAT plugin for downloading files
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import hashlib
-from pathlib import Path
-from typing import Any, Dict, Optional, Union, Tuple, Callable
-from urllib.parse import unquote, urlparse
-
-import treq
-from twisted.internet import defer
-from twisted.words.protocols.jabber import error as jabber_error
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import D_, _
-from sat.core.log import getLogger
-from sat.tools import xml_tools
-from sat.tools import stream
-from sat.tools.common import data_format
-from sat.tools.web import treq_client_no_ssl
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "File Download",
-    C.PI_IMPORT_NAME: "DOWNLOAD",
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_MAIN: "DownloadPlugin",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""File download management"""),
-}
-
-
-class DownloadPlugin(object):
-
-    def __init__(self, host):
-        log.info(_("plugin Download initialization"))
-        self.host = host
-        host.bridge.add_method(
-            "file_download",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="s",
-            method=self._file_download,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "file_download_complete",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="s",
-            method=self._file_download_complete,
-            async_=True,
-        )
-        self._download_callbacks = {}
-        self._scheme_callbacks = {}
-        self.register_scheme('http', self.download_http)
-        self.register_scheme('https', self.download_http)
-
-    def _file_download(
-            self, attachment_s: str, dest_path: str, extra_s: str, profile: str
-    ) -> defer.Deferred:
-        d = defer.ensureDeferred(self.file_download(
-            self.host.get_client(profile),
-            data_format.deserialise(attachment_s),
-            Path(dest_path),
-            data_format.deserialise(extra_s)
-        ))
-        d.addCallback(lambda ret: data_format.serialise(ret))
-        return d
-
-    async def file_download(
-        self,
-        client: SatXMPPEntity,
-        attachment: Dict[str, Any],
-        dest_path: Path,
-        extra: Optional[Dict[str, Any]] = None
-    ) -> Dict[str, Any]:
-        """Download a file using best available method
-
-        parameters are the same as for [download]
-        @return (dict): action dictionary, with progress id in case of success, else xmlui
-            message
-        """
-        try:
-            progress_id, __ = await self.download(client, attachment, dest_path, extra)
-        except Exception as e:
-            if (isinstance(e, jabber_error.StanzaError)
-                and e.condition == 'not-acceptable'):
-                reason = e.text
-            else:
-                reason = str(e)
-            msg = D_("Can't download file: {reason}").format(reason=reason)
-            log.warning(msg)
-            return {
-                "xmlui": xml_tools.note(
-                    msg, D_("Can't download file"), C.XMLUI_DATA_LVL_WARNING
-                ).toXml()
-            }
-        else:
-            return {"progress": progress_id}
-
-    def _file_download_complete(
-            self, attachment_s: str, dest_path: str, extra_s: str, profile: str
-    ) -> defer.Deferred:
-        d = defer.ensureDeferred(self.file_download_complete(
-            self.host.get_client(profile),
-            data_format.deserialise(attachment_s),
-            Path(dest_path),
-            data_format.deserialise(extra_s)
-        ))
-        d.addCallback(lambda path: str(path))
-        return d
-
-    async def file_download_complete(
-        self,
-        client: SatXMPPEntity,
-        attachment: Dict[str, Any],
-        dest_path: Path,
-        extra: Optional[Dict[str, Any]] = None
-    ) -> str:
-        """Helper method to fully download a file and return its path
-
-        parameters are the same as for [download]
-        @return (str): path to the downloaded file
-            use empty string to store the file in cache
-        """
-        __, download_d = await self.download(client, attachment, dest_path, extra)
-        dest_path = await download_d
-        return dest_path
-
-    async def download_uri(
-        self,
-        client: SatXMPPEntity,
-        uri: str,
-        dest_path: Union[Path, str],
-        extra: Optional[Dict[str, Any]] = None
-    ) -> Tuple[str, defer.Deferred]:
-        if extra is None:
-            extra = {}
-        uri_parsed = urlparse(uri, 'http')
-        if dest_path:
-            dest_path = Path(dest_path)
-            cache_uid = None
-        else:
-            filename = Path(unquote(uri_parsed.path)).name.strip() or C.FILE_DEFAULT_NAME
-            # we don't use Path.suffixes because we don't want to have more than 2
-            # suffixes, but we still want to handle suffixes like "tar.gz".
-            stem, *suffixes = filename.rsplit('.', 2)
-            # we hash the URL to have an unique identifier, and avoid double download
-            url_hash = hashlib.sha256(uri_parsed.geturl().encode()).hexdigest()
-            cache_uid = f"{stem}_{url_hash}"
-            cache_data = client.cache.get_metadata(cache_uid)
-            if cache_data is not None:
-                # file is already in cache, we return it
-                download_d = defer.succeed(cache_data['path'])
-                return '', download_d
-            else:
-                # the file is not in cache
-                unique_name = '.'.join([cache_uid] + suffixes)
-                with client.cache.cache_data(
-                    "DOWNLOAD", cache_uid, filename=unique_name) as f:
-                    # we close the file and only use its name, the file will be opened
-                    # by the registered callback
-                    dest_path = Path(f.name)
-
-        # should we check certificates?
-        check_certificate = self.host.memory.param_get_a(
-            "check_certificate", "Connection", profile_key=client.profile)
-        if not check_certificate:
-            extra['ignore_tls_errors'] = True
-            log.warning(
-                _("certificate check disabled for download, this is dangerous!"))
-
-        try:
-            callback = self._scheme_callbacks[uri_parsed.scheme]
-        except KeyError:
-            raise exceptions.NotFound(f"Can't find any handler for uri {uri}")
-        else:
-            try:
-                progress_id, download_d = await callback(
-                    client, uri_parsed, dest_path, extra)
-            except Exception as e:
-                log.warning(_(
-                    "Can't download URI {uri}: {reason}").format(
-                    uri=uri, reason=e))
-                if cache_uid is not None:
-                    client.cache.remove_from_cache(cache_uid)
-                elif dest_path.exists():
-                    dest_path.unlink()
-                raise e
-            download_d.addCallback(lambda __: dest_path)
-            return progress_id, download_d
-
-
-    async def download(
-        self,
-        client: SatXMPPEntity,
-        attachment: Dict[str, Any],
-        dest_path: Union[Path, str],
-        extra: Optional[Dict[str, Any]] = None
-    ) -> Tuple[str, defer.Deferred]:
-        """Download a file from URI using suitable method
-
-        @param uri: URI to the file to download
-        @param dest_path: where the file must be downloaded
-            if empty string, the file will be stored in local path
-        @param extra: options depending on scheme handler
-            Some common options:
-                - ignore_tls_errors(bool): True to ignore SSL/TLS certificate verification
-                  used only if HTTPS transport is needed
-        @return: ``progress_id`` and a Deferred which fire download URL when download is
-            finished.
-            ``progress_id`` can be empty string if the file already exist and is not
-            downloaded again (can happen if cache is used with empty ``dest_path``).
-        """
-        uri = attachment.get("uri")
-        if uri:
-            return await self.download_uri(client, uri, dest_path, extra)
-        else:
-            for source in attachment.get("sources", []):
-                source_type = source.get("type")
-                if not source_type:
-                    log.warning(
-                        "source type is missing for source: {source}\nattachment: "
-                        f"{attachment}"
-                    )
-                    continue
-                try:
-                    cb = self._download_callbacks[source_type]
-                except KeyError:
-                    log.warning(
-                        f"no source handler registered for {source_type!r}"
-                    )
-                else:
-                    try:
-                        return await cb(client, attachment, source, dest_path, extra)
-                    except exceptions.CancelError as e:
-                        # the handler can't or doesn't want to handle this source
-                        log.debug(
-                            f"Following source handling by {cb} has been cancelled ({e}):"
-                            f"{source}"
-                        )
-
-        log.warning(
-            "no source could be handled, we can't download the attachment:\n"
-            f"{attachment}"
-        )
-        raise exceptions.FeatureNotFound("no handler could manage the attachment")
-
-    def register_download_handler(
-        self,
-        source_type: str,
-        callback: Callable[
-            [
-                SatXMPPEntity, Dict[str, Any], Dict[str, Any], Union[str, Path],
-                Dict[str, Any]
-            ],
-            Tuple[str, defer.Deferred]
-        ]
-    ) -> None:
-        """Register a handler to manage a type of attachment source
-
-        @param source_type: ``type`` of source handled
-            This is usually the namespace of the protocol used
-        @param callback: method to call to manage the source.
-            Call arguments are the same as for [download], with an extra ``source`` dict
-            which is used just after ``attachment`` to give a quick reference to the
-            source used.
-            The callabke must return a tuple with:
-                - progress ID
-                - a Deferred which fire whant the file is fully downloaded
-        """
-        if source_type is self._download_callbacks:
-            raise exceptions.ConflictError(
-                f"The is already a callback registered for source type {source_type!r}"
-            )
-        self._download_callbacks[source_type] = callback
-
-    def register_scheme(self, scheme: str, download_cb: Callable) -> None:
-        """Register an URI scheme handler
-
-        @param scheme: URI scheme this callback is handling
-        @param download_cb: callback to download a file
-            arguments are:
-                - (SatXMPPClient) client
-                - (urllib.parse.SplitResult) parsed URI
-                - (Path) destination path where the file must be downloaded
-                - (dict) options
-            must return a tuple with progress_id and a Deferred which fire when download
-            is finished
-        """
-        if scheme in self._scheme_callbacks:
-            raise exceptions.ConflictError(
-                f"A method with scheme {scheme!r} is already registered"
-            )
-        self._scheme_callbacks[scheme] = download_cb
-
-    def unregister(self, scheme):
-        try:
-            del self._scheme_callbacks[scheme]
-        except KeyError:
-            raise exceptions.NotFound(f"No callback registered for scheme {scheme!r}")
-
-    def errback_download(self, file_obj, download_d, resp):
-        """Set file_obj and download deferred appropriatly after a network error
-
-        @param file_obj(SatFile): file where the download must be done
-        @param download_d(Deferred): deffered which must be fired on complete download
-        @param resp(treq.response.IResponse): treq response
-        """
-        msg = f"HTTP error ({resp.code}): {resp.phrase.decode()}"
-        file_obj.close(error=msg)
-        download_d.errback(exceptions.NetworkError(msg))
-
-    async def download_http(self, client, uri_parsed, dest_path, options):
-        url = uri_parsed.geturl()
-
-        if options.get('ignore_tls_errors', False):
-            log.warning(
-                "TLS certificate check disabled, this is highly insecure"
-            )
-            treq_client = treq_client_no_ssl
-        else:
-            treq_client = treq
-
-        head_data = await treq_client.head(url)
-        try:
-            content_length = int(head_data.headers.getRawHeaders('content-length')[0])
-        except (KeyError, TypeError, IndexError):
-            content_length = None
-            log.debug(f"No content lenght found at {url}")
-        file_obj = stream.SatFile(
-            self.host,
-            client,
-            dest_path,
-            mode="wb",
-            size = content_length,
-        )
-
-        progress_id = file_obj.uid
-
-        resp = await treq_client.get(url, unbuffered=True)
-        if resp.code == 200:
-            d = treq.collect(resp, file_obj.write)
-            d.addBoth(lambda _: file_obj.close())
-        else:
-            d = defer.Deferred()
-            self.errback_download(file_obj, d, resp)
-        return progress_id, d
--- a/sat/plugins/plugin_misc_email_invitation.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,521 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin for sending invitations by email
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import shortuuid
-from typing import Optional
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber import error
-from twisted.words.protocols.jabber import sasl
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-from sat.tools import utils
-from sat.tools.common import data_format
-from sat.memory import persistent
-from sat.tools.common import email as sat_email
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Email Invitations",
-    C.PI_IMPORT_NAME: "EMAIL_INVITATION",
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_DEPENDENCIES: ['XEP-0077'],
-    C.PI_RECOMMENDATIONS: ["IDENTITY"],
-    C.PI_MAIN: "InvitationsPlugin",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""invitation of people without XMPP account""")
-}
-
-
-SUFFIX_MAX = 5
-INVITEE_PROFILE_TPL = "guest@@{uuid}"
-KEY_ID = 'id'
-KEY_JID = 'jid'
-KEY_CREATED = 'created'
-KEY_LAST_CONNECTION = 'last_connection'
-KEY_GUEST_PROFILE = 'guest_profile'
-KEY_PASSWORD = 'password'
-KEY_EMAILS_EXTRA = 'emails_extra'
-EXTRA_RESERVED = {KEY_ID, KEY_JID, KEY_CREATED, 'jid_', 'jid', KEY_LAST_CONNECTION,
-                  KEY_GUEST_PROFILE, KEY_PASSWORD, KEY_EMAILS_EXTRA}
-DEFAULT_SUBJECT = D_("You have been invited by {host_name} to {app_name}")
-DEFAULT_BODY = D_("""Hello {name}!
-
-You have received an invitation from {host_name} to participate to "{app_name}".
-To join, you just have to click on the following URL:
-{url}
-
-Please note that this URL should not be shared with anybody!
-If you want more details on {app_name}, you can check {app_url}.
-
-Welcome!
-""")
-
-
-class InvitationsPlugin(object):
-
-    def __init__(self, host):
-        log.info(_("plugin Invitations initialization"))
-        self.host = host
-        self.invitations = persistent.LazyPersistentBinaryDict('invitations')
-        host.bridge.add_method("invitation_create", ".plugin", in_sign='sasssssssssa{ss}s',
-                              out_sign='a{ss}',
-                              method=self._create,
-                              async_=True)
-        host.bridge.add_method("invitation_get", ".plugin", in_sign='s', out_sign='a{ss}',
-                              method=self.get,
-                              async_=True)
-        host.bridge.add_method("invitation_delete", ".plugin", in_sign='s', out_sign='',
-                              method=self._delete,
-                              async_=True)
-        host.bridge.add_method("invitation_modify", ".plugin", in_sign='sa{ss}b',
-                              out_sign='',
-                              method=self._modify,
-                              async_=True)
-        host.bridge.add_method("invitation_list", ".plugin", in_sign='s',
-                              out_sign='a{sa{ss}}',
-                              method=self._list,
-                              async_=True)
-        host.bridge.add_method("invitation_simple_create", ".plugin", in_sign='sssss',
-                              out_sign='a{ss}',
-                              method=self._simple_create,
-                              async_=True)
-
-    def check_extra(self, extra):
-        if EXTRA_RESERVED.intersection(extra):
-            raise ValueError(
-                _("You can't use following key(s) in extra, they are reserved: {}")
-                .format(', '.join(EXTRA_RESERVED.intersection(extra))))
-
-    def _create(self, email='', emails_extra=None, jid_='', password='', name='',
-                host_name='', language='', url_template='', message_subject='',
-                message_body='', extra=None, profile=''):
-        # XXX: we don't use **kwargs here to keep arguments name for introspection with
-        #      D-Bus bridge
-        if emails_extra is None:
-            emails_extra = []
-
-        if extra is None:
-            extra = {}
-        else:
-            extra = {str(k): str(v) for k,v in extra.items()}
-
-        kwargs = {"extra": extra,
-                  KEY_EMAILS_EXTRA: [str(e) for e in emails_extra]
-                  }
-
-        # we need to be sure that values are unicode, else they won't be pickled correctly
-        # with D-Bus
-        for key in ("jid_", "password", "name", "host_name", "email", "language",
-                    "url_template", "message_subject", "message_body", "profile"):
-            value = locals()[key]
-            if value:
-                kwargs[key] = str(value)
-        return defer.ensureDeferred(self.create(**kwargs))
-
-    async def get_existing_invitation(self, email: Optional[str]) -> Optional[dict]:
-        """Retrieve existing invitation with given email
-
-        @param email: check if any invitation exist with this email
-        @return: first found invitation, or None if nothing found
-        """
-        # FIXME: This method is highly inefficient, it get all invitations and check them
-        # one by one, this is just a temporary way to avoid creating creating new accounts
-        # for an existing email. A better way will be available with Libervia 0.9.
-        # TODO: use a better way to check existing invitations
-
-        if email is None:
-            return None
-        all_invitations = await self.invitations.all()
-        for id_, invitation in all_invitations.items():
-            if invitation.get("email") == email:
-                invitation[KEY_ID] = id_
-                return invitation
-
-    async def _create_account_and_profile(
-        self,
-        id_: str,
-        kwargs: dict,
-        extra: dict
-    ) -> None:
-        """Create XMPP account and Libervia profile for guest"""
-        ## XMPP account creation
-        password = kwargs.pop('password', None)
-        if password is None:
-           password = utils.generate_password()
-        assert password
-        # XXX: password is here saved in clear in database
-        #      it is needed for invitation as the same password is used for profile
-        #      and SàT need to be able to automatically open the profile with the uuid
-        # FIXME: we could add an extra encryption key which would be used with the
-        #        uuid when the invitee is connecting (e.g. with URL). This key would
-        #        not be saved and could be used to encrypt profile password.
-        extra[KEY_PASSWORD] = password
-
-        jid_ = kwargs.pop('jid_', None)
-        if not jid_:
-            domain = self.host.memory.config_get(None, 'xmpp_domain')
-            if not domain:
-                # TODO: fallback to profile's domain
-                raise ValueError(_("You need to specify xmpp_domain in sat.conf"))
-            jid_ = "invitation-{uuid}@{domain}".format(uuid=shortuuid.uuid(),
-                                                        domain=domain)
-        jid_ = jid.JID(jid_)
-        extra[KEY_JID] = jid_.full()
-
-        if jid_.user:
-            # we don't register account if there is no user as anonymous login is then
-            # used
-            try:
-                await self.host.plugins['XEP-0077'].register_new_account(jid_, password)
-            except error.StanzaError as e:
-                prefix = jid_.user
-                idx = 0
-                while e.condition == 'conflict':
-                    if idx >= SUFFIX_MAX:
-                        raise exceptions.ConflictError(_("Can't create XMPP account"))
-                    jid_.user = prefix + '_' + str(idx)
-                    log.info(_("requested jid already exists, trying with {}".format(
-                        jid_.full())))
-                    try:
-                        await self.host.plugins['XEP-0077'].register_new_account(
-                            jid_,
-                            password
-                        )
-                    except error.StanzaError:
-                        idx += 1
-                    else:
-                        break
-                if e.condition != 'conflict':
-                    raise e
-
-            log.info(_("account {jid_} created").format(jid_=jid_.full()))
-
-        ## profile creation
-
-        extra[KEY_GUEST_PROFILE] = guest_profile = INVITEE_PROFILE_TPL.format(
-            uuid=id_
-        )
-        # profile creation should not fail as we generate unique name ourselves
-        await self.host.memory.create_profile(guest_profile, password)
-        await self.host.memory.start_session(password, guest_profile)
-        await self.host.memory.param_set("JabberID", jid_.full(), "Connection",
-                                        profile_key=guest_profile)
-        await self.host.memory.param_set("Password", password, "Connection",
-                                        profile_key=guest_profile)
-
-    async def create(self, **kwargs):
-        r"""Create an invitation
-
-        This will create an XMPP account and a profile, and use a UUID to retrieve them.
-        The profile is automatically generated in the form guest@@[UUID], this way they
-            can be retrieved easily
-        **kwargs: keywords arguments which can have the following keys, unset values are
-                  equivalent to None:
-            jid_(jid.JID, None): jid to use for invitation, the jid will be created using
-                                 XEP-0077
-                if the jid has no user part, an anonymous account will be used (no XMPP
-                    account created in this case)
-                if None, automatically generate an account name (in the form
-                    "invitation-[random UUID]@domain.tld") (note that this UUID is not the
-                    same as the invitation one, as jid can be used publicly (leaking the
-                    UUID), and invitation UUID give access to account.
-                in case of conflict, a suffix number is added to the account until a free
-                    one if found (with a failure if SUFFIX_MAX is reached)
-            password(unicode, None): password to use (will be used for XMPP account and
-                                     profile)
-                None to automatically generate one
-            name(unicode, None): name of the invitee
-                will be set as profile identity if present
-            host_name(unicode, None): name of the host
-            email(unicode, None): email to send the invitation to
-                if None, no invitation email is sent, you can still associate email using
-                    extra
-                if email is used, extra can't have "email" key
-            language(unicode): language of the invitee (used notabily to translate the
-                               invitation)
-                TODO: not used yet
-            url_template(unicode, None): template to use to construct the invitation URL
-                use {uuid} as a placeholder for identifier
-                use None if you don't want to include URL (or if it is already specified
-                    in custom message)
-                /!\ you must put full URL, don't forget https://
-                /!\ the URL will give access to the invitee account, you should warn in
-                    message to not publish it publicly
-            message_subject(unicode, None): customised message body for the invitation
-                                            email
-                None to use default subject
-                uses the same substitution as for message_body
-            message_body(unicode, None): customised message body for the invitation email
-                None to use default body
-                use {name} as a place holder for invitee name
-                use {url} as a placeholder for the invitation url
-                use {uuid} as a placeholder for the identifier
-                use {app_name} as a placeholder for this software name
-                use {app_url} as a placeholder for this software official website
-                use {profile} as a placeholder for host's profile
-                use {host_name} as a placeholder for host's name
-            extra(dict, None): extra data to associate with the invitee
-                some keys are reserved:
-                    - created (creation date)
-                if email argument is used, "email" key can't be used
-            profile(unicode, None): profile of the host (person who is inviting)
-        @return (dict[unicode, unicode]): dictionary with:
-            - UUID associated with the invitee (key: id)
-            - filled extra dictionary, as saved in the databae
-        """
-        ## initial checks
-        extra = kwargs.pop('extra', {})
-        if set(kwargs).intersection(extra):
-            raise ValueError(
-                _("You can't use following key(s) in both args and extra: {}").format(
-                ', '.join(set(kwargs).intersection(extra))))
-
-        self.check_extra(extra)
-
-        email = kwargs.pop('email', None)
-
-        existing = await self.get_existing_invitation(email)
-        if existing is not None:
-            log.info(f"There is already an invitation for {email!r}")
-            extra.update(existing)
-            del extra[KEY_ID]
-
-        emails_extra = kwargs.pop('emails_extra', [])
-        if not email and emails_extra:
-            raise ValueError(
-                _('You need to provide a main email address before using emails_extra'))
-
-        if (email is not None
-            and not 'url_template' in kwargs
-            and not 'message_body' in kwargs):
-            raise ValueError(
-                _("You need to provide url_template if you use default message body"))
-
-        ## uuid
-        log.info(_("creating an invitation"))
-        id_ = existing[KEY_ID] if existing else str(shortuuid.uuid())
-
-        if existing is None:
-            await self._create_account_and_profile(id_, kwargs, extra)
-
-        profile = kwargs.pop('profile', None)
-        guest_profile = extra[KEY_GUEST_PROFILE]
-        jid_ = jid.JID(extra[KEY_JID])
-
-        ## identity
-        name = kwargs.pop('name', None)
-        password = extra[KEY_PASSWORD]
-        if name is not None:
-            extra['name'] = name
-            try:
-                id_plugin = self.host.plugins['IDENTITY']
-            except KeyError:
-                pass
-            else:
-                await self.host.connect(guest_profile, password)
-                guest_client = self.host.get_client(guest_profile)
-                await id_plugin.set_identity(guest_client, {'nicknames': [name]})
-                await self.host.disconnect(guest_profile)
-
-        ## email
-        language = kwargs.pop('language', None)
-        if language is not None:
-            extra['language'] = language.strip()
-
-        if email is not None:
-            extra['email'] = email
-            data_format.iter2dict(KEY_EMAILS_EXTRA, extra)
-            url_template = kwargs.pop('url_template', '')
-            format_args = {
-                'uuid': id_,
-                'app_name': C.APP_NAME,
-                'app_url': C.APP_URL}
-
-            if name is None:
-                format_args['name'] = email
-            else:
-                format_args['name'] = name
-
-            if profile is None:
-                format_args['profile'] = ''
-            else:
-                format_args['profile'] = extra['profile'] = profile
-
-            host_name = kwargs.pop('host_name', None)
-            if host_name is None:
-                format_args['host_name'] = profile or _("somebody")
-            else:
-                format_args['host_name'] = extra['host_name'] = host_name
-
-            invite_url = url_template.format(**format_args)
-            format_args['url'] = invite_url
-
-            await sat_email.send_email(
-                self.host.memory.config,
-                [email] + emails_extra,
-                (kwargs.pop('message_subject', None) or DEFAULT_SUBJECT).format(
-                    **format_args),
-                (kwargs.pop('message_body', None) or DEFAULT_BODY).format(**format_args),
-            )
-
-        ## roster
-
-        # we automatically add guest to host roster (if host is specified)
-        # FIXME: a parameter to disable auto roster adding would be nice
-        if profile is not None:
-            try:
-                client = self.host.get_client(profile)
-            except Exception as e:
-                log.error(f"Can't get host profile: {profile}: {e}")
-            else:
-                await self.host.contact_update(client, jid_, name, ['guests'])
-
-        if kwargs:
-            log.warning(_("Not all arguments have been consumed: {}").format(kwargs))
-
-        ## extra data saving
-        self.invitations[id_] = extra
-
-        extra[KEY_ID] = id_
-
-        return extra
-
-    def _simple_create(self, invitee_email, invitee_name, url_template, extra_s, profile):
-        client = self.host.get_client(profile)
-        # FIXME: needed because python-dbus use a specific string class
-        invitee_email = str(invitee_email)
-        invitee_name = str(invitee_name)
-        url_template = str(url_template)
-        extra = data_format.deserialise(extra_s)
-        d = defer.ensureDeferred(
-            self.simple_create(client, invitee_email, invitee_name, url_template, extra)
-        )
-        d.addCallback(lambda data: {k: str(v) for k,v in data.items()})
-        return d
-
-    async def simple_create(
-        self, client, invitee_email, invitee_name, url_template, extra):
-        """Simplified method to invite somebody by email"""
-        return await self.create(
-            name=invitee_name,
-            email=invitee_email,
-            url_template=url_template,
-            profile=client.profile,
-        )
-
-    def get(self, id_):
-        """Retrieve invitation linked to uuid if it exists
-
-        @param id_(unicode): UUID linked to an invitation
-        @return (dict[unicode, unicode]): data associated to the invitation
-        @raise KeyError: there is not invitation with this id_
-        """
-        return self.invitations[id_]
-
-    def _delete(self, id_):
-        return defer.ensureDeferred(self.delete(id_))
-
-    async def delete(self, id_):
-        """Delete an invitation data and associated XMPP account"""
-        log.info(f"deleting invitation {id_}")
-        data = await self.get(id_)
-        guest_profile = data['guest_profile']
-        password = data['password']
-        try:
-            await self.host.connect(guest_profile, password)
-            guest_client = self.host.get_client(guest_profile)
-            # XXX: be extra careful to use guest_client and not client below, as this will
-            #   delete the associated XMPP account
-            log.debug("deleting XMPP account")
-            await self.host.plugins['XEP-0077'].unregister(guest_client, None)
-        except (error.StanzaError, sasl.SASLAuthError) as e:
-            log.warning(
-                f"Can't delete {guest_profile}'s XMPP account, maybe it as already been "
-                f"deleted: {e}")
-        try:
-            await self.host.memory.profile_delete_async(guest_profile, True)
-        except Exception as e:
-            log.warning(f"Can't delete guest profile {guest_profile}: {e}")
-        log.debug("removing guest data")
-        await self.invitations.adel(id_)
-        log.info(f"{id_} invitation has been deleted")
-
-    def _modify(self, id_, new_extra, replace):
-        return self.modify(id_, {str(k): str(v) for k,v in new_extra.items()},
-                           replace)
-
-    def modify(self, id_, new_extra, replace=False):
-        """Modify invitation data
-
-        @param id_(unicode): UUID linked to an invitation
-        @param new_extra(dict[unicode, unicode]): data to update
-            empty values will be deleted if replace is True
-        @param replace(bool): if True replace the data
-            else update them
-        @raise KeyError: there is not invitation with this id_
-        """
-        self.check_extra(new_extra)
-        def got_current_data(current_data):
-            if replace:
-                new_data = new_extra
-                for k in EXTRA_RESERVED:
-                    try:
-                        new_data[k] = current_data[k]
-                    except KeyError:
-                        continue
-            else:
-                new_data = current_data
-                for k,v in new_extra.items():
-                    if k in EXTRA_RESERVED:
-                        log.warning(_("Skipping reserved key {key}").format(key=k))
-                        continue
-                    if v:
-                        new_data[k] = v
-                    else:
-                        try:
-                            del new_data[k]
-                        except KeyError:
-                            pass
-
-            self.invitations[id_] = new_data
-
-        d = self.invitations[id_]
-        d.addCallback(got_current_data)
-        return d
-
-    def _list(self, profile=C.PROF_KEY_NONE):
-        return defer.ensureDeferred(self.list(profile))
-
-    async def list(self, profile=C.PROF_KEY_NONE):
-        """List invitations
-
-        @param profile(unicode): return invitation linked to this profile only
-            C.PROF_KEY_NONE: don't filter invitations
-        @return list(unicode): invitations uids
-        """
-        invitations = await self.invitations.all()
-        if profile != C.PROF_KEY_NONE:
-            invitations = {id_:data for id_, data in invitations.items()
-                           if data.get('profile') == profile}
-
-        return invitations
--- a/sat/plugins/plugin_misc_extra_pep.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,74 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for displaying messages from extra PEP services
-# Copyright (C) 2015 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.memory import params
-from twisted.words.protocols.jabber import jid
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Extra PEP",
-    C.PI_IMPORT_NAME: "EXTRA-PEP",
-    C.PI_TYPE: "MISC",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "ExtraPEP",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Display messages from extra PEP services"""),
-}
-
-
-PARAM_KEY = "Misc"
-PARAM_NAME = "blogs"
-PARAM_LABEL = "Blog authors following list"
-PARAM_DEFAULT = (jid.JID("salut-a-toi@libervia.org"),)
-
-
-class ExtraPEP(object):
-
-    params = """
-    <params>
-    <individual>
-    <category name="%(category_name)s" label="%(category_label)s">
-        <param name="%(param_name)s" label="%(param_label)s" type="jids_list" security="0">
-            %(jids)s
-        </param>
-     </category>
-    </individual>
-    </params>
-    """ % {
-        "category_name": PARAM_KEY,
-        "category_label": D_(PARAM_KEY),
-        "param_name": PARAM_NAME,
-        "param_label": D_(PARAM_LABEL),
-        "jids": "\n".join({elt.toXml() for elt in params.create_jid_elts(PARAM_DEFAULT)}),
-    }
-
-    def __init__(self, host):
-        log.info(_("Plugin Extra PEP initialization"))
-        self.host = host
-        host.memory.update_params(self.params)
-
-    def get_followed_entities(self, profile_key):
-        return self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile_key)
--- a/sat/plugins/plugin_misc_file.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,350 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for file tansfer
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import os
-import os.path
-from functools import partial
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.tools import xml_tools
-from sat.tools import stream
-from sat.tools import utils
-from sat.tools.common import data_format, utils as common_utils
-
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "File Tansfer",
-    C.PI_IMPORT_NAME: "FILE",
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_MAIN: "FilePlugin",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _(
-        """File Tansfer Management:
-This plugin manage the various ways of sending a file, and choose the best one."""
-    ),
-}
-
-
-SENDING = D_("Please select a file to send to {peer}")
-SENDING_TITLE = D_("File sending")
-CONFIRM = D_(
-    '{peer} wants to send the file "{name}" to you:\n{desc}\n\nThe file has a size of '
-    '{size_human}\n\nDo you accept ?'
-)
-CONFIRM_TITLE = D_("Confirm file transfer")
-CONFIRM_OVERWRITE = D_("File {} already exists, are you sure you want to overwrite ?")
-CONFIRM_OVERWRITE_TITLE = D_("File exists")
-SECURITY_LIMIT = 30
-
-PROGRESS_ID_KEY = "progress_id"
-
-
-class FilePlugin:
-    File = stream.SatFile
-
-    def __init__(self, host):
-        log.info(_("plugin File initialization"))
-        self.host = host
-        host.bridge.add_method(
-            "file_send",
-            ".plugin",
-            in_sign="ssssss",
-            out_sign="a{ss}",
-            method=self._file_send,
-            async_=True,
-        )
-        self._file_managers = []
-        host.import_menu(
-            (D_("Action"), D_("send file")),
-            self._file_send_menu,
-            security_limit=10,
-            help_string=D_("Send a file"),
-            type_=C.MENU_SINGLE,
-        )
-
-    def _file_send(
-        self,
-        peer_jid_s: str,
-        filepath: str,
-        name: str,
-        file_desc: str,
-        extra_s: str,
-        profile: str = C.PROF_KEY_NONE
-    ) -> defer.Deferred:
-        client = self.host.get_client(profile)
-        return defer.ensureDeferred(self.file_send(
-            client, jid.JID(peer_jid_s), filepath, name or None, file_desc or None,
-            data_format.deserialise(extra_s)
-        ))
-
-    async def file_send(
-        self, client, peer_jid, filepath, filename=None, file_desc=None, extra=None
-    ):
-        """Send a file using best available method
-
-        @param peer_jid(jid.JID): jid of the destinee
-        @param filepath(str): absolute path to the file
-        @param filename(unicode, None): name to use, or None to find it from filepath
-        @param file_desc(unicode, None): description of the file
-        @param profile: %(doc_profile)s
-        @return (dict): action dictionary, with progress id in case of success, else
-            xmlui message
-        """
-        if not os.path.isfile(filepath):
-            raise exceptions.DataError("The given path doesn't link to a file")
-        if not filename:
-            filename = os.path.basename(filepath) or "_"
-        for manager, priority in self._file_managers:
-            if await utils.as_deferred(manager.can_handle_file_send,
-                                      client, peer_jid, filepath):
-                try:
-                    method_name = manager.name
-                except AttributeError:
-                    method_name = manager.__class__.__name__
-                log.info(
-                    _("{name} method will be used to send the file").format(
-                        name=method_name
-                    )
-                )
-                try:
-                    progress_id = await utils.as_deferred(
-                        manager.file_send, client, peer_jid, filepath, filename, file_desc,
-                        extra
-                    )
-                except Exception as e:
-                    log.warning(
-                        _("Can't send {filepath} to {peer_jid} with {method_name}: "
-                          "{reason}").format(
-                              filepath=filepath,
-                              peer_jid=peer_jid,
-                              method_name=method_name,
-                              reason=e
-                          )
-                    )
-                    continue
-                return {"progress": progress_id}
-        msg = "Can't find any method to send file to {jid}".format(jid=peer_jid.full())
-        log.warning(msg)
-        return {
-            "xmlui": xml_tools.note(
-                "Can't transfer file", msg, C.XMLUI_DATA_LVL_WARNING
-            ).toXml()
-        }
-
-    def _on_file_choosed(self, peer_jid, data, profile):
-        client = self.host.get_client(profile)
-        cancelled = C.bool(data.get("cancelled", C.BOOL_FALSE))
-        if cancelled:
-            return
-        path = data["path"]
-        return self.file_send(client, peer_jid, path)
-
-    def _file_send_menu(self, data, profile):
-        """ XMLUI activated by menu: return file sending UI
-
-        @param profile: %(doc_profile)s
-        """
-        try:
-            jid_ = jid.JID(data["jid"])
-        except RuntimeError:
-            raise exceptions.DataError(_("Invalid JID"))
-
-        file_choosed_id = self.host.register_callback(
-            partial(self._on_file_choosed, jid_),
-            with_data=True,
-            one_shot=True,
-        )
-        xml_ui = xml_tools.XMLUI(
-            C.XMLUI_DIALOG,
-            dialog_opt={
-                C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_FILE,
-                C.XMLUI_DATA_MESS: _(SENDING).format(peer=jid_.full()),
-            },
-            title=_(SENDING_TITLE),
-            submit_id=file_choosed_id,
-        )
-
-        return {"xmlui": xml_ui.toXml()}
-
-    def register(self, manager, priority: int = 0) -> None:
-        """Register a fileSending manager
-
-        @param manager: object implementing can_handle_file_send, and file_send methods
-        @param priority: pririoty of this manager, the higher available will be used
-        """
-        m_data = (manager, priority)
-        if m_data in self._file_managers:
-            raise exceptions.ConflictError(
-                f"Manager {manager} is already registered"
-            )
-        if not hasattr(manager, "can_handle_file_send") or not hasattr(manager, "file_send"):
-            raise ValueError(
-                f'{manager} must have both "can_handle_file_send" and "file_send" methods to '
-                'be registered')
-        self._file_managers.append(m_data)
-        self._file_managers.sort(key=lambda m: m[1], reverse=True)
-
-    def unregister(self, manager):
-        for idx, data in enumerate(self._file_managers):
-            if data[0] == manager:
-                break
-        else:
-            raise exceptions.NotFound("The file manager {manager} is not registered")
-        del self._file_managers[idx]
-
-    # Dialogs with user
-    # the overwrite check is done here
-
-    def open_file_write(self, client, file_path, transfer_data, file_data, stream_object):
-        """create SatFile or FileStremaObject for the requested file and fill suitable data
-        """
-        if stream_object:
-            assert "stream_object" not in transfer_data
-            transfer_data["stream_object"] = stream.FileStreamObject(
-                self.host,
-                client,
-                file_path,
-                mode="wb",
-                uid=file_data[PROGRESS_ID_KEY],
-                size=file_data["size"],
-                data_cb=file_data.get("data_cb"),
-            )
-        else:
-            assert "file_obj" not in transfer_data
-            transfer_data["file_obj"] = stream.SatFile(
-                self.host,
-                client,
-                file_path,
-                mode="wb",
-                uid=file_data[PROGRESS_ID_KEY],
-                size=file_data["size"],
-                data_cb=file_data.get("data_cb"),
-            )
-
-    async def _got_confirmation(
-        self, client, data, peer_jid, transfer_data, file_data, stream_object
-    ):
-        """Called when the permission and dest path have been received
-
-        @param peer_jid(jid.JID): jid of the file sender
-        @param transfer_data(dict): same as for [self.get_dest_dir]
-        @param file_data(dict): same as for [self.get_dest_dir]
-        @param stream_object(bool): same as for [self.get_dest_dir]
-        return (bool): True if copy is wanted and OK
-            False if user wants to cancel
-            if file exists ask confirmation and call again self._getDestDir if needed
-        """
-        if data.get("cancelled", False):
-            return False
-        path = data["path"]
-        file_data["file_path"] = file_path = os.path.join(path, file_data["name"])
-        log.debug("destination file path set to {}".format(file_path))
-
-        # we manage case where file already exists
-        if os.path.exists(file_path):
-            overwrite = await xml_tools.defer_confirm(
-                self.host,
-                _(CONFIRM_OVERWRITE).format(file_path),
-                _(CONFIRM_OVERWRITE_TITLE),
-                action_extra={
-                    "from_jid": peer_jid.full(),
-                    "type": C.META_TYPE_OVERWRITE,
-                    "progress_id": file_data[PROGRESS_ID_KEY],
-                },
-                security_limit=SECURITY_LIMIT,
-                profile=client.profile,
-            )
-
-            if not overwrite:
-                return await self.get_dest_dir(client, peer_jid, transfer_data, file_data)
-
-        self.open_file_write(client, file_path, transfer_data, file_data, stream_object)
-        return True
-
-    async def get_dest_dir(
-        self, client, peer_jid, transfer_data, file_data, stream_object=False
-    ):
-        """Request confirmation and destination dir to user
-
-        Overwrite confirmation is managed.
-        if transfer is confirmed, 'file_obj' is added to transfer_data
-        @param peer_jid(jid.JID): jid of the file sender
-        @param filename(unicode): name of the file
-        @param transfer_data(dict): data of the transfer session,
-            it will be only used to store the file_obj.
-            "file_obj" (or "stream_object") key *MUST NOT* exist before using get_dest_dir
-        @param file_data(dict): information about the file to be transfered
-            It MUST contain the following keys:
-                - peer_jid (jid.JID): other peer jid
-                - name (unicode): name of the file to trasnsfer
-                    the name must not be empty or contain a "/" character
-                - size (int): size of the file
-                - desc (unicode): description of the file
-                - progress_id (unicode): id to use for progression
-            It *MUST NOT* contain the "peer" key
-            It may contain:
-                - data_cb (callable): method called on each data read/write
-            "file_path" will be added to this dict once destination selected
-            "size_human" will also be added with human readable file size
-        @param stream_object(bool): if True, a stream_object will be used instead of file_obj
-            a stream.FileStreamObject will be used
-        return: True if transfer is accepted
-        """
-        cont, ret_value = await self.host.trigger.async_return_point(
-            "FILE_getDestDir", client, peer_jid, transfer_data, file_data, stream_object
-        )
-        if not cont:
-            return ret_value
-        filename = file_data["name"]
-        assert filename and not "/" in filename
-        assert PROGRESS_ID_KEY in file_data
-        # human readable size
-        file_data["size_human"] = common_utils.get_human_size(file_data["size"])
-        resp_data = await xml_tools.defer_dialog(
-            self.host,
-            _(CONFIRM).format(peer=peer_jid.full(), **file_data),
-            _(CONFIRM_TITLE),
-            type_=C.XMLUI_DIALOG_FILE,
-            options={C.XMLUI_DATA_FILETYPE: C.XMLUI_DATA_FILETYPE_DIR},
-            action_extra={
-                "from_jid": peer_jid.full(),
-                "type": C.META_TYPE_FILE,
-                "progress_id": file_data[PROGRESS_ID_KEY],
-            },
-            security_limit=SECURITY_LIMIT,
-            profile=client.profile,
-        )
-
-        accepted = await self._got_confirmation(
-            client,
-            resp_data,
-            peer_jid,
-            transfer_data,
-            file_data,
-            stream_object,
-        )
-        return accepted
--- a/sat/plugins/plugin_misc_forums.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,307 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for pubsub forums
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-from sat.tools.common import uri, data_format
-from twisted.words.protocols.jabber import jid
-from twisted.words.xish import domish
-from twisted.internet import defer
-import shortuuid
-import json
-log = getLogger(__name__)
-
-NS_FORUMS = 'org.salut-a-toi.forums:0'
-NS_FORUMS_TOPICS = NS_FORUMS + '#topics'
-
-PLUGIN_INFO = {
-    C.PI_NAME: _("forums management"),
-    C.PI_IMPORT_NAME: "forums",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0277"],
-    C.PI_MAIN: "forums",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""forums management plugin""")
-}
-FORUM_ATTR = {'title', 'name', 'main-language', 'uri'}
-FORUM_SUB_ELTS = ('short-desc', 'desc')
-FORUM_TOPICS_NODE_TPL = '{node}#topics_{uuid}'
-FORUM_TOPIC_NODE_TPL = '{node}_{uuid}'
-
-
-class forums(object):
-
-    def __init__(self, host):
-        log.info(_("forums plugin initialization"))
-        self.host = host
-        self._m = self.host.plugins['XEP-0277']
-        self._p = self.host.plugins['XEP-0060']
-        self._node_options = {
-            self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
-            self._p.OPT_PERSIST_ITEMS: 1,
-            self._p.OPT_DELIVER_PAYLOADS: 1,
-            self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
-            self._p.OPT_PUBLISH_MODEL: self._p.ACCESS_OPEN,
-            }
-        host.register_namespace('forums', NS_FORUMS)
-        host.bridge.add_method("forums_get", ".plugin",
-                              in_sign='ssss', out_sign='s',
-                              method=self._get,
-                              async_=True)
-        host.bridge.add_method("forums_set", ".plugin",
-                              in_sign='sssss', out_sign='',
-                              method=self._set,
-                              async_=True)
-        host.bridge.add_method("forum_topics_get", ".plugin",
-                              in_sign='ssa{ss}s', out_sign='(aa{ss}s)',
-                              method=self._get_topics,
-                              async_=True)
-        host.bridge.add_method("forum_topic_create", ".plugin",
-                              in_sign='ssa{ss}s', out_sign='',
-                              method=self._create_topic,
-                              async_=True)
-
-    @defer.inlineCallbacks
-    def _create_forums(self, client, forums, service, node, forums_elt=None, names=None):
-        """Recursively create <forums> element(s)
-
-        @param forums(list): forums which may have subforums
-        @param service(jid.JID): service where the new nodes will be created
-        @param node(unicode): node of the forums
-            will be used as basis for the newly created nodes
-        @param parent_elt(domish.Element, None): element where the forum must be added
-            if None, the root <forums> element will be created
-        @return (domish.Element): created forums
-        """
-        if not isinstance(forums, list):
-            raise ValueError(_("forums arguments must be a list of forums"))
-        if forums_elt is None:
-            forums_elt = domish.Element((NS_FORUMS, 'forums'))
-            assert names is None
-            names = set()
-        else:
-            if names is None or forums_elt.name != 'forums':
-                raise exceptions.InternalError('invalid forums or names')
-            assert names is not None
-
-        for forum in forums:
-            if not isinstance(forum, dict):
-                raise ValueError(_("A forum item must be a dictionary"))
-            forum_elt = forums_elt.addElement('forum')
-
-            for key, value in forum.items():
-                if key == 'name' and key in names:
-                    raise exceptions.ConflictError(_("following forum name is not unique: {name}").format(name=key))
-                if key == 'uri' and not value.strip():
-                    log.info(_("creating missing forum node"))
-                    forum_node = FORUM_TOPICS_NODE_TPL.format(node=node, uuid=shortuuid.uuid())
-                    yield self._p.createNode(client, service, forum_node, self._node_options)
-                    value = uri.build_xmpp_uri('pubsub',
-                                             path=service.full(),
-                                             node=forum_node)
-                if key in FORUM_ATTR:
-                    forum_elt[key] = value.strip()
-                elif key in FORUM_SUB_ELTS:
-                    forum_elt.addElement(key, content=value)
-                elif key == 'sub-forums':
-                    sub_forums_elt = forum_elt.addElement('forums')
-                    yield self._create_forums(client, value, service, node, sub_forums_elt, names=names)
-                else:
-                    log.warning(_("Unknown forum attribute: {key}").format(key=key))
-            if not forum_elt.getAttribute('title'):
-                name = forum_elt.getAttribute('name')
-                if name:
-                    forum_elt['title'] = name
-                else:
-                    raise ValueError(_("forum need a title or a name"))
-            if not forum_elt.getAttribute('uri') and not forum_elt.children:
-                raise ValueError(_("forum need uri or sub-forums"))
-        defer.returnValue(forums_elt)
-
-    def _parse_forums(self, parent_elt=None, forums=None):
-        """Recursivly parse a <forums> elements and return corresponding forums data
-
-        @param item(domish.Element): item with <forums> element
-        @param parent_elt(domish.Element, None): element to parse
-        @return (list): parsed data
-        @raise ValueError: item is invalid
-        """
-        if parent_elt.name == 'item':
-            forums = []
-            try:
-                forums_elt = next(parent_elt.elements(NS_FORUMS, 'forums'))
-            except StopIteration:
-                raise ValueError(_("missing <forums> element"))
-        else:
-            forums_elt = parent_elt
-            if forums is None:
-                raise exceptions.InternalError('expected forums')
-            if forums_elt.name != 'forums':
-                raise ValueError(_('Unexpected element: {xml}').format(xml=forums_elt.toXml()))
-        for forum_elt in forums_elt.elements():
-            if forum_elt.name == 'forum':
-                data = {}
-                for attrib in FORUM_ATTR.intersection(forum_elt.attributes):
-                    data[attrib] = forum_elt[attrib]
-                unknown = set(forum_elt.attributes).difference(FORUM_ATTR)
-                if unknown:
-                    log.warning(_("Following attributes are unknown: {unknown}").format(unknown=unknown))
-                for elt in forum_elt.elements():
-                    if elt.name in FORUM_SUB_ELTS:
-                        data[elt.name] = str(elt)
-                    elif elt.name == 'forums':
-                        sub_forums = data['sub-forums'] = []
-                        self._parse_forums(elt, sub_forums)
-                if not 'title' in data or not {'uri', 'sub-forums'}.intersection(data):
-                    log.warning(_("invalid forum, ignoring: {xml}").format(xml=forum_elt.toXml()))
-                else:
-                    forums.append(data)
-            else:
-                log.warning(_("unkown forums sub element: {xml}").format(xml=forum_elt))
-
-        return forums
-
-    def _get(self, service=None, node=None, forums_key=None, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        if service.strip():
-            service = jid.JID(service)
-        else:
-            service = None
-        if not node.strip():
-            node = None
-        d = defer.ensureDeferred(self.get(client, service, node, forums_key or None))
-        d.addCallback(lambda data: json.dumps(data))
-        return d
-
-    async def get(self, client, service=None, node=None, forums_key=None):
-        if service is None:
-            service = client.pubsub_service
-        if node is None:
-            node = NS_FORUMS
-        if forums_key is None:
-            forums_key = 'default'
-        items_data = await self._p.get_items(client, service, node, item_ids=[forums_key])
-        item = items_data[0][0]
-        # we have the item and need to convert it to json
-        forums = self._parse_forums(item)
-        return forums
-
-    def _set(self, forums, service=None, node=None, forums_key=None, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        forums = json.loads(forums)
-        if service.strip():
-            service = jid.JID(service)
-        else:
-            service = None
-        if not node.strip():
-            node = None
-        return defer.ensureDeferred(
-            self.set(client, forums, service, node, forums_key or None)
-        )
-
-    async def set(self, client, forums, service=None, node=None, forums_key=None):
-        """Create or replace forums structure
-
-        @param forums(list): list of dictionary as follow:
-            a dictionary represent a forum metadata, with the following keys:
-                - title: title of the forum
-                - name: short name (unique in those forums) for the forum
-                - main-language: main language to be use in the forums
-                - uri: XMPP uri to the microblog node hosting the forum
-                - short-desc: short description of the forum (in main-language)
-                - desc: long description of the forum (in main-language)
-                - sub-forums: a list of sub-forums with the same structure
-            title or name is needed, and uri or sub-forums
-        @param forums_key(unicode, None): key (i.e. item id) of the forums
-            may be used to store different forums structures for different languages
-            None to use "default"
-        """
-        if service is None:
-             service = client.pubsub_service
-        if node is None:
-            node = NS_FORUMS
-        if forums_key is None:
-            forums_key = 'default'
-        forums_elt = await self._create_forums(client, forums, service, node)
-        return await self._p.send_item(
-            client, service, node, forums_elt, item_id=forums_key
-        )
-
-    def _get_topics(self, service, node, extra=None, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        extra = self._p.parse_extra(extra)
-        d = defer.ensureDeferred(
-            self.get_topics(
-                client, jid.JID(service), node, rsm_request=extra.rsm_request,
-                extra=extra.extra
-            )
-        )
-        d.addCallback(
-            lambda topics_data: (topics_data[0], data_format.serialise(topics_data[1]))
-        )
-        return d
-
-    async def get_topics(self, client, service, node, rsm_request=None, extra=None):
-        """Retrieve topics data
-
-        Topics are simple microblog URIs with some metadata duplicated from first post
-        """
-        topics_data = await self._p.get_items(
-            client, service, node, rsm_request=rsm_request, extra=extra
-        )
-        topics = []
-        item_elts, metadata = topics_data
-        for item_elt in item_elts:
-            topic_elt = next(item_elt.elements(NS_FORUMS, 'topic'))
-            title_elt = next(topic_elt.elements(NS_FORUMS, 'title'))
-            topic = {'uri': topic_elt['uri'],
-                     'author': topic_elt['author'],
-                     'title': str(title_elt)}
-            topics.append(topic)
-        return (topics, metadata)
-
-    def _create_topic(self, service, node, mb_data, profile_key):
-        client = self.host.get_client(profile_key)
-        return defer.ensureDeferred(
-            self.create_topic(client, jid.JID(service), node, mb_data)
-        )
-
-    async def create_topic(self, client, service, node, mb_data):
-        try:
-            title = mb_data['title']
-            content = mb_data.pop('content')
-        except KeyError as e:
-            raise exceptions.DataError("missing mandatory data: {key}".format(key=e.args[0]))
-        else:
-            mb_data["content_rich"] = content
-        topic_node = FORUM_TOPIC_NODE_TPL.format(node=node, uuid=shortuuid.uuid())
-        await self._p.createNode(client, service, topic_node, self._node_options)
-        await self._m.send(client, mb_data, service, topic_node)
-        topic_uri = uri.build_xmpp_uri('pubsub',
-                                     subtype='microblog',
-                                     path=service.full(),
-                                     node=topic_node)
-        topic_elt = domish.Element((NS_FORUMS, 'topic'))
-        topic_elt['uri'] = topic_uri
-        topic_elt['author'] = client.jid.userhost()
-        topic_elt.addElement('title', content = title)
-        await self._p.send_item(client, service, node, topic_elt)
--- a/sat/plugins/plugin_misc_groupblog.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,149 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for microbloging with roster access
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.internet import defer
-from sat.core import exceptions
-from wokkel import disco, data_form, iwokkel
-from zope.interface import implementer
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-
-NS_PUBSUB = "http://jabber.org/protocol/pubsub"
-NS_GROUPBLOG = "http://salut-a-toi.org/protocol/groupblog"
-# NS_PUBSUB_EXP = 'http://goffi.org/protocol/pubsub' #for non official features
-NS_PUBSUB_EXP = (
-    NS_PUBSUB
-)  # XXX: we can't use custom namespace as Wokkel's PubSubService use official NS
-NS_PUBSUB_GROUPBLOG = NS_PUBSUB_EXP + "#groupblog"
-NS_PUBSUB_ITEM_CONFIG = NS_PUBSUB_EXP + "#item-config"
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Group blogging through collections",
-    C.PI_IMPORT_NAME: "GROUPBLOG",
-    C.PI_TYPE: "MISC",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0277"],
-    C.PI_MAIN: "GroupBlog",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of microblogging fine permissions"""),
-}
-
-
-class GroupBlog(object):
-    """This class use a SàT PubSub Service to manage access on microblog"""
-
-    def __init__(self, host):
-        log.info(_("Group blog plugin initialization"))
-        self.host = host
-        self._p = self.host.plugins["XEP-0060"]
-        host.trigger.add("XEP-0277_item2data", self._item_2_data_trigger)
-        host.trigger.add("XEP-0277_data2entry", self._data_2_entry_trigger)
-        host.trigger.add("XEP-0277_comments", self._comments_trigger)
-
-    ## plugin management methods ##
-
-    def get_handler(self, client):
-        return GroupBlog_handler()
-
-    @defer.inlineCallbacks
-    def profile_connected(self, client):
-        try:
-            yield self.host.check_features(client, (NS_PUBSUB_GROUPBLOG,))
-        except exceptions.FeatureNotFound:
-            client.server_groupblog_available = False
-            log.warning(
-                _(
-                    "Server is not able to manage item-access pubsub, we can't use group blog"
-                )
-            )
-        else:
-            client.server_groupblog_available = True
-            log.info(_("Server can manage group blogs"))
-
-    def features_get(self, profile):
-        try:
-            client = self.host.get_client(profile)
-        except exceptions.ProfileNotSetError:
-            return {}
-        try:
-            return {"available": C.bool_const(client.server_groupblog_available)}
-        except AttributeError:
-            if self.host.is_connected(profile):
-                log.debug("Profile is not connected, service is not checked yet")
-            else:
-                log.error("client.server_groupblog_available should be available !")
-            return {}
-
-    def _item_2_data_trigger(self, item_elt, entry_elt, microblog_data):
-        """Parse item to find group permission elements"""
-        config_form = data_form.findForm(item_elt, NS_PUBSUB_ITEM_CONFIG)
-        if config_form is None:
-            return
-        access_model = config_form.get(self._p.OPT_ACCESS_MODEL, self._p.ACCESS_OPEN)
-        if access_model == self._p.ACCESS_PUBLISHER_ROSTER:
-            opt = self._p.OPT_ROSTER_GROUPS_ALLOWED
-            microblog_data['groups'] = config_form.fields[opt].values
-
-    def _data_2_entry_trigger(self, client, mb_data, entry_elt, item_elt):
-        """Build fine access permission if needed
-
-        This trigger check if "group*" key are present,
-        and create a fine item config to restrict view to these groups
-        """
-        groups = mb_data.get('groups', [])
-        if not groups:
-            return
-        if not client.server_groupblog_available:
-            raise exceptions.CancelError("GroupBlog is not available")
-        log.debug("This entry use group blog")
-        form = data_form.Form("submit", formNamespace=NS_PUBSUB_ITEM_CONFIG)
-        access = data_form.Field(
-            None, self._p.OPT_ACCESS_MODEL, value=self._p.ACCESS_PUBLISHER_ROSTER
-        )
-        allowed = data_form.Field(None, self._p.OPT_ROSTER_GROUPS_ALLOWED, values=groups)
-        form.addField(access)
-        form.addField(allowed)
-        item_elt.addChild(form.toElement())
-
-    def _comments_trigger(self, client, mb_data, options):
-        """This method is called when a comments node is about to be created
-
-        It changes the access mode to roster if needed, and give the authorized groups
-        """
-        if "group" in mb_data:
-            options[self._p.OPT_ACCESS_MODEL] = self._p.ACCESS_PUBLISHER_ROSTER
-            options[self._p.OPT_ROSTER_GROUPS_ALLOWED] = mb_data['groups']
-
-@implementer(iwokkel.IDisco)
-class GroupBlog_handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_GROUPBLOG)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_misc_identity.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,809 +0,0 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from collections import namedtuple
-import io
-from pathlib import Path
-from base64 import b64encode
-import hashlib
-from typing import Any, Coroutine, Dict, List, Optional, Union
-
-from twisted.internet import defer, threads
-from twisted.words.protocols.jabber import jid
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.xmpp import SatXMPPEntity
-from sat.memory import persistent
-from sat.tools import image
-from sat.tools import utils
-from sat.tools.common import data_format
-
-try:
-    from PIL import Image
-except:
-    raise exceptions.MissingModule(
-        "Missing module pillow, please download/install it from https://python-pillow.github.io"
-    )
-
-
-
-log = getLogger(__name__)
-
-
-IMPORT_NAME = "IDENTITY"
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Identity Plugin",
-    C.PI_IMPORT_NAME: IMPORT_NAME,
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [],
-    C.PI_RECOMMENDATIONS: ["XEP-0045"],
-    C.PI_MAIN: "Identity",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Identity manager"""),
-}
-
-Callback = namedtuple("Callback", ("origin", "get", "set", "priority"))
-AVATAR_DIM = (128, 128)
-
-
-class Identity:
-
-    def __init__(self, host):
-        log.info(_("Plugin Identity initialization"))
-        self.host = host
-        self._m = host.plugins.get("XEP-0045")
-        self.metadata = {
-            "avatar": {
-                "type": dict,
-                # convert avatar path to avatar metadata (and check validity)
-                "set_data_filter": self.avatar_set_data_filter,
-                # update profile avatar, so all frontends are aware
-                "set_post_treatment": self.avatar_set_post_treatment,
-                "update_is_new_data": self.avatar_update_is_new_data,
-                "update_data_filter": self.avatar_update_data_filter,
-                # we store the metadata in database, to restore it on next connection
-                # (it is stored only for roster entities)
-                "store": True,
-            },
-            "nicknames": {
-                "type": list,
-                # accumulate all nicknames from all callbacks in a list instead
-                # of returning only the data from the first successful callback
-                "get_all": True,
-                # append nicknames from roster, resource, etc.
-                "get_post_treatment": self.nicknames_get_post_treatment,
-                "update_is_new_data": self.nicknames_update_is_new_data,
-                "store": True,
-            },
-            "description": {
-                "type": str,
-                "get_all": True,
-                "get_post_treatment": self.description_get_post_treatment,
-                "store": True,
-            }
-        }
-        host.trigger.add("roster_update", self._roster_update_trigger)
-        host.memory.set_signal_on_update("avatar")
-        host.memory.set_signal_on_update("nicknames")
-        host.bridge.add_method(
-            "identity_get",
-            ".plugin",
-            in_sign="sasbs",
-            out_sign="s",
-            method=self._get_identity,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "identities_get",
-            ".plugin",
-            in_sign="asass",
-            out_sign="s",
-            method=self._get_identities,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "identities_base_get",
-            ".plugin",
-            in_sign="s",
-            out_sign="s",
-            method=self._get_base_identities,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "identity_set",
-            ".plugin",
-            in_sign="ss",
-            out_sign="",
-            method=self._set_identity,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "avatar_get",
-            ".plugin",
-            in_sign="sbs",
-            out_sign="s",
-            method=self._getAvatar,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "avatar_set",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._set_avatar,
-            async_=True,
-        )
-
-    async def profile_connecting(self, client):
-        client._identity_update_lock = []
-        # we restore known identities from database
-        client._identity_storage = persistent.LazyPersistentBinaryDict(
-            "identity", client.profile)
-
-        stored_data = await client._identity_storage.all()
-
-        to_delete = []
-
-        for key, value in stored_data.items():
-            entity_s, name = key.split('\n')
-            if name not in self.metadata.keys():
-                log.debug(f"removing {key} from storage: not an allowed metadata name")
-                to_delete.append(key)
-                continue
-            entity = jid.JID(entity_s)
-
-            if name == 'avatar':
-                if value is not None:
-                    try:
-                        cache_uid = value['cache_uid']
-                        if not cache_uid:
-                            raise ValueError
-                        filename = value['filename']
-                        if not filename:
-                            raise ValueError
-                    except (ValueError, KeyError):
-                        log.warning(
-                            f"invalid data for {entity} avatar, it will be deleted: "
-                            f"{value}")
-                        to_delete.append(key)
-                        continue
-                    cache = self.host.common_cache.get_metadata(cache_uid)
-                    if cache is None:
-                        log.debug(
-                            f"purging avatar for {entity}: it is not in cache anymore")
-                        to_delete.append(key)
-                        continue
-
-            self.host.memory.update_entity_data(
-                client, entity, name, value, silent=True
-            )
-
-        for key in to_delete:
-            await client._identity_storage.adel(key)
-
-    def _roster_update_trigger(self, client, roster_item):
-        old_item = client.roster.get_item(roster_item.jid)
-        if old_item is None or old_item.name != roster_item.name:
-            log.debug(
-                f"roster nickname has been updated to {roster_item.name!r} for "
-                f"{roster_item.jid}"
-            )
-            defer.ensureDeferred(
-                self.update(
-                    client,
-                    IMPORT_NAME,
-                    "nicknames",
-                    [roster_item.name],
-                    roster_item.jid
-                )
-            )
-        return True
-
-    def register(
-            self,
-            origin: str,
-            metadata_name: str,
-            cb_get: Union[Coroutine, defer.Deferred],
-            cb_set: Union[Coroutine, defer.Deferred],
-            priority: int=0):
-        """Register callbacks to handle identity metadata
-
-        @param origin: namespace of the plugin managing this metadata
-        @param metadata_name: name of metadata can be:
-            - avatar
-            - nicknames
-        @param cb_get: method to retrieve a metadata
-            the method will get client and metadata names to retrieve as arguments.
-        @param cb_set: method to set a metadata
-            the method will get client, metadata name to set, and value as argument.
-        @param priority: priority of this method for the given metadata.
-            methods with bigger priorities will be called first
-        """
-        if not metadata_name in self.metadata.keys():
-            raise ValueError(f"Invalid metadata_name: {metadata_name!r}")
-        callback = Callback(origin=origin, get=cb_get, set=cb_set, priority=priority)
-        cb_list = self.metadata[metadata_name].setdefault('callbacks', [])
-        cb_list.append(callback)
-        cb_list.sort(key=lambda c: c.priority, reverse=True)
-
-    def get_identity_jid(self, client, peer_jid):
-        """Return jid to use to set identity metadata
-
-        if it's a jid of a room occupant, full jid will be used
-        otherwise bare jid will be used
-        if None, bare jid of profile will be used
-        @return (jid.JID): jid to use for avatar
-        """
-        if peer_jid is None:
-            return client.jid.userhostJID()
-        if self._m is None:
-            return peer_jid.userhostJID()
-        else:
-            return self._m.get_bare_or_full(client, peer_jid)
-
-    def check_type(self, metadata_name, value):
-        """Check that type used for a metadata is the one declared in self.metadata"""
-        value_type = self.metadata[metadata_name]["type"]
-        if not isinstance(value, value_type):
-            raise ValueError(
-                f"{value} has wrong type: it is {type(value)} while {value_type} was "
-                f"expected")
-
-    def get_field_type(self, metadata_name: str) -> str:
-        """Return the type the requested field
-
-        @param metadata_name: name of the field to check
-        @raise KeyError: the request field doesn't exist
-        """
-        return self.metadata[metadata_name]["type"]
-
-    async def get(
-            self,
-            client: SatXMPPEntity,
-            metadata_name: str,
-            entity: Optional[jid.JID],
-            use_cache: bool=True,
-            prefilled_values: Optional[Dict[str, Any]]=None
-        ):
-        """Retrieve identity metadata of an entity
-
-        if metadata is already in cache, it is returned. Otherwise, registered callbacks
-        will be tried in priority order (bigger to lower)
-        @param metadata_name: name of the metadata
-            must be one of self.metadata key
-            the name will also be used as entity data name in host.memory
-        @param entity: entity for which avatar is requested
-            None to use profile's jid
-        @param use_cache: if False, cache won't be checked
-        @param prefilled_values: map of origin => value to use when `get_all` is set
-        """
-        entity = self.get_identity_jid(client, entity)
-        try:
-            metadata = self.metadata[metadata_name]
-        except KeyError:
-            raise ValueError(f"Invalid metadata name: {metadata_name!r}")
-        get_all = metadata.get('get_all', False)
-        if use_cache:
-            try:
-                data = self.host.memory.get_entity_datum(
-                    client, entity, metadata_name)
-            except (KeyError, exceptions.UnknownEntityError):
-                pass
-            else:
-                return data
-
-        try:
-            callbacks = metadata['callbacks']
-        except KeyError:
-            log.warning(_("No callback registered for {metadata_name}")
-                        .format(metadata_name=metadata_name))
-            return [] if get_all else None
-
-        if get_all:
-            all_data = []
-        elif prefilled_values is not None:
-            raise exceptions.InternalError(
-                "prefilled_values can only be used when `get_all` is set")
-
-        for callback in callbacks:
-            try:
-                if prefilled_values is not None and callback.origin in prefilled_values:
-                    data = prefilled_values[callback.origin]
-                    log.debug(
-                        f"using prefilled values {data!r} for {metadata_name} with "
-                        f"{callback.origin}")
-                else:
-                    data = await defer.ensureDeferred(callback.get(client, entity))
-            except exceptions.CancelError:
-                continue
-            except Exception as e:
-                log.warning(
-                    _("Error while trying to get {metadata_name} with {callback}: {e}")
-                    .format(callback=callback.get, metadata_name=metadata_name, e=e))
-            else:
-                if data:
-                    self.check_type(metadata_name, data)
-                    if get_all:
-                        if isinstance(data, list):
-                            all_data.extend(data)
-                        else:
-                            all_data.append(data)
-                    else:
-                        break
-        else:
-            data = None
-
-        if get_all:
-            data = all_data
-
-        post_treatment = metadata.get("get_post_treatment")
-        if post_treatment is not None:
-            data = await utils.as_deferred(post_treatment, client, entity, data)
-
-        self.host.memory.update_entity_data(
-            client, entity, metadata_name, data)
-
-        if metadata.get('store', False):
-            key = f"{entity}\n{metadata_name}"
-            await client._identity_storage.aset(key, data)
-
-        return data
-
-    async def set(self, client, metadata_name, data, entity=None):
-        """Set identity metadata for an entity
-
-        Registered callbacks will be tried in priority order (bigger to lower)
-        @param metadata_name(str): name of the metadata
-            must be one of self.metadata key
-            the name will also be used to set entity data in host.memory
-        @param data(object): value to set
-        @param entity(jid.JID, None): entity for which avatar is requested
-            None to use profile's jid
-        """
-        entity = self.get_identity_jid(client, entity)
-        metadata = self.metadata[metadata_name]
-        data_filter = metadata.get("set_data_filter")
-        if data_filter is not None:
-            data = await utils.as_deferred(data_filter, client, entity, data)
-        self.check_type(metadata_name, data)
-
-        try:
-            callbacks = metadata['callbacks']
-        except KeyError:
-            log.warning(_("No callback registered for {metadata_name}")
-                        .format(metadata_name=metadata_name))
-            return exceptions.FeatureNotFound(f"Can't set {metadata_name} for {entity}")
-
-        for callback in callbacks:
-            try:
-                await defer.ensureDeferred(callback.set(client, data, entity))
-            except exceptions.CancelError:
-                continue
-            except Exception as e:
-                log.warning(
-                    _("Error while trying to set {metadata_name} with {callback}: {e}")
-                    .format(callback=callback.set, metadata_name=metadata_name, e=e))
-            else:
-                break
-        else:
-            raise exceptions.FeatureNotFound(f"Can't set {metadata_name} for {entity}")
-
-        post_treatment = metadata.get("set_post_treatment")
-        if post_treatment is not None:
-            await utils.as_deferred(post_treatment, client, entity, data)
-
-    async def update(
-        self,
-        client: SatXMPPEntity,
-        origin: str,
-        metadata_name: str,
-        data: Any,
-        entity: Optional[jid.JID]
-    ):
-        """Update a metadata in cache
-
-        This method may be called by plugins when an identity metadata is available.
-        @param origin: namespace of the plugin which is source of the metadata
-        """
-        entity = self.get_identity_jid(client, entity)
-        if (entity, metadata_name) in client._identity_update_lock:
-            log.debug(f"update is locked for {entity}'s {metadata_name}")
-            return
-        metadata = self.metadata[metadata_name]
-
-        try:
-            cached_data = self.host.memory.get_entity_datum(
-                client, entity, metadata_name)
-        except (KeyError, exceptions.UnknownEntityError):
-            # metadata is not cached, we do the update
-            pass
-        else:
-            # metadata is cached, we check if the new value differs from the cached one
-            try:
-                update_is_new_data = metadata["update_is_new_data"]
-            except KeyError:
-                update_is_new_data = self.default_update_is_new_data
-
-            if data is None:
-                if cached_data is None:
-                    log.debug(
-                        f"{metadata_name} for {entity} is already disabled, nothing to "
-                        f"do")
-                    return
-            elif cached_data is None:
-                pass
-            elif not update_is_new_data(client, entity, cached_data, data):
-                log.debug(
-                    f"{metadata_name} for {entity} is already in cache, nothing to "
-                    f"do")
-                return
-
-        # we can't use the cache, so we do the update
-
-        log.debug(f"updating {metadata_name} for {entity}")
-
-        if metadata.get('get_all', False):
-            # get_all is set, meaning that we have to check all plugins
-            # so we first delete current cache
-            try:
-                self.host.memory.del_entity_datum(client, entity, metadata_name)
-            except (KeyError, exceptions.UnknownEntityError):
-                pass
-            # then fill it again by calling get, which will retrieve all values
-            # we lock update to avoid infinite recursions (update can be called during
-            # get callbacks)
-            client._identity_update_lock.append((entity, metadata_name))
-            await self.get(client, metadata_name, entity, prefilled_values={origin: data})
-            client._identity_update_lock.remove((entity, metadata_name))
-            return
-
-        if data is not None:
-            data_filter = metadata['update_data_filter']
-            if data_filter is not None:
-                data = await utils.as_deferred(data_filter, client, entity, data)
-            self.check_type(metadata_name, data)
-
-        self.host.memory.update_entity_data(client, entity, metadata_name, data)
-
-        if metadata.get('store', False):
-            key = f"{entity}\n{metadata_name}"
-            await client._identity_storage.aset(key, data)
-
-    def default_update_is_new_data(self, client, entity, cached_data, new_data):
-        return new_data != cached_data
-
-    def _getAvatar(self, entity, use_cache, profile):
-        client = self.host.get_client(profile)
-        entity = jid.JID(entity) if entity else None
-        d = defer.ensureDeferred(self.get(client, "avatar", entity, use_cache))
-        d.addCallback(lambda data: data_format.serialise(data))
-        return d
-
-    def _set_avatar(self, file_path, entity, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        entity = jid.JID(entity) if entity else None
-        return defer.ensureDeferred(
-            self.set(client, "avatar", file_path, entity))
-
-    def _blocking_cache_avatar(
-        self,
-        source: str,
-        avatar_data: dict[str, Any]
-    ):
-        """This method is executed in a separated thread"""
-        if avatar_data["media_type"] == "image/svg+xml":
-            # for vector image, we save directly
-            img_buf = open(avatar_data["path"], "rb")
-        else:
-            # for bitmap image, we check size and resize if necessary
-            try:
-                img = Image.open(avatar_data["path"])
-            except IOError as e:
-                raise exceptions.DataError(f"Can't open image: {e}")
-
-            if img.size != AVATAR_DIM:
-                img.thumbnail(AVATAR_DIM)
-                if img.size[0] != img.size[1]:  # we need to crop first
-                    left, upper = (0, 0)
-                    right, lower = img.size
-                    offset = abs(right - lower) / 2
-                    if right == min(img.size):
-                        upper += offset
-                        lower -= offset
-                    else:
-                        left += offset
-                        right -= offset
-                    img = img.crop((left, upper, right, lower))
-            img_buf = io.BytesIO()
-            # PNG is well supported among clients, so we convert to this format
-            img.save(img_buf, "PNG")
-            img_buf.seek(0)
-            avatar_data["media_type"] = "image/png"
-
-        media_type = avatar_data["media_type"]
-        avatar_data["base64"] = image_b64 = b64encode(img_buf.read()).decode()
-        img_buf.seek(0)
-        image_hash = hashlib.sha1(img_buf.read()).hexdigest()
-        img_buf.seek(0)
-        with self.host.common_cache.cache_data(
-            source, image_hash, media_type
-        ) as f:
-            f.write(img_buf.read())
-            avatar_data['path'] = Path(f.name)
-            avatar_data['filename'] = avatar_data['path'].name
-        avatar_data['cache_uid'] = image_hash
-
-    async def cache_avatar(self, source: str, avatar_data: Dict[str, Any]) -> None:
-        """Resize if necessary and cache avatar
-
-        @param source: source importing the avatar (usually it is plugin's import name),
-            will be used in cache metadata
-        @param avatar_data: avatar metadata as build by [avatar_set_data_filter]
-            will be updated with following keys:
-                path: updated path using cached file
-                filename: updated filename using cached file
-                base64: resized and base64 encoded avatar
-                cache_uid: SHA1 hash used as cache unique ID
-        """
-        await threads.deferToThread(self._blocking_cache_avatar, source, avatar_data)
-
-    async def avatar_set_data_filter(self, client, entity, file_path):
-        """Convert avatar file path to dict data"""
-        file_path = Path(file_path)
-        if not file_path.is_file():
-            raise ValueError(f"There is no file at {file_path} to use as avatar")
-        avatar_data = {
-            'path': file_path,
-            'filename': file_path.name,
-            'media_type': image.guess_type(file_path),
-        }
-        media_type = avatar_data['media_type']
-        if media_type is None:
-            raise ValueError(f"Can't identify type of image at {file_path}")
-        if not media_type.startswith('image/'):
-            raise ValueError(f"File at {file_path} doesn't appear to be an image")
-        await self.cache_avatar(IMPORT_NAME, avatar_data)
-        return avatar_data
-
-    async def avatar_set_post_treatment(self, client, entity, avatar_data):
-        """Update our own avatar"""
-        await self.update(client, IMPORT_NAME, "avatar", avatar_data, entity)
-
-    def avatar_build_metadata(
-            self,
-            path: Path,
-            media_type: Optional[str] = None,
-            cache_uid: Optional[str] = None
-    ) -> Optional[Dict[str, Union[str, Path, None]]]:
-        """Helper method to generate avatar metadata
-
-        @param path(str, Path, None): path to avatar file
-            avatar file must be in cache
-            None if avatar is explicitely not set
-        @param media_type(str, None): type of the avatar file (MIME type)
-        @param cache_uid(str, None): UID of avatar in cache
-        @return (dict, None): avatar metadata
-            None if avatar is not set
-        """
-        if path is None:
-            return None
-        else:
-            if cache_uid is None:
-                raise ValueError("cache_uid must be set if path is set")
-            path = Path(path)
-            if media_type is None:
-                media_type = image.guess_type(path)
-
-            return {
-                "path": path,
-                "filename": path.name,
-                "media_type": media_type,
-                "cache_uid": cache_uid,
-            }
-
-    def avatar_update_is_new_data(self, client, entity, cached_data, new_data):
-        return new_data['path'] != cached_data['path']
-
-    async def avatar_update_data_filter(self, client, entity, data):
-        if not isinstance(data, dict):
-            raise ValueError(f"Invalid data type ({type(data)}), a dict is expected")
-        mandatory_keys = {'path', 'filename', 'cache_uid'}
-        if not data.keys() >= mandatory_keys:
-            raise ValueError(f"missing avatar data keys: {mandatory_keys - data.keys()}")
-        return data
-
-    async def nicknames_get_post_treatment(self, client, entity, plugin_nicknames):
-        """Prepend nicknames from core locations + set default nickname
-
-        nicknames are checked from many locations, there is always at least
-        one nickname. First nickname of the list can be used in priority.
-        Nicknames are appended in this order:
-            - roster, plugins set nicknames
-            - if no nickname is found, user part of jid is then used, or bare jid
-              if there is no user part.
-        For MUC, room nick is always put first
-        """
-        nicknames = []
-
-        # for MUC we add resource
-        if entity.resource:
-            # get_identity_jid let the resource only if the entity is a MUC room
-            # occupant jid
-            nicknames.append(entity.resource)
-
-        # we first check roster (if we are not in a component)
-        if not client.is_component:
-            roster_item = client.roster.get_item(entity.userhostJID())
-            if roster_item is not None and roster_item.name:
-                # user set name has priority over entity set name
-                nicknames.append(roster_item.name)
-
-        nicknames.extend(plugin_nicknames)
-
-        if not nicknames:
-            if entity.user:
-                nicknames.append(entity.user.capitalize())
-            else:
-                nicknames.append(entity.userhost())
-
-        # we remove duplicates while preserving order with dict
-        return list(dict.fromkeys(nicknames))
-
-    def nicknames_update_is_new_data(self, client, entity, cached_data, new_nicknames):
-        return not set(new_nicknames).issubset(cached_data)
-
-    async def description_get_post_treatment(
-        self,
-        client: SatXMPPEntity,
-        entity: jid.JID,
-        plugin_description: List[str]
-    ) -> str:
-        """Join all descriptions in a unique string"""
-        return '\n'.join(plugin_description)
-
-    def _get_identity(self, entity_s, metadata_filter, use_cache, profile):
-        entity = jid.JID(entity_s)
-        client = self.host.get_client(profile)
-        d = defer.ensureDeferred(
-            self.get_identity(client, entity, metadata_filter, use_cache))
-        d.addCallback(data_format.serialise)
-        return d
-
-    async def get_identity(
-        self,
-        client: SatXMPPEntity,
-        entity: Optional[jid.JID] = None,
-        metadata_filter: Optional[List[str]] = None,
-        use_cache: bool = True
-    ) -> Dict[str, Any]:
-        """Retrieve identity of an entity
-
-        @param entity: entity to check
-        @param metadata_filter: if not None or empty, only return
-            metadata in this filter
-        @param use_cache: if False, cache won't be checked
-            should be True most of time, to avoid useless network requests
-        @return: identity data
-        """
-        id_data = {}
-
-        if not metadata_filter:
-            metadata_names = self.metadata.keys()
-        else:
-            metadata_names = metadata_filter
-
-        for metadata_name in metadata_names:
-            id_data[metadata_name] = await self.get(
-                client, metadata_name, entity, use_cache)
-
-        return id_data
-
-    def _get_identities(self, entities_s, metadata_filter, profile):
-        entities = [jid.JID(e) for e in entities_s]
-        client = self.host.get_client(profile)
-        d = defer.ensureDeferred(self.get_identities(client, entities, metadata_filter))
-        d.addCallback(lambda d: data_format.serialise({str(j):i for j, i in d.items()}))
-        return d
-
-    async def get_identities(
-        self,
-        client: SatXMPPEntity,
-        entities: List[jid.JID],
-        metadata_filter: Optional[List[str]] = None,
-    ) -> dict:
-        """Retrieve several identities at once
-
-        @param entities: entities from which identities must be retrieved
-        @param metadata_filter: same as for [get_identity]
-        @return: identities metadata where key is jid
-            if an error happens while retrieve a jid entity, it won't be present in the
-            result (and a warning will be logged)
-        """
-        identities = {}
-        get_identity_list = []
-        for entity_jid in entities:
-            get_identity_list.append(
-                defer.ensureDeferred(
-                    self.get_identity(
-                        client,
-                        entity=entity_jid,
-                        metadata_filter=metadata_filter,
-                    )
-                )
-            )
-        identities_result = await defer.DeferredList(get_identity_list)
-        for idx, (success, identity) in enumerate(identities_result):
-            entity_jid = entities[idx]
-            if not success:
-                log.warning(f"Can't get identity for {entity_jid}")
-            else:
-                identities[entity_jid] = identity
-        return identities
-
-    def _get_base_identities(self, profile_key):
-        client = self.host.get_client(profile_key)
-        d = defer.ensureDeferred(self.get_base_identities(client))
-        d.addCallback(lambda d: data_format.serialise({str(j):i for j, i in d.items()}))
-        return d
-
-    async def get_base_identities(
-        self,
-        client: SatXMPPEntity,
-    ) -> dict:
-        """Retrieve identities for entities in roster + own identity + invitations
-
-        @param with_guests: if True, get affiliations of people invited by email
-
-        """
-        if client.is_component:
-            entities = [client.jid.userhostJID()]
-        else:
-            entities = client.roster.get_jids() + [client.jid.userhostJID()]
-
-        return await self.get_identities(
-            client,
-            entities,
-            ['avatar', 'nicknames']
-        )
-
-    def _set_identity(self, id_data_s, profile):
-        client = self.host.get_client(profile)
-        id_data = data_format.deserialise(id_data_s)
-        return defer.ensureDeferred(self.set_identity(client, id_data))
-
-    async def set_identity(self, client, id_data):
-        """Update profile's identity
-
-        @param id_data(dict): data to update, key can be one of self.metadata keys
-        """
-        if not id_data.keys() <= self.metadata.keys():
-            raise ValueError(
-                f"Invalid metadata names: {id_data.keys() - self.metadata.keys()}")
-        for metadata_name, data in id_data.items():
-            try:
-                await self.set(client, metadata_name, data)
-            except Exception as e:
-                log.warning(
-                    _("Can't set metadata {metadata_name!r}: {reason}")
-                    .format(metadata_name=metadata_name, reason=e))
--- a/sat/plugins/plugin_misc_ip.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,330 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for IP address discovery
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import urllib.parse
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.tools import xml_tools
-from wokkel import disco, iwokkel
-from twisted.web import client as webclient
-from twisted.web import error as web_error
-from twisted.internet import defer
-from twisted.internet import reactor
-from twisted.internet import protocol
-from twisted.internet import endpoints
-from twisted.internet import error as internet_error
-from zope.interface import implementer
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.protocols.jabber.error import StanzaError
-
-log = getLogger(__name__)
-
-try:
-    import netifaces
-except ImportError:
-    log.warning(
-        "netifaces is not available, it help discovering IPs, you can install it on https://pypi.python.org/pypi/netifaces"
-    )
-    netifaces = None
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "IP discovery",
-    C.PI_IMPORT_NAME: "IP",
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0279"],
-    C.PI_RECOMMENDATIONS: ["NAT-PORT"],
-    C.PI_MAIN: "IPPlugin",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""This plugin help to discover our external IP address."""),
-}
-
-# TODO: GET_IP_PAGE should be configurable in sat.conf
-GET_IP_PAGE = (
-    "http://salut-a-toi.org/whereami/"
-)  # This page must only return external IP of the requester
-GET_IP_LABEL = D_("Allow external get IP")
-GET_IP_CATEGORY = "General"
-GET_IP_NAME = "allow_get_ip"
-GET_IP_CONFIRM_TITLE = D_("Confirm external site request")
-GET_IP_CONFIRM = D_(
-    """To facilitate data transfer, we need to contact a website.
-A request will be done on {page}
-That means that administrators of {domain} can know that you use "{app_name}" and your IP Address.
-
-IP address is an identifier to locate you on Internet (similar to a phone number).
-
-Do you agree to do this request ?
-"""
-).format(
-    page=GET_IP_PAGE, domain=urllib.parse.urlparse(GET_IP_PAGE).netloc, app_name=C.APP_NAME
-)
-NS_IP_CHECK = "urn:xmpp:sic:1"
-
-PARAMS = """
-    <params>
-    <general>
-    <category name="{category}">
-        <param name="{name}" label="{label}" type="bool" />
-    </category>
-    </general>
-    </params>
-    """.format(
-    category=GET_IP_CATEGORY, name=GET_IP_NAME, label=GET_IP_LABEL
-)
-
-
-class IPPlugin(object):
-    # TODO: refresh IP if a new connection is detected
-    # TODO: manage IPv6 when implemented in SàT
-
-    def __init__(self, host):
-        log.info(_("plugin IP discovery initialization"))
-        self.host = host
-        host.memory.update_params(PARAMS)
-
-        # NAT-Port
-        try:
-            self._nat = host.plugins["NAT-PORT"]
-        except KeyError:
-            log.debug("NAT port plugin not available")
-            self._nat = None
-
-        # XXX: cache is kept until SàT is restarted
-        #      if IP may have changed, use self.refresh_ip
-        self._external_ip_cache = None
-        self._local_ip_cache = None
-
-    def get_handler(self, client):
-        return IPPlugin_handler()
-
-    def refresh_ip(self):
-        # FIXME: use a trigger instead ?
-        self._external_ip_cache = None
-        self._local_ip_cache = None
-
-    def _external_allowed(self, client):
-        """Return value of parameter with autorisation of user to do external requests
-
-        if parameter is not set, a dialog is shown to use to get its confirmation, and parameted is set according to answer
-        @return (defer.Deferred[bool]): True if external request is autorised
-        """
-        allow_get_ip = self.host.memory.params.param_get_a(
-            GET_IP_NAME, GET_IP_CATEGORY, use_default=False
-        )
-
-        if allow_get_ip is None:
-            # we don't have autorisation from user yet to use get_ip, we ask him
-            def param_set(allowed):
-                # FIXME: we need to use bool_const as param_set only manage str/unicode
-                #        need to be fixed when params will be refactored
-                self.host.memory.param_set(
-                    GET_IP_NAME, C.bool_const(allowed), GET_IP_CATEGORY
-                )
-                return allowed
-
-            d = xml_tools.defer_confirm(
-                self.host,
-                _(GET_IP_CONFIRM),
-                _(GET_IP_CONFIRM_TITLE),
-                profile=client.profile,
-            )
-            d.addCallback(param_set)
-            return d
-
-        return defer.succeed(allow_get_ip)
-
-    def _filter_addresse(self, ip_addr):
-        """Filter acceptable addresses
-
-        For now, just remove IPv4 local addresses
-        @param ip_addr(str): IP addresse
-        @return (bool): True if addresse is acceptable
-        """
-        return not ip_addr.startswith("127.")
-
-    def _insert_first(self, addresses, ip_addr):
-        """Insert ip_addr as first item in addresses
-
-        @param addresses(list): list of IP addresses
-        @param ip_addr(str): IP addresse
-        """
-        if ip_addr in addresses:
-            if addresses[0] != ip_addr:
-                addresses.remove(ip_addr)
-                addresses.insert(0, ip_addr)
-        else:
-            addresses.insert(0, ip_addr)
-
-    async def _get_ip_from_external(self, ext_url):
-        """Get local IP by doing a connection on an external url
-
-        @param ext_utl(str): url to connect to
-        @return (str, None): return local IP, or None if it's not possible
-        """
-        url = urllib.parse.urlparse(ext_url)
-        port = url.port
-        if port is None:
-            if url.scheme == "http":
-                port = 80
-            elif url.scheme == "https":
-                port = 443
-            else:
-                log.error("Unknown url scheme: {}".format(url.scheme))
-                return None
-        if url.hostname is None:
-            log.error("Can't find url hostname for {}".format(GET_IP_PAGE))
-
-        point = endpoints.TCP4ClientEndpoint(reactor, url.hostname, port)
-
-        p = await endpoints.connectProtocol(point, protocol.Protocol())
-        local_ip = p.transport.getHost().host
-        p.transport.loseConnection()
-        return local_ip
-
-    @defer.inlineCallbacks
-    def get_local_i_ps(self, client):
-        """Try do discover local area network IPs
-
-        @return (deferred): list of lan IP addresses
-            if there are several addresses, the one used with the server is put first
-            if no address is found, localhost IP will be in the list
-        """
-        # TODO: manage permission requesting (e.g. for UMTS link)
-        if self._local_ip_cache is not None:
-            defer.returnValue(self._local_ip_cache)
-        addresses = []
-        localhost = ["127.0.0.1"]
-
-        # we first try our luck with netifaces
-        if netifaces is not None:
-            addresses = []
-            for interface in netifaces.interfaces():
-                if_addresses = netifaces.ifaddresses(interface)
-                try:
-                    inet_list = if_addresses[netifaces.AF_INET]
-                except KeyError:
-                    continue
-                for data in inet_list:
-                    addresse = data["addr"]
-                    if self._filter_addresse(addresse):
-                        addresses.append(addresse)
-
-        # then we use our connection to server
-        ip = client.xmlstream.transport.getHost().host
-        if self._filter_addresse(ip):
-            self._insert_first(addresses, ip)
-            defer.returnValue(addresses)
-
-        # if server is local, we try with NAT-Port
-        if self._nat is not None:
-            nat_ip = yield self._nat.get_ip(local=True)
-            if nat_ip is not None:
-                self._insert_first(addresses, nat_ip)
-                defer.returnValue(addresses)
-
-            if addresses:
-                defer.returnValue(addresses)
-
-        # still not luck, we need to contact external website
-        allow_get_ip = yield self._external_allowed(client)
-
-        if not allow_get_ip:
-            defer.returnValue(addresses or localhost)
-
-        try:
-            local_ip = yield defer.ensureDeferred(self._get_ip_from_external(GET_IP_PAGE))
-        except (internet_error.DNSLookupError, internet_error.TimeoutError):
-            log.warning("Can't access Domain Name System")
-        else:
-            if local_ip is not None:
-                self._insert_first(addresses, local_ip)
-
-        defer.returnValue(addresses or localhost)
-
-    @defer.inlineCallbacks
-    def get_external_ip(self, client):
-        """Try to discover external IP
-
-        @return (deferred): external IP address or None if it can't be discovered
-        """
-        if self._external_ip_cache is not None:
-            defer.returnValue(self._external_ip_cache)
-
-        # we first try with XEP-0279
-        ip_check = yield self.host.hasFeature(client, NS_IP_CHECK)
-        if ip_check:
-            log.debug("Server IP Check available, we use it to retrieve our IP")
-            iq_elt = client.IQ("get")
-            iq_elt.addElement((NS_IP_CHECK, "address"))
-            try:
-                result_elt = yield iq_elt.send()
-                address_elt = next(result_elt.elements(NS_IP_CHECK, "address"))
-                ip_elt = next(address_elt.elements(NS_IP_CHECK, "ip"))
-            except StopIteration:
-                log.warning(
-                    "Server returned invalid result on XEP-0279 request, we ignore it"
-                )
-            except StanzaError as e:
-                log.warning("error while requesting ip to server: {}".format(e))
-            else:
-                # FIXME: server IP may not be the same as external IP (server can be on local machine or network)
-                #        IP should be checked to see if we have a local one, and rejected in this case
-                external_ip = str(ip_elt)
-                log.debug("External IP found: {}".format(external_ip))
-                self._external_ip_cache = external_ip
-                defer.returnValue(self._external_ip_cache)
-
-        # then with NAT-Port
-        if self._nat is not None:
-            nat_ip = yield self._nat.get_ip()
-            if nat_ip is not None:
-                self._external_ip_cache = nat_ip
-                defer.returnValue(nat_ip)
-
-        # and finally by requesting external website
-        allow_get_ip = yield self._external_allowed(client)
-        try:
-            ip = ((yield webclient.getPage(GET_IP_PAGE.encode('utf-8')))
-                  if allow_get_ip else None)
-        except (internet_error.DNSLookupError, internet_error.TimeoutError):
-            log.warning("Can't access Domain Name System")
-            ip = None
-        except web_error.Error as e:
-            log.warning(
-                "Error while retrieving IP on {url}: {message}".format(
-                    url=GET_IP_PAGE, message=e
-                )
-            )
-            ip = None
-        else:
-            self._external_ip_cache = ip
-        defer.returnValue(ip)
-
-
-@implementer(iwokkel.IDisco)
-class IPPlugin_handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_IP_CHECK)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_misc_lists.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,519 +0,0 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import shortuuid
-from typing import List, Tuple, Optional
-from twisted.internet import defer
-from twisted.words.xish import domish
-from twisted.words.protocols.jabber import jid
-from sat.core.i18n import _, D_
-from sat.core.xmpp import SatXMPPEntity
-from sat.core.constants import Const as C
-from sat.tools import xml_tools
-from sat.tools.common import uri
-from sat.tools.common import data_format
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-# XXX: this plugin was formely named "tickets", thus the namespace keeps this
-# name
-APP_NS_TICKETS = "org.salut-a-toi.tickets:0"
-NS_TICKETS_TYPE = "org.salut-a-toi.tickets#type:0"
-
-PLUGIN_INFO = {
-    C.PI_NAME: _("Pubsub Lists"),
-    C.PI_IMPORT_NAME: "LISTS",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0346", "XEP-0277", "IDENTITY",
-                        "PUBSUB_INVITATION"],
-    C.PI_MAIN: "PubsubLists",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Pubsub lists management plugin"""),
-}
-
-TEMPLATES = {
-    "todo": {
-        "name": D_("TODO List"),
-        "icon": "check",
-        "fields": [
-            {"name": "title"},
-            {"name": "author"},
-            {"name": "created"},
-            {"name": "updated"},
-            {"name": "time_limit"},
-            {"name": "labels", "type": "text-multi"},
-            {
-                "name": "status",
-                "label": D_("status"),
-                "type": "list-single",
-                "options": [
-                    {
-                        "label": D_("to do"),
-                        "value": "todo"
-                    },
-                    {
-                        "label": D_("in progress"),
-                        "value": "in_progress"
-                    },
-                    {
-                        "label": D_("done"),
-                        "value": "done"
-                    },
-                ],
-                "value": "todo"
-            },
-            {
-                "name": "priority",
-                "label": D_("priority"),
-                "type": "list-single",
-                "options": [
-                    {
-                        "label": D_("major"),
-                        "value": "major"
-                    },
-                    {
-                        "label": D_("normal"),
-                        "value": "normal"
-                    },
-                    {
-                        "label": D_("minor"),
-                        "value": "minor"
-                    },
-                ],
-                "value": "normal"
-            },
-            {"name": "body", "type": "xhtml"},
-            {"name": "comments_uri"},
-        ]
-    },
-    "grocery": {
-        "name": D_("Grocery List"),
-        "icon": "basket",
-        "fields": [
-            {"name": "name", "label": D_("name")},
-            {"name": "quantity", "label": D_("quantity")},
-            {
-                "name": "status",
-                "label": D_("status"),
-                "type": "list-single",
-                "options": [
-                    {
-                        "label": D_("to buy"),
-                        "value": "to_buy"
-                    },
-                    {
-                        "label": D_("bought"),
-                        "value": "bought"
-                    },
-                ],
-                "value": "to_buy"
-            },
-        ]
-    },
-    "tickets": {
-        "name": D_("Tickets"),
-        "icon": "clipboard",
-        "fields": [
-            {"name": "title"},
-            {"name": "author"},
-            {"name": "created"},
-            {"name": "updated"},
-            {"name": "labels", "type": "text-multi"},
-            {
-                "name": "type",
-                "label": D_("type"),
-                "type": "list-single",
-                "options": [
-                    {
-                        "label": D_("bug"),
-                        "value": "bug"
-                    },
-                    {
-                        "label": D_("feature request"),
-                        "value": "feature"
-                    },
-                ],
-                "value": "bug"
-            },
-            {
-                "name": "status",
-                "label": D_("status"),
-                "type": "list-single",
-                "options": [
-                    {
-                        "label": D_("queued"),
-                        "value": "queued"
-                    },
-                    {
-                        "label": D_("started"),
-                        "value": "started"
-                    },
-                    {
-                        "label": D_("review"),
-                        "value": "review"
-                    },
-                    {
-                        "label": D_("closed"),
-                        "value": "closed"
-                    },
-                ],
-                "value": "queued"
-            },
-            {
-                "name": "priority",
-                "label": D_("priority"),
-                "type": "list-single",
-                "options": [
-                    {
-                        "label": D_("major"),
-                        "value": "major"
-                    },
-                    {
-                        "label": D_("normal"),
-                        "value": "normal"
-                    },
-                    {
-                        "label": D_("minor"),
-                        "value": "minor"
-                    },
-                ],
-                "value": "normal"
-            },
-            {"name": "body", "type": "xhtml"},
-            {"name": "comments_uri"},
-        ]
-    }
-}
-
-
-class PubsubLists:
-
-    def __init__(self, host):
-        log.info(_("Pubsub lists plugin initialization"))
-        self.host = host
-        self._s = self.host.plugins["XEP-0346"]
-        self.namespace = self._s.get_submitted_ns(APP_NS_TICKETS)
-        host.register_namespace("tickets", APP_NS_TICKETS)
-        host.register_namespace("tickets_type", NS_TICKETS_TYPE)
-        self.host.plugins["PUBSUB_INVITATION"].register(
-            APP_NS_TICKETS, self
-        )
-        self._p = self.host.plugins["XEP-0060"]
-        self._m = self.host.plugins["XEP-0277"]
-        host.bridge.add_method(
-            "list_get",
-            ".plugin",
-            in_sign="ssiassss",
-            out_sign="s",
-            method=lambda service, node, max_items, items_ids, sub_id, extra, profile_key:
-                self._s._get(
-                service,
-                node,
-                max_items,
-                items_ids,
-                sub_id,
-                extra,
-                default_node=self.namespace,
-                form_ns=APP_NS_TICKETS,
-                filters={
-                    "author": self._s.value_or_publisher_filter,
-                    "created": self._s.date_filter,
-                    "updated": self._s.date_filter,
-                    "time_limit": self._s.date_filter,
-                },
-                profile_key=profile_key),
-            async_=True,
-        )
-        host.bridge.add_method(
-            "list_set",
-            ".plugin",
-            in_sign="ssa{sas}ssss",
-            out_sign="s",
-            method=self._set,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "list_delete_item",
-            ".plugin",
-            in_sign="sssbs",
-            out_sign="",
-            method=self._delete,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "list_schema_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=lambda service, nodeIdentifier, profile_key: self._s._get_ui_schema(
-                service, nodeIdentifier, default_node=self.namespace,
-                profile_key=profile_key),
-            async_=True,
-        )
-        host.bridge.add_method(
-            "lists_list",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=self._lists_list,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "list_templates_names_get",
-            ".plugin",
-            in_sign="ss",
-            out_sign="s",
-            method=self._get_templates_names,
-        )
-        host.bridge.add_method(
-            "list_template_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=self._get_template,
-        )
-        host.bridge.add_method(
-            "list_template_create",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="(ss)",
-            method=self._create_template,
-            async_=True,
-        )
-
-    async def on_invitation_preflight(
-        self,
-        client: SatXMPPEntity,
-        namespace: str,
-        name: str,
-        extra: dict,
-        service: jid.JID,
-        node: str,
-        item_id: Optional[str],
-        item_elt: domish.Element
-    ) -> None:
-        try:
-            schema = await self._s.get_schema_form(client, service, node)
-        except Exception as e:
-            log.warning(f"Can't retrive node schema as {node!r} [{service}]: {e}")
-        else:
-            try:
-                field_type = schema[NS_TICKETS_TYPE]
-            except KeyError:
-                log.debug("no type found in list schema")
-            else:
-                list_elt = extra["element"] = domish.Element((APP_NS_TICKETS, "list"))
-                list_elt["type"] = field_type
-
-    def _set(self, service, node, values, schema=None, item_id=None, extra_s='',
-             profile_key=C.PROF_KEY_NONE):
-        client, service, node, schema, item_id, extra = self._s.prepare_bridge_set(
-            service, node, schema, item_id, extra_s, profile_key
-        )
-        d = defer.ensureDeferred(self.set(
-            client, service, node, values, schema, item_id, extra, deserialise=True
-        ))
-        d.addCallback(lambda ret: ret or "")
-        return d
-
-    async def set(
-        self, client, service, node, values, schema=None, item_id=None, extra=None,
-        deserialise=False, form_ns=APP_NS_TICKETS
-    ):
-        """Publish a tickets
-
-        @param node(unicode, None): Pubsub node to use
-            None to use default tickets node
-        @param values(dict[key(unicode), [iterable[object]|object]]): values of the ticket
-
-            if value is not iterable, it will be put in a list
-            'created' and 'updated' will be forced to current time:
-                - 'created' is set if item_id is None, i.e. if it's a new ticket
-                - 'updated' is set everytime
-        @param extra(dict, None): same as for [XEP-0060.send_item] with additional keys:
-            - update(bool): if True, get previous item data to merge with current one
-                if True, item_id must be set
-        other arguments are same as for [self._s.send_data_form_item]
-        @return (unicode): id of the created item
-        """
-        if not node:
-            node = self.namespace
-
-        if not item_id:
-            comments_service = await self._m.get_comments_service(client, service)
-
-            # we need to use uuid for comments node, because we don't know item id in
-            # advance (we don't want to set it ourselves to let the server choose, so we
-            # can have a nicer id if serial ids is activated)
-            comments_node = self._m.get_comments_node(
-                node + "_" + str(shortuuid.uuid())
-            )
-            options = {
-                self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
-                self._p.OPT_PERSIST_ITEMS: 1,
-                self._p.OPT_DELIVER_PAYLOADS: 1,
-                self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
-                self._p.OPT_PUBLISH_MODEL: self._p.ACCESS_OPEN,
-            }
-            await self._p.createNode(client, comments_service, comments_node, options)
-            values["comments_uri"] = uri.build_xmpp_uri(
-                "pubsub",
-                subtype="microblog",
-                path=comments_service.full(),
-                node=comments_node,
-            )
-
-        return await self._s.set(
-            client, service, node, values, schema, item_id, extra, deserialise, form_ns
-        )
-
-    def _delete(
-        self, service_s, nodeIdentifier, itemIdentifier, notify, profile_key
-    ):
-        client = self.host.get_client(profile_key)
-        return defer.ensureDeferred(self.delete(
-            client,
-            jid.JID(service_s) if service_s else None,
-            nodeIdentifier,
-            itemIdentifier,
-            notify
-        ))
-
-    async def delete(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: Optional[str],
-        itemIdentifier: str,
-        notify: Optional[bool] = None
-    ) -> None:
-        if not node:
-            node = self.namespace
-        return await self._p.retract_items(
-            service, node, (itemIdentifier,), notify, client.profile
-        )
-
-    def _lists_list(self, service, node, profile):
-        service = jid.JID(service) if service else None
-        node = node or None
-        client = self.host.get_client(profile)
-        d = defer.ensureDeferred(self.lists_list(client, service, node))
-        d.addCallback(data_format.serialise)
-        return d
-
-    async def lists_list(
-        self, client, service: Optional[jid.JID], node: Optional[str]=None
-    ) -> List[dict]:
-        """Retrieve list of pubsub lists registered in personal interests
-
-        @return list: list of lists metadata
-        """
-        items, metadata = await self.host.plugins['LIST_INTEREST'].list_interests(
-            client, service, node, namespace=APP_NS_TICKETS)
-        lists = []
-        for item in items:
-            interest_elt = item.interest
-            if interest_elt is None:
-                log.warning(f"invalid interest for {client.profile}: {item.toXml}")
-                continue
-            if interest_elt.getAttribute("namespace") != APP_NS_TICKETS:
-                continue
-            pubsub_elt = interest_elt.pubsub
-            list_data = {
-                "id": item["id"],
-                "name": interest_elt["name"],
-                "service": pubsub_elt["service"],
-                "node": pubsub_elt["node"],
-                "creator": C.bool(pubsub_elt.getAttribute("creator", C.BOOL_FALSE)),
-            }
-            try:
-                list_elt = next(pubsub_elt.elements(APP_NS_TICKETS, "list"))
-            except StopIteration:
-                pass
-            else:
-                list_type = list_data["type"] = list_elt["type"]
-                if list_type in TEMPLATES:
-                    list_data["icon_name"] = TEMPLATES[list_type]["icon"]
-            lists.append(list_data)
-
-        return lists
-
-    def _get_templates_names(self, language, profile):
-        client = self.host.get_client(profile)
-        return data_format.serialise(self.get_templates_names(client, language))
-
-    def get_templates_names(self, client, language: str) -> list:
-        """Retrieve well known list templates"""
-
-        templates = [{"id": tpl_id, "name": d["name"], "icon": d["icon"]}
-                     for tpl_id, d in TEMPLATES.items()]
-        return templates
-
-    def _get_template(self, name, language, profile):
-        client = self.host.get_client(profile)
-        return data_format.serialise(self.get_template(client, name, language))
-
-    def get_template(self, client, name: str, language: str) -> dict:
-        """Retrieve a well known template"""
-        return TEMPLATES[name]
-
-    def _create_template(self, template_id, name, access_model, profile):
-        client = self.host.get_client(profile)
-        d = defer.ensureDeferred(self.create_template(
-            client, template_id, name, access_model
-        ))
-        d.addCallback(lambda node_data: (node_data[0].full(), node_data[1]))
-        return d
-
-    async def create_template(
-        self, client, template_id: str, name: str, access_model: str
-    ) -> Tuple[jid.JID, str]:
-        """Create a list from a template"""
-        name = name.strip()
-        if not name:
-            name = shortuuid.uuid()
-        fields = TEMPLATES[template_id]["fields"].copy()
-        fields.insert(
-            0,
-            {"type": "hidden", "name": NS_TICKETS_TYPE, "value": template_id}
-        )
-        schema = xml_tools.data_dict_2_data_form(
-            {"namespace": APP_NS_TICKETS, "fields": fields}
-        ).toElement()
-
-        service = client.jid.userhostJID()
-        node = self._s.get_submitted_ns(f"{APP_NS_TICKETS}_{name}")
-        options = {
-            self._p.OPT_ACCESS_MODEL: access_model,
-        }
-        if template_id == "grocery":
-            # for grocery list, we want all publishers to be able to set all items
-            # XXX: should node options be in TEMPLATE?
-            options[self._p.OPT_OVERWRITE_POLICY] = self._p.OWPOL_ANY_PUB
-        await self._p.createNode(client, service, node, options)
-        await self._s.set_schema(client, service, node, schema)
-        list_elt = domish.Element((APP_NS_TICKETS, "list"))
-        list_elt["type"] = template_id
-        try:
-            await self.host.plugins['LIST_INTEREST'].register_pubsub(
-                client, APP_NS_TICKETS, service, node, creator=True,
-                name=name, element=list_elt)
-        except Exception as e:
-            log.warning(f"Can't add list to interests: {e}")
-        return service, node
--- a/sat/plugins/plugin_misc_merge_requests.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,353 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Pubsub Schemas
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from collections import namedtuple
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.tools.common import data_format
-from sat.core.log import getLogger
-
-
-log = getLogger(__name__)
-
-APP_NS_MERGE_REQUESTS = 'org.salut-a-toi.merge_requests:0'
-
-PLUGIN_INFO = {
-    C.PI_NAME: _("Merge requests management"),
-    C.PI_IMPORT_NAME: "MERGE_REQUESTS",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0346", "LISTS", "TEXT_SYNTAXES"],
-    C.PI_MAIN: "MergeRequests",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Merge requests management plugin""")
-}
-
-FIELD_DATA_TYPE = 'type'
-FIELD_DATA = 'request_data'
-
-
-MergeRequestHandler = namedtuple("MergeRequestHandler", ['name',
-                                                         'handler',
-                                                         'data_types',
-                                                         'short_desc',
-                                                         'priority'])
-
-
-class MergeRequests(object):
-    META_AUTHOR = 'author'
-    META_EMAIL = 'email'
-    META_TIMESTAMP = 'timestamp'
-    META_HASH = 'hash'
-    META_PARENT_HASH = 'parent_hash'
-    META_COMMIT_MSG = 'commit_msg'
-    META_DIFF = 'diff'
-    # index of the diff in the whole data
-    # needed to retrieve comments location
-    META_DIFF_IDX = 'diff_idx'
-
-    def __init__(self, host):
-        log.info(_("Merge requests plugin initialization"))
-        self.host = host
-        self._s = self.host.plugins["XEP-0346"]
-        self.namespace = self._s.get_submitted_ns(APP_NS_MERGE_REQUESTS)
-        host.register_namespace('merge_requests', self.namespace)
-        self._p = self.host.plugins["XEP-0060"]
-        self._t = self.host.plugins["LISTS"]
-        self._handlers = {}
-        self._handlers_list = []  # handlers sorted by priority
-        self._type_handlers = {}  # data type => handler map
-        host.bridge.add_method("merge_requests_get", ".plugin",
-                              in_sign='ssiassss', out_sign='s',
-                              method=self._get,
-                              async_=True
-                              )
-        host.bridge.add_method("merge_request_set", ".plugin",
-                              in_sign='ssssa{sas}ssss', out_sign='s',
-                              method=self._set,
-                              async_=True)
-        host.bridge.add_method("merge_requests_schema_get", ".plugin",
-                              in_sign='sss', out_sign='s',
-                              method=lambda service, nodeIdentifier, profile_key:
-                                self._s._get_ui_schema(service,
-                                                     nodeIdentifier,
-                                                     default_node=self.namespace,
-                                                     profile_key=profile_key),
-                              async_=True)
-        host.bridge.add_method("merge_request_parse_data", ".plugin",
-                              in_sign='ss', out_sign='aa{ss}',
-                              method=self._parse_data,
-                              async_=True)
-        host.bridge.add_method("merge_requests_import", ".plugin",
-                              in_sign='ssssa{ss}s', out_sign='',
-                              method=self._import,
-                              async_=True
-                              )
-
-    def register(self, name, handler, data_types, short_desc, priority=0):
-        """register an merge request handler
-
-        @param name(unicode): name of the handler
-        @param handler(object): instance of the handler.
-            It must have the following methods, which may all return a Deferred:
-                - check(repository)->bool: True if repository can be handled
-                - export(repository)->str: return export data, i.e. the patches
-                - parse(export_data): parse report data and return a list of dict
-                                      (1 per patch) with:
-                    - title: title of the commit message (first line)
-                    - body: body of the commit message
-        @aram data_types(list[unicode]): data types that his handler can generate or parse
-        """
-        if name in self._handlers:
-            raise exceptions.ConflictError(_("a handler with name {name} already "
-                                             "exists!").format(name = name))
-        self._handlers[name] = MergeRequestHandler(name,
-                                                   handler,
-                                                   data_types,
-                                                   short_desc,
-                                                   priority)
-        self._handlers_list.append(name)
-        self._handlers_list.sort(key=lambda name: self._handlers[name].priority)
-        if isinstance(data_types, str):
-            data_types = [data_types]
-        for data_type in data_types:
-            if data_type in self._type_handlers:
-                log.warning(_('merge requests of type {type} are already handled by '
-                              '{old_handler}, ignoring {new_handler}').format(
-                                type = data_type,
-                old_handler = self._type_handlers[data_type].name,
-                new_handler = name))
-                continue
-            self._type_handlers[data_type] = self._handlers[name]
-
-    def serialise(self, get_data):
-        tickets_xmlui, metadata, items_patches = get_data
-        tickets_xmlui_s, metadata = self._p.trans_items_data((tickets_xmlui, metadata))
-        return data_format.serialise({
-            "items": tickets_xmlui_s,
-            "metadata": metadata,
-            "items_patches": items_patches,
-        })
-
-    def _get(self, service='', node='', max_items=10, item_ids=None, sub_id=None,
-             extra="", profile_key=C.PROF_KEY_NONE):
-        extra = data_format.deserialise(extra)
-        client, service, node, max_items, extra, sub_id = self._s.prepare_bridge_get(
-            service, node, max_items, sub_id, extra, profile_key)
-        d = self.get(client, service, node or None, max_items, item_ids, sub_id or None,
-                     extra.rsm_request, extra.extra)
-        d.addCallback(self.serialise)
-        return d
-
-    @defer.inlineCallbacks
-    def get(self, client, service=None, node=None, max_items=None, item_ids=None,
-            sub_id=None, rsm_request=None, extra=None):
-        """Retrieve merge requests and convert them to XMLUI
-
-        @param extra(XEP-0060.parse, None): can have following keys:
-            - update(bool): if True, will return list of parsed request data
-        other params are the same as for [TICKETS._get]
-        @return (tuple[list[unicode], list[dict[unicode, unicode]])): tuple with
-            - XMLUI of the tickets, like [TICKETS._get]
-            - node metadata
-            - list of parsed request data (if extra['parse'] is set, else empty list)
-        """
-        if not node:
-            node = self.namespace
-        if extra is None:
-            extra = {}
-        # XXX: Q&D way to get list for labels when displaying them, but text when we
-        #      have to modify them
-        if C.bool(extra.get('labels_as_list', C.BOOL_FALSE)):
-            filters = {'labels': self._s.textbox_2_list_filter}
-        else:
-            filters = {}
-        tickets_xmlui, metadata = yield defer.ensureDeferred(
-            self._s.get_data_form_items(
-                client,
-                service,
-                node,
-                max_items=max_items,
-                item_ids=item_ids,
-                sub_id=sub_id,
-                rsm_request=rsm_request,
-                extra=extra,
-                form_ns=APP_NS_MERGE_REQUESTS,
-                filters = filters)
-        )
-        parsed_patches = []
-        if extra.get('parse', False):
-            for ticket in tickets_xmlui:
-                request_type = ticket.named_widgets[FIELD_DATA_TYPE].value
-                request_data = ticket.named_widgets[FIELD_DATA].value
-                parsed_data = yield self.parse_data(request_type, request_data)
-                parsed_patches.append(parsed_data)
-        defer.returnValue((tickets_xmlui, metadata, parsed_patches))
-
-    def _set(self, service, node, repository, method, values, schema=None, item_id=None,
-             extra="", profile_key=C.PROF_KEY_NONE):
-        client, service, node, schema, item_id, extra = self._s.prepare_bridge_set(
-            service, node, schema, item_id, extra, profile_key)
-        d = defer.ensureDeferred(
-            self.set(
-                client, service, node, repository, method, values, schema,
-                item_id or None, extra, deserialise=True
-            )
-        )
-        d.addCallback(lambda ret: ret or '')
-        return d
-
-    async def set(self, client, service, node, repository, method='auto', values=None,
-            schema=None, item_id=None, extra=None, deserialise=False):
-        """Publish a tickets
-
-        @param service(None, jid.JID): Pubsub service to use
-        @param node(unicode, None): Pubsub node to use
-            None to use default tickets node
-        @param repository(unicode): path to the repository where the code stands
-        @param method(unicode): name of one of the registered handler,
-                                or "auto" to try autodetection.
-        other arguments are same as for [TICKETS.set]
-        @return (unicode): id of the created item
-        """
-        if not node:
-            node = self.namespace
-        if values is None:
-            values = {}
-        update = extra.get('update', False)
-        if not repository and not update:
-            # in case of update, we may re-user former patches data
-            # so repository is not mandatory
-            raise exceptions.DataError(_("repository must be specified"))
-
-        if FIELD_DATA in values:
-            raise exceptions.DataError(_("{field} is set by backend, you must not set "
-                                         "it in frontend").format(field = FIELD_DATA))
-
-        if repository:
-            if method == 'auto':
-                for name in self._handlers_list:
-                    handler = self._handlers[name].handler
-                    can_handle = await handler.check(repository)
-                    if can_handle:
-                        log.info(_("{name} handler will be used").format(name=name))
-                        break
-                else:
-                    log.warning(_("repository {path} can't be handled by any installed "
-                                  "handler").format(
-                        path = repository))
-                    raise exceptions.NotFound(_("no handler for this repository has "
-                                                "been found"))
-            else:
-                try:
-                    handler = self._handlers[name].handler
-                except KeyError:
-                    raise exceptions.NotFound(_("No handler of this name found"))
-
-            data = await handler.export(repository)
-            if not data.strip():
-                raise exceptions.DataError(_('export data is empty, do you have any '
-                                             'change to send?'))
-
-            if not values.get('title') or not values.get('body'):
-                patches = handler.parse(data, values.get(FIELD_DATA_TYPE))
-                commits_msg = patches[-1][self.META_COMMIT_MSG]
-                msg_lines = commits_msg.splitlines()
-                if not values.get('title'):
-                    values['title'] = msg_lines[0]
-                if not values.get('body'):
-                    ts = self.host.plugins['TEXT_SYNTAXES']
-                    xhtml = await ts.convert(
-                        '\n'.join(msg_lines[1:]),
-                        syntax_from = ts.SYNTAX_TEXT,
-                        syntax_to = ts.SYNTAX_XHTML,
-                        profile = client.profile)
-                    values['body'] = '<div xmlns="{ns}">{xhtml}</div>'.format(
-                        ns=C.NS_XHTML, xhtml=xhtml)
-
-            values[FIELD_DATA] = data
-
-        item_id = await self._t.set(client, service, node, values, schema, item_id, extra,
-                                    deserialise, form_ns=APP_NS_MERGE_REQUESTS)
-        return item_id
-
-    def _parse_data(self, data_type, data):
-        d = self.parse_data(data_type, data)
-        d.addCallback(lambda parsed_patches:
-            {key: str(value) for key, value in parsed_patches.items()})
-        return d
-
-    def parse_data(self, data_type, data):
-        """Parse a merge request data according to type
-
-        @param data_type(unicode): type of the data to parse
-        @param data(unicode): data to parse
-        @return(list[dict[unicode, unicode]]): parsed data
-            key of dictionary are self.META_* or keys specifics to handler
-        @raise NotFound: no handler can parse this data_type
-        """
-        try:
-            handler = self._type_handlers[data_type]
-        except KeyError:
-            raise exceptions.NotFound(_('No handler can handle data type "{type}"')
-                                      .format(type=data_type))
-        return defer.maybeDeferred(handler.handler.parse, data, data_type)
-
-    def _import(self, repository, item_id, service=None, node=None, extra=None,
-                profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        service = jid.JID(service) if service else None
-        d = self.import_request(client, repository, item_id, service, node or None,
-                                extra=extra or None)
-        return d
-
-    @defer.inlineCallbacks
-    def import_request(self, client, repository, item, service=None, node=None,
-                       extra=None):
-        """import a merge request in specified directory
-
-        @param repository(unicode): path to the repository where the code stands
-        """
-        if not node:
-            node = self.namespace
-        tickets_xmlui, metadata = yield defer.ensureDeferred(
-            self._s.get_data_form_items(
-                client,
-                service,
-                node,
-                max_items=1,
-                item_ids=[item],
-                form_ns=APP_NS_MERGE_REQUESTS)
-        )
-        ticket_xmlui = tickets_xmlui[0]
-        data = ticket_xmlui.named_widgets[FIELD_DATA].value
-        data_type = ticket_xmlui.named_widgets[FIELD_DATA_TYPE].value
-        try:
-            handler = self._type_handlers[data_type]
-        except KeyError:
-            raise exceptions.NotFound(_('No handler found to import {data_type}')
-                                      .format(data_type=data_type))
-        log.info(_("Importing patch [{item_id}] using {name} handler").format(
-            item_id = item,
-            name = handler.name))
-        yield handler.handler.import_(repository, data, data_type, item, service, node,
-                                      extra)
--- a/sat/plugins/plugin_misc_nat_port.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,222 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for NAT port mapping
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core import exceptions
-from twisted.internet import threads
-from twisted.internet import defer
-from twisted.python import failure
-import threading
-
-try:
-    import miniupnpc
-except ImportError:
-    raise exceptions.MissingModule(
-        "Missing module MiniUPnPc, please download/install it (and its Python binding) at http://miniupnp.free.fr/ (or use pip install miniupnpc)"
-    )
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "NAT port mapping",
-    C.PI_IMPORT_NAME: "NAT-PORT",
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_MAIN: "NatPort",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Automatic NAT port mapping using UPnP"""),
-}
-
-STARTING_PORT = 6000  # starting point to automatically find a port
-DEFAULT_DESC = (
-    "SaT port mapping"
-)  # we don't use "à" here as some bugged NAT don't manage charset correctly
-
-
-class MappingError(Exception):
-    pass
-
-
-class NatPort(object):
-    # TODO: refresh data if a new connection is detected (see plugin_misc_ip)
-
-    def __init__(self, host):
-        log.info(_("plugin NAT Port initialization"))
-        self.host = host
-        self._external_ip = None
-        self._initialised = defer.Deferred()
-        self._upnp = miniupnpc.UPnP()  # will be None if no device is available
-        self._upnp.discoverdelay = 200
-        self._mutex = threading.Lock()  # used to protect access to self._upnp
-        self._starting_port_cache = None  # used to cache the first available port
-        self._to_unmap = []  # list of tuples (ext_port, protocol) of ports to unmap on unload
-        discover_d = threads.deferToThread(self._discover)
-        discover_d.chainDeferred(self._initialised)
-        self._initialised.addErrback(self._init_failed)
-
-    def unload(self):
-        if self._to_unmap:
-            log.info("Cleaning mapped ports")
-            return threads.deferToThread(self._unmap_ports_blocking)
-
-    def _init_failed(self, failure_):
-        e = failure_.trap(exceptions.NotFound, exceptions.FeatureNotFound)
-        if e == exceptions.FeatureNotFound:
-            log.info("UPnP-IGD seems to be not activated on the device")
-        else:
-            log.info("UPnP-IGD not available")
-        self._upnp = None
-
-    def _discover(self):
-        devices = self._upnp.discover()
-        if devices:
-            log.info("{nb} UPnP-IGD device(s) found".format(nb=devices))
-        else:
-            log.info("Can't find UPnP-IGD device on the local network")
-            raise failure.Failure(exceptions.NotFound())
-        self._upnp.selectigd()
-        try:
-            self._external_ip = self._upnp.externalipaddress()
-        except Exception:
-            raise failure.Failure(exceptions.FeatureNotFound())
-
-    def get_ip(self, local=False):
-        """Return IP address found with UPnP-IGD
-
-        @param local(bool): True to get external IP address, False to get local network one
-        @return (None, str): found IP address, or None of something got wrong
-        """
-
-        def get_ip(__):
-            if self._upnp is None:
-                return None
-            # lanaddr can be the empty string if not found,
-            # we need to return None in this case
-            return (self._upnp.lanaddr or None) if local else self._external_ip
-
-        return self._initialised.addCallback(get_ip)
-
-    def _unmap_ports_blocking(self):
-        """Unmap ports mapped in this session"""
-        self._mutex.acquire()
-        try:
-            for port, protocol in self._to_unmap:
-                log.info("Unmapping port {}".format(port))
-                unmapping = self._upnp.deleteportmapping(
-                    # the last parameter is remoteHost, we don't use it
-                    port,
-                    protocol,
-                    "",
-                )
-
-                if not unmapping:
-                    log.error(
-                        "Can't unmap port {port} ({protocol})".format(
-                            port=port, protocol=protocol
-                        )
-                    )
-            del self._to_unmap[:]
-        finally:
-            self._mutex.release()
-
-    def _map_port_blocking(self, int_port, ext_port, protocol, desc):
-        """Internal blocking method to map port
-
-        @param int_port(int): internal port to use
-        @param ext_port(int): external port to use, or None to find one automatically
-        @param protocol(str): 'TCP' or 'UDP'
-        @param desc(str): description of the mapping
-        @param return(int, None): external port used in case of success, otherwise None
-        """
-        # we use mutex to avoid race condition if 2 threads
-        # try to acquire a port at the same time
-        self._mutex.acquire()
-        try:
-            if ext_port is None:
-                # find a free port
-                starting_port = self._starting_port_cache
-                ext_port = STARTING_PORT if starting_port is None else starting_port
-                ret = self._upnp.getspecificportmapping(ext_port, protocol)
-                while ret != None and ext_port < 65536:
-                    ext_port += 1
-                    ret = self._upnp.getspecificportmapping(ext_port, protocol)
-                if starting_port is None:
-                    # XXX: we cache the first successfuly found external port
-                    #      to avoid testing again the first series the next time
-                    self._starting_port_cache = ext_port
-
-            try:
-                mapping = self._upnp.addportmapping(
-                    # the last parameter is remoteHost, we don't use it
-                    ext_port,
-                    protocol,
-                    self._upnp.lanaddr,
-                    int_port,
-                    desc,
-                    "",
-                )
-            except Exception as e:
-                log.error(_("addportmapping error: {msg}").format(msg=e))
-                raise failure.Failure(MappingError())
-
-            if not mapping:
-                raise failure.Failure(MappingError())
-            else:
-                self._to_unmap.append((ext_port, protocol))
-        finally:
-            self._mutex.release()
-
-        return ext_port
-
-    def map_port(self, int_port, ext_port=None, protocol="TCP", desc=DEFAULT_DESC):
-        """Add a port mapping
-
-        @param int_port(int): internal port to use
-        @param ext_port(int,None): external port to use, or None to find one automatically
-        @param protocol(str): 'TCP' or 'UDP'
-        @param desc(unicode): description of the mapping
-            Some UPnP IGD devices have broken encoding. It's probably a good idea to avoid non-ascii chars here
-        @return (D(int, None)): external port used in case of success, otherwise None
-        """
-        if self._upnp is None:
-            return defer.succeed(None)
-
-        def mapping_cb(ext_port):
-            log.info(
-                "{protocol} mapping from {int_port} to {ext_port} successful".format(
-                    protocol=protocol, int_port=int_port, ext_port=ext_port
-                )
-            )
-            return ext_port
-
-        def mapping_eb(failure_):
-            failure_.trap(MappingError)
-            log.warning("Can't map internal {int_port}".format(int_port=int_port))
-
-        def mapping_unknown_eb(failure_):
-            log.error(_("error while trying to map ports: {msg}").format(msg=failure_))
-
-        d = threads.deferToThread(
-            self._map_port_blocking, int_port, ext_port, protocol, desc
-        )
-        d.addCallbacks(mapping_cb, mapping_eb)
-        d.addErrback(mapping_unknown_eb)
-        return d
--- a/sat/plugins/plugin_misc_quiz.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,456 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing Quiz game
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.words.xish import domish
-from twisted.internet import reactor
-from twisted.words.protocols.jabber import client as jabber_client, jid
-from time import time
-
-
-NS_QG = "http://www.goffi.org/protocol/quiz"
-QG_TAG = "quiz"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Quiz game plugin",
-    C.PI_IMPORT_NAME: "Quiz",
-    C.PI_TYPE: "Game",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249", "ROOM-GAME"],
-    C.PI_MAIN: "Quiz",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Quiz game"""),
-}
-
-
-class Quiz(object):
-    def inherit_from_room_game(self, host):
-        global RoomGame
-        RoomGame = host.plugins["ROOM-GAME"].__class__
-        self.__class__ = type(
-            self.__class__.__name__, (self.__class__, RoomGame, object), {}
-        )
-
-    def __init__(self, host):
-        log.info(_("Plugin Quiz initialization"))
-        self.inherit_from_room_game(host)
-        RoomGame._init_(
-            self,
-            host,
-            PLUGIN_INFO,
-            (NS_QG, QG_TAG),
-            game_init={"stage": None},
-            player_init={"score": 0},
-        )
-        host.bridge.add_method(
-            "quiz_game_launch",
-            ".plugin",
-            in_sign="asss",
-            out_sign="",
-            method=self._prepare_room,
-        )  # args: players, room_jid, profile
-        host.bridge.add_method(
-            "quiz_game_create",
-            ".plugin",
-            in_sign="sass",
-            out_sign="",
-            method=self._create_game,
-        )  # args: room_jid, players, profile
-        host.bridge.add_method(
-            "quiz_game_ready",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._player_ready,
-        )  # args: player, referee, profile
-        host.bridge.add_method(
-            "quiz_game_answer",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="",
-            method=self.player_answer,
-        )
-        host.bridge.add_signal(
-            "quiz_game_started", ".plugin", signature="ssass"
-        )  # args: room_jid, referee, players, profile
-        host.bridge.add_signal(
-            "quiz_game_new",
-            ".plugin",
-            signature="sa{ss}s",
-            doc={
-                "summary": "Start a new game",
-                "param_0": "room_jid: jid of game's room",
-                "param_1": "game_data: data of the game",
-                "param_2": "%(doc_profile)s",
-            },
-        )
-        host.bridge.add_signal(
-            "quiz_game_question",
-            ".plugin",
-            signature="sssis",
-            doc={
-                "summary": "Send the current question",
-                "param_0": "room_jid: jid of game's room",
-                "param_1": "question_id: question id",
-                "param_2": "question: question to ask",
-                "param_3": "timer: timer",
-                "param_4": "%(doc_profile)s",
-            },
-        )
-        host.bridge.add_signal(
-            "quiz_game_player_buzzed",
-            ".plugin",
-            signature="ssbs",
-            doc={
-                "summary": "A player just pressed the buzzer",
-                "param_0": "room_jid: jid of game's room",
-                "param_1": "player: player who pushed the buzzer",
-                "param_2": "pause: should the game be paused ?",
-                "param_3": "%(doc_profile)s",
-            },
-        )
-        host.bridge.add_signal(
-            "quiz_game_player_says",
-            ".plugin",
-            signature="sssis",
-            doc={
-                "summary": "A player just pressed the buzzer",
-                "param_0": "room_jid: jid of game's room",
-                "param_1": "player: player who pushed the buzzer",
-                "param_2": "text: what the player say",
-                "param_3": "delay: how long, in seconds, the text must appear",
-                "param_4": "%(doc_profile)s",
-            },
-        )
-        host.bridge.add_signal(
-            "quiz_game_answer_result",
-            ".plugin",
-            signature="ssba{si}s",
-            doc={
-                "summary": "Result of the just given answer",
-                "param_0": "room_jid: jid of game's room",
-                "param_1": "player: player who gave the answer",
-                "param_2": "good_answer: True if the answer is right",
-                "param_3": "score: dict of score with player as key",
-                "param_4": "%(doc_profile)s",
-            },
-        )
-        host.bridge.add_signal(
-            "quiz_game_timer_expired",
-            ".plugin",
-            signature="ss",
-            doc={
-                "summary": "Nobody answered the question in time",
-                "param_0": "room_jid: jid of game's room",
-                "param_1": "%(doc_profile)s",
-            },
-        )
-        host.bridge.add_signal(
-            "quiz_game_timer_restarted",
-            ".plugin",
-            signature="sis",
-            doc={
-                "summary": "Nobody answered the question in time",
-                "param_0": "room_jid: jid of game's room",
-                "param_1": "time_left: time left before timer expiration",
-                "param_2": "%(doc_profile)s",
-            },
-        )
-
-    def __game_data_to_xml(self, game_data):
-        """Convert a game data dict to domish element"""
-        game_data_elt = domish.Element((None, "game_data"))
-        for data in game_data:
-            data_elt = domish.Element((None, data))
-            data_elt.addContent(game_data[data])
-            game_data_elt.addChild(data_elt)
-        return game_data_elt
-
-    def __xml_to_game_data(self, game_data_elt):
-        """Convert a domish element with game_data to a dict"""
-        game_data = {}
-        for data_elt in game_data_elt.elements():
-            game_data[data_elt.name] = str(data_elt)
-        return game_data
-
-    def __answer_result_to_signal_args(self, answer_result_elt):
-        """Parse answer result element and return a tuple of signal arguments
-        @param answer_result_elt: answer result element
-        @return: (player, good_answer, score)"""
-        score = {}
-        for score_elt in answer_result_elt.elements():
-            score[score_elt["player"]] = int(score_elt["score"])
-        return (
-            answer_result_elt["player"],
-            answer_result_elt["good_answer"] == str(True),
-            score,
-        )
-
-    def __answer_result(self, player_answering, good_answer, game_data):
-        """Convert a domish an answer_result element
-        @param player_answering: player who gave the answer
-        @param good_answer: True is the answer is right
-        @param game_data: data of the game"""
-        players_data = game_data["players_data"]
-        score = {}
-        for player in game_data["players"]:
-            score[player] = players_data[player]["score"]
-
-        answer_result_elt = domish.Element((None, "answer_result"))
-        answer_result_elt["player"] = player_answering
-        answer_result_elt["good_answer"] = str(good_answer)
-
-        for player in score:
-            score_elt = domish.Element((None, "score"))
-            score_elt["player"] = player
-            score_elt["score"] = str(score[player])
-            answer_result_elt.addChild(score_elt)
-
-        return answer_result_elt
-
-    def __ask_question(self, question_id, question, timer):
-        """Create a element for asking a question"""
-        question_elt = domish.Element((None, "question"))
-        question_elt["id"] = question_id
-        question_elt["timer"] = str(timer)
-        question_elt.addContent(question)
-        return question_elt
-
-    def __start_play(self, room_jid, game_data, profile):
-        """Start the game (tell to the first player after dealer to play"""
-        client = self.host.get_client(profile)
-        game_data["stage"] = "play"
-        next_player_idx = game_data["current_player"] = (
-            game_data["init_player"] + 1
-        ) % len(
-            game_data["players"]
-        )  # the player after the dealer start
-        game_data["first_player"] = next_player = game_data["players"][next_player_idx]
-        to_jid = jid.JID(room_jid.userhost() + "/" + next_player)
-        mess = self.createGameElt(to_jid)
-        mess.firstChildElement().addElement("your_turn")
-        client.send(mess)
-
-    def player_answer(self, player, referee, answer, profile_key=C.PROF_KEY_NONE):
-        """Called when a player give an answer"""
-        client = self.host.get_client(profile_key)
-        log.debug(
-            "new player answer (%(profile)s): %(answer)s"
-            % {"profile": client.profile, "answer": answer}
-        )
-        mess = self.createGameElt(jid.JID(referee))
-        answer_elt = mess.firstChildElement().addElement("player_answer")
-        answer_elt["player"] = player
-        answer_elt.addContent(answer)
-        client.send(mess)
-
-    def timer_expired(self, room_jid, profile):
-        """Called when nobody answered the question in time"""
-        client = self.host.get_client(profile)
-        game_data = self.games[room_jid]
-        game_data["stage"] = "expired"
-        mess = self.createGameElt(room_jid)
-        mess.firstChildElement().addElement("timer_expired")
-        client.send(mess)
-        reactor.callLater(4, self.ask_question, room_jid, client.profile)
-
-    def pause_timer(self, room_jid):
-        """Stop the timer and save the time left"""
-        game_data = self.games[room_jid]
-        left = max(0, game_data["timer"].getTime() - time())
-        game_data["timer"].cancel()
-        game_data["time_left"] = int(left)
-        game_data["previous_stage"] = game_data["stage"]
-        game_data["stage"] = "paused"
-
-    def restart_timer(self, room_jid, profile):
-        """Restart a timer with the saved time"""
-        client = self.host.get_client(profile)
-        game_data = self.games[room_jid]
-        assert game_data["time_left"] is not None
-        mess = self.createGameElt(room_jid)
-        mess.firstChildElement().addElement("timer_restarted")
-        jabber_client.restarted_elt["time_left"] = str(game_data["time_left"])
-        client.send(mess)
-        game_data["timer"] = reactor.callLater(
-            game_data["time_left"], self.timer_expired, room_jid, profile
-        )
-        game_data["time_left"] = None
-        game_data["stage"] = game_data["previous_stage"]
-        del game_data["previous_stage"]
-
-    def ask_question(self, room_jid, profile):
-        """Ask a new question"""
-        client = self.host.get_client(profile)
-        game_data = self.games[room_jid]
-        game_data["stage"] = "question"
-        game_data["question_id"] = "1"
-        timer = 30
-        mess = self.createGameElt(room_jid)
-        mess.firstChildElement().addChild(
-            self.__ask_question(
-                game_data["question_id"], "Quel est l'âge du capitaine ?", timer
-            )
-        )
-        client.send(mess)
-        game_data["timer"] = reactor.callLater(
-            timer, self.timer_expired, room_jid, profile
-        )
-        game_data["time_left"] = None
-
-    def check_answer(self, room_jid, player, answer, profile):
-        """Check if the answer given is right"""
-        client = self.host.get_client(profile)
-        game_data = self.games[room_jid]
-        players_data = game_data["players_data"]
-        good_answer = game_data["question_id"] == "1" and answer == "42"
-        players_data[player]["score"] += 1 if good_answer else -1
-        players_data[player]["score"] = min(9, max(0, players_data[player]["score"]))
-
-        mess = self.createGameElt(room_jid)
-        mess.firstChildElement().addChild(
-            self.__answer_result(player, good_answer, game_data)
-        )
-        client.send(mess)
-
-        if good_answer:
-            reactor.callLater(4, self.ask_question, room_jid, profile)
-        else:
-            reactor.callLater(4, self.restart_timer, room_jid, profile)
-
-    def new_game(self, room_jid, profile):
-        """Launch a new round"""
-        common_data = {"game_score": 0}
-        new_game_data = {
-            "instructions": _(
-                """Bienvenue dans cette partie rapide de quizz, le premier à atteindre le score de 9 remporte le jeu
-
-Attention, tu es prêt ?"""
-            )
-        }
-        msg_elts = self.__game_data_to_xml(new_game_data)
-        RoomGame.new_round(self, room_jid, (common_data, msg_elts), profile)
-        reactor.callLater(10, self.ask_question, room_jid, profile)
-
-    def room_game_cmd(self, mess_elt, profile):
-        client = self.host.get_client(profile)
-        from_jid = jid.JID(mess_elt["from"])
-        room_jid = jid.JID(from_jid.userhost())
-        game_elt = mess_elt.firstChildElement()
-        game_data = self.games[room_jid]
-        #  if 'players_data' in game_data:
-        #      players_data = game_data['players_data']
-
-        for elt in game_elt.elements():
-
-            if elt.name == "started":  # new game created
-                players = []
-                for player in elt.elements():
-                    players.append(str(player))
-                self.host.bridge.quiz_game_started(
-                    room_jid.userhost(), from_jid.full(), players, profile
-                )
-
-            elif elt.name == "player_ready":  # ready to play
-                player = elt["player"]
-                status = self.games[room_jid]["status"]
-                nb_players = len(self.games[room_jid]["players"])
-                status[player] = "ready"
-                log.debug(
-                    _("Player %(player)s is ready to start [status: %(status)s]")
-                    % {"player": player, "status": status}
-                )
-                if (
-                    list(status.values()).count("ready") == nb_players
-                ):  # everybody is ready, we can start the game
-                    self.new_game(room_jid, profile)
-
-            elif elt.name == "game_data":
-                self.host.bridge.quiz_game_new(
-                    room_jid.userhost(), self.__xml_to_game_data(elt), profile
-                )
-
-            elif elt.name == "question":  # A question is asked
-                self.host.bridge.quiz_game_question(
-                    room_jid.userhost(),
-                    elt["id"],
-                    str(elt),
-                    int(elt["timer"]),
-                    profile,
-                )
-
-            elif elt.name == "player_answer":
-                player = elt["player"]
-                pause = (
-                    game_data["stage"] == "question"
-                )  # we pause the game only if we are have a question at the moment
-                # we first send a buzzer message
-                mess = self.createGameElt(room_jid)
-                buzzer_elt = mess.firstChildElement().addElement("player_buzzed")
-                buzzer_elt["player"] = player
-                buzzer_elt["pause"] = str(pause)
-                client.send(mess)
-                if pause:
-                    self.pause_timer(room_jid)
-                    # and we send the player answer
-                    mess = self.createGameElt(room_jid)
-                    _answer = str(elt)
-                    say_elt = mess.firstChildElement().addElement("player_says")
-                    say_elt["player"] = player
-                    say_elt.addContent(_answer)
-                    say_elt["delay"] = "3"
-                    reactor.callLater(2, client.send, mess)
-                    reactor.callLater(
-                        6, self.check_answer, room_jid, player, _answer, profile=profile
-                    )
-
-            elif elt.name == "player_buzzed":
-                self.host.bridge.quiz_game_player_buzzed(
-                    room_jid.userhost(), elt["player"], elt["pause"] == str(True), profile
-                )
-
-            elif elt.name == "player_says":
-                self.host.bridge.quiz_game_player_says(
-                    room_jid.userhost(),
-                    elt["player"],
-                    str(elt),
-                    int(elt["delay"]),
-                    profile,
-                )
-
-            elif elt.name == "answer_result":
-                player, good_answer, score = self.__answer_result_to_signal_args(elt)
-                self.host.bridge.quiz_game_answer_result(
-                    room_jid.userhost(), player, good_answer, score, profile
-                )
-
-            elif elt.name == "timer_expired":
-                self.host.bridge.quiz_game_timer_expired(room_jid.userhost(), profile)
-
-            elif elt.name == "timer_restarted":
-                self.host.bridge.quiz_game_timer_restarted(
-                    room_jid.userhost(), int(elt["time_left"]), profile
-                )
-
-            else:
-                log.error(_("Unmanaged game element: %s") % elt.name)
--- a/sat/plugins/plugin_misc_radiocol.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,370 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing Radiocol
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.words.xish import domish
-from twisted.internet import reactor
-from twisted.words.protocols.jabber import jid
-from twisted.internet import defer
-from sat.core import exceptions
-import os.path
-import copy
-import time
-from os import unlink
-
-try:
-    from mutagen.oggvorbis import OggVorbis, OggVorbisHeaderError
-    from mutagen.mp3 import MP3, HeaderNotFoundError
-    from mutagen.easyid3 import EasyID3
-    from mutagen.id3 import ID3NoHeaderError
-except ImportError:
-    raise exceptions.MissingModule(
-        "Missing module Mutagen, please download/install from https://bitbucket.org/lazka/mutagen"
-    )
-
-
-NC_RADIOCOL = "http://www.goffi.org/protocol/radiocol"
-RADIOC_TAG = "radiocol"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Radio collective plugin",
-    C.PI_IMPORT_NAME: "Radiocol",
-    C.PI_TYPE: "Exp",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249", "ROOM-GAME"],
-    C.PI_MAIN: "Radiocol",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of radio collective"""),
-}
-
-
-# Number of songs needed in the queue before we start playing
-QUEUE_TO_START = 2
-# Maximum number of songs in the queue (the song being currently played doesn't count)
-QUEUE_LIMIT = 2
-
-
-class Radiocol(object):
-    def inherit_from_room_game(self, host):
-        global RoomGame
-        RoomGame = host.plugins["ROOM-GAME"].__class__
-        self.__class__ = type(
-            self.__class__.__name__, (self.__class__, RoomGame, object), {}
-        )
-
-    def __init__(self, host):
-        log.info(_("Radio collective initialization"))
-        self.inherit_from_room_game(host)
-        RoomGame._init_(
-            self,
-            host,
-            PLUGIN_INFO,
-            (NC_RADIOCOL, RADIOC_TAG),
-            game_init={
-                "queue": [],
-                "upload": True,
-                "playing": None,
-                "playing_time": 0,
-                "to_delete": {},
-            },
-        )
-        self.host = host
-        host.bridge.add_method(
-            "radiocol_launch",
-            ".plugin",
-            in_sign="asss",
-            out_sign="",
-            method=self._prepare_room,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "radiocol_create",
-            ".plugin",
-            in_sign="sass",
-            out_sign="",
-            method=self._create_game,
-        )
-        host.bridge.add_method(
-            "radiocol_song_added",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._radiocol_song_added,
-            async_=True,
-        )
-        host.bridge.add_signal(
-            "radiocol_players", ".plugin", signature="ssass"
-        )  # room_jid, referee, players, profile
-        host.bridge.add_signal(
-            "radiocol_started", ".plugin", signature="ssasais"
-        )  # room_jid, referee, players, [QUEUE_TO_START, QUEUE_LIMIT], profile
-        host.bridge.add_signal(
-            "radiocol_song_rejected", ".plugin", signature="sss"
-        )  # room_jid, reason, profile
-        host.bridge.add_signal(
-            "radiocol_preload", ".plugin", signature="ssssssss"
-        )  # room_jid, timestamp, filename, title, artist, album, profile
-        host.bridge.add_signal(
-            "radiocol_play", ".plugin", signature="sss"
-        )  # room_jid, filename, profile
-        host.bridge.add_signal(
-            "radiocol_no_upload", ".plugin", signature="ss"
-        )  # room_jid, profile
-        host.bridge.add_signal(
-            "radiocol_upload_ok", ".plugin", signature="ss"
-        )  # room_jid, profile
-
-    def __create_preload_elt(self, sender, song_added_elt):
-        preload_elt = copy.deepcopy(song_added_elt)
-        preload_elt.name = "preload"
-        preload_elt["sender"] = sender
-        preload_elt["timestamp"] = str(time.time())
-        # attributes filename, title, artist, album, length have been copied
-        # XXX: the frontend should know the temporary directory where file is put
-        return preload_elt
-
-    def _radiocol_song_added(self, referee_s, song_path, profile):
-        return self.radiocol_song_added(jid.JID(referee_s), song_path, profile)
-
-    def radiocol_song_added(self, referee, song_path, profile):
-        """This method is called by libervia when a song has been uploaded
-        @param referee (jid.JID): JID of the referee in the room (room userhost + '/' + nick)
-        @param song_path (unicode): absolute path of the song added
-        @param profile_key (unicode): %(doc_profile_key)s
-        @return: a Deferred instance
-        """
-        # XXX: this is a Q&D way for the proof of concept. In the future, the song should
-        #     be streamed to the backend using XMPP file copy
-        #     Here we cheat because we know we are on the same host, and we don't
-        #     check data. Referee will have to parse the song himself to check it
-        try:
-            if song_path.lower().endswith(".mp3"):
-                actual_song = MP3(song_path)
-                try:
-                    song = EasyID3(song_path)
-
-                    class Info(object):
-                        def __init__(self, length):
-                            self.length = length
-
-                    song.info = Info(actual_song.info.length)
-                except ID3NoHeaderError:
-                    song = actual_song
-            else:
-                song = OggVorbis(song_path)
-        except (OggVorbisHeaderError, HeaderNotFoundError):
-            # this file is not ogg vorbis nor mp3, we reject it
-            self.delete_file(song_path)  # FIXME: same host trick (see note above)
-            return defer.fail(
-                exceptions.DataError(
-                    D_(
-                        "The uploaded file has been rejected, only Ogg Vorbis and MP3 songs are accepted."
-                    )
-                )
-            )
-
-        attrs = {
-            "filename": os.path.basename(song_path),
-            "title": song.get("title", ["Unknown"])[0],
-            "artist": song.get("artist", ["Unknown"])[0],
-            "album": song.get("album", ["Unknown"])[0],
-            "length": str(song.info.length),
-        }
-        radio_data = self.games[
-            referee.userhostJID()
-        ]  # FIXME: referee comes from Libervia's client side, it's unsecure
-        radio_data["to_delete"][
-            attrs["filename"]
-        ] = (
-            song_path
-        )  # FIXME: works only because of the same host trick, see the note under the docstring
-        return self.send(referee, ("", "song_added"), attrs, profile=profile)
-
-    def play_next(self, room_jid, profile):
-        """"Play next song in queue if exists, and put a timer
-        which trigger after the song has been played to play next one"""
-        # TODO: songs need to be erased once played or found invalids
-        #      ==> unlink done the Q&D way with the same host trick (see above)
-        radio_data = self.games[room_jid]
-        if len(radio_data["players"]) == 0:
-            log.debug(_("No more participants in the radiocol: cleaning data"))
-            radio_data["queue"] = []
-            for filename in radio_data["to_delete"]:
-                self.delete_file(filename, radio_data)
-            radio_data["to_delete"] = {}
-        queue = radio_data["queue"]
-        if not queue:
-            # nothing left to play, we need to wait for uploads
-            radio_data["playing"] = None
-            return
-        song = queue.pop(0)
-        filename, length = song["filename"], float(song["length"])
-        self.send(room_jid, ("", "play"), {"filename": filename}, profile=profile)
-        radio_data["playing"] = song
-        radio_data["playing_time"] = time.time()
-
-        if not radio_data["upload"] and len(queue) < QUEUE_LIMIT:
-            # upload is blocked and we now have resources to get more, we reactivate it
-            self.send(room_jid, ("", "upload_ok"), profile=profile)
-            radio_data["upload"] = True
-
-        reactor.callLater(length, self.play_next, room_jid, profile)
-        # we wait more than the song length to delete the file, to manage poorly reactive networks/clients
-        reactor.callLater(
-            length + 90, self.delete_file, filename, radio_data
-        )  # FIXME: same host trick (see above)
-
-    def delete_file(self, filename, radio_data=None):
-        """
-        Delete a previously uploaded file.
-        @param filename: filename to delete, or full filepath if radio_data is None
-        @param radio_data: current game data
-        @return: True if the file has been deleted
-        """
-        if radio_data:
-            try:
-                file_to_delete = radio_data["to_delete"][filename]
-            except KeyError:
-                log.error(
-                    _("INTERNAL ERROR: can't find full path of the song to delete")
-                )
-                return False
-        else:
-            file_to_delete = filename
-        try:
-            unlink(file_to_delete)
-        except OSError:
-            log.error(
-                _("INTERNAL ERROR: can't find %s on the file system" % file_to_delete)
-            )
-            return False
-        return True
-
-    def room_game_cmd(self, mess_elt, profile):
-        from_jid = jid.JID(mess_elt["from"])
-        room_jid = from_jid.userhostJID()
-        nick = self.host.plugins["XEP-0045"].get_room_nick(room_jid, profile)
-
-        radio_elt = mess_elt.firstChildElement()
-        radio_data = self.games[room_jid]
-        if "queue" in radio_data:
-            queue = radio_data["queue"]
-
-        from_referee = self.is_referee(room_jid, from_jid.resource)
-        to_referee = self.is_referee(room_jid, jid.JID(mess_elt["to"]).user)
-        is_player = self.is_player(room_jid, nick)
-        for elt in radio_elt.elements():
-            if not from_referee and not (to_referee and elt.name == "song_added"):
-                continue  # sender must be referee, expect when a song is submitted
-            if not is_player and (elt.name not in ("started", "players")):
-                continue  # user is in the room but not playing
-
-            if elt.name in (
-                "started",
-                "players",
-            ):  # new game created and/or players list updated
-                players = []
-                for player in elt.elements():
-                    players.append(str(player))
-                signal = (
-                    self.host.bridge.radiocol_started
-                    if elt.name == "started"
-                    else self.host.bridge.radiocol_players
-                )
-                signal(
-                    room_jid.userhost(),
-                    from_jid.full(),
-                    players,
-                    [QUEUE_TO_START, QUEUE_LIMIT],
-                    profile,
-                )
-            elif elt.name == "preload":  # a song is in queue and must be preloaded
-                self.host.bridge.radiocol_preload(
-                    room_jid.userhost(),
-                    elt["timestamp"],
-                    elt["filename"],
-                    elt["title"],
-                    elt["artist"],
-                    elt["album"],
-                    elt["sender"],
-                    profile,
-                )
-            elif elt.name == "play":
-                self.host.bridge.radiocol_play(
-                    room_jid.userhost(), elt["filename"], profile
-                )
-            elif elt.name == "song_rejected":  # a song has been refused
-                self.host.bridge.radiocol_song_rejected(
-                    room_jid.userhost(), elt["reason"], profile
-                )
-            elif elt.name == "no_upload":
-                self.host.bridge.radiocol_no_upload(room_jid.userhost(), profile)
-            elif elt.name == "upload_ok":
-                self.host.bridge.radiocol_upload_ok(room_jid.userhost(), profile)
-            elif elt.name == "song_added":  # a song has been added
-                # FIXME: we are KISS for the proof of concept: every song is added, to a limit of 3 in queue.
-                #       Need to manage some sort of rules to allow peoples to send songs
-                if len(queue) >= QUEUE_LIMIT:
-                    # there are already too many songs in queue, we reject this one
-                    # FIXME: add an error code
-                    self.send(
-                        from_jid,
-                        ("", "song_rejected"),
-                        {"reason": "Too many songs in queue"},
-                        profile=profile,
-                    )
-                    return
-
-                # The song is accepted and added in queue
-                preload_elt = self.__create_preload_elt(from_jid.resource, elt)
-                queue.append(preload_elt)
-
-                if len(queue) >= QUEUE_LIMIT:
-                    # We are at the limit, we refuse new upload until next play
-                    self.send(room_jid, ("", "no_upload"), profile=profile)
-                    radio_data["upload"] = False
-
-                self.send(room_jid, preload_elt, profile=profile)
-                if not radio_data["playing"] and len(queue) == QUEUE_TO_START:
-                    # We have not started playing yet, and we have QUEUE_TO_START
-                    # songs in queue. We can now start the party :)
-                    self.play_next(room_jid, profile)
-            else:
-                log.error(_("Unmanaged game element: %s") % elt.name)
-
-    def get_sync_data_for_player(self, room_jid, nick):
-        game_data = self.games[room_jid]
-        elements = []
-        if game_data["playing"]:
-            preload = copy.deepcopy(game_data["playing"])
-            current_time = game_data["playing_time"] + 1 if self.testing else time.time()
-            preload["filename"] += "#t=%.2f" % (current_time - game_data["playing_time"])
-            elements.append(preload)
-            play = domish.Element(("", "play"))
-            play["filename"] = preload["filename"]
-            elements.append(play)
-        if len(game_data["queue"]) > 0:
-            elements.extend(copy.deepcopy(game_data["queue"]))
-            if len(game_data["queue"]) == QUEUE_LIMIT:
-                elements.append(domish.Element(("", "no_upload")))
-        return elements
--- a/sat/plugins/plugin_misc_register_account.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,153 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT plugin for registering a new XMPP account
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core.constants import Const as C
-from twisted.words.protocols.jabber import jid
-from sat.memory.memory import Sessions
-from sat.tools import xml_tools
-from sat.tools.xml_tools import SAT_FORM_PREFIX, SAT_PARAM_SEPARATOR
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Register Account Plugin",
-    C.PI_IMPORT_NAME: "REGISTER-ACCOUNT",
-    C.PI_TYPE: "MISC",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0077"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "RegisterAccount",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Register XMPP account"""),
-}
-
-
-class RegisterAccount(object):
-    # FIXME: this plugin is messy and difficult to read, it needs to be cleaned up and documented
-
-    def __init__(self, host):
-        log.info(_("Plugin Register Account initialization"))
-        self.host = host
-        self._sessions = Sessions()
-        host.register_callback(
-            self.register_new_account_cb, with_data=True, force_id="register_new_account"
-        )
-        self.__register_account_id = host.register_callback(
-            self._register_confirmation, with_data=True
-        )
-
-    def register_new_account_cb(self, data, profile):
-        """Called when the user click on the "New account" button."""
-        session_data = {}
-
-        # FIXME: following loop is overcomplicated, hard to read
-        # FIXME: while used with parameters, hashed password is used and overwrite clear one
-        for param in ("JabberID", "Password", C.FORCE_PORT_PARAM, C.FORCE_SERVER_PARAM):
-            try:
-                session_data[param] = data[
-                    SAT_FORM_PREFIX + "Connection" + SAT_PARAM_SEPARATOR + param
-                ]
-            except KeyError:
-                if param in (C.FORCE_PORT_PARAM, C.FORCE_SERVER_PARAM):
-                    session_data[param] = ""
-
-        for param in ("JabberID", "Password"):
-            if not session_data[param]:
-                form_ui = xml_tools.XMLUI("popup", title=D_("Missing values"))
-                form_ui.addText(
-                    D_("No user JID or password given: can't register new account.")
-                )
-                return {"xmlui": form_ui.toXml()}
-
-        session_data["user"], host, resource = jid.parse(session_data["JabberID"])
-        session_data["server"] = session_data[C.FORCE_SERVER_PARAM] or host
-        session_id, __ = self._sessions.new_session(session_data, profile=profile)
-        form_ui = xml_tools.XMLUI(
-            "form",
-            title=D_("Register new account"),
-            submit_id=self.__register_account_id,
-            session_id=session_id,
-        )
-        form_ui.addText(
-            D_("Do you want to register a new XMPP account {jid}?").format(
-                jid=session_data["JabberID"]
-            )
-        )
-        return {"xmlui": form_ui.toXml()}
-
-    def _register_confirmation(self, data, profile):
-        """Save the related parameters and proceed the registration."""
-        session_data = self._sessions.profile_get(data["session_id"], profile)
-
-        self.host.memory.param_set(
-            "JabberID", session_data["JabberID"], "Connection", profile_key=profile
-        )
-        self.host.memory.param_set(
-            "Password", session_data["Password"], "Connection", profile_key=profile
-        )
-        self.host.memory.param_set(
-            C.FORCE_SERVER_PARAM,
-            session_data[C.FORCE_SERVER_PARAM],
-            "Connection",
-            profile_key=profile,
-        )
-        self.host.memory.param_set(
-            C.FORCE_PORT_PARAM,
-            session_data[C.FORCE_PORT_PARAM],
-            "Connection",
-            profile_key=profile,
-        )
-
-        d = self._register_new_account(
-            jid.JID(session_data["JabberID"]),
-            session_data["Password"],
-            None,
-            session_data["server"],
-        )
-        del self._sessions[data["session_id"]]
-        return d
-
-    def _register_new_account(self, client, jid_, password, email, server):
-        #  FIXME: port is not set here
-        def registered_cb(__):
-            xmlui = xml_tools.XMLUI("popup", title=D_("Confirmation"))
-            xmlui.addText(D_("Registration successful."))
-            return {"xmlui": xmlui.toXml()}
-
-        def registered_eb(failure):
-            xmlui = xml_tools.XMLUI("popup", title=D_("Failure"))
-            xmlui.addText(D_("Registration failed: %s") % failure.getErrorMessage())
-            try:
-                if failure.value.condition == "conflict":
-                    xmlui.addText(
-                        D_("Username already exists, please choose an other one.")
-                    )
-            except AttributeError:
-                pass
-            return {"xmlui": xmlui.toXml()}
-
-        registered_d = self.host.plugins["XEP-0077"].register_new_account(
-            client, jid_, password, email=email, host=server, port=C.XMPP_C2S_PORT
-        )
-        registered_d.addCallbacks(registered_cb, registered_eb)
-        return registered_d
--- a/sat/plugins/plugin_misc_room_game.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,784 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.words.protocols.jabber import jid
-from twisted.words.xish import domish
-from twisted.internet import defer
-from time import time
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-import copy
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-
-# Don't forget to set it to False before you commit
-_DEBUG = False
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Room game",
-    C.PI_IMPORT_NAME: "ROOM-GAME",
-    C.PI_TYPE: "MISC",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249"],
-    C.PI_MAIN: "RoomGame",
-    C.PI_HANDLER: "no",  # handler MUST be "no" (dynamic inheritance)
-    C.PI_DESCRIPTION: _("""Base class for MUC games"""),
-}
-
-
-# FIXME: this plugin is broken, need to be fixed
-
-
-class RoomGame(object):
-    """This class is used to help launching a MUC game.
-
-    bridge methods callbacks: _prepare_room, _player_ready, _create_game
-    Triggered methods: user_joined_trigger, user_left_trigger
-    Also called from subclasses: new_round
-
-    For examples of messages sequences, please look in sub-classes.
-    """
-
-    # Values for self.invite_mode (who can invite after the game creation)
-    FROM_ALL, FROM_NONE, FROM_REFEREE, FROM_PLAYERS = range(0, 4)
-    # Values for self.wait_mode (for who we should wait before creating the game)
-    FOR_ALL, FOR_NONE = range(0, 2)
-    # Values for self.join_mode (who can join the game - NONE means solo game)
-    ALL, INVITED, NONE = range(0, 3)
-    # Values for ready_mode (how to turn a MUC user into a player)
-    ASK, FORCE = range(0, 2)
-
-    MESSAGE = "/message"
-    REQUEST = '%s/%s[@xmlns="%s"]'
-
-    def __init__(self, host):
-        """For other plugin to dynamically inherit this class, it is necessary to not use __init__ but _init_.
-        The subclass itself must be initialized this way:
-
-        class MyGame(object):
-
-            def inherit_from_room_game(self, host):
-                global RoomGame
-                RoomGame = host.plugins["ROOM-GAME"].__class__
-                self.__class__ = type(self.__class__.__name__, (self.__class__, RoomGame, object), {})
-
-            def __init__(self, host):
-                self.inherit_from_room_game(host)
-                RoomGame._init_(self, host, ...)
-
-        """
-        self.host = host
-
-    def _init_(self, host, plugin_info, ns_tag, game_init=None, player_init=None):
-        """
-        @param host
-        @param plugin_info: PLUGIN_INFO map of the game plugin
-        @param ns_tag: couple (nameservice, tag) to construct the messages
-        @param game_init: dictionary for general game initialization
-        @param player_init: dictionary for player initialization, applicable to each player
-        """
-        self.host = host
-        self.name = plugin_info["import_name"]
-        self.ns_tag = ns_tag
-        self.request = self.REQUEST % (self.MESSAGE, ns_tag[1], ns_tag[0])
-        if game_init is None:
-            game_init = {}
-        if player_init is None:
-            player_init = {}
-        self.game_init = game_init
-        self.player_init = player_init
-        self.games = {}
-        self.invitations = {}  # values are a couple (x, y) with x the time and y a list of users
-
-        # These are the default settings, which can be overwritten by child class after initialization
-        self.invite_mode = self.FROM_PLAYERS if self.player_init == {} else self.FROM_NONE
-        self.wait_mode = self.FOR_NONE if self.player_init == {} else self.FOR_ALL
-        self.join_mode = self.INVITED
-        self.ready_mode = self.FORCE  # TODO: asking for confirmation is not implemented
-
-        # this has been added for testing purpose. It is sometimes needed to remove a dependence
-        # while building the synchronization data, for example to replace a call to time.time()
-        # by an arbitrary value. If needed, this attribute would be set to True from the testcase.
-        self.testing = False
-
-        host.trigger.add("MUC user joined", self.user_joined_trigger)
-        host.trigger.add("MUC user left", self.user_left_trigger)
-
-    def _create_or_invite(self, room_jid, other_players, profile):
-        """
-        This is called only when someone explicitly wants to play.
-
-        The game will not be created if one already exists in the room,
-        also its creation could be postponed until all the expected players
-        join the room (in that case it will be created from user_joined_trigger).
-        @param room (wokkel.muc.Room): the room
-        @param other_players (list[jid.JID]): list of the other players JID (bare)
-        """
-        # FIXME: broken !
-        raise NotImplementedError("To be fixed")
-        client = self.host.get_client(profile)
-        user_jid = self.host.get_jid_n_stream(profile)[0]
-        nick = self.host.plugins["XEP-0045"].get_room_nick(client, room_jid)
-        nicks = [nick]
-        if self._game_exists(room_jid):
-            if not self._check_join_auth(room_jid, user_jid, nick):
-                return
-            nicks.extend(self._invite_players(room_jid, other_players, nick, profile))
-            self._update_players(room_jid, nicks, True, profile)
-        else:
-            self._init_game(room_jid, nick)
-            (auth, waiting, missing) = self._check_wait_auth(room_jid, other_players)
-            nicks.extend(waiting)
-            nicks.extend(self._invite_players(room_jid, missing, nick, profile))
-            if auth:
-                self.create_game(room_jid, nicks, profile)
-            else:
-                self._update_players(room_jid, nicks, False, profile)
-
-    def _init_game(self, room_jid, referee_nick):
-        """
-
-        @param room_jid (jid.JID): JID of the room
-        @param referee_nick (unicode): nickname of the referee
-        """
-        # Important: do not add the referee to 'players' yet. For a
-        # <players /> message to be emitted whenever a new player is joining,
-        # it is necessary to not modify 'players' outside of _update_players.
-        referee_jid = jid.JID(room_jid.userhost() + "/" + referee_nick)
-        self.games[room_jid] = {
-            "referee": referee_jid,
-            "players": [],
-            "started": False,
-            "status": {},
-        }
-        self.games[room_jid].update(copy.deepcopy(self.game_init))
-        self.invitations.setdefault(room_jid, [])
-
-    def _game_exists(self, room_jid, started=False):
-        """Return True if a game has been initialized/started.
-        @param started: if False, the game must be initialized to return True,
-        otherwise it must be initialized and started with create_game.
-        @return: True if a game is initialized/started in that room"""
-        return room_jid in self.games and (not started or self.games[room_jid]["started"])
-
-    def _check_join_auth(self, room_jid, user_jid=None, nick="", verbose=False):
-        """Checks if this profile is allowed to join the game.
-
-        The parameter nick is used to check if the user is already
-        a player in that game. When this method is called from
-        user_joined_trigger, nick is also used to check the user
-        identity instead of user_jid_s (see TODO comment below).
-        @param room_jid (jid.JID): the JID of the room hosting the game
-        @param user_jid (jid.JID): JID of the user
-        @param nick (unicode): nick of the user
-        @return: True if this profile can join the game
-        """
-        auth = False
-        if not self._game_exists(room_jid):
-            auth = False
-        elif self.join_mode == self.ALL or self.is_player(room_jid, nick):
-            auth = True
-        elif self.join_mode == self.INVITED:
-            # considering all the batches of invitations
-            for invitations in self.invitations[room_jid]:
-                if user_jid is not None:
-                    if user_jid.userhostJID() in invitations[1]:
-                        auth = True
-                        break
-                else:
-                    # TODO: that's not secure enough but what to do if
-                    # wokkel.muc.User's 'entity' attribute is not set?!
-                    if nick in [invited.user for invited in invitations[1]]:
-                        auth = True
-                        break
-
-        if not auth and (verbose or _DEBUG):
-            log.debug(
-                _("%(user)s not allowed to join the game %(game)s in %(room)s")
-                % {
-                    "user": user_jid.userhost() or nick,
-                    "game": self.name,
-                    "room": room_jid.userhost(),
-                }
-            )
-        return auth
-
-    def _update_players(self, room_jid, nicks, sync, profile):
-        """Update the list of players and signal to the room that some players joined the game.
-        If sync is True, the news players are synchronized with the game data they have missed.
-        Remark: self.games[room_jid]['players'] should not be modified outside this method.
-        @param room_jid (jid.JID): JID of the room
-        @param nicks (list[unicode]): list of players nicks in the room (referee included, in first position)
-        @param sync (bool): set to True to send synchronization data to the new players
-        @param profile (unicode): %(doc_profile)s
-        """
-        if nicks == []:
-            return
-        # this is better than set(nicks).difference(...) as it keeps the order
-        new_nicks = [
-            nick for nick in nicks if nick not in self.games[room_jid]["players"]
-        ]
-        if len(new_nicks) == 0:
-            return
-
-        def setStatus(status):
-            for nick in new_nicks:
-                self.games[room_jid]["status"][nick] = status
-
-        sync = (
-            sync
-            and self._game_exists(room_jid, True)
-            and len(self.games[room_jid]["players"]) > 0
-        )
-        setStatus("desync" if sync else "init")
-        self.games[room_jid]["players"].extend(new_nicks)
-        self._synchronize_room(room_jid, [room_jid], profile)
-        if sync:
-            setStatus("init")
-
-    def _synchronize_room(self, room_jid, recipients, profile):
-        """Communicate the list of players to the whole room or only to some users,
-        also send the synchronization data to the players who recently joined the game.
-        @param room_jid (jid.JID): JID of the room
-        @recipients (list[jid.JID]): list of JIDs, the recipients of the message could be:
-            - room JID
-            - room JID + "/" + user nick
-        @param profile (unicode): %(doc_profile)s
-        """
-        if self._game_exists(room_jid, started=True):
-            element = self._create_start_element(self.games[room_jid]["players"])
-        else:
-            element = self._create_start_element(
-                self.games[room_jid]["players"], name="players"
-            )
-        elements = [(element, None, None)]
-
-        sync_args = []
-        sync_data = self._get_sync_data(room_jid)
-        for nick in sync_data:
-            user_jid = jid.JID(room_jid.userhost() + "/" + nick)
-            if user_jid in recipients:
-                user_elements = copy.deepcopy(elements)
-                for child in sync_data[nick]:
-                    user_elements.append((child, None, None))
-                recipients.remove(user_jid)
-            else:
-                user_elements = [(child, None, None) for child in sync_data[nick]]
-            sync_args.append(([user_jid, user_elements], {"profile": profile}))
-
-        for recipient in recipients:
-            self._send_elements(recipient, elements, profile=profile)
-        for args, kwargs in sync_args:
-            self._send_elements(*args, **kwargs)
-
-    def _get_sync_data(self, room_jid, force_nicks=None):
-        """The synchronization data are returned for each player who
-        has the state 'desync' or if he's been contained by force_nicks.
-        @param room_jid (jid.JID): JID of the room
-        @param force_nicks: force the synchronization for this list of the nicks
-        @return: a mapping between player nicks and a list of elements to
-        be sent by self._synchronize_room for the game to be synchronized.
-        """
-        if not self._game_exists(room_jid):
-            return {}
-        data = {}
-        status = self.games[room_jid]["status"]
-        nicks = [nick for nick in status if status[nick] == "desync"]
-        if force_nicks is None:
-            force_nicks = []
-        for nick in force_nicks:
-            if nick not in nicks:
-                nicks.append(nick)
-        for nick in nicks:
-            elements = self.get_sync_data_for_player(room_jid, nick)
-            if elements:
-                data[nick] = elements
-        return data
-
-    def get_sync_data_for_player(self, room_jid, nick):
-        """This method may (and should probably) be overwritten by a child class.
-        @param room_jid (jid.JID): JID of the room
-        @param nick: the nick of the player to be synchronized
-        @return: a list of elements to synchronize this player with the game.
-        """
-        return []
-
-    def _invite_players(self, room_jid, other_players, nick, profile):
-        """Invite players to a room, associated game may exist or not.
-
-        @param other_players (list[jid.JID]): list of the players to invite
-        @param nick (unicode): nick of the user who send the invitation
-        @return: list[unicode] of room nicks for invited players who are already in the room
-        """
-        raise NotImplementedError("Need to be fixed !")
-        # FIXME: this is broken and unsecure !
-        if not self._check_invite_auth(room_jid, nick):
-            return []
-        # TODO: remove invitation waiting for too long, using the time data
-        self.invitations[room_jid].append(
-            (time(), [player.userhostJID() for player in other_players])
-        )
-        nicks = []
-        for player_jid in [player.userhostJID() for player in other_players]:
-            # TODO: find a way to make it secure
-            other_nick = self.host.plugins["XEP-0045"].getRoomEntityNick(
-                room_jid, player_jid, secure=self.testing
-            )
-            if other_nick is None:
-                self.host.plugins["XEP-0249"].invite(
-                    player_jid, room_jid, {"game": self.name}, profile
-                )
-            else:
-                nicks.append(other_nick)
-        return nicks
-
-    def _check_invite_auth(self, room_jid, nick, verbose=False):
-        """Checks if this user is allowed to invite players
-
-        @param room_jid (jid.JID): JID of the room
-        @param nick: user nick in the room
-        @param verbose: display debug message
-        @return: True if the user is allowed to invite other players
-        """
-        auth = False
-        if self.invite_mode == self.FROM_ALL or not self._game_exists(room_jid):
-            auth = True
-        elif self.invite_mode == self.FROM_NONE:
-            auth = not self._game_exists(room_jid, started=True) and self.is_referee(
-                room_jid, nick
-            )
-        elif self.invite_mode == self.FROM_REFEREE:
-            auth = self.is_referee(room_jid, nick)
-        elif self.invite_mode == self.FROM_PLAYERS:
-            auth = self.is_player(room_jid, nick)
-        if not auth and (verbose or _DEBUG):
-            log.debug(
-                _("%(user)s not allowed to invite for the game %(game)s in %(room)s")
-                % {"user": nick, "game": self.name, "room": room_jid.userhost()}
-            )
-        return auth
-
-    def is_referee(self, room_jid, nick):
-        """Checks if the player with this nick is the referee for the game in this room"
-        @param room_jid (jid.JID): room JID
-        @param nick: user nick in the room
-        @return: True if the user is the referee of the game in this room
-        """
-        if not self._game_exists(room_jid):
-            return False
-        return (
-            jid.JID(room_jid.userhost() + "/" + nick) == self.games[room_jid]["referee"]
-        )
-
-    def is_player(self, room_jid, nick):
-        """Checks if the user with this nick is a player for the game in this room.
-        @param room_jid (jid.JID): JID of the room
-        @param nick: user nick in the room
-        @return: True if the user is a player of the game in this room
-        """
-        if not self._game_exists(room_jid):
-            return False
-        # Important: the referee is not in the 'players' list right after
-        # the game initialization, that's why we do also check with is_referee
-        return nick in self.games[room_jid]["players"] or self.is_referee(room_jid, nick)
-
-    def _check_wait_auth(self, room, other_players, verbose=False):
-        """Check if we must wait for other players before starting the game.
-
-        @param room (wokkel.muc.Room): the room
-        @param other_players (list[jid.JID]): list of the players without the referee
-        @param verbose (bool): display debug message
-        @return: (x, y, z) with:
-            x: False if we must wait, True otherwise
-            y: the nicks of the players that have been checked and confirmed
-            z: the JID of the players that have not been checked or that are missing
-        """
-        if self.wait_mode == self.FOR_NONE or other_players == []:
-            result = (True, [], other_players)
-        elif len(room.roster) < len(other_players):
-            # do not check the players until we may actually have them all
-            result = (False, [], other_players)
-        else:
-            # TODO: find a way to make it secure
-            (nicks, missing) = self.host.plugins["XEP-0045"].getRoomNicksOfUsers(
-                room, other_players, secure=False
-            )
-            result = (len(nicks) == len(other_players), nicks, missing)
-        if not result[0] and (verbose or _DEBUG):
-            log.debug(
-                _(
-                    "Still waiting for %(users)s before starting the game %(game)s in %(room)s"
-                )
-                % {
-                    "users": result[2],
-                    "game": self.name,
-                    "room": room.occupantJID.userhost(),
-                }
-            )
-        return result
-
-    def get_unique_name(self, muc_service=None, profile_key=C.PROF_KEY_NONE):
-        """Generate unique room name
-
-        @param muc_service (jid.JID): you can leave empty to autofind the muc service
-        @param profile_key (unicode): %(doc_profile_key)s
-        @return: jid.JID (unique name for a new room to be created)
-        """
-        client = self.host.get_client(profile_key)
-        # FIXME: jid.JID must be used instead of strings
-        room = self.host.plugins["XEP-0045"].get_unique_name(client, muc_service)
-        return jid.JID("sat_%s_%s" % (self.name.lower(), room.userhost()))
-
-    def _prepare_room(
-        self, other_players=None, room_jid_s="", profile_key=C.PROF_KEY_NONE
-    ):
-        room_jid = jid.JID(room_jid_s) if room_jid_s else None
-        other_players = [jid.JID(player).userhostJID() for player in other_players]
-        return self.prepare_room(other_players, room_jid, profile_key)
-
-    def prepare_room(self, other_players=None, room_jid=None, profile_key=C.PROF_KEY_NONE):
-        """Prepare the room for a game: create it if it doesn't exist and invite players.
-
-        @param other_players (list[JID]): list of other players JID (bare)
-        @param room_jid (jid.JID): JID of the room, or None to generate a unique name
-        @param profile_key (unicode): %(doc_profile_key)s
-        """
-        # FIXME: need to be refactored
-        client = self.host.get_client(profile_key)
-        log.debug(_("Preparing room for %s game") % self.name)
-        profile = self.host.memory.get_profile_name(profile_key)
-        if not profile:
-            log.error(_("Unknown profile"))
-            return defer.succeed(None)
-        if other_players is None:
-            other_players = []
-
-        # Create/join the given room, or a unique generated one if no room is specified.
-        if room_jid is None:
-            room_jid = self.get_unique_name(profile_key=profile_key)
-        else:
-            self.host.plugins["XEP-0045"].check_room_joined(client, room_jid)
-            self._create_or_invite(client, room_jid, other_players)
-            return defer.succeed(None)
-
-        user_jid = self.host.get_jid_n_stream(profile)[0]
-        d = self.host.plugins["XEP-0045"].join(room_jid, user_jid.user, {}, profile)
-        return d.addCallback(
-            lambda __: self._create_or_invite(client, room_jid, other_players)
-        )
-
-    def user_joined_trigger(self, room, user, profile):
-        """This trigger is used to check if the new user can take part of a game, create the game if we were waiting for him or just update the players list.
-
-        @room: wokkel.muc.Room object. room.roster is a dict{wokkel.muc.User.nick: wokkel.muc.User}
-        @user: wokkel.muc.User object. user.nick is a unicode and user.entity a JID
-        @return: True to not interrupt the main process.
-        """
-        room_jid = room.occupantJID.userhostJID()
-        profile_nick = room.occupantJID.resource
-        if not self.is_referee(room_jid, profile_nick):
-            return True  # profile is not the referee
-        if not self._check_join_auth(
-            room_jid, user.entity if user.entity else None, user.nick
-        ):
-            # user not allowed but let him know that we are playing :p
-            self._synchronize_room(
-                room_jid, [jid.JID(room_jid.userhost() + "/" + user.nick)], profile
-            )
-            return True
-        if self.wait_mode == self.FOR_ALL:
-            # considering the last batch of invitations
-            batch = len(self.invitations[room_jid]) - 1
-            if batch < 0:
-                log.error(
-                    "Invitations from %s to play %s in %s have been lost!"
-                    % (profile_nick, self.name, room_jid.userhost())
-                )
-                return True
-            other_players = self.invitations[room_jid][batch][1]
-            (auth, nicks, __) = self._check_wait_auth(room, other_players)
-            if auth:
-                del self.invitations[room_jid][batch]
-                nicks.insert(0, profile_nick)  # add the referee
-                self.create_game(room_jid, nicks, profile_key=profile)
-                return True
-        # let the room know that a new player joined
-        self._update_players(room_jid, [user.nick], True, profile)
-        return True
-
-    def user_left_trigger(self, room, user, profile):
-        """This trigger is used to update or stop the game when a user leaves.
-
-        @room: wokkel.muc.Room object. room.roster is a dict{wokkel.muc.User.nick: wokkel.muc.User}
-        @user: wokkel.muc.User object. user.nick is a unicode and user.entity a JID
-        @return: True to not interrupt the main process.
-        """
-        room_jid = room.occupantJID.userhostJID()
-        profile_nick = room.occupantJID.resource
-        if not self.is_referee(room_jid, profile_nick):
-            return True  # profile is not the referee
-        if self.is_player(room_jid, user.nick):
-            try:
-                self.games[room_jid]["players"].remove(user.nick)
-            except ValueError:
-                pass
-            if len(self.games[room_jid]["players"]) == 0:
-                return True
-            if self.wait_mode == self.FOR_ALL:
-                # allow this user to join the game again
-                user_jid = user.entity.userhostJID()
-                if len(self.invitations[room_jid]) == 0:
-                    self.invitations[room_jid].append((time(), [user_jid]))
-                else:
-                    batch = 0  # add to the first batch of invitations
-                    if user_jid not in self.invitations[room_jid][batch][1]:
-                        self.invitations[room_jid][batch][1].append(user_jid)
-        return True
-
-    def _check_create_game_and_init(self, room_jid, profile):
-        """Check if that profile can create the game. If the game can be created
-        but is not initialized yet, this method will also do the initialization.
-
-        @param room_jid (jid.JID): JID of the room
-        @param profile
-        @return: a couple (create, sync) with:
-                - create: set to True to allow the game creation
-                - sync: set to True to advice a game synchronization
-        """
-        user_nick = self.host.plugins["XEP-0045"].get_room_nick(room_jid, profile)
-        if not user_nick:
-            log.error(
-                "Internal error: profile %s has not joined the room %s"
-                % (profile, room_jid.userhost())
-            )
-            return False, False
-        if self._game_exists(room_jid):
-            is_referee = self.is_referee(room_jid, user_nick)
-            if self._game_exists(room_jid, started=True):
-                log.info(
-                    _("%(game)s game already created in room %(room)s")
-                    % {"game": self.name, "room": room_jid.userhost()}
-                )
-                return False, is_referee
-            elif not is_referee:
-                log.info(
-                    _("%(game)s game in room %(room)s can only be created by %(user)s")
-                    % {"game": self.name, "room": room_jid.userhost(), "user": user_nick}
-                )
-                return False, False
-        else:
-            self._init_game(room_jid, user_nick)
-        return True, False
-
-    def _create_game(self, room_jid_s, nicks=None, profile_key=C.PROF_KEY_NONE):
-        self.create_game(jid.JID(room_jid_s), nicks, profile_key)
-
-    def create_game(self, room_jid, nicks=None, profile_key=C.PROF_KEY_NONE):
-        """Create a new game.
-
-        This can be called directly from a frontend and skips all the checks and invitation system,
-        but the game must not exist and all the players must be in the room already.
-        @param room_jid (jid.JID): JID of the room
-        @param nicks (list[unicode]): list of players nicks in the room (referee included, in first position)
-        @param profile_key (unicode): %(doc_profile_key)s
-        """
-        log.debug(
-            _("Creating %(game)s game in room %(room)s")
-            % {"game": self.name, "room": room_jid}
-        )
-        profile = self.host.memory.get_profile_name(profile_key)
-        if not profile:
-            log.error(_("profile %s is unknown") % profile_key)
-            return
-        (create, sync) = self._check_create_game_and_init(room_jid, profile)
-        if nicks is None:
-            nicks = []
-        if not create:
-            if sync:
-                self._update_players(room_jid, nicks, True, profile)
-            return
-        self.games[room_jid]["started"] = True
-        self._update_players(room_jid, nicks, False, profile)
-        if self.player_init:
-            # specific data to each player (score, private data)
-            self.games[room_jid].setdefault("players_data", {})
-            for nick in nicks:
-                # The dict must be COPIED otherwise it is shared between all users
-                self.games[room_jid]["players_data"][nick] = copy.deepcopy(
-                    self.player_init
-                )
-
-    def _player_ready(self, player_nick, referee_jid_s, profile_key=C.PROF_KEY_NONE):
-        self.player_ready(player_nick, jid.JID(referee_jid_s), profile_key)
-
-    def player_ready(self, player_nick, referee_jid, profile_key=C.PROF_KEY_NONE):
-        """Must be called when player is ready to start a new game
-
-        @param player: the player nick in the room
-        @param referee_jid (jid.JID): JID of the referee
-        """
-        profile = self.host.memory.get_profile_name(profile_key)
-        if not profile:
-            log.error(_("profile %s is unknown") % profile_key)
-            return
-        log.debug("new player ready: %s" % profile)
-        # TODO: we probably need to add the game and room names in the sent message
-        self.send(referee_jid, "player_ready", {"player": player_nick}, profile=profile)
-
-    def new_round(self, room_jid, data, profile):
-        """Launch a new round (reinit the user data)
-
-        @param room_jid: room userhost
-        @param data: a couple (common_data, msg_elts) with:
-                    - common_data: backend initialization data for the new round
-                    - msg_elts: dict to map each user to his specific initialization message
-        @param profile
-        """
-        log.debug(_("new round for %s game") % self.name)
-        game_data = self.games[room_jid]
-        players = game_data["players"]
-        players_data = game_data["players_data"]
-        game_data["stage"] = "init"
-
-        common_data, msg_elts = copy.deepcopy(data) if data is not None else (None, None)
-
-        if isinstance(msg_elts, dict):
-            for player in players:
-                to_jid = jid.JID(room_jid.userhost() + "/" + player)  # FIXME: gof:
-                elem = (
-                    msg_elts[player]
-                    if isinstance(msg_elts[player], domish.Element)
-                    else None
-                )
-                self.send(to_jid, elem, profile=profile)
-        elif isinstance(msg_elts, domish.Element):
-            self.send(room_jid, msg_elts, profile=profile)
-        if common_data is not None:
-            for player in players:
-                players_data[player].update(copy.deepcopy(common_data))
-
-    def _create_game_elt(self, to_jid):
-        """Create a generic domish Element for the game messages
-
-        @param to_jid: JID of the recipient
-        @return: the created element
-        """
-        type_ = "normal" if to_jid.resource else "groupchat"
-        elt = domish.Element((None, "message"))
-        elt["to"] = to_jid.full()
-        elt["type"] = type_
-        elt.addElement(self.ns_tag)
-        return elt
-
-    def _create_start_element(self, players=None, name="started"):
-        """Create a domish Element listing the game users
-
-        @param players: list of the players
-        @param name: element name:
-                    - "started" to signal the players that the game has been started
-                    - "players" to signal the list of players when the game is not started yet
-        @return the create element
-        """
-        started_elt = domish.Element((None, name))
-        if players is None:
-            return started_elt
-        idx = 0
-        for player in players:
-            player_elt = domish.Element((None, "player"))
-            player_elt.addContent(player)
-            player_elt["index"] = str(idx)
-            idx += 1
-            started_elt.addChild(player_elt)
-        return started_elt
-
-    def _send_elements(self, to_jid, data, profile=None):
-        """ TODO
-
-        @param to_jid: recipient JID
-        @param data: list of (elem, attr, content) with:
-                    - elem: domish.Element, unicode or a couple:
-                            - domish.Element to be directly added as a child to the message
-                            - unicode name or couple (uri, name) to create a new domish.Element
-                              and add it as a child to the message (see domish.Element.addElement)
-                    - attrs: dictionary of attributes for the new child
-                    - content: unicode that is appended to the child content
-        @param profile: the profile from which the message is sent
-        @return: a Deferred instance
-        """
-        client = self.host.get_client(profile)
-        msg = self._create_game_elt(to_jid)
-        for elem, attrs, content in data:
-            if elem is not None:
-                if isinstance(elem, domish.Element):
-                    msg.firstChildElement().addChild(elem)
-                else:
-                    elem = msg.firstChildElement().addElement(elem)
-                if attrs is not None:
-                    elem.attributes.update(attrs)
-                if content is not None:
-                    elem.addContent(content)
-        client.send(msg)
-        return defer.succeed(None)
-
-    def send(self, to_jid, elem=None, attrs=None, content=None, profile=None):
-        """ TODO
-
-        @param to_jid: recipient JID
-        @param elem: domish.Element, unicode or a couple:
-                    - domish.Element to be directly added as a child to the message
-                    - unicode name or couple (uri, name) to create a new domish.Element
-                      and add it as a child to the message (see domish.Element.addElement)
-        @param attrs: dictionary of attributes for the new child
-        @param content: unicode that is appended to the child content
-        @param profile: the profile from which the message is sent
-        @return: a Deferred instance
-        """
-        return self._send_elements(to_jid, [(elem, attrs, content)], profile)
-
-    def get_handler(self, client):
-        return RoomGameHandler(self)
-
-
-@implementer(iwokkel.IDisco)
-class RoomGameHandler(XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(
-            self.plugin_parent.request,
-            self.plugin_parent.room_game_cmd,
-            profile=self.parent.profile,
-        )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(self.plugin_parent.ns_tag[0])]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_misc_static_blog.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,108 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for static blogs
-# Copyright (C) 2014 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.tools import xml_tools
-
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Static Blog Plugin",
-    C.PI_IMPORT_NAME: "STATIC-BLOG",
-    C.PI_TYPE: "MISC",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [],
-    C.PI_RECOMMENDATIONS: [
-        "MISC-ACCOUNT"
-    ],  # TODO: remove when all blogs can be retrieved
-    C.PI_MAIN: "StaticBlog",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Plugin for static blogs"""),
-}
-
-
-class StaticBlog(object):
-
-    params = """
-    <params>
-    <individual>
-    <category name="{category_name}" label="{category_label}">
-        <param name="{title_name}" label="{title_label}" value="" type="string" security="0"/>
-        <param name="{banner_name}" label="{banner_label}" value="" type="string" security="0"/>
-        <param name="{background_name}" label="{background_label}" value ="" type="string" security="0"/>
-        <param name="{keywords_name}" label="{keywords_label}" value="" type="string" security="0"/>
-        <param name="{description_name}" label="{description_label}" value="" type="string" security="0"/>
-     </category>
-    </individual>
-    </params>
-    """.format(
-        category_name=C.STATIC_BLOG_KEY,
-        category_label=D_(C.STATIC_BLOG_KEY),
-        title_name=C.STATIC_BLOG_PARAM_TITLE,
-        title_label=D_("Page title"),
-        banner_name=C.STATIC_BLOG_PARAM_BANNER,
-        banner_label=D_("Banner URL"),
-        background_name="Background",
-        background_label=D_("Background image URL"),
-        keywords_name=C.STATIC_BLOG_PARAM_KEYWORDS,
-        keywords_label=D_("Keywords"),
-        description_name=C.STATIC_BLOG_PARAM_DESCRIPTION,
-        description_label=D_("Description"),
-    )
-
-    def __init__(self, host):
-        try:  # TODO: remove this attribute when all blogs can be retrieved
-            self.domain = host.plugins["MISC-ACCOUNT"].account_domain_new_get()
-        except KeyError:
-            self.domain = None
-        host.memory.update_params(self.params)
-        # host.import_menu((D_("User"), D_("Public blog")), self._display_public_blog, security_limit=1, help_string=D_("Display public blog page"), type_=C.MENU_JID_CONTEXT)
-
-    def _display_public_blog(self, menu_data, profile):
-        """Check if the blog can be displayed and answer the frontend.
-
-        @param menu_data: %(menu_data)s
-        @param profile: %(doc_profile)s
-        @return: dict
-        """
-        # FIXME: "public_blog" key has been removed
-        # TODO: replace this with a more generic widget call with URIs
-        try:
-            user_jid = jid.JID(menu_data["jid"])
-        except KeyError:
-            log.error(_("jid key is not present !"))
-            return defer.fail(exceptions.DataError)
-
-        # TODO: remove this check when all blogs can be retrieved
-        if self.domain and user_jid.host != self.domain:
-            info_ui = xml_tools.XMLUI("popup", title=D_("Not available"))
-            info_ui.addText(
-                D_("Retrieving a blog from an external domain is not implemented yet.")
-            )
-            return {"xmlui": info_ui.toXml()}
-
-        return {"public_blog": user_jid.userhost()}
--- a/sat/plugins/plugin_misc_tarot.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,901 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing French Tarot game
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.words.xish import domish
-from twisted.words.protocols.jabber import jid
-from twisted.internet import defer
-from wokkel import data_form
-
-from sat.memory import memory
-from sat.tools import xml_tools
-from sat_frontends.tools.games import TarotCard
-import random
-
-
-NS_CG = "http://www.goffi.org/protocol/card_game"
-CG_TAG = "card_game"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Tarot cards plugin",
-    C.PI_IMPORT_NAME: "Tarot",
-    C.PI_TYPE: "Misc",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0045", "XEP-0249", "ROOM-GAME"],
-    C.PI_MAIN: "Tarot",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Tarot card game"""),
-}
-
-
-class Tarot(object):
-    def inherit_from_room_game(self, host):
-        global RoomGame
-        RoomGame = host.plugins["ROOM-GAME"].__class__
-        self.__class__ = type(
-            self.__class__.__name__, (self.__class__, RoomGame, object), {}
-        )
-
-    def __init__(self, host):
-        log.info(_("Plugin Tarot initialization"))
-        self._sessions = memory.Sessions()
-        self.inherit_from_room_game(host)
-        RoomGame._init_(
-            self,
-            host,
-            PLUGIN_INFO,
-            (NS_CG, CG_TAG),
-            game_init={
-                "hand_size": 18,
-                "init_player": 0,
-                "current_player": None,
-                "contrat": None,
-                "stage": None,
-            },
-            player_init={"score": 0},
-        )
-        self.contrats = [
-            _("Passe"),
-            _("Petite"),
-            _("Garde"),
-            _("Garde Sans"),
-            _("Garde Contre"),
-        ]
-        host.bridge.add_method(
-            "tarot_game_launch",
-            ".plugin",
-            in_sign="asss",
-            out_sign="",
-            method=self._prepare_room,
-            async_=True,
-        )  # args: players, room_jid, profile
-        host.bridge.add_method(
-            "tarot_game_create",
-            ".plugin",
-            in_sign="sass",
-            out_sign="",
-            method=self._create_game,
-        )  # args: room_jid, players, profile
-        host.bridge.add_method(
-            "tarot_game_ready",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._player_ready,
-        )  # args: player, referee, profile
-        host.bridge.add_method(
-            "tarot_game_play_cards",
-            ".plugin",
-            in_sign="ssa(ss)s",
-            out_sign="",
-            method=self.play_cards,
-        )  # args: player, referee, cards, profile
-        host.bridge.add_signal(
-            "tarot_game_players", ".plugin", signature="ssass"
-        )  # args: room_jid, referee, players, profile
-        host.bridge.add_signal(
-            "tarot_game_started", ".plugin", signature="ssass"
-        )  # args: room_jid, referee, players, profile
-        host.bridge.add_signal(
-            "tarot_game_new", ".plugin", signature="sa(ss)s"
-        )  # args: room_jid, hand, profile
-        host.bridge.add_signal(
-            "tarot_game_choose_contrat", ".plugin", signature="sss"
-        )  # args: room_jid, xml_data, profile
-        host.bridge.add_signal(
-            "tarot_game_show_cards", ".plugin", signature="ssa(ss)a{ss}s"
-        )  # args: room_jid, type ["chien", "poignée",...], cards, data[dict], profile
-        host.bridge.add_signal(
-            "tarot_game_cards_played", ".plugin", signature="ssa(ss)s"
-        )  # args: room_jid, player, type ["chien", "poignée",...], cards, data[dict], profile
-        host.bridge.add_signal(
-            "tarot_game_your_turn", ".plugin", signature="ss"
-        )  # args: room_jid, profile
-        host.bridge.add_signal(
-            "tarot_game_score", ".plugin", signature="ssasass"
-        )  # args: room_jid, xml_data, winners (list of nicks), loosers (list of nicks), profile
-        host.bridge.add_signal(
-            "tarot_game_invalid_cards", ".plugin", signature="ssa(ss)a(ss)s"
-        )  # args: room_jid, game phase, played_cards, invalid_cards, profile
-        self.deck_ordered = []
-        for value in ["excuse"] + list(map(str, list(range(1, 22)))):
-            self.deck_ordered.append(TarotCard(("atout", value)))
-        for suit in ["pique", "coeur", "carreau", "trefle"]:
-            for value in list(map(str, list(range(1, 11)))) + ["valet", "cavalier", "dame", "roi"]:
-                self.deck_ordered.append(TarotCard((suit, value)))
-        self.__choose_contrat_id = host.register_callback(
-            self._contrat_choosed, with_data=True
-        )
-        self.__score_id = host.register_callback(self._score_showed, with_data=True)
-
-    def __card_list_to_xml(self, cards_list, elt_name):
-        """Convert a card list to domish element"""
-        cards_list_elt = domish.Element((None, elt_name))
-        for card in cards_list:
-            card_elt = domish.Element((None, "card"))
-            card_elt["suit"] = card.suit
-            card_elt["value"] = card.value
-            cards_list_elt.addChild(card_elt)
-        return cards_list_elt
-
-    def __xml_to_list(self, cards_list_elt):
-        """Convert a domish element with cards to a list of tuples"""
-        cards_list = []
-        for card in cards_list_elt.elements():
-            cards_list.append((card["suit"], card["value"]))
-        return cards_list
-
-    def __ask_contrat(self):
-        """Create a element for asking contrat"""
-        contrat_elt = domish.Element((None, "contrat"))
-        form = data_form.Form("form", title=_("contrat selection"))
-        field = data_form.Field(
-            "list-single",
-            "contrat",
-            options=list(map(data_form.Option, self.contrats)),
-            required=True,
-        )
-        form.addField(field)
-        contrat_elt.addChild(form.toElement())
-        return contrat_elt
-
-    def __give_scores(self, scores, winners, loosers):
-        """Create an element to give scores
-        @param scores: unicode (can contain line feed)
-        @param winners: list of unicode nicks of winners
-        @param loosers: list of unicode nicks of loosers"""
-
-        score_elt = domish.Element((None, "score"))
-        form = data_form.Form("form", title=_("scores"))
-        for line in scores.split("\n"):
-            field = data_form.Field("fixed", value=line)
-            form.addField(field)
-        score_elt.addChild(form.toElement())
-        for winner in winners:
-            winner_elt = domish.Element((None, "winner"))
-            winner_elt.addContent(winner)
-            score_elt.addChild(winner_elt)
-        for looser in loosers:
-            looser_elt = domish.Element((None, "looser"))
-            looser_elt.addContent(looser)
-            score_elt.addChild(looser_elt)
-        return score_elt
-
-    def __invalid_cards_elt(self, played_cards, invalid_cards, game_phase):
-        """Create a element for invalid_cards error
-        @param list_cards: list of Card
-        @param game_phase: phase of the game ['ecart', 'play']"""
-        error_elt = domish.Element((None, "error"))
-        played_elt = self.__card_list_to_xml(played_cards, "played")
-        invalid_elt = self.__card_list_to_xml(invalid_cards, "invalid")
-        error_elt["type"] = "invalid_cards"
-        error_elt["phase"] = game_phase
-        error_elt.addChild(played_elt)
-        error_elt.addChild(invalid_elt)
-        return error_elt
-
-    def __next_player(self, game_data, next_pl=None):
-        """Increment player number & return player name
-        @param next_pl: if given, then next_player is forced to this one
-        """
-        if next_pl:
-            game_data["current_player"] = game_data["players"].index(next_pl)
-            return next_pl
-        else:
-            pl_idx = game_data["current_player"] = (
-                game_data["current_player"] + 1
-            ) % len(game_data["players"])
-            return game_data["players"][pl_idx]
-
-    def __winner(self, game_data):
-        """give the nick of the player who win this trick"""
-        players_data = game_data["players_data"]
-        first = game_data["first_player"]
-        first_idx = game_data["players"].index(first)
-        suit_asked = None
-        strongest = None
-        winner = None
-        for idx in [(first_idx + i) % 4 for i in range(4)]:
-            player = game_data["players"][idx]
-            card = players_data[player]["played"]
-            if card.value == "excuse":
-                continue
-            if suit_asked is None:
-                suit_asked = card.suit
-            if (card.suit == suit_asked or card.suit == "atout") and card > strongest:
-                strongest = card
-                winner = player
-        assert winner
-        return winner
-
-    def __excuse_hack(self, game_data, played, winner):
-        """give a low card to other team and keep excuse if trick is lost
-        @param game_data: data of the game
-        @param played: cards currently on the table
-        @param winner: nick of the trick winner"""
-        # TODO: manage the case where excuse is played on the last trick (and lost)
-        players_data = game_data["players_data"]
-        excuse = TarotCard(("atout", "excuse"))
-
-        # we first check if the Excuse was already played
-        # and if somebody is waiting for a card
-        for player in game_data["players"]:
-            if players_data[player]["wait_for_low"]:
-                # the excuse owner has to give a card to somebody
-                if winner == player:
-                    # the excuse owner win the trick, we check if we have something to give
-                    for card in played:
-                        if card.points == 0.5:
-                            pl_waiting = players_data[player]["wait_for_low"]
-                            played.remove(card)
-                            players_data[pl_waiting]["levees"].append(card)
-                            log.debug(
-                                _(
-                                    "Player %(excuse_owner)s give %(card_waited)s to %(player_waiting)s for Excuse compensation"
-                                )
-                                % {
-                                    "excuse_owner": player,
-                                    "card_waited": card,
-                                    "player_waiting": pl_waiting,
-                                }
-                            )
-                            return
-                return
-
-        if excuse not in played:
-            # the Excuse is not on the table, nothing to do
-            return
-
-        excuse_player = None  # Who has played the Excuse ?
-        for player in game_data["players"]:
-            if players_data[player]["played"] == excuse:
-                excuse_player = player
-                break
-
-        if excuse_player == winner:
-            return  # the excuse player win the trick, nothing to do
-
-        # first we remove the excuse from played cards
-        played.remove(excuse)
-        # then we give it back to the original owner
-        owner_levees = players_data[excuse_player]["levees"]
-        owner_levees.append(excuse)
-        # finally we give a low card to the trick winner
-        low_card = None
-        # We look backward in cards won by the Excuse owner to
-        # find a low value card
-        for card_idx in range(len(owner_levees) - 1, -1, -1):
-            if owner_levees[card_idx].points == 0.5:
-                low_card = owner_levees[card_idx]
-                del owner_levees[card_idx]
-                players_data[winner]["levees"].append(low_card)
-                log.debug(
-                    _(
-                        "Player %(excuse_owner)s give %(card_waited)s to %(player_waiting)s for Excuse compensation"
-                    )
-                    % {
-                        "excuse_owner": excuse_player,
-                        "card_waited": low_card,
-                        "player_waiting": winner,
-                    }
-                )
-                break
-        if not low_card:  # The player has no low card yet
-            # TODO: manage case when player never win a trick with low card
-            players_data[excuse_player]["wait_for_low"] = winner
-            log.debug(
-                _(
-                    "%(excuse_owner)s keep the Excuse but has not card to give, %(winner)s is waiting for one"
-                )
-                % {"excuse_owner": excuse_player, "winner": winner}
-            )
-
-    def __draw_game(self, game_data):
-        """The game is draw, no score change
-        @param game_data: data of the game
-        @return: tuple with (string victory message, list of winners, list of loosers)"""
-        players_data = game_data["players_data"]
-        scores_str = _("Draw game")
-        scores_str += "\n"
-        for player in game_data["players"]:
-            scores_str += _(
-                "\n--\n%(player)s:\nscore for this game ==> %(score_game)i\ntotal score ==> %(total_score)i"
-            ) % {
-                "player": player,
-                "score_game": 0,
-                "total_score": players_data[player]["score"],
-            }
-        log.debug(scores_str)
-
-        return (scores_str, [], [])
-
-    def __calculate_scores(self, game_data):
-        """The game is finished, time to know who won :)
-        @param game_data: data of the game
-        @return: tuple with (string victory message, list of winners, list of loosers)"""
-        players_data = game_data["players_data"]
-        levees = players_data[game_data["attaquant"]]["levees"]
-        score = 0
-        nb_bouts = 0
-        bouts = []
-        for card in levees:
-            if card.bout:
-                nb_bouts += 1
-                bouts.append(card.value)
-            score += card.points
-
-        # We do a basic check on score calculation
-        check_score = 0
-        defenseurs = game_data["players"][:]
-        defenseurs.remove(game_data["attaquant"])
-        for defenseur in defenseurs:
-            for card in players_data[defenseur]["levees"]:
-                check_score += card.points
-        if game_data["contrat"] == "Garde Contre":
-            for card in game_data["chien"]:
-                check_score += card.points
-        assert score + check_score == 91
-
-        point_limit = None
-        if nb_bouts == 3:
-            point_limit = 36
-        elif nb_bouts == 2:
-            point_limit = 41
-        elif nb_bouts == 1:
-            point_limit = 51
-        else:
-            point_limit = 56
-        if game_data["contrat"] == "Petite":
-            contrat_mult = 1
-        elif game_data["contrat"] == "Garde":
-            contrat_mult = 2
-        elif game_data["contrat"] == "Garde Sans":
-            contrat_mult = 4
-        elif game_data["contrat"] == "Garde Contre":
-            contrat_mult = 6
-        else:
-            log.error(_("INTERNAL ERROR: contrat not managed (mispelled ?)"))
-            assert False
-
-        victory = score >= point_limit
-        margin = abs(score - point_limit)
-        points_defenseur = (margin + 25) * contrat_mult * (-1 if victory else 1)
-        winners = []
-        loosers = []
-        player_score = {}
-        for player in game_data["players"]:
-            # TODO: adjust this for 3 and 5 players variants
-            # TODO: manage bonuses (petit au bout, poignée, chelem)
-            player_score[player] = (
-                points_defenseur
-                if player != game_data["attaquant"]
-                else points_defenseur * -3
-            )
-            players_data[player]["score"] += player_score[
-                player
-            ]  # we add score of this game to the global score
-            if player_score[player] > 0:
-                winners.append(player)
-            else:
-                loosers.append(player)
-
-        scores_str = _(
-            "The attacker (%(attaquant)s) makes %(points)i and needs to make %(point_limit)i (%(nb_bouts)s oulder%(plural)s%(separator)s%(bouts)s): (s)he %(victory)s"
-        ) % {
-            "attaquant": game_data["attaquant"],
-            "points": score,
-            "point_limit": point_limit,
-            "nb_bouts": nb_bouts,
-            "plural": "s" if nb_bouts > 1 else "",
-            "separator": ": " if nb_bouts != 0 else "",
-            "bouts": ",".join(map(str, bouts)),
-            "victory": "wins" if victory else "looses",
-        }
-        scores_str += "\n"
-        for player in game_data["players"]:
-            scores_str += _(
-                "\n--\n%(player)s:\nscore for this game ==> %(score_game)i\ntotal score ==> %(total_score)i"
-            ) % {
-                "player": player,
-                "score_game": player_score[player],
-                "total_score": players_data[player]["score"],
-            }
-        log.debug(scores_str)
-
-        return (scores_str, winners, loosers)
-
-    def __invalid_cards(self, game_data, cards):
-        """Checks that the player has the right to play what he wants to
-        @param game_data: Game data
-        @param cards: cards the player want to play
-        @return forbidden_cards cards or empty list if cards are ok"""
-        forbidden_cards = []
-        if game_data["stage"] == "ecart":
-            for card in cards:
-                if card.bout or card.value == "roi":
-                    forbidden_cards.append(card)
-                # TODO: manage case where atouts (trumps) are in the dog
-        elif game_data["stage"] == "play":
-            biggest_atout = None
-            suit_asked = None
-            players = game_data["players"]
-            players_data = game_data["players_data"]
-            idx = players.index(game_data["first_player"])
-            current_idx = game_data["current_player"]
-            current_player = players[current_idx]
-            if idx == current_idx:
-                # the player is the first to play, he can play what he wants
-                return forbidden_cards
-            while idx != current_idx:
-                player = players[idx]
-                played_card = players_data[player]["played"]
-                if not suit_asked and played_card.value != "excuse":
-                    suit_asked = played_card.suit
-                if played_card.suit == "atout" and played_card > biggest_atout:
-                    biggest_atout = played_card
-                idx = (idx + 1) % len(players)
-            has_suit = (
-                False
-            )  # True if there is one card of the asked suit in the hand of the player
-            has_atout = False
-            biggest_hand_atout = None
-
-            for hand_card in game_data["hand"][current_player]:
-                if hand_card.suit == suit_asked:
-                    has_suit = True
-                if hand_card.suit == "atout":
-                    has_atout = True
-                if hand_card.suit == "atout" and hand_card > biggest_hand_atout:
-                    biggest_hand_atout = hand_card
-
-            assert len(cards) == 1
-            card = cards[0]
-            if card.suit != suit_asked and has_suit and card.value != "excuse":
-                forbidden_cards.append(card)
-                return forbidden_cards
-            if card.suit != suit_asked and card.suit != "atout" and has_atout:
-                forbidden_cards.append(card)
-                return forbidden_cards
-            if (
-                card.suit == "atout"
-                and card < biggest_atout
-                and biggest_hand_atout > biggest_atout
-                and card.value != "excuse"
-            ):
-                forbidden_cards.append(card)
-        else:
-            log.error(_("Internal error: unmanaged game stage"))
-        return forbidden_cards
-
-    def __start_play(self, room_jid, game_data, profile):
-        """Start the game (tell to the first player after dealer to play"""
-        game_data["stage"] = "play"
-        next_player_idx = game_data["current_player"] = (
-            game_data["init_player"] + 1
-        ) % len(
-            game_data["players"]
-        )  # the player after the dealer start
-        game_data["first_player"] = next_player = game_data["players"][next_player_idx]
-        to_jid = jid.JID(room_jid.userhost() + "/" + next_player)  # FIXME: gof:
-        self.send(to_jid, "your_turn", profile=profile)
-
-    def _contrat_choosed(self, raw_data, profile):
-        """Will be called when the contrat is selected
-        @param raw_data: contains the choosed session id and the chosen contrat
-        @param profile_key: profile
-        """
-        try:
-            session_data = self._sessions.profile_get(raw_data["session_id"], profile)
-        except KeyError:
-            log.warning(_("session id doesn't exist, session has probably expired"))
-            # TODO: send error dialog
-            return defer.succeed({})
-
-        room_jid = session_data["room_jid"]
-        referee_jid = self.games[room_jid]["referee"]
-        player = self.host.plugins["XEP-0045"].get_room_nick(room_jid, profile)
-        data = xml_tools.xmlui_result_2_data_form_result(raw_data)
-        contrat = data["contrat"]
-        log.debug(
-            _("contrat [%(contrat)s] choosed by %(profile)s")
-            % {"contrat": contrat, "profile": profile}
-        )
-        d = self.send(
-            referee_jid,
-            ("", "contrat_choosed"),
-            {"player": player},
-            content=contrat,
-            profile=profile,
-        )
-        d.addCallback(lambda ignore: {})
-        del self._sessions[raw_data["session_id"]]
-        return d
-
-    def _score_showed(self, raw_data, profile):
-        """Will be called when the player closes the score dialog
-        @param raw_data: nothing to retrieve from here but the session id
-        @param profile_key: profile
-        """
-        try:
-            session_data = self._sessions.profile_get(raw_data["session_id"], profile)
-        except KeyError:
-            log.warning(_("session id doesn't exist, session has probably expired"))
-            # TODO: send error dialog
-            return defer.succeed({})
-
-        room_jid_s = session_data["room_jid"].userhost()
-        # XXX: empty hand means to the frontend "reset the display"...
-        self.host.bridge.tarot_game_new(room_jid_s, [], profile)
-        del self._sessions[raw_data["session_id"]]
-        return defer.succeed({})
-
-    def play_cards(self, player, referee, cards, profile_key=C.PROF_KEY_NONE):
-        """Must be call by player when the contrat is selected
-        @param player: player's name
-        @param referee: arbiter jid
-        @cards: cards played (list of tuples)
-        @profile_key: profile
-        """
-        profile = self.host.memory.get_profile_name(profile_key)
-        if not profile:
-            log.error(_("profile %s is unknown") % profile_key)
-            return
-        log.debug(
-            _("Cards played by %(profile)s: [%(cards)s]")
-            % {"profile": profile, "cards": cards}
-        )
-        elem = self.__card_list_to_xml(TarotCard.from_tuples(cards), "cards_played")
-        self.send(jid.JID(referee), elem, {"player": player}, profile=profile)
-
-    def new_round(self, room_jid, profile):
-        game_data = self.games[room_jid]
-        players = game_data["players"]
-        game_data["first_player"] = None  # first player for the current trick
-        game_data["contrat"] = None
-        common_data = {
-            "contrat": None,
-            "levees": [],  # cards won
-            "played": None,  # card on the table
-            "wait_for_low": None,  # Used when a player wait for a low card because of excuse
-        }
-
-        hand = game_data["hand"] = {}
-        hand_size = game_data["hand_size"]
-        chien = game_data["chien"] = []
-        deck = self.deck_ordered[:]
-        random.shuffle(deck)
-        for i in range(4):
-            hand[players[i]] = deck[0:hand_size]
-            del deck[0:hand_size]
-        chien.extend(deck)
-        del (deck[:])
-        msg_elts = {}
-        for player in players:
-            msg_elts[player] = self.__card_list_to_xml(hand[player], "hand")
-
-        RoomGame.new_round(self, room_jid, (common_data, msg_elts), profile)
-
-        pl_idx = game_data["current_player"] = (game_data["init_player"] + 1) % len(
-            players
-        )  # the player after the dealer start
-        player = players[pl_idx]
-        to_jid = jid.JID(room_jid.userhost() + "/" + player)  # FIXME: gof:
-        self.send(to_jid, self.__ask_contrat(), profile=profile)
-
-    def room_game_cmd(self, mess_elt, profile):
-        """
-        @param mess_elt: instance of twisted.words.xish.domish.Element
-        """
-        client = self.host.get_client(profile)
-        from_jid = jid.JID(mess_elt["from"])
-        room_jid = jid.JID(from_jid.userhost())
-        nick = self.host.plugins["XEP-0045"].get_room_nick(client, room_jid)
-
-        game_elt = mess_elt.firstChildElement()
-        game_data = self.games[room_jid]
-        is_player = self.is_player(room_jid, nick)
-        if "players_data" in game_data:
-            players_data = game_data["players_data"]
-
-        for elt in game_elt.elements():
-            if not is_player and (elt.name not in ("started", "players")):
-                continue  # user is in the room but not playing
-
-            if elt.name in (
-                "started",
-                "players",
-            ):  # new game created and/or players list updated
-                players = []
-                for player in elt.elements():
-                    players.append(str(player))
-                signal = (
-                    self.host.bridge.tarot_game_started
-                    if elt.name == "started"
-                    else self.host.bridge.tarot_game_players
-                )
-                signal(room_jid.userhost(), from_jid.full(), players, profile)
-
-            elif elt.name == "player_ready":  # ready to play
-                player = elt["player"]
-                status = self.games[room_jid]["status"]
-                nb_players = len(self.games[room_jid]["players"])
-                status[player] = "ready"
-                log.debug(
-                    _("Player %(player)s is ready to start [status: %(status)s]")
-                    % {"player": player, "status": status}
-                )
-                if (
-                    list(status.values()).count("ready") == nb_players
-                ):  # everybody is ready, we can start the game
-                    self.new_round(room_jid, profile)
-
-            elif elt.name == "hand":  # a new hand has been received
-                self.host.bridge.tarot_game_new(
-                    room_jid.userhost(), self.__xml_to_list(elt), profile
-                )
-
-            elif elt.name == "contrat":  # it's time to choose contrat
-                form = data_form.Form.fromElement(elt.firstChildElement())
-                session_id, session_data = self._sessions.new_session(profile=profile)
-                session_data["room_jid"] = room_jid
-                xml_data = xml_tools.data_form_2_xmlui(
-                    form, self.__choose_contrat_id, session_id
-                ).toXml()
-                self.host.bridge.tarot_game_choose_contrat(
-                    room_jid.userhost(), xml_data, profile
-                )
-
-            elif elt.name == "contrat_choosed":
-                # TODO: check we receive the contrat from the right person
-                # TODO: use proper XEP-0004 way for answering form
-                player = elt["player"]
-                players_data[player]["contrat"] = str(elt)
-                contrats = [players_data[p]["contrat"] for p in game_data["players"]]
-                if contrats.count(None):
-                    # not everybody has choosed his contrat, it's next one turn
-                    player = self.__next_player(game_data)
-                    to_jid = jid.JID(room_jid.userhost() + "/" + player)  # FIXME: gof:
-                    self.send(to_jid, self.__ask_contrat(), profile=profile)
-                else:
-                    best_contrat = [None, "Passe"]
-                    for player in game_data["players"]:
-                        contrat = players_data[player]["contrat"]
-                        idx_best = self.contrats.index(best_contrat[1])
-                        idx_pl = self.contrats.index(contrat)
-                        if idx_pl > idx_best:
-                            best_contrat[0] = player
-                            best_contrat[1] = contrat
-                    if best_contrat[1] == "Passe":
-                        log.debug(_("Everybody is passing, round ended"))
-                        to_jid = jid.JID(room_jid.userhost())
-                        self.send(
-                            to_jid,
-                            self.__give_scores(*self.__draw_game(game_data)),
-                            profile=profile,
-                        )
-                        game_data["init_player"] = (game_data["init_player"] + 1) % len(
-                            game_data["players"]
-                        )  # we change the dealer
-                        for player in game_data["players"]:
-                            game_data["status"][player] = "init"
-                        return
-                    log.debug(
-                        _("%(player)s win the bid with %(contrat)s")
-                        % {"player": best_contrat[0], "contrat": best_contrat[1]}
-                    )
-                    game_data["contrat"] = best_contrat[1]
-
-                    if (
-                        game_data["contrat"] == "Garde Sans"
-                        or game_data["contrat"] == "Garde Contre"
-                    ):
-                        self.__start_play(room_jid, game_data, profile)
-                        game_data["attaquant"] = best_contrat[0]
-                    else:
-                        # Time to show the chien to everybody
-                        to_jid = jid.JID(room_jid.userhost())  # FIXME: gof:
-                        elem = self.__card_list_to_xml(game_data["chien"], "chien")
-                        self.send(
-                            to_jid, elem, {"attaquant": best_contrat[0]}, profile=profile
-                        )
-                        # the attacker (attaquant) get the chien
-                        game_data["hand"][best_contrat[0]].extend(game_data["chien"])
-                        del game_data["chien"][:]
-
-                    if game_data["contrat"] == "Garde Sans":
-                        # The chien go into attaquant's (attacker) levees
-                        players_data[best_contrat[0]]["levees"].extend(game_data["chien"])
-                        del game_data["chien"][:]
-
-            elif elt.name == "chien":  # we have received the chien
-                log.debug(_("tarot: chien received"))
-                data = {"attaquant": elt["attaquant"]}
-                game_data["stage"] = "ecart"
-                game_data["attaquant"] = elt["attaquant"]
-                self.host.bridge.tarot_game_show_cards(
-                    room_jid.userhost(), "chien", self.__xml_to_list(elt), data, profile
-                )
-
-            elif elt.name == "cards_played":
-                if game_data["stage"] == "ecart":
-                    # TODO: show atouts (trumps) if player put some in écart
-                    assert (
-                        game_data["attaquant"] == elt["player"]
-                    )  # TODO: throw an xml error here
-                    list_cards = TarotCard.from_tuples(self.__xml_to_list(elt))
-                    # we now check validity of card
-                    invalid_cards = self.__invalid_cards(game_data, list_cards)
-                    if invalid_cards:
-                        elem = self.__invalid_cards_elt(
-                            list_cards, invalid_cards, game_data["stage"]
-                        )
-                        self.send(
-                            jid.JID(room_jid.userhost() + "/" + elt["player"]),
-                            elem,
-                            profile=profile,
-                        )
-                        return
-
-                    # FIXME: gof: manage Garde Sans & Garde Contre cases
-                    players_data[elt["player"]]["levees"].extend(
-                        list_cards
-                    )  # we add the chien to attaquant's levées
-                    for card in list_cards:
-                        game_data["hand"][elt["player"]].remove(card)
-
-                    self.__start_play(room_jid, game_data, profile)
-
-                elif game_data["stage"] == "play":
-                    current_player = game_data["players"][game_data["current_player"]]
-                    cards = TarotCard.from_tuples(self.__xml_to_list(elt))
-
-                    if mess_elt["type"] == "groupchat":
-                        self.host.bridge.tarot_game_cards_played(
-                            room_jid.userhost(),
-                            elt["player"],
-                            self.__xml_to_list(elt),
-                            profile,
-                        )
-                    else:
-                        # we first check validity of card
-                        invalid_cards = self.__invalid_cards(game_data, cards)
-                        if invalid_cards:
-                            elem = self.__invalid_cards_elt(
-                                cards, invalid_cards, game_data["stage"]
-                            )
-                            self.send(
-                                jid.JID(room_jid.userhost() + "/" + current_player),
-                                elem,
-                                profile=profile,
-                            )
-                            return
-                        # the card played is ok, we forward it to everybody
-                        # first we remove it from the hand and put in on the table
-                        game_data["hand"][current_player].remove(cards[0])
-                        players_data[current_player]["played"] = cards[0]
-
-                        # then we forward the message
-                        self.send(room_jid, elt, profile=profile)
-
-                        # Did everybody played ?
-                        played = [
-                            players_data[player]["played"]
-                            for player in game_data["players"]
-                        ]
-                        if all(played):
-                            # everybody has played
-                            winner = self.__winner(game_data)
-                            log.debug(_("The winner of this trick is %s") % winner)
-                            # the winner win the trick
-                            self.__excuse_hack(game_data, played, winner)
-                            players_data[elt["player"]]["levees"].extend(played)
-                            # nothing left on the table
-                            for player in game_data["players"]:
-                                players_data[player]["played"] = None
-                            if len(game_data["hand"][current_player]) == 0:
-                                # no card left: the game is finished
-                                elem = self.__give_scores(
-                                    *self.__calculate_scores(game_data)
-                                )
-                                self.send(room_jid, elem, profile=profile)
-                                game_data["init_player"] = (
-                                    game_data["init_player"] + 1
-                                ) % len(
-                                    game_data["players"]
-                                )  # we change the dealer
-                                for player in game_data["players"]:
-                                    game_data["status"][player] = "init"
-                                return
-                            # next player is the winner
-                            next_player = game_data["first_player"] = self.__next_player(
-                                game_data, winner
-                            )
-                        else:
-                            next_player = self.__next_player(game_data)
-
-                        # finally, we tell to the next player to play
-                        to_jid = jid.JID(room_jid.userhost() + "/" + next_player)
-                        self.send(to_jid, "your_turn", profile=profile)
-
-            elif elt.name == "your_turn":
-                self.host.bridge.tarot_game_your_turn(room_jid.userhost(), profile)
-
-            elif elt.name == "score":
-                form_elt = next(elt.elements(name="x", uri="jabber:x:data"))
-                winners = []
-                loosers = []
-                for winner in elt.elements(name="winner", uri=NS_CG):
-                    winners.append(str(winner))
-                for looser in elt.elements(name="looser", uri=NS_CG):
-                    loosers.append(str(looser))
-                form = data_form.Form.fromElement(form_elt)
-                session_id, session_data = self._sessions.new_session(profile=profile)
-                session_data["room_jid"] = room_jid
-                xml_data = xml_tools.data_form_2_xmlui(
-                    form, self.__score_id, session_id
-                ).toXml()
-                self.host.bridge.tarot_game_score(
-                    room_jid.userhost(), xml_data, winners, loosers, profile
-                )
-            elif elt.name == "error":
-                if elt["type"] == "invalid_cards":
-                    played_cards = self.__xml_to_list(
-                        next(elt.elements(name="played", uri=NS_CG))
-                    )
-                    invalid_cards = self.__xml_to_list(
-                        next(elt.elements(name="invalid", uri=NS_CG))
-                    )
-                    self.host.bridge.tarot_game_invalid_cards(
-                        room_jid.userhost(),
-                        elt["phase"],
-                        played_cards,
-                        invalid_cards,
-                        profile,
-                    )
-                else:
-                    log.error(_("Unmanaged error type: %s") % elt["type"])
-            else:
-                log.error(_("Unmanaged card game element: %s") % elt.name)
-
-    def get_sync_data_for_player(self, room_jid, nick):
-        return []
--- a/sat/plugins/plugin_misc_text_commands.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,471 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT plugin for managing text commands
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from twisted.words.protocols.jabber import jid
-from twisted.internet import defer
-from twisted.python import failure
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-from sat.tools import utils
-
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Text commands",
-    C.PI_IMPORT_NAME: C.TEXT_CMDS,
-    C.PI_TYPE: "Misc",
-    C.PI_PROTOCOLS: ["XEP-0245"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "TextCommands",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""IRC like text commands"""),
-}
-
-
-class InvalidCommandSyntax(Exception):
-    """Throwed while parsing @command in docstring if syntax is invalid"""
-
-    pass
-
-
-CMD_KEY = "@command"
-CMD_TYPES = ("group", "one2one", "all")
-FEEDBACK_INFO_TYPE = "TEXT_CMD"
-
-
-class TextCommands(object):
-    # FIXME: doc strings for commands have to be translatable
-    #       plugins need a dynamic translation system (translation
-    #       should be downloadable independently)
-
-    HELP_SUGGESTION = _(
-        "Type '/help' to get a list of the available commands. If you didn't want to "
-        "use a command, please start your message with '//' to escape the slash."
-    )
-
-    def __init__(self, host):
-        log.info(_("Text commands initialization"))
-        self.host = host
-        # this is internal command, so we set high priority
-        host.trigger.add("sendMessage", self.send_message_trigger, priority=1000000)
-        self._commands = {}
-        self._whois = []
-        self.register_text_commands(self)
-
-    def _parse_doc_string(self, cmd, cmd_name):
-        """Parse a docstring to get text command data
-
-        @param cmd: function or method callback for the command,
-            its docstring will be used for self documentation in the following way:
-            - first line is the command short documentation, shown with /help
-            - @command keyword can be used,
-              see http://wiki.goffi.org/wiki/Coding_style/en for documentation
-        @return (dict): dictionary with parsed data where key can be:
-            - "doc_short_help" (default: ""): the untranslated short documentation
-            - "type" (default "all"): the command type as specified in documentation
-            - "args" (default: ""): the arguments available, using syntax specified in
-                documentation.
-            - "doc_arg_[name]": the doc of [name] argument
-        """
-        data = {
-            "doc_short_help": "",
-            "type": "all",
-            "args": "",
-        }
-        docstring = cmd.__doc__
-        if docstring is None:
-            log.warning("No docstring found for command {}".format(cmd_name))
-            docstring = ""
-
-        doc_data = docstring.split("\n")
-        data["doc_short_help"] = doc_data[0]
-
-        try:
-            cmd_indent = 0  # >0 when @command is found are we are parsing it
-
-            for line in doc_data:
-                stripped = line.strip()
-                if cmd_indent and line[cmd_indent : cmd_indent + 5] == "    -":
-                    colon_idx = line.find(":")
-                    if colon_idx == -1:
-                        raise InvalidCommandSyntax(
-                            "No colon found in argument description"
-                        )
-                    arg_name = line[cmd_indent + 6 : colon_idx].strip()
-                    if not arg_name:
-                        raise InvalidCommandSyntax(
-                            "No name found in argument description"
-                        )
-                    arg_help = line[colon_idx + 1 :].strip()
-                    data["doc_arg_{}".format(arg_name)] = arg_help
-                elif cmd_indent:
-                    # we are parsing command and indent level is not good, it's finished
-                    break
-                elif stripped.startswith(CMD_KEY):
-                    cmd_indent = line.find(CMD_KEY)
-
-                    # type
-                    colon_idx = stripped.find(":")
-                    if colon_idx == -1:
-                        raise InvalidCommandSyntax("missing colon")
-                    type_data = stripped[len(CMD_KEY) : colon_idx].strip()
-                    if len(type_data) == 0:
-                        type_data = "(all)"
-                    elif (
-                        len(type_data) <= 2 or type_data[0] != "(" or type_data[-1] != ")"
-                    ):
-                        raise InvalidCommandSyntax("Bad type data syntax")
-                    type_ = type_data[1:-1]
-                    if type_ not in CMD_TYPES:
-                        raise InvalidCommandSyntax("Unknown type {}".format(type_))
-                    data["type"] = type_
-
-                    # args
-                    data["args"] = stripped[colon_idx + 1 :].strip()
-        except InvalidCommandSyntax as e:
-            log.warning(
-                "Invalid command syntax for command {command}: {message}".format(
-                    command=cmd_name, message=e.message
-                )
-            )
-
-        return data
-
-    def register_text_commands(self, instance):
-        """ Add a text command
-
-        @param instance: instance of a class containing text commands
-        """
-        for attr in dir(instance):
-            if attr.startswith("cmd_"):
-                cmd = getattr(instance, attr)
-                if not callable(cmd):
-                    log.warning(_("Skipping not callable [%s] attribute") % attr)
-                    continue
-                cmd_name = attr[4:]
-                if not cmd_name:
-                    log.warning(_("Skipping cmd_ method"))
-                if cmd_name in self._commands:
-                    suff = 2
-                    while (cmd_name + str(suff)) in self._commands:
-                        suff += 1
-                    new_name = cmd_name + str(suff)
-                    log.warning(
-                        _(
-                            "Conflict for command [{old_name}], renaming it to [{new_name}]"
-                        ).format(old_name=cmd_name, new_name=new_name)
-                    )
-                    cmd_name = new_name
-                self._commands[cmd_name] = cmd_data = {"callback": cmd}
-                cmd_data.update(self._parse_doc_string(cmd, cmd_name))
-                log.info(_("Registered text command [%s]") % cmd_name)
-
-    def add_who_is_cb(self, callback, priority=0):
-        """Add a callback which give information to the /whois command
-
-        @param callback: a callback which will be called with the following arguments
-            - whois_msg: list of information strings to display, callback need to append
-                         its own strings to it
-            - target_jid: full jid from whom we want information
-            - profile: %(doc_profile)s
-        @param priority: priority of the information to show (the highest priority will
-            be displayed first)
-        """
-        self._whois.append((priority, callback))
-        self._whois.sort(key=lambda item: item[0], reverse=True)
-
-    def send_message_trigger(
-        self, client, mess_data, pre_xml_treatments, post_xml_treatments
-    ):
-        """Install SendMessage command hook """
-        pre_xml_treatments.addCallback(self._send_message_cmd_hook, client)
-        return True
-
-    def _send_message_cmd_hook(self, mess_data, client):
-        """ Check text commands in message, and react consequently
-
-        msg starting with / are potential command. If a command is found, it is executed,
-        else an help message is sent.
-        msg starting with // are escaped: they are sent with a single /
-        commands can abord message sending (if they return anything evaluating to False),
-        or continue it (if they return True), eventually after modifying the message
-        an "unparsed" key is added to message, containing part of the message not yet
-        parsed.
-        Commands can be deferred or not
-        @param mess_data(dict): data comming from sendMessage trigger
-        @param profile: %(doc_profile)s
-        """
-        try:
-            msg = mess_data["message"][""]
-            msg_lang = ""
-        except KeyError:
-            try:
-                # we have not default message, we try to take the first found
-                msg_lang, msg = next(iter(mess_data["message"].items()))
-            except StopIteration:
-                log.debug("No message found, skipping text commands")
-                return mess_data
-
-        try:
-            if msg[:2] == "//":
-                # we have a double '/', it's the escape sequence
-                mess_data["message"][msg_lang] = msg[1:]
-                return mess_data
-            if msg[0] != "/":
-                return mess_data
-        except IndexError:
-            return mess_data
-
-        # we have a command
-        d = None
-        command = msg[1:].partition(" ")[0].lower().strip()
-        if not command.isidentifier():
-            self.feed_back(
-                client,
-                _("Invalid command /%s. ") % command + self.HELP_SUGGESTION,
-                mess_data,
-            )
-            raise failure.Failure(exceptions.CancelError())
-
-        # looks like an actual command, we try to call the corresponding method
-        def ret_handling(ret):
-            """ Handle command return value:
-            if ret is True, normally send message (possibly modified by command)
-            else, abord message sending
-            """
-            if ret:
-                return mess_data
-            else:
-                log.debug("text command detected ({})".format(command))
-                raise failure.Failure(exceptions.CancelError())
-
-        def generic_errback(failure):
-            try:
-                msg = "with condition {}".format(failure.value.condition)
-            except AttributeError:
-                msg = "with error {}".format(failure.value)
-            self.feed_back(client, "Command failed {}".format(msg), mess_data)
-            return False
-
-        mess_data["unparsed"] = msg[
-            1 + len(command) :
-        ]  # part not yet parsed of the message
-        try:
-            cmd_data = self._commands[command]
-        except KeyError:
-            self.feed_back(
-                client,
-                _("Unknown command /%s. ") % command + self.HELP_SUGGESTION,
-                mess_data,
-            )
-            log.debug("text command help message")
-            raise failure.Failure(exceptions.CancelError())
-        else:
-            if not self._context_valid(mess_data, cmd_data):
-                # The command is not launched in the right context, we throw a message with help instructions
-                context_txt = (
-                    _("group discussions")
-                    if cmd_data["type"] == "group"
-                    else _("one to one discussions")
-                )
-                feedback = _("/{command} command only applies in {context}.").format(
-                    command=command, context=context_txt
-                )
-                self.feed_back(
-                    client, "{} {}".format(feedback, self.HELP_SUGGESTION), mess_data
-                )
-                log.debug("text command invalid message")
-                raise failure.Failure(exceptions.CancelError())
-            else:
-                d = utils.as_deferred(cmd_data["callback"], client, mess_data)
-                d.addErrback(generic_errback)
-                d.addCallback(ret_handling)
-
-        return d
-
-    def _context_valid(self, mess_data, cmd_data):
-        """Tell if a command can be used in the given context
-
-        @param mess_data(dict): message data as given in sendMessage trigger
-        @param cmd_data(dict): command data as returned by self._parse_doc_string
-        @return (bool): True if command can be used in this context
-        """
-        if (cmd_data["type"] == "group" and mess_data["type"] != "groupchat") or (
-            cmd_data["type"] == "one2one" and mess_data["type"] == "groupchat"
-        ):
-            return False
-        return True
-
-    def get_room_jid(self, arg, service_jid):
-        """Return a room jid with a shortcut
-
-        @param arg: argument: can be a full room jid (e.g.: sat@chat.jabberfr.org)
-                    or a shortcut (e.g.: sat or sat@ for sat on current service)
-        @param service_jid: jid of the current service (e.g.: chat.jabberfr.org)
-        """
-        nb_arobas = arg.count("@")
-        if nb_arobas == 1:
-            if arg[-1] != "@":
-                return jid.JID(arg)
-            return jid.JID(arg + service_jid)
-        return jid.JID(f"{arg}@{service_jid}")
-
-    def feed_back(self, client, message, mess_data, info_type=FEEDBACK_INFO_TYPE):
-        """Give a message back to the user"""
-        if mess_data["type"] == "groupchat":
-            to_ = mess_data["to"].userhostJID()
-        else:
-            to_ = client.jid
-
-        # we need to invert send message back, so sender need to original destinee
-        mess_data["from"] = mess_data["to"]
-        mess_data["to"] = to_
-        mess_data["type"] = C.MESS_TYPE_INFO
-        mess_data["message"] = {"": message}
-        mess_data["extra"]["info_type"] = info_type
-        client.message_send_to_bridge(mess_data)
-
-    def cmd_whois(self, client, mess_data):
-        """show informations on entity
-
-        @command: [JID|ROOM_NICK]
-            - JID: entity to request
-            - ROOM_NICK: nick of the room to request
-        """
-        log.debug("Catched whois command")
-
-        entity = mess_data["unparsed"].strip()
-
-        if mess_data["type"] == "groupchat":
-            room = mess_data["to"].userhostJID()
-            try:
-                if self.host.plugins["XEP-0045"].is_nick_in_room(client, room, entity):
-                    entity = "%s/%s" % (room, entity)
-            except KeyError:
-                log.warning("plugin XEP-0045 is not present")
-
-        if not entity:
-            target_jid = mess_data["to"]
-        else:
-            try:
-                target_jid = jid.JID(entity)
-                if not target_jid.user or not target_jid.host:
-                    raise jid.InvalidFormat
-            except (RuntimeError, jid.InvalidFormat, AttributeError):
-                self.feed_back(client, _("Invalid jid, can't whois"), mess_data)
-                return False
-
-        if not target_jid.resource:
-            target_jid.resource = self.host.memory.main_resource_get(client, target_jid)
-
-        whois_msg = [_("whois for %(jid)s") % {"jid": target_jid}]
-
-        d = defer.succeed(None)
-        for __, callback in self._whois:
-            d.addCallback(
-                lambda __: callback(client, whois_msg, mess_data, target_jid)
-            )
-
-        def feed_back(__):
-            self.feed_back(client, "\n".join(whois_msg), mess_data)
-            return False
-
-        d.addCallback(feed_back)
-        return d
-
-    def _get_args_help(self, cmd_data):
-        """Return help string for args of cmd_name, according to docstring data
-
-        @param cmd_data: command data
-        @return (list[unicode]): help strings
-        """
-        strings = []
-        for doc_name, doc_help in cmd_data.items():
-            if doc_name.startswith("doc_arg_"):
-                arg_name = doc_name[8:]
-                strings.append(
-                    "- {name}: {doc_help}".format(name=arg_name, doc_help=_(doc_help))
-                )
-
-        return strings
-
-    def cmd_me(self, client, mess_data):
-        """display a message at third person
-
-        @command (all): message
-            - message: message to show at third person
-                e.g.: "/me clenches his fist" will give "[YOUR_NICK] clenches his fist"
-        """
-        # We just ignore the command as the match is done on receiption by clients
-        return True
-
-    def cmd_whoami(self, client, mess_data):
-        """give your own jid"""
-        self.feed_back(client, client.jid.full(), mess_data)
-
-    def cmd_help(self, client, mess_data):
-        """show help on available commands
-
-        @command: [cmd_name]
-            - cmd_name: name of the command for detailed help
-        """
-        cmd_name = mess_data["unparsed"].strip()
-        if cmd_name and cmd_name[0] == "/":
-            cmd_name = cmd_name[1:]
-        if cmd_name and cmd_name not in self._commands:
-            self.feed_back(
-                client, _("Invalid command name [{}]\n".format(cmd_name)), mess_data
-            )
-            cmd_name = ""
-        if not cmd_name:
-            # we show the global help
-            longuest = max([len(command) for command in self._commands])
-            help_cmds = []
-
-            for command in sorted(self._commands):
-                cmd_data = self._commands[command]
-                if not self._context_valid(mess_data, cmd_data):
-                    continue
-                spaces = (longuest - len(command)) * " "
-                help_cmds.append(
-                    "    /{command}: {spaces} {short_help}".format(
-                        command=command,
-                        spaces=spaces,
-                        short_help=cmd_data["doc_short_help"],
-                    )
-                )
-
-            help_mess = _("Text commands available:\n%s") % ("\n".join(help_cmds),)
-        else:
-            # we show detailled help for a command
-            cmd_data = self._commands[cmd_name]
-            syntax = cmd_data["args"]
-            help_mess = _("/{name}: {short_help}\n{syntax}{args_help}").format(
-                name=cmd_name,
-                short_help=cmd_data["doc_short_help"],
-                syntax=_(" " * 4 + "syntax: {}\n").format(syntax) if syntax else "",
-                args_help="\n".join(
-                    [" " * 8 + "{}".format(line) for line in self._get_args_help(cmd_data)]
-                ),
-            )
-
-        self.feed_back(client, help_mess, mess_data)
--- a/sat/plugins/plugin_misc_text_syntaxes.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,479 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing various text syntaxes
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from functools import partial
-from html import escape
-import re
-from typing import Set
-
-from twisted.internet import defer
-from twisted.internet.threads import deferToThread
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.i18n import D_, _
-from sat.core.log import getLogger
-from sat.tools import xml_tools
-
-try:
-    from lxml import html
-    from lxml.html import clean
-    from lxml import etree
-except ImportError:
-    raise exceptions.MissingModule(
-        "Missing module lxml, please download/install it from http://lxml.de/"
-    )
-
-log = getLogger(__name__)
-
-CATEGORY = D_("Composition")
-NAME = "Syntax"
-_SYNTAX_XHTML = "xhtml"  # must be lower case
-_SYNTAX_CURRENT = "@CURRENT@"
-
-# TODO: check/adapt following list
-# list initialy based on feedparser list (http://pythonhosted.org/feedparser/html-sanitization.html)
-STYLES_WHITELIST = (
-    "azimuth",
-    "background-color",
-    "border-bottom-color",
-    "border-collapse",
-    "border-color",
-    "border-left-color",
-    "border-right-color",
-    "border-top-color",
-    "clear",
-    "color",
-    "cursor",
-    "direction",
-    "display",
-    "elevation",
-    "float",
-    "font",
-    "font-family",
-    "font-size",
-    "font-style",
-    "font-variant",
-    "font-weight",
-    "height",
-    "letter-spacing",
-    "line-height",
-    "overflow",
-    "pause",
-    "pause-after",
-    "pause-before",
-    "pitch",
-    "pitch-range",
-    "richness",
-    "speak",
-    "speak-header",
-    "speak-numeral",
-    "speak-punctuation",
-    "speech-rate",
-    "stress",
-    "text-align",
-    "text-decoration",
-    "text-indent",
-    "unicode-bidi",
-    "vertical-align",
-    "voice-family",
-    "volume",
-    "white-space",
-    "width",
-)
-
-# cf. https://www.w3.org/TR/html/syntax.html#void-elements
-VOID_ELEMENTS = (
-    "area",
-    "base",
-    "br",
-    "col",
-    "embed",
-    "hr",
-    "img",
-    "input",
-    "keygen",
-    "link",
-    "menuitem",
-    "meta",
-    "param",
-    "source",
-    "track",
-    "wbr")
-
-SAFE_ATTRS = html.defs.safe_attrs.union({"style", "poster", "controls"}) - {"id"}
-SAFE_CLASSES = {
-    # those classes are used for code highlighting
-    "bp", "c", "ch", "cm", "cp", "cpf", "cs", "dl", "err", "fm", "gd", "ge", "get", "gh",
-    "gi", "go", "gp", "gr", "gs", "gt", "gu", "highlight", "hll", "il", "k", "kc", "kd",
-    "kn", "kp", "kr", "kt", "m", "mb", "mf", "mh", "mi", "mo", "na", "nb", "nc", "nd",
-    "ne", "nf", "ni", "nl", "nn", "no", "nt", "nv", "o", "ow", "s", "sa", "sb", "sc",
-    "sd", "se", "sh", "si", "sr", "ss", "sx", "vc", "vg", "vi", "vm", "w", "write",
-}
-STYLES_VALUES_REGEX = (
-    r"^("
-    + "|".join(
-        [
-            "([a-z-]+)",  # alphabetical names
-            "(#[0-9a-f]+)",  # hex value
-            "(\d+(.\d+)? *(|%|em|ex|px|in|cm|mm|pt|pc))",  # values with units (or not)
-            "rgb\( *((\d+(.\d+)?), *){2}(\d+(.\d+)?) *\)",  # rgb function
-            "rgba\( *((\d+(.\d+)?), *){3}(\d+(.\d+)?) *\)",  # rgba function
-        ]
-    )
-    + ") *(!important)?$"
-)  # we accept "!important" at the end
-STYLES_ACCEPTED_VALUE = re.compile(STYLES_VALUES_REGEX)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Text syntaxes",
-    C.PI_IMPORT_NAME: "TEXT_SYNTAXES",
-    C.PI_TYPE: "MISC",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "TextSyntaxes",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _(
-        """Management of various text syntaxes (XHTML-IM, Markdown, etc)"""
-    ),
-}
-
-
-class TextSyntaxes(object):
-    """ Text conversion class
-    XHTML utf-8 is used as intermediate language for conversions
-    """
-
-    OPT_DEFAULT = "DEFAULT"
-    OPT_HIDDEN = "HIDDEN"
-    OPT_NO_THREAD = "NO_THREAD"
-    SYNTAX_XHTML = _SYNTAX_XHTML
-    SYNTAX_MARKDOWN = "markdown"
-    SYNTAX_TEXT = "text"
-    # default_syntax must be lower case
-    default_syntax = SYNTAX_XHTML
-
-
-    def __init__(self, host):
-        log.info(_("Text syntaxes plugin initialization"))
-        self.host = host
-        self.syntaxes = {}
-
-        self.params = """
-            <params>
-            <individual>
-            <category name="%(category_name)s" label="%(category_label)s">
-                <param name="%(name)s" label="%(label)s" type="list" security="0">
-                    %(options)s
-                </param>
-            </category>
-            </individual>
-            </params>
-        """
-
-        self.params_data = {
-            "category_name": CATEGORY,
-            "category_label": _(CATEGORY),
-            "name": NAME,
-            "label": _(NAME),
-            "syntaxes": self.syntaxes,
-        }
-
-        self.add_syntax(
-            self.SYNTAX_XHTML,
-            lambda xhtml: defer.succeed(xhtml),
-            lambda xhtml: defer.succeed(xhtml),
-            TextSyntaxes.OPT_NO_THREAD,
-        )
-        # TODO: text => XHTML should add <a/> to url like in frontends
-        #       it's probably best to move sat_frontends.tools.strings to sat.tools.common or similar
-        self.add_syntax(
-            self.SYNTAX_TEXT,
-            lambda text: escape(text),
-            lambda xhtml: self._remove_markups(xhtml),
-            [TextSyntaxes.OPT_HIDDEN],
-        )
-        try:
-            import markdown, html2text
-            from markdown.extensions import Extension
-
-            # XXX: we disable raw HTML parsing by default, to avoid parsing error
-            #      when the user is not aware of markdown and HTML
-            class EscapeHTML(Extension):
-                def extendMarkdown(self, md):
-                    md.preprocessors.deregister('html_block')
-                    md.inlinePatterns.deregister('html')
-
-            def _html2text(html, baseurl=""):
-                h = html2text.HTML2Text(baseurl=baseurl)
-                h.body_width = 0  # do not truncate the lines, it breaks the long URLs
-                return h.handle(html)
-
-            self.add_syntax(
-                self.SYNTAX_MARKDOWN,
-                partial(markdown.markdown,
-                        extensions=[
-                            EscapeHTML(),
-                            'nl2br',
-                            'codehilite',
-                            'fenced_code',
-                            'sane_lists',
-                            'tables',
-                            ],
-                        extension_configs = {
-                            "codehilite": {
-                                "css_class": "highlight",
-                            }
-                        }),
-                _html2text,
-                [TextSyntaxes.OPT_DEFAULT],
-            )
-        except ImportError:
-            log.warning("markdown or html2text not found, can't use Markdown syntax")
-            log.info(
-                "You can download/install them from https://pythonhosted.org/Markdown/ "
-                "and https://github.com/Alir3z4/html2text/"
-            )
-        host.bridge.add_method(
-            "syntax_convert",
-            ".plugin",
-            in_sign="sssbs",
-            out_sign="s",
-            async_=True,
-            method=self.convert,
-        )
-        host.bridge.add_method(
-            "syntax_get", ".plugin", in_sign="s", out_sign="s", method=self.get_syntax
-        )
-        if xml_tools.clean_xhtml is None:
-            log.debug("Installing cleaning method")
-            xml_tools.clean_xhtml = self.clean_xhtml
-
-    def _update_param_options(self):
-        data_synt = self.syntaxes
-        default_synt = TextSyntaxes.default_syntax
-        syntaxes = []
-
-        for syntax in list(data_synt.keys()):
-            flags = data_synt[syntax]["flags"]
-            if TextSyntaxes.OPT_HIDDEN not in flags:
-                syntaxes.append(syntax)
-
-        syntaxes.sort(key=lambda synt: synt.lower())
-        options = []
-
-        for syntax in syntaxes:
-            selected = 'selected="true"' if syntax == default_synt else ""
-            options.append('<option value="%s" %s/>' % (syntax, selected))
-
-        self.params_data["options"] = "\n".join(options)
-        self.host.memory.update_params(self.params % self.params_data)
-
-    def get_current_syntax(self, profile):
-        """ Return the selected syntax for the given profile
-
-        @param profile: %(doc_profile)s
-        @return: profile selected syntax
-        """
-        return self.host.memory.param_get_a(NAME, CATEGORY, profile_key=profile)
-
-    def _log_error(self, failure, action="converting syntax"):
-        log.error(
-            "Error while {action}: {failure}".format(action=action, failure=failure)
-        )
-        return failure
-
-    def clean_style(self, styles_raw: str) -> str:
-        """"Clean unsafe CSS styles
-
-        Remove styles not in the whitelist, or where the value doesn't match the regex
-        @param styles_raw: CSS styles
-        @return: cleaned styles
-        """
-        styles: List[str] = styles_raw.split(";")
-        cleaned_styles = []
-        for style in styles:
-            try:
-                key, value = style.split(":")
-            except ValueError:
-                continue
-            key = key.lower().strip()
-            if key not in STYLES_WHITELIST:
-                continue
-            value = value.lower().strip()
-            if not STYLES_ACCEPTED_VALUE.match(value):
-                continue
-            if value == "none":
-                continue
-            cleaned_styles.append((key, value))
-        return "; ".join(
-            ["%s: %s" % (key_, value_) for key_, value_ in cleaned_styles]
-        )
-
-    def clean_classes(self, classes_raw: str) -> str:
-        """Remove any non whitelisted class
-
-        @param classes_raw: classes set on an element
-        @return: remaining classes (can be empty string)
-        """
-        return " ".join(SAFE_CLASSES.intersection(classes_raw.split()))
-
-    def clean_xhtml(self, xhtml):
-        """Clean XHTML text by removing potentially dangerous/malicious parts
-
-        @param xhtml(unicode, lxml.etree._Element): raw HTML/XHTML text to clean
-        @return (unicode): cleaned XHTML
-        """
-
-        if isinstance(xhtml, str):
-            try:
-                xhtml_elt = html.fromstring(xhtml)
-            except etree.ParserError as e:
-                if not xhtml.strip():
-                    return ""
-                log.error("Can't clean XHTML: {xhtml}".format(xhtml=xhtml))
-                raise e
-        elif isinstance(xhtml, html.HtmlElement):
-            xhtml_elt = xhtml
-        else:
-            log.error("Only strings and HtmlElements can be cleaned")
-            raise exceptions.DataError
-        cleaner = clean.Cleaner(
-            style=False, add_nofollow=False, safe_attrs=SAFE_ATTRS
-        )
-        xhtml_elt = cleaner.clean_html(xhtml_elt)
-        for elt in xhtml_elt.xpath("//*[@style]"):
-            elt.set("style", self.clean_style(elt.get("style")))
-        for elt in xhtml_elt.xpath("//*[@class]"):
-            elt.set("class", self.clean_classes(elt.get("class")))
-        # we remove self-closing elements for non-void elements
-        for element in xhtml_elt.iter(tag=etree.Element):
-            if not element.text:
-                if element.tag in VOID_ELEMENTS:
-                    element.text = None
-                else:
-                    element.text = ''
-        return html.tostring(xhtml_elt, encoding=str, method="xml")
-
-    def convert(self, text, syntax_from, syntax_to=_SYNTAX_XHTML, safe=True,
-                profile=None):
-        """Convert a text between two syntaxes
-
-        @param text: text to convert
-        @param syntax_from: source syntax (e.g. "markdown")
-        @param syntax_to: dest syntax (e.g.: "XHTML")
-        @param safe: clean resulting XHTML to avoid malicious code if True
-        @param profile: needed only when syntax_from or syntax_to is set to
-            _SYNTAX_CURRENT
-        @return(unicode): converted text
-        """
-        # FIXME: convert should be abled to handle domish.Element directly
-        #        when dealing with XHTML
-        # TODO: a way for parser to return parsing errors/warnings
-
-        if syntax_from == _SYNTAX_CURRENT:
-            syntax_from = self.get_current_syntax(profile)
-        else:
-            syntax_from = syntax_from.lower().strip()
-        if syntax_to == _SYNTAX_CURRENT:
-            syntax_to = self.get_current_syntax(profile)
-        else:
-            syntax_to = syntax_to.lower().strip()
-        syntaxes = self.syntaxes
-        if syntax_from not in syntaxes:
-            raise exceptions.NotFound(syntax_from)
-        if syntax_to not in syntaxes:
-            raise exceptions.NotFound(syntax_to)
-        d = None
-
-        if TextSyntaxes.OPT_NO_THREAD in syntaxes[syntax_from]["flags"]:
-            d = defer.maybeDeferred(syntaxes[syntax_from]["to"], text)
-        else:
-            d = deferToThread(syntaxes[syntax_from]["to"], text)
-
-        # TODO: keep only body element and change it to a div here ?
-
-        if safe:
-            d.addCallback(self.clean_xhtml)
-
-        if TextSyntaxes.OPT_NO_THREAD in syntaxes[syntax_to]["flags"]:
-            d.addCallback(syntaxes[syntax_to]["from"])
-        else:
-            d.addCallback(lambda xhtml: deferToThread(syntaxes[syntax_to]["from"], xhtml))
-
-        # converters can add new lines that disturb the microblog change detection
-        d.addCallback(lambda text: text.rstrip())
-        return d
-
-    def add_syntax(self, name, to_xhtml_cb, from_xhtml_cb, flags=None):
-        """Add a new syntax to the manager
-
-        @param name: unique name of the syntax
-        @param to_xhtml_cb: callback to convert from syntax to XHTML
-        @param from_xhtml_cb: callback to convert from XHTML to syntax
-        @param flags: set of optional flags, can be:
-            TextSyntaxes.OPT_DEFAULT: use as the default syntax (replace former one)
-            TextSyntaxes.OPT_HIDDEN: do not show in parameters
-            TextSyntaxes.OPT_NO_THREAD: do not defer to thread when converting (the callback may then return a deferred)
-        """
-        flags = flags if flags is not None else []
-        if TextSyntaxes.OPT_HIDDEN in flags and TextSyntaxes.OPT_DEFAULT in flags:
-            raise ValueError(
-                "{} and {} are mutually exclusive".format(
-                    TextSyntaxes.OPT_HIDDEN, TextSyntaxes.OPT_DEFAULT
-                )
-            )
-
-        syntaxes = self.syntaxes
-        key = name.lower().strip()
-        if key in syntaxes:
-            raise exceptions.ConflictError(
-                "This syntax key already exists: {}".format(key)
-            )
-        syntaxes[key] = {
-            "name": name,
-            "to": to_xhtml_cb,
-            "from": from_xhtml_cb,
-            "flags": flags,
-        }
-        if TextSyntaxes.OPT_DEFAULT in flags:
-            TextSyntaxes.default_syntax = key
-
-        self._update_param_options()
-
-    def get_syntax(self, name):
-        """get syntax key corresponding to a name
-
-        @raise exceptions.NotFound: syntax doesn't exist
-        """
-        key = name.lower().strip()
-        if key in self.syntaxes:
-            return key
-        raise exceptions.NotFound
-
-    def _remove_markups(self, xhtml):
-        """Remove XHTML markups from the given string.
-
-        @param xhtml: the XHTML string to be cleaned
-        @return: the cleaned string
-        """
-        cleaner = clean.Cleaner(kill_tags=["style"])
-        cleaned = cleaner.clean_html(html.fromstring(xhtml))
-        return html.tostring(cleaned, encoding=str, method="text")
--- a/sat/plugins/plugin_misc_upload.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,181 +0,0 @@
-#!/usr/bin/env python3
-
-# SAT plugin for uploading files
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import os
-import os.path
-from pathlib import Path
-from typing import Optional, Tuple, Union
-
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber import error as jabber_error
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import D_, _
-from sat.core.log import getLogger
-from sat.tools import xml_tools
-from sat.tools.common import data_format
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "File Upload",
-    C.PI_IMPORT_NAME: "UPLOAD",
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_MAIN: "UploadPlugin",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""File upload management"""),
-}
-
-
-UPLOADING = D_("Please select a file to upload")
-UPLOADING_TITLE = D_("File upload")
-
-
-class UploadPlugin(object):
-    # TODO: plugin unload
-
-    def __init__(self, host):
-        log.info(_("plugin Upload initialization"))
-        self.host = host
-        host.bridge.add_method(
-            "file_upload",
-            ".plugin",
-            in_sign="sssss",
-            out_sign="a{ss}",
-            method=self._file_upload,
-            async_=True,
-        )
-        self._upload_callbacks = []
-
-    def _file_upload(
-        self, filepath, filename, upload_jid_s="", options='', profile=C.PROF_KEY_NONE
-    ):
-        client = self.host.get_client(profile)
-        upload_jid = jid.JID(upload_jid_s) if upload_jid_s else None
-        options = data_format.deserialise(options)
-
-        return defer.ensureDeferred(self.file_upload(
-            client, filepath, filename or None, upload_jid, options
-        ))
-
-    async def file_upload(self, client, filepath, filename, upload_jid, options):
-        """Send a file using best available method
-
-        parameters are the same as for [upload]
-        @return (dict): action dictionary, with progress id in case of success, else xmlui
-            message
-        """
-        try:
-            progress_id, __ = await self.upload(
-                client, filepath, filename, upload_jid, options)
-        except Exception as e:
-            if (isinstance(e, jabber_error.StanzaError)
-                and e.condition == 'not-acceptable'):
-                reason = e.text
-            else:
-                reason = str(e)
-            msg = D_("Can't upload file: {reason}").format(reason=reason)
-            log.warning(msg)
-            return {
-                "xmlui": xml_tools.note(
-                    msg, D_("Can't upload file"), C.XMLUI_DATA_LVL_WARNING
-                ).toXml()
-            }
-        else:
-            return {"progress": progress_id}
-
-    async def upload(
-        self,
-        client: SatXMPPEntity,
-        filepath: Union[Path, str],
-        filename: Optional[str] = None,
-        upload_jid: Optional[jid.JID] = None,
-        extra: Optional[dict]=None
-    ) -> Tuple[str, defer.Deferred]:
-        """Send a file using best available method
-
-        @param filepath: absolute path to the file
-        @param filename: name to use for the upload
-            None to use basename of the path
-        @param upload_jid: upload capable entity jid,
-            or None to use autodetected, if possible
-        @param extra: extra data/options to use for the upload, may be:
-            - ignore_tls_errors(bool): True to ignore SSL/TLS certificate verification
-                used only if HTTPS transport is needed
-            - progress_id(str): id to use for progression
-                if not specified, one will be generated
-        @param profile: %(doc_profile)s
-        @return: progress_id and a Deferred which fire download URL when upload is
-            finished
-        """
-        if extra is None:
-            extra = {}
-        if not os.path.isfile(filepath):
-            raise exceptions.DataError("The given path doesn't link to a file")
-        for method_name, available_cb, upload_cb, priority in self._upload_callbacks:
-            if upload_jid is None:
-                try:
-                    upload_jid = await available_cb(client, upload_jid)
-                except exceptions.NotFound:
-                    continue  # no entity managing this extension found
-
-            log.info(
-                "{name} method will be used to upload the file".format(name=method_name)
-            )
-            progress_id, download_d = await upload_cb(
-                client, filepath, filename, upload_jid, extra
-            )
-            return progress_id, download_d
-
-        raise exceptions.NotFound("Can't find any method to upload a file")
-
-    def register(self, method_name, available_cb, upload_cb, priority=0):
-        """Register a fileUploading method
-
-        @param method_name(unicode): short name for the method, must be unique
-        @param available_cb(callable): method to call to check if this method is usable
-           the callback must take two arguments: upload_jid (can be None) and profile
-           the callback must return the first entity found (being upload_jid or one of its
-           components)
-           exceptions.NotFound must be raised if no entity has been found
-        @param upload_cb(callable): method to upload a file
-            must have the same signature as [file_upload]
-            must return a tuple with progress_id and a Deferred which fire download URL
-            when upload is finished
-        @param priority(int): pririoty of this method, the higher available will be used
-        """
-        assert method_name
-        for data in self._upload_callbacks:
-            if method_name == data[0]:
-                raise exceptions.ConflictError(
-                    "A method with this name is already registered"
-                )
-        self._upload_callbacks.append((method_name, available_cb, upload_cb, priority))
-        self._upload_callbacks.sort(key=lambda data: data[3], reverse=True)
-
-    def unregister(self, method_name):
-        for idx, data in enumerate(self._upload_callbacks):
-            if data[0] == method_name:
-                del [idx]
-                return
-        raise exceptions.NotFound("The name to unregister doesn't exist")
--- a/sat/plugins/plugin_misc_uri_finder.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,96 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin to find URIs
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from twisted.internet import defer
-import textwrap
-log = getLogger(__name__)
-import json
-import os.path
-import os
-import re
-
-PLUGIN_INFO = {
-    C.PI_NAME: _("URI finder"),
-    C.PI_IMPORT_NAME: "uri_finder",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "URIFinder",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: textwrap.dedent(_("""\
-    Plugin to find URIs in well know location.
-    This allows to retrieve settings to work with a project (e.g. pubsub node used for merge-requests).
-    """))
-}
-
-
-SEARCH_FILES = ('readme', 'contributing')
-
-
-class URIFinder(object):
-
-    def __init__(self, host):
-        log.info(_("URI finder plugin initialization"))
-        self.host = host
-        host.bridge.add_method("uri_find", ".plugin",
-                              in_sign='sas', out_sign='a{sa{ss}}',
-                              method=self.find,
-                              async_=True)
-
-    def find(self, path, keys):
-        """Look for URI in well known locations
-
-        @param path(unicode): path to start with
-        @param keys(list[unicode]): keys lookeds after
-            e.g.: "tickets", "merge-requests"
-        @return (dict[unicode, unicode]): map from key to found uri
-        """
-        keys_re = '|'.join(keys)
-        label_re = r'"(?P<label>[^"]+)"'
-        uri_re = re.compile(r'(?P<key>{keys_re})[ :]? +(?P<uri>xmpp:\S+)(?:.*use {label_re} label)?'.format(
-            keys_re=keys_re, label_re = label_re))
-        path = os.path.normpath(path)
-        if not os.path.isdir(path) or not os.path.isabs(path):
-            raise ValueError('path must be an absolute path to a directory')
-
-        found_uris = {}
-        while path != '/':
-            for filename in os.listdir(path):
-                name, __ = os.path.splitext(filename)
-                if name.lower() in SEARCH_FILES:
-                    file_path = os.path.join(path, filename)
-                    with open(file_path) as f:
-                        for m in uri_re.finditer(f.read()):
-                            key = m.group('key')
-                            uri = m.group('uri')
-                            label = m.group('label')
-                            if key in found_uris:
-                                log.warning(_("Ignoring already found uri for key \"{key}\"").format(key=key))
-                            else:
-                                uri_data = found_uris[key] = {'uri': uri}
-                                if label is not None:
-                                    uri_data['labels'] = json.dumps([label])
-            if found_uris:
-                break
-            path = os.path.dirname(path)
-
-        return defer.succeed(found_uris)
--- a/sat/plugins/plugin_misc_watched.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,91 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT plugin to be notified on some entities presence
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core import exceptions
-from sat.tools import xml_tools
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Watched",
-    C.PI_IMPORT_NAME: "WATCHED",
-    C.PI_TYPE: "Misc",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "Watched",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _(
-        """Watch for entities presence, and send notification accordingly"""
-    ),
-}
-
-
-CATEGORY = D_("Misc")
-NAME = "Watched"
-NOTIF = D_("Watched entity {entity} is connected")
-
-
-class Watched(object):
-    params = """
-    <params>
-    <individual>
-    <category name="{category_name}" label="{category_label}">
-        <param name="{name}" label="{label}" type="jids_list" security="0" />
-    </category>
-    </individual>
-    </params>
-    """.format(
-        category_name=CATEGORY, category_label=_(CATEGORY), name=NAME, label=_(NAME)
-    )
-
-    def __init__(self, host):
-        log.info(_("Watched initialisation"))
-        self.host = host
-        host.memory.update_params(self.params)
-        host.trigger.add("presence_received", self._presence_received_trigger)
-
-    def _presence_received_trigger(self, client, entity, show, priority, statuses):
-        if show == C.PRESENCE_UNAVAILABLE:
-            return True
-
-        # we check that the previous presence was unavailable (no notification else)
-        try:
-            old_show = self.host.memory.get_entity_datum(
-                client, entity, "presence").show
-        except (KeyError, exceptions.UnknownEntityError):
-            old_show = C.PRESENCE_UNAVAILABLE
-
-        if old_show == C.PRESENCE_UNAVAILABLE:
-            watched = self.host.memory.param_get_a(
-                NAME, CATEGORY, profile_key=client.profile)
-            if entity in watched or entity.userhostJID() in watched:
-                self.host.action_new(
-                    {
-                        "xmlui": xml_tools.note(
-                            _(NOTIF).format(entity=entity.full())
-                        ).toXml()
-                    },
-                    profile=client.profile,
-                )
-
-        return True
--- a/sat/plugins/plugin_misc_welcome.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,104 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for file tansfer
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.tools import xml_tools
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Welcome",
-    C.PI_IMPORT_NAME: "WELCOME",
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_MAIN: "Welcome",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _(
-        """Plugin which manage welcome message and things to to on first connection."""
-    ),
-}
-
-
-WELCOME_PARAM_CATEGORY = "General"
-WELCOME_PARAM_NAME = "welcome"
-WELCOME_PARAM_LABEL = D_("Display welcome message")
-WELCOME_MSG_TITLE = D_("Welcome to Libervia/Salut à Toi")
-# XXX: this message is mainly targetting libervia new users for now
-#      (i.e.: it may look weird on other frontends)
-WELCOME_MSG = D_(
-    """Welcome to a free (as in freedom) network!
-
-If you have any trouble, or you want to help us for the bug hunting, you can contact us in real time chat by using the “Help / Official chat room”  menu.
-
-To use Libervia, you'll need to add contacts, either people you know, or people you discover by using the “Contacts / Search directory” menu.
-
-We hope that you'll enjoy using this project.
-
-The Libervia/Salut à Toi Team
-"""
-)
-
-
-PARAMS = """
-    <params>
-    <individual>
-    <category name="{category}">
-        <param name="{name}" label="{label}" type="bool" />
-    </category>
-    </individual>
-    </params>
-    """.format(
-    category=WELCOME_PARAM_CATEGORY, name=WELCOME_PARAM_NAME, label=WELCOME_PARAM_LABEL
-)
-
-
-class Welcome(object):
-    def __init__(self, host):
-        log.info(_("plugin Welcome initialization"))
-        self.host = host
-        host.memory.update_params(PARAMS)
-
-    def profile_connected(self, client):
-        # XXX: if you wan to try first_start again, you'll have to remove manually
-        #      the welcome value from your profile params in sat.db
-        welcome = self.host.memory.params.param_get_a(
-            WELCOME_PARAM_NAME,
-            WELCOME_PARAM_CATEGORY,
-            use_default=False,
-            profile_key=client.profile,
-        )
-        if welcome is None:
-            first_start = True
-            welcome = True
-        else:
-            first_start = False
-
-        if welcome:
-            xmlui = xml_tools.note(WELCOME_MSG, WELCOME_MSG_TITLE)
-            self.host.action_new({"xmlui": xmlui.toXml()}, profile=client.profile)
-            self.host.memory.param_set(
-                WELCOME_PARAM_NAME,
-                C.BOOL_FALSE,
-                WELCOME_PARAM_CATEGORY,
-                profile_key=client.profile,
-            )
-
-        self.host.trigger.point("WELCOME", first_start, welcome, client.profile)
--- a/sat/plugins/plugin_misc_xmllog.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,82 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT plugin for managing raw XML log
-# Copyright (C) 2011  Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from twisted.words.xish import domish
-from functools import partial
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Raw XML log Plugin",
-    C.PI_IMPORT_NAME: "XmlLog",
-    C.PI_TYPE: "Misc",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "XmlLog",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Send raw XML logs to bridge"""),
-}
-
-
-class XmlLog(object):
-
-    params = """
-    <params>
-    <general>
-    <category name="Debug">
-        <param name="Xml log" label="%(label_xmllog)s" value="false" type="bool" />
-    </category>
-    </general>
-    </params>
-    """ % {
-        "label_xmllog": _("Activate XML log")
-    }
-
-    def __init__(self, host):
-        log.info(_("Plugin XML Log initialization"))
-        self.host = host
-        host.memory.update_params(self.params)
-        host.bridge.add_signal(
-            "xml_log", ".plugin", signature="sss"
-        )  # args: direction("IN" or "OUT"), xml_data, profile
-
-        host.trigger.add("stream_hooks", self.add_hooks)
-
-    def add_hooks(self, client, receive_hooks, send_hooks):
-        self.do_log = self.host.memory.param_get_a("Xml log", "Debug")
-        if self.do_log:
-            receive_hooks.append(partial(self.on_receive, client=client))
-            send_hooks.append(partial(self.on_send, client=client))
-            log.info(_("XML log activated"))
-        return True
-
-    def on_receive(self, element, client):
-        self.host.bridge.xml_log("IN", element.toXml(), client.profile)
-
-    def on_send(self, obj, client):
-        if isinstance(obj, str):
-            xml_log = obj
-        elif isinstance(obj, domish.Element):
-            xml_log = obj.toXml()
-        else:
-            log.error(_("INTERNAL ERROR: Unmanaged XML type"))
-        self.host.bridge.xml_log("OUT", xml_log, client.profile)
--- a/sat/plugins/plugin_pubsub_cache.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,861 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for PubSub Caching
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import time
-from datetime import datetime
-from typing import Optional, List, Tuple, Dict, Any
-from twisted.words.protocols.jabber import jid, error
-from twisted.words.xish import domish
-from twisted.internet import defer
-from wokkel import pubsub, rsm
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-from sat.core.core_types import SatXMPPEntity
-from sat.tools import xml_tools, utils
-from sat.tools.common import data_format
-from sat.memory.sqla import PubsubNode, PubsubItem, SyncState, IntegrityError
-
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "PubSub Cache",
-    C.PI_IMPORT_NAME: "PUBSUB_CACHE",
-    C.PI_TYPE: C.PLUG_TYPE_PUBSUB,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0059", "XEP-0060"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "PubsubCache",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Local Cache for PubSub"""),
-}
-
-ANALYSER_KEYS_TO_COPY = ("name", "type", "to_sync", "parser")
-# maximum of items to cache
-CACHE_LIMIT = 5000
-# number of second before a progress caching is considered failed and tried again
-PROGRESS_DEADLINE = 60 * 60 * 6
-
-
-
-class PubsubCache:
-    # TODO: there is currently no notification for (un)subscribe events with XEP-0060,
-    #   but it would be necessary to have this data if some devices unsubscribe a cached
-    #   node, as we can then get out of sync. A protoXEP could be proposed to fix this
-    #   situation.
-    # TODO: handle configuration events
-
-    def __init__(self, host):
-        log.info(_("PubSub Cache initialization"))
-        strategy = host.memory.config_get(None, "pubsub_cache_strategy")
-        if strategy == "no_cache":
-            log.info(
-                _(
-                    "Pubsub cache won't be used due to pubsub_cache_strategy={value} "
-                    "setting."
-                ).format(value=repr(strategy))
-            )
-            self.use_cache = False
-        else:
-            self.use_cache = True
-        self.host = host
-        self._p = host.plugins["XEP-0060"]
-        self.analysers = {}
-        # map for caching in progress (node, service) => Deferred
-        self.in_progress = {}
-        self.host.trigger.add("XEP-0060_getItems", self._get_items_trigger)
-        self._p.add_managed_node(
-            "",
-            items_cb=self.on_items_event,
-            delete_cb=self.on_delete_event,
-            purge_db=self.on_purge_event,
-        )
-        host.bridge.add_method(
-            "ps_cache_get",
-            ".plugin",
-            in_sign="ssiassss",
-            out_sign="s",
-            method=self._get_items_from_cache,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_cache_sync",
-            ".plugin",
-            "sss",
-            out_sign="",
-            method=self._synchronise,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_cache_purge",
-            ".plugin",
-            "s",
-            out_sign="",
-            method=self._purge,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_cache_reset",
-            ".plugin",
-            "",
-            out_sign="",
-            method=self._reset,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_cache_search",
-            ".plugin",
-            "s",
-            out_sign="s",
-            method=self._search,
-            async_=True,
-        )
-
-    def register_analyser(self, analyser: dict) -> None:
-        """Register a new pubsub node analyser
-
-        @param analyser: An analyser is a dictionary which may have the following keys
-        (keys with a ``*`` are mandatory, at least one of ``node`` or ``namespace`` keys
-        must be used):
-
-            :name (str)*:
-              a unique name for this analyser. This name will be stored in database
-              to retrieve the analyser when necessary (notably to get the parsing method),
-              thus it is recommended to use a stable name such as the source plugin name
-              instead of a name which may change with standard evolution, such as the
-              feature namespace.
-
-            :type (str)*:
-              indicates what kind of items we are dealing with. Type must be a human
-              readable word, as it may be used in searches. Good types examples are
-              **blog** or **event**.
-
-            :node (str):
-              prefix of a node name which may be used to identify its type. Example:
-              *urn:xmpp:microblog:0* (a node starting with this name will be identified as
-              *blog* node).
-
-            :namespace (str):
-              root namespace of items. When analysing a node, the first item will be
-              retrieved. The analyser will be chosen its given namespace match the
-              namespace of the first child element of ``<item>`` element.
-
-            :to_sync (bool):
-              if True, the node must be synchronised in cache. The default False value
-              means that the pubsub service will always be requested.
-
-            :parser (callable):
-              method (which may be sync, a coroutine or a method returning a "Deferred")
-              to call to parse the ``domish.Element`` of the item. The result must be
-              dictionary which can be serialised to JSON.
-
-              The method must have the following signature:
-
-              .. function:: parser(client: SatXMPPEntity, item_elt: domish.Element, \
-                                   service: Optional[jid.JID], node: Optional[str]) \
-                                   -> dict
-                :noindex:
-
-            :match_cb (callable):
-              method (which may be sync, a coroutine or a method returning a "Deferred")
-              called when the analyser matches. The method is called with the curreny
-              analyse which is can modify **in-place**.
-
-              The method must have the following signature:
-
-              .. function:: match_cb(client: SatXMPPEntity, analyse: dict) -> None
-                :noindex:
-
-        @raise exceptions.Conflict: a analyser with this name already exists
-        """
-
-        name = analyser.get("name", "").strip().lower()
-        # we want the normalised name
-        analyser["name"] = name
-        if not name:
-            raise ValueError('"name" is mandatory in analyser')
-        if "type" not in analyser:
-            raise ValueError('"type" is mandatory in analyser')
-        type_test_keys = {"node", "namespace"}
-        if not type_test_keys.intersection(analyser):
-            raise ValueError(f'at least one of {type_test_keys} must be used')
-        if name in self.analysers:
-            raise exceptions.Conflict(
-                f"An analyser with the name {name!r} is already registered"
-            )
-        self.analysers[name] = analyser
-
-    async def cache_items(
-        self,
-        client: SatXMPPEntity,
-        pubsub_node: PubsubNode,
-        items: List[domish.Element]
-    ) -> None:
-        try:
-            parser = self.analysers[pubsub_node.analyser].get("parser")
-        except KeyError:
-            parser = None
-
-        if parser is not None:
-            parsed_items = [
-                await utils.as_deferred(
-                    parser,
-                    client,
-                    item,
-                    pubsub_node.service,
-                    pubsub_node.name
-                )
-                for item in items
-            ]
-        else:
-            parsed_items = None
-
-        await self.host.memory.storage.cache_pubsub_items(
-            client, pubsub_node, items, parsed_items
-        )
-
-    async def _cache_node(
-        self,
-        client: SatXMPPEntity,
-        pubsub_node: PubsubNode
-    ) -> None:
-        await self.host.memory.storage.update_pubsub_node_sync_state(
-            pubsub_node, SyncState.IN_PROGRESS
-        )
-        service, node = pubsub_node.service, pubsub_node.name
-        try:
-            log.debug(
-                f"Caching node {node!r} at {service} for {client.profile}"
-            )
-            if not pubsub_node.subscribed:
-                try:
-                    sub = await self._p.subscribe(client, service, node)
-                except Exception as e:
-                    log.warning(
-                        _(
-                            "Can't subscribe node {pubsub_node}, that means that "
-                            "synchronisation can't be maintained: {reason}"
-                        ).format(pubsub_node=pubsub_node, reason=e)
-                    )
-                else:
-                    if sub.state == "subscribed":
-                        sub_id = sub.subscriptionIdentifier
-                        log.debug(
-                            f"{pubsub_node} subscribed (subscription id: {sub_id!r})"
-                        )
-                        pubsub_node.subscribed = True
-                        await self.host.memory.storage.add(pubsub_node)
-                    else:
-                        log.warning(
-                            _(
-                                "{pubsub_node} is not subscribed, that means that "
-                                "synchronisation can't be maintained, and you may have "
-                                "to enforce subscription manually. Subscription state: "
-                                "{state}"
-                            ).format(pubsub_node=pubsub_node, state=sub.state)
-                        )
-
-            try:
-                await self.host.check_features(
-                    client, [rsm.NS_RSM, self._p.DISCO_RSM], pubsub_node.service
-                )
-            except error.StanzaError as e:
-                if e.condition == "service-unavailable":
-                    log.warning(
-                        "service {service} is hidding disco infos, we'll only cache "
-                        "latest 20 items"
-                    )
-                    items, __ = await client.pubsub_client.items(
-                        pubsub_node.service, pubsub_node.name, maxItems=20
-                    )
-                    await self.cache_items(
-                        client, pubsub_node, items
-                    )
-                else:
-                    raise e
-            except exceptions.FeatureNotFound:
-                log.warning(
-                    f"service {service} doesn't handle Result Set Management "
-                    "(XEP-0059), we'll only cache latest 20 items"
-                )
-                items, __ = await client.pubsub_client.items(
-                    pubsub_node.service, pubsub_node.name, maxItems=20
-                )
-                await self.cache_items(
-                    client, pubsub_node, items
-                )
-            else:
-                rsm_p = self.host.plugins["XEP-0059"]
-                rsm_request = rsm.RSMRequest()
-                cached_ids = set()
-                while True:
-                    items, rsm_response = await client.pubsub_client.items(
-                        service, node, rsm_request=rsm_request
-                    )
-                    await self.cache_items(
-                        client, pubsub_node, items
-                    )
-                    for item in items:
-                        item_id = item["id"]
-                        if item_id in cached_ids:
-                            log.warning(
-                                f"Pubsub node {node!r} at {service} is returning several "
-                                f"times the same item ({item_id!r}). This is illegal "
-                                "behaviour, and it means that Pubsub service "
-                                f"{service} is buggy and can't be cached properly. "
-                                f"Please report this to {service.host} administrators"
-                            )
-                            rsm_request = None
-                            break
-                        cached_ids.add(item["id"])
-                        if len(cached_ids) >= CACHE_LIMIT:
-                            log.warning(
-                                f"Pubsub node {node!r} at {service} contains more items "
-                                f"than the cache limit ({CACHE_LIMIT}). We stop "
-                                "caching here, at item {item['id']!r}."
-                            )
-                            rsm_request = None
-                            break
-                    rsm_request = rsm_p.get_next_request(rsm_request, rsm_response)
-                    if rsm_request is None:
-                        break
-
-            await self.host.memory.storage.update_pubsub_node_sync_state(
-                pubsub_node, SyncState.COMPLETED
-            )
-        except Exception as e:
-            import traceback
-            tb = traceback.format_tb(e.__traceback__)
-            log.error(
-                f"Can't cache node {node!r} at {service} for {client.profile}: {e}\n{tb}"
-            )
-            await self.host.memory.storage.update_pubsub_node_sync_state(
-                pubsub_node, SyncState.ERROR
-            )
-            await self.host.memory.storage.delete_pubsub_items(pubsub_node)
-            raise e
-
-    def _cache_node_clean(self, __, pubsub_node):
-        del self.in_progress[(pubsub_node.service, pubsub_node.name)]
-
-    def cache_node(
-        self,
-        client: SatXMPPEntity,
-        pubsub_node: PubsubNode
-    ) -> None:
-        """Launch node caching as a background task"""
-        d = defer.ensureDeferred(self._cache_node(client, pubsub_node))
-        d.addBoth(self._cache_node_clean, pubsub_node=pubsub_node)
-        self.in_progress[(pubsub_node.service, pubsub_node.name)] = d
-        return d
-
-    async def analyse_node(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        node: str,
-        pubsub_node : PubsubNode = None,
-    ) -> dict:
-        """Use registered analysers on a node to determine what it is used for"""
-        analyse = {"service": service, "node": node}
-        if pubsub_node is None:
-            try:
-                first_item = (await client.pubsub_client.items(
-                    service, node, 1
-                ))[0][0]
-            except IndexError:
-                pass
-            except error.StanzaError as e:
-                if e.condition == "item-not-found":
-                    pass
-                else:
-                    log.warning(
-                        f"Can't retrieve last item on node {node!r} at service "
-                        f"{service} for {client.profile}: {e}"
-                    )
-            else:
-                try:
-                    uri = first_item.firstChildElement().uri
-                except Exception as e:
-                    log.warning(
-                        f"Can't retrieve item namespace on node {node!r} at service "
-                        f"{service} for {client.profile}: {e}"
-                    )
-                else:
-                    analyse["namespace"] = uri
-            try:
-                conf = await self._p.getConfiguration(client, service, node)
-            except Exception as e:
-                log.warning(
-                    f"Can't retrieve configuration for node {node!r} at service {service} "
-                    f"for {client.profile}: {e}"
-                )
-            else:
-                analyse["conf"] = conf
-
-        for analyser in self.analysers.values():
-            try:
-                an_node = analyser["node"]
-            except KeyError:
-                pass
-            else:
-                if node.startswith(an_node):
-                    for key in ANALYSER_KEYS_TO_COPY:
-                        try:
-                            analyse[key] = analyser[key]
-                        except KeyError:
-                            pass
-                    found = True
-                    break
-            try:
-                namespace = analyse["namespace"]
-                an_namespace = analyser["namespace"]
-            except KeyError:
-                pass
-            else:
-                if namespace == an_namespace:
-                    for key in ANALYSER_KEYS_TO_COPY:
-                        try:
-                            analyse[key] = analyser[key]
-                        except KeyError:
-                            pass
-                    found = True
-                    break
-
-        else:
-            found = False
-            log.debug(
-                f"node {node!r} at service {service} doesn't match any known type"
-            )
-        if found:
-            try:
-                match_cb = analyser["match_cb"]
-            except KeyError:
-                pass
-            else:
-                await utils.as_deferred(match_cb, client, analyse)
-        return analyse
-
-    def _get_items_from_cache(
-        self, service="", node="", max_items=10, item_ids=None, sub_id=None,
-        extra="", profile_key=C.PROF_KEY_NONE
-    ):
-        d = defer.ensureDeferred(self._a_get_items_from_cache(
-            service, node, max_items, item_ids, sub_id, extra, profile_key
-        ))
-        d.addCallback(self._p.trans_items_data)
-        d.addCallback(self._p.serialise_items)
-        return d
-
-    async def _a_get_items_from_cache(
-        self, service, node, max_items, item_ids, sub_id, extra, profile_key
-    ):
-        client = self.host.get_client(profile_key)
-        service = jid.JID(service) if service else client.jid.userhostJID()
-        pubsub_node = await self.host.memory.storage.get_pubsub_node(
-            client, service, node
-        )
-        if pubsub_node is None:
-            raise exceptions.NotFound(
-                f"{node!r} at {service} doesn't exist in cache for {client.profile!r}"
-            )
-        max_items = None if max_items == C.NO_LIMIT else max_items
-        extra = self._p.parse_extra(data_format.deserialise(extra))
-        items, metadata = await self.get_items_from_cache(
-            client,
-            pubsub_node,
-            max_items,
-            item_ids,
-            sub_id or None,
-            extra.rsm_request,
-            extra.extra,
-        )
-        return [i.data for i in items], metadata
-
-    async def get_items_from_cache(
-        self,
-        client: SatXMPPEntity,
-        node: PubsubNode,
-        max_items: Optional[int] = None,
-        item_ids: Optional[List[str]] = None,
-        sub_id: Optional[str] = None,
-        rsm_request: Optional[rsm.RSMRequest] = None,
-        extra: Optional[Dict[str, Any]] = None
-    ) -> Tuple[List[PubsubItem], dict]:
-        """Get items from cache, using same arguments as for external Pubsub request"""
-        if extra is None:
-            extra = {}
-        if "mam" in extra:
-            raise NotImplementedError("MAM queries are not supported yet")
-        if max_items is None and rsm_request is None:
-            max_items = 20
-            pubsub_items, metadata = await self.host.memory.storage.get_items(
-                node, max_items=max_items, item_ids=item_ids or None,
-                order_by=extra.get(C.KEY_ORDER_BY)
-            )
-        elif max_items is not None:
-            if rsm_request is not None:
-                raise exceptions.InternalError(
-                    "Pubsub max items and RSM must not be used at the same time"
-                )
-            elif item_ids:
-                raise exceptions.InternalError(
-                    "Pubsub max items and item IDs must not be used at the same time"
-                )
-            pubsub_items, metadata = await self.host.memory.storage.get_items(
-                node, max_items=max_items, order_by=extra.get(C.KEY_ORDER_BY)
-            )
-        else:
-            desc = False
-            if rsm_request.before == "":
-                before = None
-                desc = True
-            else:
-                before = rsm_request.before
-            pubsub_items, metadata = await self.host.memory.storage.get_items(
-                node, max_items=rsm_request.max, before=before, after=rsm_request.after,
-                from_index=rsm_request.index, order_by=extra.get(C.KEY_ORDER_BY),
-                desc=desc, force_rsm=True,
-            )
-
-        return pubsub_items, metadata
-
-    async def on_items_event(self, client, event):
-        node = await self.host.memory.storage.get_pubsub_node(
-            client, event.sender, event.nodeIdentifier
-        )
-        if node is None:
-            return
-        if node.sync_state in (SyncState.COMPLETED, SyncState.IN_PROGRESS):
-            items = []
-            retract_ids = []
-            for elt in event.items:
-                if elt.name == "item":
-                    items.append(elt)
-                elif elt.name == "retract":
-                    item_id = elt.getAttribute("id")
-                    if not item_id:
-                        log.warning(
-                            "Ignoring invalid retract item element: "
-                            f"{xml_tools.p_fmt_elt(elt)}"
-                        )
-                        continue
-
-                    retract_ids.append(elt["id"])
-                else:
-                    log.warning(
-                        f"Unexpected Pubsub event element: {xml_tools.p_fmt_elt(elt)}"
-                    )
-            if items:
-                log.debug(f"[{client.profile}] caching new items received from {node}")
-                await self.cache_items(
-                    client, node, items
-                )
-            if retract_ids:
-                log.debug(f"deleting retracted items from {node}")
-                await self.host.memory.storage.delete_pubsub_items(
-                    node, items_names=retract_ids
-                )
-
-    async def on_delete_event(self, client, event):
-        log.debug(
-            f"deleting node {event.nodeIdentifier} from {event.sender} for "
-            f"{client.profile}"
-        )
-        await self.host.memory.storage.delete_pubsub_node(
-            [client.profile], [event.sender], [event.nodeIdentifier]
-        )
-
-    async def on_purge_event(self, client, event):
-        node = await self.host.memory.storage.get_pubsub_node(
-            client, event.sender, event.nodeIdentifier
-        )
-        if node is None:
-            return
-        log.debug(f"purging node {node} for {client.profile}")
-        await self.host.memory.storage.delete_pubsub_items(node)
-
-    async def _get_items_trigger(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: str,
-        max_items: Optional[int],
-        item_ids: Optional[List[str]],
-        sub_id: Optional[str],
-        rsm_request: Optional[rsm.RSMRequest],
-        extra: dict
-    ) -> Tuple[bool, Optional[Tuple[List[dict], dict]]]:
-        if not self.use_cache:
-            log.debug("cache disabled in settings")
-            return True, None
-        if extra.get(C.KEY_USE_CACHE) == False:
-            log.debug("skipping pubsub cache as requested")
-            return True, None
-        if service is None:
-            service = client.jid.userhostJID()
-        for __ in range(5):
-            pubsub_node = await self.host.memory.storage.get_pubsub_node(
-                client, service, node
-            )
-            if pubsub_node is not None and pubsub_node.sync_state == SyncState.COMPLETED:
-                analyse = {"to_sync": True}
-            else:
-                analyse = await self.analyse_node(client, service, node)
-
-            if pubsub_node is None:
-                try:
-                    pubsub_node = await self.host.memory.storage.set_pubsub_node(
-                        client,
-                        service,
-                        node,
-                        analyser=analyse.get("name"),
-                        type_=analyse.get("type"),
-                        subtype=analyse.get("subtype"),
-                    )
-                except IntegrityError as e:
-                    if "unique" in str(e.orig).lower():
-                        log.debug(
-                            "race condition on pubsub node creation in cache, trying "
-                            "again"
-                        )
-                    else:
-                        raise e
-            break
-        else:
-            raise exceptions.InternalError(
-                "Too many IntegrityError with UNIQUE constraint, something is going wrong"
-            )
-
-        if analyse.get("to_sync"):
-            if pubsub_node.sync_state == SyncState.COMPLETED:
-                if "mam" in extra:
-                    log.debug("MAM caching is not supported yet, skipping cache")
-                    return True, None
-                pubsub_items, metadata = await self.get_items_from_cache(
-                    client, pubsub_node, max_items, item_ids, sub_id, rsm_request, extra
-                )
-                return False, ([i.data for i in pubsub_items], metadata)
-
-            if pubsub_node.sync_state == SyncState.IN_PROGRESS:
-                if (service, node) not in self.in_progress:
-                    log.warning(
-                        f"{pubsub_node} is reported as being cached, but not caching is "
-                        "in progress, this is most probably due to the backend being "
-                        "restarted. Resetting the status, caching will be done again."
-                    )
-                    pubsub_node.sync_state = None
-                    await self.host.memory.storage.delete_pubsub_items(pubsub_node)
-                elif time.time() - pubsub_node.sync_state_updated > PROGRESS_DEADLINE:
-                    log.warning(
-                        f"{pubsub_node} is in progress for too long "
-                        f"({pubsub_node.sync_state_updated//60} minutes), "
-                        "cancelling it and retrying."
-                    )
-                    self.in_progress.pop[(service, node)].cancel()
-                    pubsub_node.sync_state = None
-                    await self.host.memory.storage.delete_pubsub_items(pubsub_node)
-                else:
-                    log.debug(
-                        f"{pubsub_node} synchronisation is already in progress, skipping"
-                    )
-            if pubsub_node.sync_state is None:
-                key = (service, node)
-                if key in self.in_progress:
-                    raise exceptions.InternalError(
-                        f"There is already a caching in progress for {pubsub_node}, this "
-                        "should not happen"
-                    )
-                self.cache_node(client, pubsub_node)
-            elif pubsub_node.sync_state == SyncState.ERROR:
-                log.debug(
-                    f"{pubsub_node} synchronisation has previously failed, skipping"
-                )
-
-        return True, None
-
-    async def _subscribe_trigger(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        nodeIdentifier: str,
-        sub_jid: Optional[jid.JID],
-        options: Optional[dict],
-        subscription: pubsub.Subscription
-    ) -> None:
-        pass
-
-    async def _unsubscribe_trigger(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        nodeIdentifier: str,
-        sub_jid,
-        subscriptionIdentifier,
-        sender,
-    ) -> None:
-        pass
-
-    def _synchronise(self, service, node, profile_key):
-        client = self.host.get_client(profile_key)
-        service = client.jid.userhostJID() if not service else jid.JID(service)
-        return defer.ensureDeferred(self.synchronise(client, service, node))
-
-    async def synchronise(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        node: str,
-        resync: bool = True
-    ) -> None:
-        """Synchronise a node with a pubsub service
-
-        The node will be synchronised even if there is no matching analyser.
-
-        Note that when a node is synchronised, it is automatically subscribed.
-        @param resync: if True and the node is already synchronised, it will be
-            resynchronised (all items will be deleted and re-downloaded).
-
-        """
-        pubsub_node = await self.host.memory.storage.get_pubsub_node(
-            client, service, node
-        )
-        if pubsub_node is None:
-            log.info(
-                _(
-                    "Synchronising the new node {node} at {service}"
-                ).format(node=node, service=service.full)
-            )
-            analyse = await self.analyse_node(client, service, node)
-            pubsub_node = await self.host.memory.storage.set_pubsub_node(
-                client,
-                service,
-                node,
-                analyser=analyse.get("name"),
-                type_=analyse.get("type"),
-            )
-        elif not resync and pubsub_node.sync_state is not None:
-                # the node exists, nothing to do
-                return
-
-        if ((pubsub_node.sync_state == SyncState.IN_PROGRESS
-             or (service, node) in self.in_progress)):
-            log.warning(
-                _(
-                    "{node} at {service} is already being synchronised, can't do a new "
-                    "synchronisation."
-                ).format(node=node, service=service)
-            )
-        else:
-            log.info(
-                _(
-                    "(Re)Synchronising the node {node} at {service} on user request"
-                ).format(node=node, service=service.full())
-            )
-            # we first delete and recreate the node (will also delete its items)
-            await self.host.memory.storage.delete(pubsub_node)
-            analyse = await self.analyse_node(client, service, node)
-            pubsub_node = await self.host.memory.storage.set_pubsub_node(
-                client,
-                service,
-                node,
-                analyser=analyse.get("name"),
-                type_=analyse.get("type"),
-            )
-            # then we can put node in cache
-            await self.cache_node(client, pubsub_node)
-
-    async def purge(self, purge_filters: dict) -> None:
-        """Remove items according to filters
-
-        filters can have on of the following keys, all are optional:
-
-            :services:
-                list of JIDs of services from which items must be deleted
-            :nodes:
-                list of node names to delete
-            :types:
-                list of node types to delete
-            :subtypes:
-                list of node subtypes to delete
-            :profiles:
-                list of profiles from which items must be deleted
-            :created_before:
-                datetime before which items must have been created to be deleted
-            :created_update:
-                datetime before which items must have been updated last to be deleted
-        """
-        purge_filters["names"] = purge_filters.pop("nodes", None)
-        await self.host.memory.storage.purge_pubsub_items(**purge_filters)
-
-    def _purge(self, purge_filters: str) -> None:
-        purge_filters = data_format.deserialise(purge_filters)
-        for key in "created_before", "updated_before":
-            try:
-                purge_filters[key] = datetime.fromtimestamp(purge_filters[key])
-            except (KeyError, TypeError):
-                pass
-        return defer.ensureDeferred(self.purge(purge_filters))
-
-    async def reset(self) -> None:
-        """Remove ALL nodes and items from cache
-
-        After calling this method, cache will be refilled progressively as if it where new
-        """
-        await self.host.memory.storage.delete_pubsub_node(None, None, None)
-
-    def _reset(self) -> defer.Deferred:
-        return defer.ensureDeferred(self.reset())
-
-    async def search(self, query: dict) -> List[PubsubItem]:
-        """Search pubsub items in cache"""
-        return await self.host.memory.storage.search_pubsub_items(query)
-
-    async def serialisable_search(self, query: dict) -> List[dict]:
-        """Search pubsub items in cache and returns parsed data
-
-        The returned data can be serialised.
-
-        "pubsub_service" and "pubsub_name" will be added to each data (both as strings)
-        """
-        items = await self.search(query)
-        ret = []
-        for item in items:
-            parsed = item.parsed
-            parsed["pubsub_service"] = item.node.service.full()
-            parsed["pubsub_node"] = item.node.name
-            if query.get("with_payload"):
-                parsed["item_payload"] = item.data.toXml()
-            parsed["node_profile"] = self.host.memory.storage.get_profile_by_id(
-                item.node.profile_id
-            )
-
-            ret.append(parsed)
-        return ret
-
-    def _search(self, query: str) -> defer.Deferred:
-        query = data_format.deserialise(query)
-        services = query.get("services")
-        if services:
-            query["services"] = [jid.JID(s) for s in services]
-        d = defer.ensureDeferred(self.serialisable_search(query))
-        d.addCallback(data_format.serialise)
-        return d
--- a/sat/plugins/plugin_sec_aesgcm.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,327 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin for handling AES-GCM file encryption
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import re
-from textwrap import dedent
-from functools import partial
-from urllib import parse
-import mimetypes
-import secrets
-from cryptography.hazmat.primitives import ciphers
-from cryptography.hazmat.primitives.ciphers import modes
-from cryptography.hazmat import backends
-from cryptography.exceptions import AlreadyFinalized
-import treq
-from twisted.internet import defer
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.tools import stream
-from sat.core.log import getLogger
-from sat.tools.web import treq_client_no_ssl
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "AES-GCM",
-    C.PI_IMPORT_NAME: "AES-GCM",
-    C.PI_TYPE: "SEC",
-    C.PI_PROTOCOLS: ["OMEMO Media sharing"],
-    C.PI_DEPENDENCIES: ["XEP-0363", "XEP-0384", "DOWNLOAD", "ATTACH"],
-    C.PI_MAIN: "AESGCM",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: dedent(_("""\
-    Implementation of AES-GCM scheme, a way to encrypt files (not official XMPP standard).
-    See https://xmpp.org/extensions/inbox/omemo-media-sharing.html for details
-    """)),
-}
-
-AESGCM_RE = re.compile(
-    r'aesgcm:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9'
-    r'()@:%_\+.~#?&\/\/=]*)')
-
-
-class AESGCM(object):
-
-    def __init__(self, host):
-        self.host = host
-        log.info(_("AESGCM plugin initialization"))
-        self._http_upload = host.plugins['XEP-0363']
-        self._attach = host.plugins["ATTACH"]
-        host.plugins["DOWNLOAD"].register_scheme(
-            "aesgcm", self.download
-        )
-        self._attach.register(
-            self.can_handle_attachment, self.attach, encrypted=True)
-        host.trigger.add("XEP-0363_upload_pre_slot", self._upload_pre_slot)
-        host.trigger.add("XEP-0363_upload", self._upload_trigger)
-        host.trigger.add("message_received", self._message_received_trigger)
-
-    async def download(self, client, uri_parsed, dest_path, options):
-        fragment = bytes.fromhex(uri_parsed.fragment)
-
-        # legacy method use 16 bits IV, but OMEMO media sharing published spec indicates
-        # which is 12 bits IV (AES-GCM spec recommandation), so we have to determine
-        # which size has been used.
-        if len(fragment) == 48:
-            iv_size = 16
-        elif len(fragment) == 44:
-            iv_size = 12
-        else:
-            raise ValueError(
-                f"Invalid URL fragment, can't decrypt file at {uri_parsed.get_url()}")
-
-        iv, key = fragment[:iv_size], fragment[iv_size:]
-
-        decryptor = ciphers.Cipher(
-            ciphers.algorithms.AES(key),
-            modes.GCM(iv),
-            backend=backends.default_backend(),
-        ).decryptor()
-
-        download_url = parse.urlunparse(
-            ('https', uri_parsed.netloc, uri_parsed.path, '', '', ''))
-
-        if options.get('ignore_tls_errors', False):
-            log.warning(
-                "TLS certificate check disabled, this is highly insecure"
-            )
-            treq_client = treq_client_no_ssl
-        else:
-            treq_client = treq
-
-        head_data = await treq_client.head(download_url)
-        content_length = int(head_data.headers.getRawHeaders('content-length')[0])
-        # the 128 bits tag is put at the end
-        file_size = content_length - 16
-
-        file_obj = stream.SatFile(
-            self.host,
-            client,
-            dest_path,
-            mode="wb",
-            size = file_size,
-        )
-
-        progress_id = file_obj.uid
-
-        resp = await treq_client.get(download_url, unbuffered=True)
-        if resp.code == 200:
-            d = treq.collect(resp, partial(
-                self.on_data_download,
-                client=client,
-                file_obj=file_obj,
-                decryptor=decryptor))
-        else:
-            d = defer.Deferred()
-            self.host.plugins["DOWNLOAD"].errback_download(file_obj, d, resp)
-        return progress_id, d
-
-    async def can_handle_attachment(self, client, data):
-        try:
-            await self._http_upload.get_http_upload_entity(client)
-        except exceptions.NotFound:
-            return False
-        else:
-            return True
-
-    async def _upload_cb(self, client, filepath, filename, extra):
-        extra['encryption'] = C.ENC_AES_GCM
-        return await self._http_upload.file_http_upload(
-            client=client,
-            filepath=filepath,
-            filename=filename,
-            extra=extra
-        )
-
-    async def attach(self, client, data):
-        # XXX: the attachment removal/resend code below is due to the one file per
-        #   message limitation of OMEMO media sharing unofficial XEP. We have to remove
-        #   attachments from original message, and send them one by one.
-        # TODO: this is to be removed when a better mechanism is available with OMEMO (now
-        #   possible with the 0.4 version of OMEMO, it's possible to encrypt other stanza
-        #   elements than body).
-        attachments = data["extra"][C.KEY_ATTACHMENTS]
-        if not data['message'] or data['message'] == {'': ''}:
-            extra_attachments = attachments[1:]
-            del attachments[1:]
-            await self._attach.upload_files(client, data, upload_cb=self._upload_cb)
-        else:
-            # we have a message, we must send first attachment separately
-            extra_attachments = attachments[:]
-            attachments.clear()
-            del data["extra"][C.KEY_ATTACHMENTS]
-
-        body_elt = data["xml"].body
-        if body_elt is None:
-            body_elt = data["xml"].addElement("body")
-
-        for attachment in attachments:
-            body_elt.addContent(attachment["url"])
-
-        for attachment in extra_attachments:
-            # we send all remaining attachment in a separate message
-            await client.sendMessage(
-                to_jid=data['to'],
-                message={'': ''},
-                subject=data['subject'],
-                mess_type=data['type'],
-                extra={C.KEY_ATTACHMENTS: [attachment]},
-            )
-
-        if ((not data['extra']
-             and (not data['message'] or data['message'] == {'': ''})
-             and not data['subject'])):
-            # nothing left to send, we can cancel the message
-            raise exceptions.CancelError("Cancelled by AESGCM attachment handling")
-
-    def on_data_download(self, data, client, file_obj, decryptor):
-        if file_obj.tell() + len(data) > file_obj.size:
-            # we're reaching end of file with this bunch of data
-            # we may still have a last bunch if the tag is incomplete
-            bytes_left = file_obj.size - file_obj.tell()
-            if bytes_left > 0:
-                decrypted = decryptor.update(data[:bytes_left])
-                file_obj.write(decrypted)
-                tag = data[bytes_left:]
-            else:
-                tag = data
-            if len(tag) < 16:
-                # the tag is incomplete, either we'll get the rest in next data bunch
-                # or we have already the other part from last bunch of data
-                try:
-                    # we store partial tag in decryptor._sat_tag
-                    tag = decryptor._sat_tag + tag
-                except AttributeError:
-                    # no other part, we'll get the rest at next bunch
-                    decryptor.sat_tag = tag
-                else:
-                    # we have the complete tag, it must be 128 bits
-                    if len(tag) != 16:
-                        raise ValueError(f"Invalid tag: {tag}")
-            remain = decryptor.finalize_with_tag(tag)
-            file_obj.write(remain)
-            file_obj.close()
-        else:
-            decrypted = decryptor.update(data)
-            file_obj.write(decrypted)
-
-    def _upload_pre_slot(self, client, extra, file_metadata):
-        if extra.get('encryption') != C.ENC_AES_GCM:
-            return True
-        # the tag is appended to the file
-        file_metadata["size"] += 16
-        return True
-
-    def _encrypt(self, data, encryptor):
-        if data:
-            return encryptor.update(data)
-        else:
-            try:
-                # end of file is reached, me must finalize
-                ret = encryptor.finalize()
-                tag = encryptor.tag
-                return ret + tag
-            except AlreadyFinalized:
-                # as we have already finalized, we can now send EOF
-                return b''
-
-    def _upload_trigger(self, client, extra, sat_file, file_producer, slot):
-        if extra.get('encryption') != C.ENC_AES_GCM:
-            return True
-        log.debug("encrypting file with AES-GCM")
-        iv = secrets.token_bytes(12)
-        key = secrets.token_bytes(32)
-        fragment = f'{iv.hex()}{key.hex()}'
-        ori_url = parse.urlparse(slot.get)
-        # we change the get URL with the one with aesgcm scheme and containing the
-        # encoded key + iv
-        slot.get = parse.urlunparse(['aesgcm', *ori_url[1:5], fragment])
-
-        # encrypted data size will be bigger than original file size
-        # so we need to check with final data length to avoid a warning on close()
-        sat_file.check_size_with_read = True
-
-        # file_producer get length directly from file, and this cause trouble as
-        # we have to change the size because of encryption. So we adapt it here,
-        # else the producer would stop reading prematurely
-        file_producer.length = sat_file.size
-
-        encryptor = ciphers.Cipher(
-            ciphers.algorithms.AES(key),
-            modes.GCM(iv),
-            backend=backends.default_backend(),
-        ).encryptor()
-
-        if sat_file.data_cb is not None:
-            raise exceptions.InternalError(
-                f"data_cb was expected to be None, it is set to {sat_file.data_cb}")
-
-        # with data_cb we encrypt the file on the fly
-        sat_file.data_cb = partial(self._encrypt, encryptor=encryptor)
-        return True
-
-
-    def _pop_aesgcm_links(self, match, links):
-        link = match.group()
-        if link not in links:
-            links.append(link)
-        return ""
-
-    def _check_aesgcm_attachments(self, client, data):
-        if not data.get('message'):
-            return data
-        links = []
-
-        for lang, message in list(data['message'].items()):
-            message = AESGCM_RE.sub(
-                partial(self._pop_aesgcm_links, links=links),
-                message)
-            if links:
-                message = message.strip()
-                if not message:
-                    del data['message'][lang]
-                else:
-                    data['message'][lang] = message
-                mess_encrypted = client.encryption.isEncrypted(data)
-                attachments = data['extra'].setdefault(C.KEY_ATTACHMENTS, [])
-                for link in links:
-                    path = parse.urlparse(link).path
-                    attachment = {
-                        "url": link,
-                    }
-                    media_type = mimetypes.guess_type(path, strict=False)[0]
-                    if media_type is not None:
-                        attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE] = media_type
-
-                    if mess_encrypted:
-                        # we don't add the encrypted flag if the message itself is not
-                        # encrypted, because the decryption key is part of the link,
-                        # so sending it over unencrypted channel is like having no
-                        # encryption at all.
-                        attachment['encrypted'] = True
-                    attachments.append(attachment)
-
-        return data
-
-    def _message_received_trigger(self, client, message_elt, post_treat):
-        # we use a post_treat callback instead of "message_parse" trigger because we need
-        # to check if the "encrypted" flag is set to decide if we add the same flag to the
-        # attachment
-        post_treat.addCallback(partial(self._check_aesgcm_attachments, client))
-        return True
--- a/sat/plugins/plugin_sec_otr.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,839 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for OTR encryption
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-# XXX: thanks to Darrik L Mazey for his documentation
-#      (https://blog.darmasoft.net/2013/06/30/using-pure-python-otr.html)
-#      this implentation is based on it
-
-import copy
-import time
-import uuid
-from binascii import hexlify, unhexlify
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.tools import xml_tools
-from twisted.words.protocols.jabber import jid
-from twisted.python import failure
-from twisted.internet import defer
-from sat.memory import persistent
-import potr
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "OTR",
-    C.PI_IMPORT_NAME: "OTR",
-    C.PI_MODES: [C.PLUG_MODE_CLIENT],
-    C.PI_TYPE: "SEC",
-    C.PI_PROTOCOLS: ["XEP-0364"],
-    C.PI_DEPENDENCIES: ["XEP-0280", "XEP-0334"],
-    C.PI_MAIN: "OTR",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of OTR"""),
-}
-
-NS_OTR = "urn:xmpp:otr:0"
-PRIVATE_KEY = "PRIVATE KEY"
-OTR_MENU = D_("OTR")
-AUTH_TXT = D_(
-    "To authenticate your correspondent, you need to give your below fingerprint "
-    "*BY AN EXTERNAL CANAL* (i.e. not in this chat), and check that the one he gives "
-    "you is the same as below. If there is a mismatch, there can be a spy between you!"
-)
-DROP_TXT = D_(
-    "You private key is used to encrypt messages for your correspondent, nobody except "
-    "you must know it, if you are in doubt, you should drop it!\n\nAre you sure you "
-    "want to drop your private key?"
-)
-# NO_LOG_AND = D_(u"/!\\Your history is not logged anymore, and")   # FIXME: not used at the moment
-NO_ADV_FEATURES = D_("Some of advanced features are disabled !")
-
-DEFAULT_POLICY_FLAGS = {"ALLOW_V1": False, "ALLOW_V2": True, "REQUIRE_ENCRYPTION": True}
-
-OTR_STATE_TRUSTED = "trusted"
-OTR_STATE_UNTRUSTED = "untrusted"
-OTR_STATE_UNENCRYPTED = "unencrypted"
-OTR_STATE_ENCRYPTED = "encrypted"
-
-
-class Context(potr.context.Context):
-    def __init__(self, context_manager, other_jid):
-        self.context_manager = context_manager
-        super(Context, self).__init__(context_manager.account, other_jid)
-
-    @property
-    def host(self):
-        return self.context_manager.host
-
-    @property
-    def _p_hints(self):
-        return self.context_manager.parent._p_hints
-
-    @property
-    def _p_carbons(self):
-        return self.context_manager.parent._p_carbons
-
-    def get_policy(self, key):
-        if key in DEFAULT_POLICY_FLAGS:
-            return DEFAULT_POLICY_FLAGS[key]
-        else:
-            return False
-
-    def inject(self, msg_str, appdata=None):
-        """Inject encrypted data in the stream
-
-        if appdata is not None, we are sending a message in sendMessageDataTrigger
-        stanza will be injected directly if appdata is None,
-        else we just update the element and follow normal workflow
-        @param msg_str(str): encrypted message body
-        @param appdata(None, dict): None for signal message,
-            message data when an encrypted message is going to be sent
-        """
-        assert isinstance(self.peer, jid.JID)
-        msg = msg_str.decode('utf-8')
-        client = self.user.client
-        log.debug("injecting encrypted message to {to}".format(to=self.peer))
-        if appdata is None:
-            mess_data = {
-                "from": client.jid,
-                "to": self.peer,
-                "uid": str(uuid.uuid4()),
-                "message": {"": msg},
-                "subject": {},
-                "type": "chat",
-                "extra": {},
-                "timestamp": time.time(),
-            }
-            client.generate_message_xml(mess_data)
-            xml = mess_data['xml']
-            self._p_carbons.set_private(xml)
-            self._p_hints.add_hint_elements(xml, [
-                self._p_hints.HINT_NO_COPY,
-                self._p_hints.HINT_NO_PERMANENT_STORE])
-            client.send(mess_data["xml"])
-        else:
-            message_elt = appdata["xml"]
-            assert message_elt.name == "message"
-            message_elt.addElement("body", content=msg)
-
-    def stop_cb(self, __, feedback):
-        client = self.user.client
-        self.host.bridge.otr_state(
-            OTR_STATE_UNENCRYPTED, self.peer.full(), client.profile
-        )
-        client.feedback(self.peer, feedback)
-
-    def stop_eb(self, failure_):
-        # encryption may be already stopped in case of manual stop
-        if not failure_.check(exceptions.NotFound):
-            log.error("Error while stopping OTR encryption: {msg}".format(msg=failure_))
-
-    def is_trusted(self):
-        # we have to check value because potr code says that a 2-tuples should be
-        # returned while in practice it's either None or u"trusted"
-        trusted = self.getCurrentTrust()
-        if trusted is None:
-            return False
-        elif trusted == 'trusted':
-            return True
-        else:
-            log.error("Unexpected getCurrentTrust() value: {value}".format(
-                value=trusted))
-            return False
-
-    def set_state(self, state):
-        client = self.user.client
-        old_state = self.state
-        super(Context, self).set_state(state)
-        log.debug("set_state: %s (old_state=%s)" % (state, old_state))
-
-        if state == potr.context.STATE_PLAINTEXT:
-            feedback = _("/!\\ conversation with %(other_jid)s is now UNENCRYPTED") % {
-                "other_jid": self.peer.full()
-            }
-            d = defer.ensureDeferred(client.encryption.stop(self.peer, NS_OTR))
-            d.addCallback(self.stop_cb, feedback=feedback)
-            d.addErrback(self.stop_eb)
-            return
-        elif state == potr.context.STATE_ENCRYPTED:
-            defer.ensureDeferred(client.encryption.start(self.peer, NS_OTR))
-            try:
-                trusted = self.is_trusted()
-            except TypeError:
-                trusted = False
-            trusted_str = _("trusted") if trusted else _("untrusted")
-
-            if old_state == potr.context.STATE_ENCRYPTED:
-                feedback = D_(
-                    "{trusted} OTR conversation with {other_jid} REFRESHED"
-                ).format(trusted=trusted_str, other_jid=self.peer.full())
-            else:
-                feedback = D_(
-                    "{trusted} encrypted OTR conversation started with {other_jid}\n"
-                    "{extra_info}"
-                ).format(
-                    trusted=trusted_str,
-                    other_jid=self.peer.full(),
-                    extra_info=NO_ADV_FEATURES,
-                )
-            self.host.bridge.otr_state(
-                OTR_STATE_ENCRYPTED, self.peer.full(), client.profile
-            )
-        elif state == potr.context.STATE_FINISHED:
-            feedback = D_("OTR conversation with {other_jid} is FINISHED").format(
-                other_jid=self.peer.full()
-            )
-            d = defer.ensureDeferred(client.encryption.stop(self.peer, NS_OTR))
-            d.addCallback(self.stop_cb, feedback=feedback)
-            d.addErrback(self.stop_eb)
-            return
-        else:
-            log.error(D_("Unknown OTR state"))
-            return
-
-        client.feedback(self.peer, feedback)
-
-    def disconnect(self):
-        """Disconnect the session."""
-        if self.state != potr.context.STATE_PLAINTEXT:
-            super(Context, self).disconnect()
-
-    def finish(self):
-        """Finish the session
-
-        avoid to send any message but the user still has to end the session himself.
-        """
-        if self.state == potr.context.STATE_ENCRYPTED:
-            self.processTLVs([potr.proto.DisconnectTLV()])
-
-
-class Account(potr.context.Account):
-    # TODO: manage trusted keys: if a fingerprint is not used anymore,
-    #       we have no way to remove it from database yet (same thing for a
-    #       correspondent jid)
-    # TODO: manage explicit message encryption
-
-    def __init__(self, host, client):
-        log.debug("new account: %s" % client.jid)
-        if not client.jid.resource:
-            log.warning("Account created without resource")
-        super(Account, self).__init__(str(client.jid), "xmpp", 1024)
-        self.host = host
-        self.client = client
-
-    def load_privkey(self):
-        log.debug("load_privkey")
-        return self.privkey
-
-    def save_privkey(self):
-        log.debug("save_privkey")
-        if self.privkey is None:
-            raise exceptions.InternalError(_("Save is called but privkey is None !"))
-        priv_key = hexlify(self.privkey.serializePrivateKey())
-        encrypted_priv_key = self.host.memory.encrypt_value(priv_key, self.client.profile)
-        self.client._otr_data[PRIVATE_KEY] = encrypted_priv_key
-
-    def load_trusts(self):
-        trust_data = self.client._otr_data.get("trust", {})
-        for jid_, jid_data in trust_data.items():
-            for fingerprint, trust_level in jid_data.items():
-                log.debug(
-                    'setting trust for {jid}: [{fingerprint}] = "{trust_level}"'.format(
-                        jid=jid_, fingerprint=fingerprint, trust_level=trust_level
-                    )
-                )
-                self.trusts.setdefault(jid.JID(jid_), {})[fingerprint] = trust_level
-
-    def save_trusts(self):
-        log.debug("saving trusts for {profile}".format(profile=self.client.profile))
-        log.debug("trusts = {}".format(self.client._otr_data["trust"]))
-        self.client._otr_data.force("trust")
-
-    def set_trust(self, other_jid, fingerprint, trustLevel):
-        try:
-            trust_data = self.client._otr_data["trust"]
-        except KeyError:
-            trust_data = {}
-            self.client._otr_data["trust"] = trust_data
-        jid_data = trust_data.setdefault(other_jid.full(), {})
-        jid_data[fingerprint] = trustLevel
-        super(Account, self).set_trust(other_jid, fingerprint, trustLevel)
-
-
-class ContextManager(object):
-    def __init__(self, parent, client):
-        self.parent = parent
-        self.account = Account(parent.host, client)
-        self.contexts = {}
-
-    @property
-    def host(self):
-        return self.parent.host
-
-    def start_context(self, other_jid):
-        assert isinstance(other_jid, jid.JID)
-        context = self.contexts.setdefault(
-            other_jid, Context(self, other_jid)
-        )
-        return context
-
-    def get_context_for_user(self, other):
-        log.debug("get_context_for_user [%s]" % other)
-        if not other.resource:
-            log.warning("get_context_for_user called with a bare jid: %s" % other.full())
-        return self.start_context(other)
-
-
-class OTR(object):
-
-    def __init__(self, host):
-        log.info(_("OTR plugin initialization"))
-        self.host = host
-        self.context_managers = {}
-        self.skipped_profiles = (
-            set()
-        )  #  FIXME: OTR should not be skipped per profile, this need to be refactored
-        self._p_hints = host.plugins["XEP-0334"]
-        self._p_carbons = host.plugins["XEP-0280"]
-        host.trigger.add("message_received", self.message_received_trigger, priority=100000)
-        host.trigger.add("sendMessage", self.send_message_trigger, priority=100000)
-        host.trigger.add("send_message_data", self._send_message_data_trigger)
-        host.bridge.add_method(
-            "skip_otr", ".plugin", in_sign="s", out_sign="", method=self._skip_otr
-        )  # FIXME: must be removed, must be done on per-message basis
-        host.bridge.add_signal(
-            "otr_state", ".plugin", signature="sss"
-        )  # args: state, destinee_jid, profile
-        # XXX: menus are disabled in favor to the new more generic encryption menu
-        #      there are let here commented for a little while as a reference
-        # host.import_menu(
-        #     (OTR_MENU, D_(u"Start/Refresh")),
-        #     self._otr_start_refresh,
-        #     security_limit=0,
-        #     help_string=D_(u"Start or refresh an OTR session"),
-        #     type_=C.MENU_SINGLE,
-        # )
-        # host.import_menu(
-        #     (OTR_MENU, D_(u"End session")),
-        #     self._otr_session_end,
-        #     security_limit=0,
-        #     help_string=D_(u"Finish an OTR session"),
-        #     type_=C.MENU_SINGLE,
-        # )
-        # host.import_menu(
-        #     (OTR_MENU, D_(u"Authenticate")),
-        #     self._otr_authenticate,
-        #     security_limit=0,
-        #     help_string=D_(u"Authenticate user/see your fingerprint"),
-        #     type_=C.MENU_SINGLE,
-        # )
-        # host.import_menu(
-        #     (OTR_MENU, D_(u"Drop private key")),
-        #     self._drop_priv_key,
-        #     security_limit=0,
-        #     type_=C.MENU_SINGLE,
-        # )
-        host.trigger.add("presence_received", self._presence_received_trigger)
-        self.host.register_encryption_plugin(self, "OTR", NS_OTR, directed=True)
-
-    def _skip_otr(self, profile):
-        """Tell the backend to not handle OTR for this profile.
-
-        @param profile (str): %(doc_profile)s
-        """
-        # FIXME: should not be done per profile but per message, using extra data
-        #        for message received, profile wide hook may be need, but client
-        #        should be used anyway instead of a class attribute
-        self.skipped_profiles.add(profile)
-
-    @defer.inlineCallbacks
-    def profile_connecting(self, client):
-        if client.profile in self.skipped_profiles:
-            return
-        ctxMng = client._otr_context_manager = ContextManager(self, client)
-        client._otr_data = persistent.PersistentBinaryDict(NS_OTR, client.profile)
-        yield client._otr_data.load()
-        encrypted_priv_key = client._otr_data.get(PRIVATE_KEY, None)
-        if encrypted_priv_key is not None:
-            priv_key = self.host.memory.decrypt_value(
-                encrypted_priv_key, client.profile
-            )
-            ctxMng.account.privkey = potr.crypt.PK.parsePrivateKey(
-                unhexlify(priv_key.encode('utf-8'))
-            )[0]
-        else:
-            ctxMng.account.privkey = None
-        ctxMng.account.load_trusts()
-
-    def profile_disconnected(self, client):
-        if client.profile in self.skipped_profiles:
-            self.skipped_profiles.remove(client.profile)
-            return
-        for context in list(client._otr_context_manager.contexts.values()):
-            context.disconnect()
-        del client._otr_context_manager
-
-    # encryption plugin methods
-
-    def start_encryption(self, client, entity_jid):
-        self.start_refresh(client, entity_jid)
-
-    def stop_encryption(self, client, entity_jid):
-        self.end_session(client, entity_jid)
-
-    def get_trust_ui(self, client, entity_jid):
-        if not entity_jid.resource:
-            entity_jid.resource = self.host.memory.main_resource_get(
-                client, entity_jid
-            )  # FIXME: temporary and unsecure, must be changed when frontends
-               #        are refactored
-        ctxMng = client._otr_context_manager
-        otrctx = ctxMng.get_context_for_user(entity_jid)
-        priv_key = ctxMng.account.privkey
-
-        if priv_key is None:
-            # we have no private key yet
-            dialog = xml_tools.XMLUI(
-                C.XMLUI_DIALOG,
-                dialog_opt={
-                    C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_MESSAGE,
-                    C.XMLUI_DATA_MESS: _(
-                        "You have no private key yet, start an OTR conversation to "
-                        "have one"
-                    ),
-                    C.XMLUI_DATA_LVL: C.XMLUI_DATA_LVL_WARNING,
-                },
-                title=_("No private key"),
-            )
-            return dialog
-
-        other_fingerprint = otrctx.getCurrentKey()
-
-        if other_fingerprint is None:
-            # we have a private key, but not the fingerprint of our correspondent
-            dialog = xml_tools.XMLUI(
-                C.XMLUI_DIALOG,
-                dialog_opt={
-                    C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_MESSAGE,
-                    C.XMLUI_DATA_MESS: _(
-                        "Your fingerprint is:\n{fingerprint}\n\n"
-                        "Start an OTR conversation to have your correspondent one."
-                    ).format(fingerprint=priv_key),
-                    C.XMLUI_DATA_LVL: C.XMLUI_DATA_LVL_INFO,
-                },
-                title=_("Fingerprint"),
-            )
-            return dialog
-
-        def set_trust(raw_data, profile):
-            if xml_tools.is_xmlui_cancelled(raw_data):
-                return {}
-            # This method is called when authentication form is submited
-            data = xml_tools.xmlui_result_2_data_form_result(raw_data)
-            if data["match"] == "yes":
-                otrctx.setCurrentTrust(OTR_STATE_TRUSTED)
-                note_msg = _("Your correspondent {correspondent} is now TRUSTED")
-                self.host.bridge.otr_state(
-                    OTR_STATE_TRUSTED, entity_jid.full(), client.profile
-                )
-            else:
-                otrctx.setCurrentTrust("")
-                note_msg = _("Your correspondent {correspondent} is now UNTRUSTED")
-                self.host.bridge.otr_state(
-                    OTR_STATE_UNTRUSTED, entity_jid.full(), client.profile
-                )
-            note = xml_tools.XMLUI(
-                C.XMLUI_DIALOG,
-                dialog_opt={
-                    C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE,
-                    C.XMLUI_DATA_MESS: note_msg.format(correspondent=otrctx.peer),
-                },
-            )
-            return {"xmlui": note.toXml()}
-
-        submit_id = self.host.register_callback(set_trust, with_data=True, one_shot=True)
-        trusted = otrctx.is_trusted()
-
-        xmlui = xml_tools.XMLUI(
-            C.XMLUI_FORM,
-            title=_("Authentication ({entity_jid})").format(entity_jid=entity_jid.full()),
-            submit_id=submit_id,
-        )
-        xmlui.addText(_(AUTH_TXT))
-        xmlui.addDivider()
-        xmlui.addText(
-            D_("Your own fingerprint is:\n{fingerprint}").format(fingerprint=priv_key)
-        )
-        xmlui.addText(
-            D_("Your correspondent fingerprint should be:\n{fingerprint}").format(
-                fingerprint=other_fingerprint
-            )
-        )
-        xmlui.addDivider("blank")
-        xmlui.change_container("pairs")
-        xmlui.addLabel(D_("Is your correspondent fingerprint the same as here ?"))
-        xmlui.addList(
-            "match", [("yes", _("yes")), ("no", _("no"))], ["yes" if trusted else "no"]
-        )
-        return xmlui
-
-    def _otr_start_refresh(self, menu_data, profile):
-        """Start or refresh an OTR session
-
-        @param menu_data: %(menu_data)s
-        @param profile: %(doc_profile)s
-        """
-        client = self.host.get_client(profile)
-        try:
-            to_jid = jid.JID(menu_data["jid"])
-        except KeyError:
-            log.error(_("jid key is not present !"))
-            return defer.fail(exceptions.DataError)
-        self.start_refresh(client, to_jid)
-        return {}
-
-    def start_refresh(self, client, to_jid):
-        """Start or refresh an OTR session
-
-        @param to_jid(jid.JID): jid to start encrypted session with
-        """
-        encrypted_session = client.encryption.getSession(to_jid.userhostJID())
-        if encrypted_session and encrypted_session['plugin'].namespace != NS_OTR:
-            raise exceptions.ConflictError(_(
-                "Can't start an OTR session, there is already an encrypted session "
-                "with {name}").format(name=encrypted_session['plugin'].name))
-        if not to_jid.resource:
-            to_jid.resource = self.host.memory.main_resource_get(
-                client, to_jid
-            )  # FIXME: temporary and unsecure, must be changed when frontends
-               #        are refactored
-        otrctx = client._otr_context_manager.get_context_for_user(to_jid)
-        query = otrctx.sendMessage(0, b"?OTRv?")
-        otrctx.inject(query)
-
-    def _otr_session_end(self, menu_data, profile):
-        """End an OTR session
-
-        @param menu_data: %(menu_data)s
-        @param profile: %(doc_profile)s
-        """
-        client = self.host.get_client(profile)
-        try:
-            to_jid = jid.JID(menu_data["jid"])
-        except KeyError:
-            log.error(_("jid key is not present !"))
-            return defer.fail(exceptions.DataError)
-        self.end_session(client, to_jid)
-        return {}
-
-    def end_session(self, client, to_jid):
-        """End an OTR session"""
-        if not to_jid.resource:
-            to_jid.resource = self.host.memory.main_resource_get(
-                client, to_jid
-            )  # FIXME: temporary and unsecure, must be changed when frontends
-               #        are refactored
-        otrctx = client._otr_context_manager.get_context_for_user(to_jid)
-        otrctx.disconnect()
-        return {}
-
-    def _otr_authenticate(self, menu_data, profile):
-        """End an OTR session
-
-        @param menu_data: %(menu_data)s
-        @param profile: %(doc_profile)s
-        """
-        client = self.host.get_client(profile)
-        try:
-            to_jid = jid.JID(menu_data["jid"])
-        except KeyError:
-            log.error(_("jid key is not present !"))
-            return defer.fail(exceptions.DataError)
-        return self.authenticate(client, to_jid)
-
-    def authenticate(self, client, to_jid):
-        """Authenticate other user and see our own fingerprint"""
-        xmlui = self.get_trust_ui(client, to_jid)
-        return {"xmlui": xmlui.toXml()}
-
-    def _drop_priv_key(self, menu_data, profile):
-        """Drop our private Key
-
-        @param menu_data: %(menu_data)s
-        @param profile: %(doc_profile)s
-        """
-        client = self.host.get_client(profile)
-        try:
-            to_jid = jid.JID(menu_data["jid"])
-            if not to_jid.resource:
-                to_jid.resource = self.host.memory.main_resource_get(
-                    client, to_jid
-                )  # FIXME: temporary and unsecure, must be changed when frontends
-                   #        are refactored
-        except KeyError:
-            log.error(_("jid key is not present !"))
-            return defer.fail(exceptions.DataError)
-
-        ctxMng = client._otr_context_manager
-        if ctxMng.account.privkey is None:
-            return {
-                "xmlui": xml_tools.note(_("You don't have a private key yet !")).toXml()
-            }
-
-        def drop_key(data, profile):
-            if C.bool(data["answer"]):
-                # we end all sessions
-                for context in list(ctxMng.contexts.values()):
-                    context.disconnect()
-                ctxMng.account.privkey = None
-                ctxMng.account.getPrivkey()  # as account.privkey is None, getPrivkey
-                                             # will generate a new key, and save it
-                return {
-                    "xmlui": xml_tools.note(
-                        D_("Your private key has been dropped")
-                    ).toXml()
-                }
-            return {}
-
-        submit_id = self.host.register_callback(drop_key, with_data=True, one_shot=True)
-
-        confirm = xml_tools.XMLUI(
-            C.XMLUI_DIALOG,
-            title=_("Confirm private key drop"),
-            dialog_opt={"type": C.XMLUI_DIALOG_CONFIRM, "message": _(DROP_TXT)},
-            submit_id=submit_id,
-        )
-        return {"xmlui": confirm.toXml()}
-
-    def _received_treatment(self, data, client):
-        from_jid = data["from"]
-        log.debug("_received_treatment [from_jid = %s]" % from_jid)
-        otrctx = client._otr_context_manager.get_context_for_user(from_jid)
-
-        try:
-            message = (
-                next(iter(data["message"].values()))
-            )  # FIXME: Q&D fix for message refactoring, message is now a dict
-            res = otrctx.receiveMessage(message.encode("utf-8"))
-        except (potr.context.UnencryptedMessage, potr.context.NotOTRMessage):
-            # potr has a bug with Python 3 and test message against str while bytes are
-            # expected, resulting in a NoOTRMessage raised instead of UnencryptedMessage;
-            # so we catch NotOTRMessage as a workaround
-            # TODO: report this upstream
-            encrypted = False
-            if otrctx.state == potr.context.STATE_ENCRYPTED:
-                log.warning(
-                    "Received unencrypted message in an encrypted context (from {jid})"
-                    .format(jid=from_jid.full())
-                )
-
-                feedback = (
-                    D_(
-                        "WARNING: received unencrypted data in a supposedly encrypted "
-                        "context"
-                    ),
-                )
-                client.feedback(from_jid, feedback)
-        except potr.context.NotEncryptedError:
-            msg = D_("WARNING: received OTR encrypted data in an unencrypted context")
-            log.warning(msg)
-            feedback = msg
-            client.feedback(from_jid, msg)
-            raise failure.Failure(exceptions.CancelError(msg))
-        except potr.context.ErrorReceived as e:
-            msg = D_("WARNING: received OTR error message: {msg}".format(msg=e))
-            log.warning(msg)
-            feedback = msg
-            client.feedback(from_jid, msg)
-            raise failure.Failure(exceptions.CancelError(msg))
-        except potr.crypt.InvalidParameterError as e:
-            msg = D_("Error while trying de decrypt OTR message: {msg}".format(msg=e))
-            log.warning(msg)
-            feedback = msg
-            client.feedback(from_jid, msg)
-            raise failure.Failure(exceptions.CancelError(msg))
-        except StopIteration:
-            return data
-        else:
-            encrypted = True
-
-        if encrypted:
-            if res[0] != None:
-                # decrypted messages handling.
-                # receiveMessage() will return a tuple,
-                # the first part of which will be the decrypted message
-                data["message"] = {
-                    "": res[0]
-                }  # FIXME: Q&D fix for message refactoring, message is now a dict
-                try:
-                    # we want to keep message in history, even if no store is
-                    # requested in message hints
-                    del data["history"]
-                except KeyError:
-                    pass
-                # TODO: add skip history as an option, but by default we don't skip it
-                # data[u'history'] = C.HISTORY_SKIP # we send the decrypted message to
-                                                    # frontends, but we don't want it in
-                                                    # history
-            else:
-                raise failure.Failure(
-                    exceptions.CancelError("Cancelled by OTR")
-                )  # no message at all (no history, no signal)
-
-            client.encryption.mark_as_encrypted(data, namespace=NS_OTR)
-            trusted = otrctx.is_trusted()
-
-            if trusted:
-                client.encryption.mark_as_trusted(data)
-            else:
-                client.encryption.mark_as_untrusted(data)
-
-        return data
-
-    def _received_treatment_for_skipped_profiles(self, data):
-        """This profile must be skipped because the frontend manages OTR itself,
-
-        but we still need to check if the message must be stored in history or not
-        """
-        #  XXX: FIXME: this should not be done on a per-profile basis, but  per-message
-        try:
-            message = (
-                iter(data["message"].values()).next().encode("utf-8")
-            )  # FIXME: Q&D fix for message refactoring, message is now a dict
-        except StopIteration:
-            return data
-        if message.startswith(potr.proto.OTRTAG):
-            #  FIXME: it may be better to cancel the message and send it direclty to
-            #         bridge
-            #        this is used by Libervia, but this may send garbage message to
-            #        other frontends
-            #        if they are used at the same time as Libervia.
-            #        Hard to avoid with decryption on Libervia though.
-            data["history"] = C.HISTORY_SKIP
-        return data
-
-    def message_received_trigger(self, client, message_elt, post_treat):
-        if client.is_component:
-            return True
-        if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT:
-            # OTR is not possible in group chats
-            return True
-        from_jid = jid.JID(message_elt['from'])
-        if not from_jid.resource or from_jid.userhostJID() == client.jid.userhostJID():
-            # OTR is only usable when resources are present
-            return True
-        if client.profile in self.skipped_profiles:
-            post_treat.addCallback(self._received_treatment_for_skipped_profiles)
-        else:
-            post_treat.addCallback(self._received_treatment, client)
-        return True
-
-    def _send_message_data_trigger(self, client, mess_data):
-        if client.is_component:
-            return True
-        encryption = mess_data.get(C.MESS_KEY_ENCRYPTION)
-        if encryption is None or encryption['plugin'].namespace != NS_OTR:
-            return
-        to_jid = mess_data['to']
-        if not to_jid.resource:
-            to_jid.resource = self.host.memory.main_resource_get(
-                client, to_jid
-            )  # FIXME: temporary and unsecure, must be changed when frontends
-        otrctx = client._otr_context_manager.get_context_for_user(to_jid)
-        message_elt = mess_data["xml"]
-        if otrctx.state == potr.context.STATE_ENCRYPTED:
-            log.debug("encrypting message")
-            body = None
-            for child in list(message_elt.children):
-                if child.name == "body":
-                    # we remove all unencrypted body,
-                    # and will only encrypt the first one
-                    if body is None:
-                        body = child
-                    message_elt.children.remove(child)
-                elif child.name == "html":
-                    # we don't want any XHTML-IM element
-                    message_elt.children.remove(child)
-            if body is None:
-                log.warning("No message found")
-            else:
-                self._p_carbons.set_private(message_elt)
-                self._p_hints.add_hint_elements(message_elt, [
-                    self._p_hints.HINT_NO_COPY,
-                    self._p_hints.HINT_NO_PERMANENT_STORE])
-                otrctx.sendMessage(0, str(body).encode("utf-8"), appdata=mess_data)
-        else:
-            feedback = D_(
-                "Your message was not sent because your correspondent closed the "
-                "encrypted conversation on his/her side. "
-                "Either close your own side, or refresh the session."
-            )
-            log.warning(_("Message discarded because closed encryption channel"))
-            client.feedback(to_jid, feedback)
-            raise failure.Failure(exceptions.CancelError("Cancelled by OTR plugin"))
-
-    def send_message_trigger(self, client, mess_data, pre_xml_treatments,
-                           post_xml_treatments):
-        if client.is_component:
-            return True
-        if mess_data["type"] == "groupchat":
-            return True
-
-        if client.profile in self.skipped_profiles:
-            #  FIXME: should not be done on a per-profile basis
-            return True
-
-        to_jid = copy.copy(mess_data["to"])
-        if client.encryption.getSession(to_jid.userhostJID()):
-            # there is already an encrypted session with this entity
-            return True
-
-        if not to_jid.resource:
-            to_jid.resource = self.host.memory.main_resource_get(
-                client, to_jid
-            )  # FIXME: full jid may not be known
-
-        otrctx = client._otr_context_manager.get_context_for_user(to_jid)
-
-        if otrctx.state != potr.context.STATE_PLAINTEXT:
-            defer.ensureDeferred(client.encryption.start(to_jid, NS_OTR))
-            client.encryption.set_encryption_flag(mess_data)
-            if not mess_data["to"].resource:
-                # if not resource was given, we force it here
-                mess_data["to"] = to_jid
-        return True
-
-    def _presence_received_trigger(self, client, entity, show, priority, statuses):
-        if show != C.PRESENCE_UNAVAILABLE:
-            return True
-        if not entity.resource:
-            try:
-                entity.resource = self.host.memory.main_resource_get(
-                    client, entity
-                )  # FIXME: temporary and unsecure, must be changed when frontends
-                   #        are refactored
-            except exceptions.UnknownEntityError:
-                return True  #  entity was not connected
-        if entity in client._otr_context_manager.contexts:
-            otrctx = client._otr_context_manager.get_context_for_user(entity)
-            otrctx.disconnect()
-        return True
--- a/sat/plugins/plugin_sec_oxps.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,788 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for Pubsub Encryption
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import base64
-import dataclasses
-import secrets
-import time
-from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
-from collections import OrderedDict
-
-import shortuuid
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid, xmlstream
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from wokkel import rsm
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.memory import persistent
-from sat.tools import utils
-from sat.tools import xml_tools
-from sat.tools.common import data_format
-from sat.tools.common import uri
-from sat.tools.common.async_utils import async_lru
-
-from .plugin_xep_0373 import NS_OX, get_gpg_provider
-
-
-log = getLogger(__name__)
-
-IMPORT_NAME = "OXPS"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "OpenPGP for XMPP Pubsub",
-    C.PI_IMPORT_NAME: IMPORT_NAME,
-    C.PI_TYPE: C.PLUG_TYPE_XEP,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0334", "XEP-0373"],
-    C.PI_MAIN: "PubsubEncryption",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Pubsub e2e encryption via OpenPGP"""),
-}
-NS_OXPS = "urn:xmpp:openpgp:pubsub:0"
-
-KEY_REVOKED = "revoked"
-CACHE_MAX = 5
-
-
-@dataclasses.dataclass
-class SharedSecret:
-    id: str
-    key: str
-    timestamp: float
-    # bare JID of who has generated the secret
-    origin: jid.JID
-    revoked: bool = False
-    shared_with: Set[jid.JID] = dataclasses.field(default_factory=set)
-
-
-class PubsubEncryption:
-    namespace = NS_OXPS
-
-    def __init__(self, host):
-        log.info(_("OpenPGP for XMPP Pubsub plugin initialization"))
-        host.register_namespace("oxps", NS_OXPS)
-        self.host = host
-        self._p = host.plugins["XEP-0060"]
-        self._h = host.plugins["XEP-0334"]
-        self._ox = host.plugins["XEP-0373"]
-        host.trigger.add("XEP-0060_publish", self._publish_trigger)
-        host.trigger.add("XEP-0060_items", self._items_trigger)
-        host.trigger.add(
-            "message_received",
-            self._message_received_trigger,
-        )
-        host.bridge.add_method(
-            "ps_secret_share",
-            ".plugin",
-            in_sign="sssass",
-            out_sign="",
-            method=self._ps_secret_share,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_secret_revoke",
-            ".plugin",
-            in_sign="sssass",
-            out_sign="",
-            method=self._ps_secret_revoke,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_secret_rotate",
-            ".plugin",
-            in_sign="ssass",
-            out_sign="",
-            method=self._ps_secret_rotate,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_secrets_list",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=self._ps_secrets_list,
-            async_=True,
-        )
-
-    def get_handler(self, client):
-        return PubsubEncryption_Handler()
-
-    async def profile_connecting(self, client):
-        client.__storage = persistent.LazyPersistentBinaryDict(
-            IMPORT_NAME, client.profile
-        )
-        # cache to avoid useless DB access, and to avoid race condition by ensuring that
-        # the same shared_secrets instance is always used for a given node.
-        client.__cache = OrderedDict()
-        self.gpg_provider = get_gpg_provider(self.host, client)
-
-    async def load_secrets(
-        self,
-        client: SatXMPPEntity,
-        node_uri: str
-    ) -> Optional[Dict[str, SharedSecret]]:
-        """Load shared secret from databse or cache
-
-        A cache is used per client to avoid usueless db access, as shared secrets are
-        often needed several times in a row. Cache is also necessary to avoir race
-        condition, when updating a secret, by ensuring that the same instance is used
-        for all updates during a session.
-
-        @param node_uri: XMPP URI of the encrypted pubsub node
-        @return shared secrets, or None if no secrets are known yet
-        """
-        try:
-            shared_secrets = client.__cache[node_uri]
-        except KeyError:
-            pass
-        else:
-            client.__cache.move_to_end(node_uri)
-            return shared_secrets
-
-        secrets_as_dict = await client.__storage.get(node_uri)
-
-        if secrets_as_dict is None:
-            return None
-        else:
-            shared_secrets = {
-                s["id"]: SharedSecret(
-                    id=s["id"],
-                    key=s["key"],
-                    timestamp=s["timestamp"],
-                    origin=jid.JID(s["origin"]),
-                    revoked=s["revoked"],
-                    shared_with={jid.JID(w) for w in s["shared_with"]}
-                ) for s in secrets_as_dict
-            }
-            client.__cache[node_uri] = shared_secrets
-            while len(client.__cache) > CACHE_MAX:
-                client.__cache.popitem(False)
-            return shared_secrets
-
-    def __secrect_dict_factory(self, data: List[Tuple[str, Any]]) -> Dict[str, Any]:
-        ret = {}
-        for k, v in data:
-            if k == "origin":
-                v = v.full()
-            elif k == "shared_with":
-                v = [j.full() for j in v]
-            ret[k] = v
-        return ret
-
-    async def store_secrets(
-        self,
-        client: SatXMPPEntity,
-        node_uri: str,
-        shared_secrets: Dict[str, SharedSecret]
-    ) -> None:
-        """Store shared secrets to database
-
-        Shared secrets are serialised before being stored.
-        If ``node_uri`` is not in cache, the shared_secrets instance is also put in cache/
-
-        @param node_uri: XMPP URI of the encrypted pubsub node
-        @param shared_secrets: shared secrets to store
-        """
-        if node_uri not in client.__cache:
-            client.__cache[node_uri] = shared_secrets
-            while len(client.__cache) > CACHE_MAX:
-                client.__cache.popitem(False)
-
-        secrets_as_dict = [
-            dataclasses.asdict(s, dict_factory=self.__secrect_dict_factory)
-            for s in shared_secrets.values()
-        ]
-        await client.__storage.aset(node_uri, secrets_as_dict)
-
-    def generate_secret(self, client: SatXMPPEntity) -> SharedSecret:
-        """Generate a new shared secret"""
-        log.info("Generating a new shared secret.")
-        secret_key = secrets.token_urlsafe(64)
-        secret_id = shortuuid.uuid()
-        return SharedSecret(
-            id = secret_id,
-            key = secret_key,
-            timestamp = time.time(),
-            origin = client.jid.userhostJID()
-        )
-
-    def _ps_secret_revoke(
-        self,
-        service: str,
-        node: str,
-        secret_id: str,
-        recipients: List[str],
-        profile_key: str
-    ) -> defer.Deferred:
-        return defer.ensureDeferred(
-            self.revoke(
-                self.host.get_client(profile_key),
-                jid.JID(service) if service else None,
-                node,
-                secret_id,
-                [jid.JID(r) for r in recipients] or None,
-            )
-        )
-
-    async def revoke(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: str,
-        secret_id: str,
-        recipients: Optional[Iterable[jid.JID]] = None
-    ) -> None:
-        """Revoke a secret and notify entities
-
-        @param service: pubsub/PEP service where the node is
-        @param node: node name
-        @param secret_id: ID of the secret to revoke (must have been generated by
-            ourselves)
-        recipients: JIDs of entities to send the revocation notice to. If None, all
-            entities known to have the shared secret will be notified.
-            Use empty list if you don't want to notify anybody (not recommended)
-        """
-        if service is None:
-            service = client.jid.userhostJID()
-        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
-        shared_secrets = await self.load_secrets(client, node_uri)
-        if not shared_secrets:
-            raise exceptions.NotFound(f"No shared secret is known for {node_uri}")
-        try:
-            shared_secret = shared_secrets[secret_id]
-        except KeyError:
-            raise exceptions.NotFound(
-                f"No shared secret with ID {secret_id!r} has been found for {node_uri}"
-            )
-        else:
-            if shared_secret.origin != client.jid.userhostJID():
-                raise exceptions.PermissionError(
-                    f"The shared secret {shared_secret.id} originate from "
-                    f"{shared_secret.origin}, not you ({client.jid.userhostJID()}). You "
-                    "can't revoke it"
-                )
-            shared_secret.revoked = True
-        await self.store_secrets(client, node_uri, shared_secrets)
-        log.info(
-            f"shared secret {secret_id!r} for {node_uri} has been revoked."
-        )
-        if recipients is None:
-            recipients = shared_secret.shared_with
-        if recipients:
-            for recipient in recipients:
-                await self.send_revoke_notification(
-                    client, service, node, shared_secret.id, recipient
-                )
-            log.info(
-                f"shared secret {shared_secret.id} revocation notification for "
-                f"{node_uri} has been send to {''.join(str(r) for r in recipients)}"
-            )
-        else:
-            log.info(
-                "Due to empty recipients list, no revocation notification has been sent "
-                f"for shared secret {shared_secret.id} for {node_uri}"
-            )
-
-    async def send_revoke_notification(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        node: str,
-        secret_id: str,
-        recipient: jid.JID
-    ) -> None:
-        revoke_elt = domish.Element((NS_OXPS, "revoke"))
-        revoke_elt["jid"] = service.full()
-        revoke_elt["node"] = node
-        revoke_elt["id"] = secret_id
-        signcrypt_elt, payload_elt = self._ox.build_signcrypt_element([recipient])
-        payload_elt.addChild(revoke_elt)
-        openpgp_elt = await self._ox.build_openpgp_element(
-            client, signcrypt_elt, {recipient}
-        )
-        message_elt = domish.Element((None, "message"))
-        message_elt["from"] = client.jid.full()
-        message_elt["to"] = recipient.full()
-        message_elt.addChild((openpgp_elt))
-        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
-        client.send(message_elt)
-
-    def _ps_secret_share(
-        self,
-        recipient: str,
-        service: str,
-        node: str,
-        secret_ids: List[str],
-        profile_key: str
-    ) -> defer.Deferred:
-        return defer.ensureDeferred(
-            self.share_secrets(
-                self.host.get_client(profile_key),
-                jid.JID(recipient),
-                jid.JID(service) if service else None,
-                node,
-                secret_ids or None,
-            )
-        )
-
-    async def share_secret(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: str,
-        shared_secret: SharedSecret,
-        recipient: jid.JID
-    ) -> None:
-        """Create and send <shared-secret> element"""
-        if service is None:
-            service = client.jid.userhostJID()
-        shared_secret_elt = domish.Element((NS_OXPS, "shared-secret"))
-        shared_secret_elt["jid"] = service.full()
-        shared_secret_elt["node"] = node
-        shared_secret_elt["id"] = shared_secret.id
-        shared_secret_elt["timestamp"] = utils.xmpp_date(shared_secret.timestamp)
-        if shared_secret.revoked:
-            shared_secret_elt["revoked"] = C.BOOL_TRUE
-        # TODO: add type attribute
-        shared_secret_elt.addContent(shared_secret.key)
-        signcrypt_elt, payload_elt = self._ox.build_signcrypt_element([recipient])
-        payload_elt.addChild(shared_secret_elt)
-        openpgp_elt = await self._ox.build_openpgp_element(
-            client, signcrypt_elt, {recipient}
-        )
-        message_elt = domish.Element((None, "message"))
-        message_elt["from"] = client.jid.full()
-        message_elt["to"] = recipient.full()
-        message_elt.addChild((openpgp_elt))
-        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
-        client.send(message_elt)
-        shared_secret.shared_with.add(recipient)
-
-    async def share_secrets(
-        self,
-        client: SatXMPPEntity,
-        recipient: jid.JID,
-        service: Optional[jid.JID],
-        node: str,
-        secret_ids: Optional[List[str]] = None,
-    ) -> None:
-        """Share secrets of a pubsub node with a recipient
-
-        @param recipient: who to share secrets with
-        @param service: pubsub/PEP service where the node is
-        @param node: node name
-        @param secret_ids: IDs of the secrets to share, or None to share all known secrets
-            (disabled or not)
-        """
-        if service is None:
-            service = client.jid.userhostJID()
-        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
-        shared_secrets = await self.load_secrets(client, node_uri)
-        if shared_secrets is None:
-            # no secret shared yet, let's generate one
-            shared_secret = self.generate_secret(client)
-            shared_secrets = {shared_secret.id: shared_secret}
-            await self.store_secrets(client, node_uri, shared_secrets)
-        if secret_ids is None:
-            # we share all secrets of the node
-            to_share = shared_secrets.values()
-        else:
-            try:
-                to_share = [shared_secrets[s_id] for s_id in secret_ids]
-            except KeyError as e:
-                raise exceptions.NotFound(
-                    f"no shared secret found with given ID: {e}"
-                )
-        for shared_secret in to_share:
-            await self.share_secret(client, service, node, shared_secret, recipient)
-        await self.store_secrets(client, node_uri, shared_secrets)
-
-    def _ps_secret_rotate(
-        self,
-        service: str,
-        node: str,
-        recipients: List[str],
-        profile_key: str,
-    ) -> defer.Deferred:
-        return defer.ensureDeferred(
-            self.rotate_secret(
-                self.host.get_client(profile_key),
-                jid.JID(service) if service else None,
-                node,
-                [jid.JID(r) for r in recipients] or None
-            )
-        )
-
-    async def rotate_secret(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: str,
-        recipients: Optional[List[jid.JID]] = None
-    ) -> None:
-        """Revoke all current known secrets, create and share a new one
-
-        @param service: pubsub/PEP service where the node is
-        @param node: node name
-        @param recipients: who must receive the new shared secret
-            if None, all recipients known to have last active shared secret will get the
-            new secret
-        """
-        if service is None:
-            service = client.jid.userhostJID()
-        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
-        shared_secrets = await self.load_secrets(client, node_uri)
-        if shared_secrets is None:
-            shared_secrets = {}
-        for shared_secret in shared_secrets.values():
-            if not shared_secret.revoked:
-                await self.revoke(client, service, node, shared_secret.id)
-                shared_secret.revoked = True
-
-        if recipients is None:
-            if shared_secrets:
-                # we get recipients from latests shared secret's shared_with list,
-                # regarless of deprecation (cause all keys may be deprecated)
-                recipients = list(sorted(
-                    shared_secrets.values(),
-                    key=lambda s: s.timestamp,
-                    reverse=True
-                )[0].shared_with)
-            else:
-                recipients = []
-
-        shared_secret = self.generate_secret(client)
-        shared_secrets[shared_secret.id] = shared_secret
-        # we send notification to last entities known to already have the shared secret
-        for recipient in recipients:
-            await self.share_secret(client, service, node, shared_secret, recipient)
-        await self.store_secrets(client, node_uri, shared_secrets)
-
-    def _ps_secrets_list(
-        self,
-        service: str,
-        node: str,
-        profile_key: str
-    ) -> defer.Deferred:
-        d = defer.ensureDeferred(
-            self.list_shared_secrets(
-                self.host.get_client(profile_key),
-                jid.JID(service) if service else None,
-                node,
-            )
-        )
-        d.addCallback(lambda ret: data_format.serialise(ret))
-        return d
-
-    async def list_shared_secrets(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: str,
-    ) -> List[Dict[str, Any]]:
-        """Retrieve for shared secrets of a pubsub node
-
-        @param service: pubsub/PEP service where the node is
-        @param node: node name
-        @return: shared secrets data
-        @raise exceptions.NotFound: no shared secret found for this node
-        """
-        if service is None:
-            service = client.jid.userhostJID()
-        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
-        shared_secrets = await self.load_secrets(client, node_uri)
-        if shared_secrets is None:
-            raise exceptions.NotFound(f"No shared secrets found for {node_uri}")
-        return [
-            dataclasses.asdict(s, dict_factory=self.__secrect_dict_factory)
-            for s in shared_secrets.values()
-        ]
-
-    async def handle_revoke_elt(
-        self,
-        client: SatXMPPEntity,
-        sender: jid.JID,
-        revoke_elt: domish.Element
-    ) -> None:
-        """Parse a <revoke> element and update local secrets
-
-        @param sender: bare jid of the entity who has signed the secret
-        @param revoke: <revoke/> element
-        """
-        try:
-            service = jid.JID(revoke_elt["jid"])
-            node = revoke_elt["node"]
-            secret_id = revoke_elt["id"]
-        except (KeyError, RuntimeError) as e:
-            log.warning(
-                f"ignoring invalid <revoke> element: {e}\n{revoke_elt.toXml()}"
-            )
-            return
-        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
-        shared_secrets = await self.load_secrets(client, node_uri)
-        if shared_secrets is None:
-            log.warning(
-                f"Can't revoke shared secret {secret_id}: no known shared secrets for "
-                f"{node_uri}"
-            )
-            return
-
-        if any(s.origin != sender for s in shared_secrets.values()):
-            log.warning(
-                f"Rejecting shared secret revocation signed by invalid entity ({sender}):"
-                f"\n{revoke_elt.toXml}"
-            )
-            return
-
-        try:
-            shared_secret = shared_secrets[secret_id]
-        except KeyError:
-            log.warning(
-                f"Can't revoke shared secret {secret_id}: this secret ID is unknown for "
-                f"{node_uri}"
-            )
-            return
-
-        shared_secret.revoked = True
-        await self.store_secrets(client, node_uri, shared_secrets)
-        log.info(f"Shared secret {secret_id} has been revoked for {node_uri}")
-
-    async def handle_shared_secret_elt(
-        self,
-        client: SatXMPPEntity,
-        sender: jid.JID,
-        shared_secret_elt: domish.Element
-    ) -> None:
-        """Parse a <shared-secret> element and update local secrets
-
-        @param sender: bare jid of the entity who has signed the secret
-        @param shared_secret_elt: <shared-secret/> element
-        """
-        try:
-            service = jid.JID(shared_secret_elt["jid"])
-            node = shared_secret_elt["node"]
-            secret_id = shared_secret_elt["id"]
-            timestamp = utils.parse_xmpp_date(shared_secret_elt["timestamp"])
-            # TODO: handle "type" attribute
-            revoked = C.bool(shared_secret_elt.getAttribute("revoked", C.BOOL_FALSE))
-        except (KeyError, RuntimeError, ValueError) as e:
-            log.warning(
-                f"ignoring invalid <shared-secret> element: "
-                f"{e}\n{shared_secret_elt.toXml()}"
-            )
-            return
-        key = str(shared_secret_elt)
-        if not key:
-            log.warning(
-                "ignoring <shared-secret> element with empty key: "
-                f"{shared_secret_elt.toXml()}"
-            )
-            return
-        shared_secret = SharedSecret(
-            id=secret_id, key=key, timestamp=timestamp, origin=sender, revoked=revoked
-        )
-        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
-        shared_secrets = await self.load_secrets(client, node_uri)
-        if shared_secrets is None:
-            shared_secrets = {}
-            # no known shared secret yet for this node, we have to trust first user who
-            # send it
-        else:
-            if any(s.origin != sender for s in shared_secrets.values()):
-                log.warning(
-                    f"Rejecting shared secret signed by invalid entity ({sender}):\n"
-                    f"{shared_secret_elt.toXml}"
-                )
-                return
-
-        shared_secrets[shared_secret.id] = shared_secret
-        await self.store_secrets(client, node_uri, shared_secrets)
-        log.info(
-            f"shared secret {shared_secret.id} added for {node_uri} [{client.profile}]"
-        )
-
-    async def _publish_trigger(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        node: str,
-        items: Optional[List[domish.Element]],
-        options: Optional[dict],
-        sender: jid.JID,
-        extra: Dict[str, Any]
-    ) -> bool:
-        if not items or not extra.get("encrypted"):
-            return True
-        node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
-        shared_secrets = await self.load_secrets(client, node_uri)
-        if shared_secrets is None:
-            shared_secrets = {}
-            shared_secret = None
-        else:
-            current_secrets = [s for s in shared_secrets.values() if not s.revoked]
-            if not current_secrets:
-                shared_secret = None
-            elif len(current_secrets) > 1:
-                log.warning(
-                    f"more than one active shared secret found for node {node!r} at "
-                    f"{service}, using the most recent one"
-                )
-                current_secrets.sort(key=lambda s: s.timestamp, reverse=True)
-                shared_secret = current_secrets[0]
-            else:
-                shared_secret = current_secrets[0]
-
-        if shared_secret is None:
-            if any(s.origin != client.jid.userhostJID() for s in shared_secrets.values()):
-                raise exceptions.PermissionError(
-                    "there is no known active shared secret, and you are not the "
-                    "creator of previous shared secrets, we can't encrypt items at "
-                    f"{node_uri} ."
-                )
-            shared_secret = self.generate_secret(client)
-            shared_secrets[shared_secret.id] = shared_secret
-            await self.store_secrets(client, node_uri, shared_secrets)
-            # TODO: notify other entities
-
-        for item in items:
-            item_elts = list(item.elements())
-            if len(item_elts) != 1:
-                raise ValueError(
-                    f"there should be exactly one item payload: {item.toXml()}"
-                )
-            item_payload = item_elts[0]
-            log.debug(f"encrypting item {item.getAttribute('id', '')}")
-            encrypted_item = self.gpg_provider.encrypt_symmetrically(
-                item_payload.toXml().encode(), shared_secret.key
-            )
-            item.children.clear()
-            encrypted_elt = domish.Element((NS_OXPS, "encrypted"))
-            encrypted_elt["key"] = shared_secret.id
-            encrypted_elt.addContent(base64.b64encode(encrypted_item).decode())
-            item.addChild(encrypted_elt)
-
-        return True
-
-    async def _items_trigger(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: str,
-        items: List[domish.Element],
-        rsm_response: rsm.RSMResponse,
-        extra: Dict[str, Any],
-    ) -> bool:
-        if not extra.get(C.KEY_DECRYPT, True):
-            return True
-        if service is None:
-            service = client.jid.userhostJID()
-        shared_secrets = None
-        for item in items:
-            payload = item.firstChildElement()
-            if (payload is not None
-                and payload.name == "encrypted"
-                and payload.uri == NS_OXPS):
-                encrypted_elt = payload
-                secret_id = encrypted_elt.getAttribute("key")
-                if not secret_id:
-                    log.warning(
-                        f'"key" attribute is missing from encrypted item: {item.toXml()}'
-                    )
-                    continue
-                if shared_secrets is None:
-                    node_uri = uri.build_xmpp_uri("pubsub", path=service.full(), node=node)
-                    shared_secrets = await self.load_secrets(client, node_uri)
-                    if shared_secrets is None:
-                        log.warning(
-                            f"No known shared secret for {node_uri}, can't decrypt"
-                        )
-                        return True
-                try:
-                    shared_secret = shared_secrets[secret_id]
-                except KeyError:
-                    log.warning(
-                        f"No key known for encrypted item {item['id']!r} (shared secret "
-                        f"id: {secret_id!r})"
-                    )
-                    continue
-                log.debug(f"decrypting item {item.getAttribute('id', '')}")
-                decrypted = self.gpg_provider.decrypt_symmetrically(
-                    base64.b64decode(str(encrypted_elt)),
-                    shared_secret.key
-                )
-                decrypted_elt = xml_tools.parse(decrypted)
-                item.children.clear()
-                item.addChild(decrypted_elt)
-                extra.setdefault("encrypted", {})[item["id"]] = {"type": NS_OXPS}
-        return True
-
-    async def _message_received_trigger(
-        self,
-        client: SatXMPPEntity,
-        message_elt: domish.Element,
-        post_treat: defer.Deferred
-    ) -> bool:
-        sender = jid.JID(message_elt["from"]).userhostJID()
-        # there may be an openpgp element if OXIM is not activate, in this case we have to
-        # decrypt it here
-        openpgp_elt = next(message_elt.elements(NS_OX, "openpgp"), None)
-        if openpgp_elt is not None:
-            try:
-                payload_elt, __ = await self._ox.unpack_openpgp_element(
-                    client,
-                    openpgp_elt,
-                    "signcrypt",
-                    sender
-                )
-            except Exception as e:
-                log.warning(f"Can't decrypt element: {e}\n{message_elt.toXml()}")
-                return False
-            message_elt.children.remove(openpgp_elt)
-            for c in payload_elt.children:
-                message_elt.addChild(c)
-
-        shared_secret_elt = next(message_elt.elements(NS_OXPS, "shared-secret"), None)
-        if shared_secret_elt is None:
-            # no <shared-secret>, we check if there is a <revoke> element
-            revoke_elt = next(message_elt.elements(NS_OXPS, "revoke"), None)
-            if revoke_elt is None:
-                return True
-            else:
-                await self.handle_revoke_elt(client, sender, revoke_elt)
-        else:
-            await self.handle_shared_secret_elt(client, sender, shared_secret_elt)
-
-        return False
-
-
-@implementer(iwokkel.IDisco)
-class PubsubEncryption_Handler(xmlstream.XMPPHandler):
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_OXPS)]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_sec_pte.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,171 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for Pubsub Targeted Encryption
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Any, Dict, List, Optional
-
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid, xmlstream
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from wokkel import rsm
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-
-log = getLogger(__name__)
-
-IMPORT_NAME = "PTE"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Pubsub Targeted Encryption",
-    C.PI_IMPORT_NAME: IMPORT_NAME,
-    C.PI_TYPE: C.PLUG_TYPE_XEP,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0384"],
-    C.PI_MAIN: "PTE",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Encrypt some items to specific entities"""),
-}
-NS_PTE = "urn:xmpp:pte:0"
-
-
-class PTE:
-    namespace = NS_PTE
-
-    def __init__(self, host):
-        log.info(_("Pubsub Targeted Encryption plugin initialization"))
-        host.register_namespace("pte", NS_PTE)
-        self.host = host
-        self._o = host.plugins["XEP-0384"]
-        host.trigger.add("XEP-0060_publish", self._publish_trigger)
-        host.trigger.add("XEP-0060_items", self._items_trigger)
-
-    def get_handler(self, client):
-        return PTE_Handler()
-
-    async def _publish_trigger(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        node: str,
-        items: Optional[List[domish.Element]],
-        options: Optional[dict],
-        sender: jid.JID,
-        extra: Dict[str, Any]
-    ) -> bool:
-        if not items or extra.get("encrypted_for") is None:
-            return True
-        encrypt_data = extra["encrypted_for"]
-        try:
-            targets = {jid.JID(t) for t in encrypt_data["targets"]}
-        except (KeyError, RuntimeError):
-            raise exceptions.DataError(f"Invalid encryption data: {encrypt_data}")
-        for item in items:
-            log.debug(
-                f"encrypting item {item.getAttribute('id', '')} for "
-                f"{', '.join(t.full() for t in targets)}"
-            )
-            encryption_type = encrypt_data.get("type", self._o.NS_TWOMEMO)
-            if encryption_type != self._o.NS_TWOMEMO:
-                raise NotImplementedError("only TWOMEMO is supported for now")
-            await self._o.encrypt(
-                client,
-                self._o.NS_TWOMEMO,
-                item,
-                targets,
-                is_muc_message=False,
-                stanza_id=None
-            )
-            item_elts = list(item.elements())
-            if len(item_elts) != 1:
-                raise ValueError(
-                    f"there should be exactly one item payload: {item.toXml()}"
-                )
-            encrypted_payload = item_elts[0]
-            item.children.clear()
-            encrypted_elt = item.addElement((NS_PTE, "encrypted"))
-            encrypted_elt["by"] = sender.userhost()
-            encrypted_elt["type"] = encryption_type
-            encrypted_elt.addChild(encrypted_payload)
-
-        return True
-
-    async def _items_trigger(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: str,
-        items: List[domish.Element],
-        rsm_response: rsm.RSMResponse,
-        extra: Dict[str, Any],
-    ) -> bool:
-        if not extra.get(C.KEY_DECRYPT, True):
-            return True
-        if service is None:
-            service = client.jid.userhostJID()
-        for item in items:
-            payload = item.firstChildElement()
-            if (payload is not None
-                and payload.name == "encrypted"
-                and payload.uri == NS_PTE):
-                encrypted_elt = payload
-                item.children.clear()
-                try:
-                    encryption_type = encrypted_elt.getAttribute("type")
-                    encrypted_by = jid.JID(encrypted_elt["by"])
-                except (KeyError, RuntimeError):
-                    raise exceptions.DataError(
-                        f"invalid <encrypted> element: {encrypted_elt.toXml()}"
-                    )
-                if encryption_type!= self._o.NS_TWOMEMO:
-                    raise NotImplementedError("only TWOMEMO is supported for now")
-                log.debug(f"decrypting item {item.getAttribute('id', '')}")
-
-                # FIXME: we do use _message_received_trigger now to decrypt the stanza, a
-                #   cleaner separated decrypt method should be used
-                encrypted_elt["from"] = encrypted_by.full()
-                if not await self._o._message_received_trigger(
-                    client,
-                    encrypted_elt,
-                    defer.Deferred()
-                ) or not encrypted_elt.children:
-                    raise exceptions.EncryptionError("can't decrypt the message")
-
-                item.addChild(encrypted_elt.firstChildElement())
-
-                extra.setdefault("encrypted", {})[item["id"]] = {
-                    "type": NS_PTE,
-                    "algorithm": encryption_type
-                }
-        return True
-
-
-@implementer(iwokkel.IDisco)
-class PTE_Handler(xmlstream.XMPPHandler):
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_PTE)]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_sec_pubsub_signing.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,335 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for Pubsub Items Signature
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import base64
-import time
-from typing import Any, Dict, List, Optional
-
-from lxml import etree
-import shortuuid
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid, xmlstream
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from wokkel import pubsub
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools import utils
-from sat.tools.common import data_format
-
-from .plugin_xep_0373 import VerificationFailed
-
-
-log = getLogger(__name__)
-
-IMPORT_NAME = "pubsub-signing"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Pubsub Signing",
-    C.PI_IMPORT_NAME: IMPORT_NAME,
-    C.PI_TYPE: C.PLUG_TYPE_XEP,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0373", "XEP-0470"],
-    C.PI_MAIN: "PubsubSigning",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _(
-        """Pubsub Signature can be used to strongly authenticate a pubsub item"""
-    ),
-}
-NS_PUBSUB_SIGNING = "urn:xmpp:pubsub-signing:0"
-NS_PUBSUB_SIGNING_OPENPGP = "urn:xmpp:pubsub-signing:openpgp:0"
-
-
-class PubsubSigning:
-    namespace = NS_PUBSUB_SIGNING
-
-    def __init__(self, host):
-        log.info(_("Pubsub Signing plugin initialization"))
-        host.register_namespace("pubsub-signing", NS_PUBSUB_SIGNING)
-        self.host = host
-        self._p = host.plugins["XEP-0060"]
-        self._ox = host.plugins["XEP-0373"]
-        self._a = host.plugins["XEP-0470"]
-        self._a.register_attachment_handler(
-            "signature", NS_PUBSUB_SIGNING, self.signature_get, self.signature_set
-        )
-        host.trigger.add("XEP-0060_publish", self._publish_trigger)
-        host.bridge.add_method(
-            "ps_signature_check",
-            ".plugin",
-            in_sign="sssss",
-            out_sign="s",
-            method=self._check,
-            async_=True,
-        )
-
-    def get_handler(self, client):
-        return PubsubSigning_Handler()
-
-    def get_data_to_sign(
-        self,
-        item_elt: domish.Element,
-        to_jid: jid.JID,
-        timestamp: float,
-        signer: str,
-    ) -> bytes:
-        """Generate the wrapper element, normalize, serialize and return it"""
-        # we remove values which must not be in the serialised data
-        item_id = item_elt.attributes.pop("id")
-        item_publisher = item_elt.attributes.pop("publisher", None)
-        item_parent = item_elt.parent
-
-        # we need to be sure that item element namespace is right
-        item_elt.uri = item_elt.defaultUri = pubsub.NS_PUBSUB
-
-        sign_data_elt = domish.Element((NS_PUBSUB_SIGNING, "sign-data"))
-        to_elt = sign_data_elt.addElement("to")
-        to_elt["jid"] = to_jid.userhost()
-        time_elt = sign_data_elt.addElement("time")
-        time_elt["stamp"] = utils.xmpp_date(timestamp)
-        sign_data_elt.addElement("signer", content=signer)
-        sign_data_elt.addChild(item_elt)
-        # FIXME: xml_tools.domish_elt_2_et_elt must be used once implementation is
-        #   complete. For now serialisation/deserialisation is more secure.
-        # et_sign_data_elt = xml_tools.domish_elt_2_et_elt(sign_data_elt, True)
-        et_sign_data_elt = etree.fromstring(sign_data_elt.toXml())
-        to_sign = etree.tostring(
-            et_sign_data_elt,
-            method="c14n2",
-            with_comments=False,
-            strip_text=True
-        )
-        # the data to sign is serialised, we cna restore original values
-        item_elt["id"] = item_id
-        if item_publisher is not None:
-            item_elt["publisher"] = item_publisher
-        item_elt.parent = item_parent
-        return to_sign
-
-    def _check(
-        self,
-        service: str,
-        node: str,
-        item_id: str,
-        signature_data_s: str,
-        profile_key: str,
-    ) -> defer.Deferred:
-        d = defer.ensureDeferred(
-            self.check(
-                self.host.get_client(profile_key),
-                jid.JID(service),
-                node,
-                item_id,
-                data_format.deserialise(signature_data_s)
-            )
-        )
-        d.addCallback(data_format.serialise)
-        return d
-
-    async def check(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        node: str,
-        item_id: str,
-        signature_data: Dict[str, Any],
-    ) -> Dict[str, Any]:
-        items, __ = await self._p.get_items(
-            client, service, node, item_ids=[item_id]
-        )
-        if not items != 1:
-            raise exceptions.NotFound(
-                f"target item not found for {item_id!r} at {node!r} for {service}"
-            )
-        item_elt = items[0]
-        timestamp = signature_data["timestamp"]
-        signers = signature_data["signers"]
-        if not signers:
-            raise ValueError("we must have at least one signer to check the signature")
-        if len(signers) > 1:
-            raise NotImplemented("multiple signers are not supported yet")
-        signer = jid.JID(signers[0])
-        signature = base64.b64decode(signature_data["signature"])
-        verification_keys = {
-            k for k in await self._ox.import_all_public_keys(client, signer)
-            if client.gpg_provider.can_sign(k)
-        }
-        signed_data = self.get_data_to_sign(item_elt, service, timestamp, signer.full())
-        try:
-            client.gpg_provider.verify_detached(signed_data, signature, verification_keys)
-        except VerificationFailed:
-            validated = False
-        else:
-            validated = True
-
-        trusts = {
-            k.fingerprint: (await self._ox.get_trust(client, k, signer)).value.lower()
-            for k in verification_keys
-        }
-        return {
-            "signer": signer.full(),
-            "validated": validated,
-            "trusts": trusts,
-        }
-
-    def signature_get(
-        self,
-        client: SatXMPPEntity,
-        attachments_elt: domish.Element,
-        data: Dict[str, Any],
-    ) -> None:
-        try:
-            signature_elt = next(
-                attachments_elt.elements(NS_PUBSUB_SIGNING, "signature")
-            )
-        except StopIteration:
-            pass
-        else:
-            time_elts = list(signature_elt.elements(NS_PUBSUB_SIGNING, "time"))
-            if len(time_elts) != 1:
-                raise exceptions.DataError("only a single <time/> element is allowed")
-            try:
-                timestamp = utils.parse_xmpp_date(time_elts[0]["stamp"])
-            except (KeyError, exceptions.ParsingError):
-                raise exceptions.DataError(
-                    "invalid time element: {signature_elt.toXml()}"
-                )
-
-            signature_data: Dict[str, Any] = {
-                "timestamp": timestamp,
-                "signers": [
-                    str(s) for s in signature_elt.elements(NS_PUBSUB_SIGNING, "signer")
-                ]
-            }
-            # FIXME: only OpenPGP signature is available for now, to be updated if and
-            #   when more algorithms are available.
-            sign_elt = next(
-                signature_elt.elements(NS_PUBSUB_SIGNING_OPENPGP, "sign"),
-                None
-            )
-            if sign_elt is None:
-                log.warning(
-                    "no known signature profile element found, ignoring signature: "
-                    f"{signature_elt.toXml()}"
-                )
-                return
-            else:
-                signature_data["signature"] = str(sign_elt)
-
-            data["signature"] = signature_data
-
-    async def signature_set(
-        self,
-        client: SatXMPPEntity,
-        attachments_data: Dict[str, Any],
-        former_elt: Optional[domish.Element]
-    ) -> Optional[domish.Element]:
-        signature_data = attachments_data["extra"].get("signature")
-        if signature_data is None:
-            return former_elt
-        elif signature_data:
-            item_elt = signature_data.get("item_elt")
-            service = jid.JID(attachments_data["service"])
-            if item_elt is None:
-                node = attachments_data["node"]
-                item_id = attachments_data["id"]
-                items, __ = await self._p.get_items(
-                    client, service, node, item_ids=[item_id]
-                )
-                if not items != 1:
-                    raise exceptions.NotFound(
-                        f"target item not found for {item_id!r} at {node!r} for {service}"
-                    )
-                item_elt = items[0]
-
-            signer = signature_data.get("signer") or client.jid.userhost()
-            timestamp = time.time()
-            timestamp_xmpp = utils.xmpp_date(timestamp)
-            to_sign = self.get_data_to_sign(item_elt, service, timestamp, signer)
-
-            signature_elt = domish.Element(
-                (NS_PUBSUB_SIGNING, "signature"),
-            )
-            time_elt = signature_elt.addElement("time")
-            time_elt["stamp"] = timestamp_xmpp
-            signature_elt.addElement("signer", content=signer)
-
-            sign_elt = signature_elt.addElement((NS_PUBSUB_SIGNING_OPENPGP, "sign"))
-            signing_keys = {
-                k for k in self._ox.list_secret_keys(client)
-                if client.gpg_provider.can_sign(k.public_key)
-            }
-            # the base64 encoded signature itself
-            sign_elt.addContent(
-                base64.b64encode(
-                    client.gpg_provider.sign_detached(to_sign, signing_keys)
-                ).decode()
-            )
-            return signature_elt
-        else:
-            return None
-
-    async def _publish_trigger(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        node: str,
-        items: Optional[List[domish.Element]],
-        options: Optional[dict],
-        sender: jid.JID,
-        extra: Dict[str, Any]
-    ) -> bool:
-        if not items or not extra.get("signed"):
-            return True
-
-        for item_elt in items:
-            # we need an ID to find corresponding attachment node, and so to sign an item
-            if not item_elt.hasAttribute("id"):
-                item_elt["id"] = shortuuid.uuid()
-            await self._a.set_attachements(
-                client,
-                {
-                    "service": service.full(),
-                    "node": node,
-                    "id": item_elt["id"],
-                    "extra": {
-                        "signature": {
-                            "item_elt": item_elt,
-                            "signer": sender.userhost(),
-                        }
-                    }
-                }
-            )
-
-        return True
-
-
-@implementer(iwokkel.IDisco)
-class PubsubSigning_Handler(xmlstream.XMPPHandler):
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_PUBSUB_SIGNING)]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_syntax_wiki_dotclear.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,678 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT plugin for Dotclear Wiki Syntax
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-# XXX: ref used: http://dotclear.org/documentation/2.0/usage/syntaxes#wiki-syntax-and-xhtml-equivalent
-
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from twisted.words.xish import domish
-from sat.tools import xml_tools
-import copy
-import re
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Dotclear Wiki Syntax Plugin",
-    C.PI_IMPORT_NAME: "SYNT_DC_WIKI",
-    C.PI_TYPE: C.PLUG_TYPE_SYNTAXE,
-    C.PI_DEPENDENCIES: ["TEXT_SYNTAXES"],
-    C.PI_MAIN: "DCWikiSyntax",
-    C.PI_HANDLER: "",
-    C.PI_DESCRIPTION: _("""Implementation of Dotclear wiki syntax"""),
-}
-
-NOTE_TPL = "[{}]"  # Note template
-NOTE_A_REV_TPL = "rev_note_{}"
-NOTE_A_TPL = "note_{}"
-ESCAPE_CHARS_BASE = r"(?P<escape_char>[][{}%|\\/*#@{{}}~$-])"
-ESCAPE_CHARS_EXTRA = (
-    r"!?_+'()"
-)  # These chars are not escaped in XHTML => dc_wiki conversion,
-# but are used in the other direction
-ESCAPE_CHARS = ESCAPE_CHARS_BASE.format("")
-FLAG_UL = "ul"  # must be the name of the element
-FLAG_OL = "ol"
-ELT_WITH_STYLE = ("img", "div")  # elements where a style attribute is expected
-
-wiki = [
-    r"\\" + ESCAPE_CHARS_BASE.format(ESCAPE_CHARS_EXTRA),
-    r"^!!!!!(?P<h1_title>.+?)$",
-    r"^!!!!(?P<h2_title>.+?)$",
-    r"^!!!(?P<h3_title>.+?)$",
-    r"^!!(?P<h4_title>.+?)$",
-    r"^!(?P<h5_title>.+?)$",
-    r"^----$(?P<horizontal_rule>)",
-    r"^\*(?P<list_bullet>.*?)$",
-    r"^#(?P<list_ordered>.*?)$",
-    r"^ (?P<preformated>.*?)$",
-    r"^> +?(?P<quote>.*?)$",
-    r"''(?P<emphasis>.+?)''",
-    r"__(?P<strong_emphasis>.+?)__",
-    r"%%%(?P<line_break>)",
-    r"\+\+(?P<insertion>.+?)\+\+",
-    r"--(?P<deletion>.+?)--",
-    r"\[(?P<link>.+?)\]",
-    r"\(\((?P<image>.+?)\)\)",
-    r"~(?P<anchor>.+?)~",
-    r"\?\?(?P<acronym>.+?\|.+?)\?\?",
-    r"{{(?P<inline_quote>.+?)}}",
-    r"@@(?P<code>.+?)@@",
-    r"\$\$(?P<footnote>.+?)\$\$",
-    r"(?P<text>.+?)",
-]
-
-wiki_re = re.compile("|".join(wiki), re.MULTILINE | re.DOTALL)
-wiki_block_level_re = re.compile(
-    r"^///html(?P<html>.+?)///\n\n|(?P<paragraph>.+?)(?:\n{2,}|\Z)",
-    re.MULTILINE | re.DOTALL,
-)
-
-
-class DCWikiParser(object):
-    def __init__(self):
-        self._footnotes = None
-        for i in range(5):
-            setattr(
-                self,
-                "parser_h{}_title".format(i),
-                lambda string, parent, i=i: self._parser_title(
-                    string, parent, "h{}".format(i)
-                ),
-            )
-
-    def parser_paragraph(self, string, parent):
-        p_elt = parent.addElement("p")
-        self._parse(string, p_elt)
-
-    def parser_html(self, string, parent):
-        wrapped_html = "<div>{}</div>".format(string)
-        try:
-            div_elt = xml_tools.ElementParser()(wrapped_html)
-        except domish.ParserError as e:
-            log.warning("Error while parsing HTML content, ignoring it: {}".format(e))
-            return
-        children = list(div_elt.elements())
-        if len(children) == 1 and children[0].name == "div":
-            div_elt = children[0]
-        parent.addChild(div_elt)
-
-    def parser_escape_char(self, string, parent):
-        parent.addContent(string)
-
-    def _parser_title(self, string, parent, name):
-        elt = parent.addElement(name)
-        elt.addContent(string)
-
-    def parser_horizontal_rule(self, string, parent):
-        parent.addElement("hr")
-
-    def _parser_list(self, string, parent, list_type):
-        depth = 0
-        while string[depth : depth + 1] == "*":
-            depth += 1
-
-        string = string[depth:].lstrip()
-
-        for i in range(depth + 1):
-            list_elt = getattr(parent, list_type)
-            if not list_elt:
-                parent = parent.addElement(list_type)
-            else:
-                parent = list_elt
-
-        li_elt = parent.addElement("li")
-        self._parse(string, li_elt)
-
-    def parser_list_bullet(self, string, parent):
-        self._parser_list(string, parent, "ul")
-
-    def parser_list_ordered(self, string, parent):
-        self._parser_list(string, parent, "ol")
-
-    def parser_preformated(self, string, parent):
-        pre_elt = parent.pre
-        if pre_elt is None:
-            pre_elt = parent.addElement("pre")
-        else:
-            # we are on a new line, and this is important for <pre/>
-            pre_elt.addContent("\n")
-        pre_elt.addContent(string)
-
-    def parser_quote(self, string, parent):
-        blockquote_elt = parent.blockquote
-        if blockquote_elt is None:
-            blockquote_elt = parent.addElement("blockquote")
-        p_elt = blockquote_elt.p
-        if p_elt is None:
-            p_elt = blockquote_elt.addElement("p")
-        else:
-            string = "\n" + string
-
-        self._parse(string, p_elt)
-
-    def parser_emphasis(self, string, parent):
-        em_elt = parent.addElement("em")
-        self._parse(string, em_elt)
-
-    def parser_strong_emphasis(self, string, parent):
-        strong_elt = parent.addElement("strong")
-        self._parse(string, strong_elt)
-
-    def parser_line_break(self, string, parent):
-        parent.addElement("br")
-
-    def parser_insertion(self, string, parent):
-        ins_elt = parent.addElement("ins")
-        self._parse(string, ins_elt)
-
-    def parser_deletion(self, string, parent):
-        del_elt = parent.addElement("del")
-        self._parse(string, del_elt)
-
-    def parser_link(self, string, parent):
-        url_data = string.split("|")
-        a_elt = parent.addElement("a")
-        length = len(url_data)
-        if length == 1:
-            url = url_data[0]
-            a_elt["href"] = url
-            a_elt.addContent(url)
-        else:
-            name = url_data[0]
-            url = url_data[1]
-            a_elt["href"] = url
-            a_elt.addContent(name)
-            if length >= 3:
-                a_elt["lang"] = url_data[2]
-            if length >= 4:
-                a_elt["title"] = url_data[3]
-            if length > 4:
-                log.warning("too much data for url, ignoring extra data")
-
-    def parser_image(self, string, parent):
-        image_data = string.split("|")
-        img_elt = parent.addElement("img")
-
-        for idx, attribute in enumerate(("src", "alt", "position", "longdesc")):
-            try:
-                data = image_data[idx]
-            except IndexError:
-                break
-
-            if attribute != "position":
-                img_elt[attribute] = data
-            else:
-                data = data.lower()
-                if data in ("l", "g"):
-                    img_elt["style"] = "display:block; float:left; margin:0 1em 1em 0"
-                elif data in ("r", "d"):
-                    img_elt["style"] = "display:block; float:right; margin:0 0 1em 1em"
-                elif data == "c":
-                    img_elt[
-                        "style"
-                    ] = "display:block; margin-left:auto; margin-right:auto"
-                else:
-                    log.warning("bad position argument for image, ignoring it")
-
-    def parser_anchor(self, string, parent):
-        a_elt = parent.addElement("a")
-        a_elt["id"] = string
-
-    def parser_acronym(self, string, parent):
-        acronym, title = string.split("|", 1)
-        acronym_elt = parent.addElement("acronym", content=acronym)
-        acronym_elt["title"] = title
-
-    def parser_inline_quote(self, string, parent):
-        quote_data = string.split("|")
-        quote = quote_data[0]
-        q_elt = parent.addElement("q", content=quote)
-        for idx, attribute in enumerate(("lang", "cite"), 1):
-            try:
-                data = quote_data[idx]
-            except IndexError:
-                break
-            q_elt[attribute] = data
-
-    def parser_code(self, string, parent):
-        parent.addElement("code", content=string)
-
-    def parser_footnote(self, string, parent):
-        idx = len(self._footnotes) + 1
-        note_txt = NOTE_TPL.format(idx)
-        sup_elt = parent.addElement("sup")
-        sup_elt["class"] = "note"
-        a_elt = sup_elt.addElement("a", content=note_txt)
-        a_elt["id"] = NOTE_A_REV_TPL.format(idx)
-        a_elt["href"] = "#{}".format(NOTE_A_TPL.format(idx))
-
-        p_elt = domish.Element((None, "p"))
-        a_elt = p_elt.addElement("a", content=note_txt)
-        a_elt["id"] = NOTE_A_TPL.format(idx)
-        a_elt["href"] = "#{}".format(NOTE_A_REV_TPL.format(idx))
-        self._parse(string, p_elt)
-        # footnotes are actually added at the end of the parsing
-        self._footnotes.append(p_elt)
-
-    def parser_text(self, string, parent):
-        parent.addContent(string)
-
-    def _parse(self, string, parent, block_level=False):
-        regex = wiki_block_level_re if block_level else wiki_re
-
-        for match in regex.finditer(string):
-            if match.lastgroup is None:
-                parent.addContent(string)
-                return
-            matched = match.group(match.lastgroup)
-            try:
-                parser = getattr(self, "parser_{}".format(match.lastgroup))
-            except AttributeError:
-                log.warning("No parser found for {}".format(match.lastgroup))
-                # parent.addContent(string)
-                continue
-            parser(matched, parent)
-
-    def parse(self, string):
-        self._footnotes = []
-        div_elt = domish.Element((None, "div"))
-        self._parse(string, parent=div_elt, block_level=True)
-        if self._footnotes:
-            foot_div_elt = div_elt.addElement("div")
-            foot_div_elt["class"] = "footnotes"
-            # we add a simple horizontal rule which can be customized
-            # with footnotes class, instead of a text which would need
-            # to be translated
-            foot_div_elt.addElement("hr")
-            for elt in self._footnotes:
-                foot_div_elt.addChild(elt)
-        return div_elt
-
-
-class XHTMLParser(object):
-    def __init__(self):
-        self.flags = None
-        self.toto = 0
-        self.footnotes = None  # will hold a map from url to buffer id
-        for i in range(1, 6):
-            setattr(
-                self,
-                "parser_h{}".format(i),
-                lambda elt, buf, level=i: self.parser_heading(elt, buf, level),
-            )
-
-    def parser_a(self, elt, buf):
-        try:
-            url = elt["href"]
-        except KeyError:
-            # probably an anchor
-            try:
-                id_ = elt["id"]
-                if not id_:
-                    # we don't want empty values
-                    raise KeyError
-            except KeyError:
-                self.parser_generic(elt, buf)
-            else:
-                buf.append("~~{}~~".format(id_))
-            return
-
-        link_data = [url]
-        name = str(elt)
-        if name != url:
-            link_data.insert(0, name)
-
-        lang = elt.getAttribute("lang")
-        title = elt.getAttribute("title")
-        if lang is not None:
-            link_data.append(lang)
-        elif title is not None:
-            link_data.appand("")
-        if title is not None:
-            link_data.append(title)
-        buf.append("[")
-        buf.append("|".join(link_data))
-        buf.append("]")
-
-    def parser_acronym(self, elt, buf):
-        try:
-            title = elt["title"]
-        except KeyError:
-            log.debug("Acronyme without title, using generic parser")
-            self.parser_generic(elt, buf)
-            return
-        buf.append("??{}|{}??".format(str(elt), title))
-
-    def parser_blockquote(self, elt, buf):
-        # we remove wrapping <p> to avoid empty line with "> "
-        children = list(
-            [child for child in elt.children if str(child).strip() not in ("", "\n")]
-        )
-        if len(children) == 1 and children[0].name == "p":
-            elt = children[0]
-        tmp_buf = []
-        self.parse_children(elt, tmp_buf)
-        blockquote = "> " + "\n> ".join("".join(tmp_buf).split("\n"))
-        buf.append(blockquote)
-
-    def parser_br(self, elt, buf):
-        buf.append("%%%")
-
-    def parser_code(self, elt, buf):
-        buf.append("@@")
-        self.parse_children(elt, buf)
-        buf.append("@@")
-
-    def parser_del(self, elt, buf):
-        buf.append("--")
-        self.parse_children(elt, buf)
-        buf.append("--")
-
-    def parser_div(self, elt, buf):
-        if elt.getAttribute("class") == "footnotes":
-            self.parser_footnote(elt, buf)
-        else:
-            self.parse_children(elt, buf, block=True)
-
-    def parser_em(self, elt, buf):
-        buf.append("''")
-        self.parse_children(elt, buf)
-        buf.append("''")
-
-    def parser_h6(self, elt, buf):
-        # XXX: <h6/> heading is not managed by wiki syntax
-        #      so we handle it with a <h5/>
-        elt = copy.copy(elt)  # we don't want to change to original element
-        elt.name = "h5"
-        self._parse(elt, buf)
-
-    def parser_hr(self, elt, buf):
-        buf.append("\n----\n")
-
-    def parser_img(self, elt, buf):
-        try:
-            url = elt["src"]
-        except KeyError:
-            log.warning("Ignoring <img/> without src")
-            return
-
-        image_data = [url]
-
-        alt = elt.getAttribute("alt")
-        style = elt.getAttribute("style", "")
-        desc = elt.getAttribute("longdesc")
-
-        if "0 1em 1em 0" in style:
-            position = "L"
-        elif "0 0 1em 1em" in style:
-            position = "R"
-        elif "auto" in style:
-            position = "C"
-        else:
-            position = None
-
-        if alt:
-            image_data.append(alt)
-        elif position or desc:
-            image_data.append("")
-
-        if position:
-            image_data.append(position)
-        elif desc:
-            image_data.append("")
-
-        if desc:
-            image_data.append(desc)
-
-        buf.append("((")
-        buf.append("|".join(image_data))
-        buf.append("))")
-
-    def parser_ins(self, elt, buf):
-        buf.append("++")
-        self.parse_children(elt, buf)
-        buf.append("++")
-
-    def parser_li(self, elt, buf):
-        flag = None
-        current_flag = None
-        bullets = []
-        for flag in reversed(self.flags):
-            if flag in (FLAG_UL, FLAG_OL):
-                if current_flag is None:
-                    current_flag = flag
-                if flag == current_flag:
-                    bullets.append("*" if flag == FLAG_UL else "#")
-                else:
-                    break
-
-        if flag != current_flag and buf[-1] == " ":
-            # this trick is to avoid a space when we switch
-            # from (un)ordered to the other type on the same row
-            # e.g. *# unorder + ordered item
-            del buf[-1]
-
-        buf.extend(bullets)
-
-        buf.append(" ")
-        self.parse_children(elt, buf)
-        buf.append("\n")
-
-    def parser_ol(self, elt, buf):
-        self.parser_list(elt, buf, FLAG_OL)
-
-    def parser_p(self, elt, buf):
-        self.parse_children(elt, buf)
-        buf.append("\n\n")
-
-    def parser_pre(self, elt, buf):
-        pre = "".join(
-            [
-                child.toXml() if domish.IElement.providedBy(child) else str(child)
-                for child in elt.children
-            ]
-        )
-        pre = " " + "\n ".join(pre.split("\n"))
-        buf.append(pre)
-
-    def parser_q(self, elt, buf):
-        quote_data = [str(elt)]
-
-        lang = elt.getAttribute("lang")
-        cite = elt.getAttribute("url")
-
-        if lang:
-            quote_data.append(lang)
-        elif cite:
-            quote_data.append("")
-
-        if cite:
-            quote_data.append(cite)
-
-        buf.append("{{")
-        buf.append("|".join(quote_data))
-        buf.append("}}")
-
-    def parser_span(self, elt, buf):
-        self.parse_children(elt, buf, block=True)
-
-    def parser_strong(self, elt, buf):
-        buf.append("__")
-        self.parse_children(elt, buf)
-        buf.append("__")
-
-    def parser_sup(self, elt, buf):
-        # sup is mainly used for footnotes, so we check if we have an anchor inside
-        children = list(
-            [child for child in elt.children if str(child).strip() not in ("", "\n")]
-        )
-        if (
-            len(children) == 1
-            and domish.IElement.providedBy(children[0])
-            and children[0].name == "a"
-            and "#" in children[0].getAttribute("href", "")
-        ):
-            url = children[0]["href"]
-            note_id = url[url.find("#") + 1 :]
-            if not note_id:
-                log.warning("bad link found in footnote")
-                self.parser_generic(elt, buf)
-                return
-            # this looks like a footnote
-            buf.append("$$")
-            buf.append(" ")  # placeholder
-            self.footnotes[note_id] = len(buf) - 1
-            buf.append("$$")
-        else:
-            self.parser_generic(elt, buf)
-
-    def parser_ul(self, elt, buf):
-        self.parser_list(elt, buf, FLAG_UL)
-
-    def parser_list(self, elt, buf, type_):
-        self.flags.append(type_)
-        self.parse_children(elt, buf, block=True)
-        idx = 0
-        for flag in reversed(self.flags):
-            idx -= 1
-            if flag == type_:
-                del self.flags[idx]
-                break
-
-        if idx == 0:
-            raise exceptions.InternalError("flag has been removed by an other parser")
-
-    def parser_heading(self, elt, buf, level):
-        buf.append((6 - level) * "!")
-        for child in elt.children:
-            # we ignore other elements for a Hx title
-            self.parser_text(child, buf)
-        buf.append("\n")
-
-    def parser_footnote(self, elt, buf):
-        for elt in elt.elements():
-            # all children other than <p/> are ignored
-            if elt.name == "p":
-                a_elt = elt.a
-                if a_elt is None:
-                    log.warning(
-                        "<p/> element doesn't contain <a/> in footnote, ignoring it"
-                    )
-                    continue
-                try:
-                    note_idx = self.footnotes[a_elt["id"]]
-                except KeyError:
-                    log.warning("Note id doesn't match any known note, ignoring it")
-                # we create a dummy element to parse all children after the <a/>
-                dummy_elt = domish.Element((None, "note"))
-                a_idx = elt.children.index(a_elt)
-                dummy_elt.children = elt.children[a_idx + 1 :]
-                note_buf = []
-                self.parse_children(dummy_elt, note_buf)
-                # now we can replace the placeholder
-                buf[note_idx] = "".join(note_buf)
-
-    def parser_text(self, txt, buf, keep_whitespaces=False):
-        txt = str(txt)
-        if not keep_whitespaces:
-            # we get text and only let one inter word space
-            txt = " ".join(txt.split())
-        txt = re.sub(ESCAPE_CHARS, r"\\\1", txt)
-        if txt:
-            buf.append(txt)
-        return txt
-
-    def parser_generic(self, elt, buf):
-        # as dotclear wiki syntax handle arbitrary XHTML code
-        # we use this feature to add elements that we don't know
-        buf.append("\n\n///html\n{}\n///\n\n".format(elt.toXml()))
-
-    def parse_children(self, elt, buf, block=False):
-        first_visible = True
-        for child in elt.children:
-            if not block and not first_visible and buf and buf[-1][-1] not in (" ", "\n"):
-                # we add separation if it isn't already there
-                buf.append(" ")
-            if domish.IElement.providedBy(child):
-                self._parse(child, buf)
-                first_visible = False
-            else:
-                appended = self.parser_text(child, buf)
-                if appended:
-                    first_visible = False
-
-    def _parse(self, elt, buf):
-        elt_name = elt.name.lower()
-        style = elt.getAttribute("style")
-        if style and elt_name not in ELT_WITH_STYLE:
-            # if we have style we use generic parser to put raw HTML
-            # to avoid losing it
-            parser = self.parser_generic
-        else:
-            try:
-                parser = getattr(self, "parser_{}".format(elt_name))
-            except AttributeError:
-                log.debug(
-                    "Can't find parser for {} element, using generic one".format(elt.name)
-                )
-                parser = self.parser_generic
-        parser(elt, buf)
-
-    def parse(self, elt):
-        self.flags = []
-        self.footnotes = {}
-        buf = []
-        self._parse(elt, buf)
-        return "".join(buf)
-
-    def parseString(self, string):
-        wrapped_html = "<div>{}</div>".format(string)
-        try:
-            div_elt = xml_tools.ElementParser()(wrapped_html)
-        except domish.ParserError as e:
-            log.warning("Error while parsing HTML content: {}".format(e))
-            return
-        children = list(div_elt.elements())
-        if len(children) == 1 and children[0].name == "div":
-            div_elt = children[0]
-        return self.parse(div_elt)
-
-
-class DCWikiSyntax(object):
-    SYNTAX_NAME = "wiki_dotclear"
-
-    def __init__(self, host):
-        log.info(_("Dotclear wiki syntax plugin initialization"))
-        self.host = host
-        self._dc_parser = DCWikiParser()
-        self._xhtml_parser = XHTMLParser()
-        self._stx = self.host.plugins["TEXT_SYNTAXES"]
-        self._stx.add_syntax(
-            self.SYNTAX_NAME, self.parse_wiki, self.parse_xhtml, [self._stx.OPT_NO_THREAD]
-        )
-
-    def parse_wiki(self, wiki_stx):
-        div_elt = self._dc_parser.parse(wiki_stx)
-        return div_elt.toXml()
-
-    def parse_xhtml(self, xhtml):
-        return self._xhtml_parser.parseString(xhtml)
--- a/sat/plugins/plugin_tickets_import.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,191 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT plugin for import external ticketss
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.internet import defer
-from sat.tools.common import uri
-from sat.tools import utils
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "tickets import",
-    C.PI_IMPORT_NAME: "TICKETS_IMPORT",
-    C.PI_TYPE: C.PLUG_TYPE_IMPORT,
-    C.PI_DEPENDENCIES: ["IMPORT", "XEP-0060", "XEP-0277", "XEP-0346"],
-    C.PI_MAIN: "TicketsImportPlugin",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _(
-        """Tickets import management:
-This plugin manage the different tickets importers which can register to it, and handle generic importing tasks."""
-    ),
-}
-
-OPT_MAPPING = "mapping"
-FIELDS_LIST = ("labels", "cc_emails")  # fields which must have a list as value
-FIELDS_DATE = ("created", "updated")
-
-NS_TICKETS = "fdp/submitted/org.salut-a-toi.tickets:0"
-
-
-class TicketsImportPlugin(object):
-    BOOL_OPTIONS = ()
-    JSON_OPTIONS = (OPT_MAPPING,)
-    OPT_DEFAULTS = {}
-
-    def __init__(self, host):
-        log.info(_("plugin Tickets import initialization"))
-        self.host = host
-        self._importers = {}
-        self._p = host.plugins["XEP-0060"]
-        self._m = host.plugins["XEP-0277"]
-        self._s = host.plugins["XEP-0346"]
-        host.plugins["IMPORT"].initialize(self, "tickets")
-
-    @defer.inlineCallbacks
-    def import_item(
-        self, client, item_import_data, session, options, return_data, service, node
-    ):
-        """
-
-        @param item_import_data(dict): no key is mandatory, but if a key doesn't exists in dest form, it will be ignored.
-            Following names are recommendations which should be used where suitable in importers.
-            except if specified in description, values are unicode
-            'id': unique id (must be unique in the node) of the ticket
-            'title': title (or short description/summary) of the ticket
-            'body': main description of the ticket
-            'created': date of creation (unix time)
-            'updated': date of last update (unix time)
-            'author': full name of reporter
-            'author_jid': jid of reporter
-            'author_email': email of reporter
-            'assigned_to_name': full name of person working on it
-            'assigned_to_email': email of person working on it
-            'cc_emails': list of emails subscribed to the ticket
-            'priority': priority of the ticket
-            'severity': severity of the ticket
-            'labels': list of unicode values to use as label
-            'product': product concerned by this ticket
-            'component': part of the product concerned by this ticket
-            'version': version of the product/component concerned by this ticket
-            'platform': platform converned by this ticket
-            'os': operating system concerned by this ticket
-            'status': current status of the ticket, values:
-                - "queued": ticket is waiting
-                - "started": progress is ongoing
-                - "review": ticket is fixed and waiting for review
-                - "closed": ticket is finished or invalid
-            'milestone': target milestone for this ticket
-            'comments': list of microblog data (comment metadata, check [XEP_0277.send] data argument)
-        @param options(dict, None): Below are the generic options,
-            tickets importer can have specific ones. All options are serialized unicode values
-            generic options:
-                - OPT_MAPPING (json): dict of imported ticket key => exported ticket key
-                    e.g.: if you want to map "component" to "labels", you can specify:
-                        {'component': 'labels'}
-                    If you specify several import ticket key to the same dest key,
-                    the values will be joined with line feeds
-        """
-        if "comments_uri" in item_import_data:
-            raise exceptions.DataError(
-                _("comments_uri key will be generated and must not be used by importer")
-            )
-        for key in FIELDS_LIST:
-            if not isinstance(item_import_data.get(key, []), list):
-                raise exceptions.DataError(_("{key} must be a list").format(key=key))
-        for key in FIELDS_DATE:
-            try:
-                item_import_data[key] = utils.xmpp_date(item_import_data[key])
-            except KeyError:
-                continue
-        if session["root_node"] is None:
-            session["root_node"] = NS_TICKETS
-        if not "schema" in session:
-            session["schema"] = yield self._s.get_schema_form(
-                client, service, node or session["root_node"]
-            )
-        defer.returnValue(item_import_data)
-
-    @defer.inlineCallbacks
-    def import_sub_items(self, client, item_import_data, ticket_data, session, options):
-        # TODO: force "open" permission (except if private, check below)
-        # TODO: handle "private" metadata, to have non public access for node
-        # TODO: node access/publish model should be customisable
-        comments = ticket_data.get("comments", [])
-        service = yield self._m.get_comments_service(client)
-        node = self._m.get_comments_node(session["root_node"] + "_" + ticket_data["id"])
-        node_options = {
-            self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
-            self._p.OPT_PERSIST_ITEMS: 1,
-            self._p.OPT_DELIVER_PAYLOADS: 1,
-            self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
-            self._p.OPT_PUBLISH_MODEL: self._p.ACCESS_OPEN,
-        }
-        yield self._p.create_if_new_node(client, service, node, options=node_options)
-        ticket_data["comments_uri"] = uri.build_xmpp_uri(
-            "pubsub", subtype="microblog", path=service.full(), node=node
-        )
-        for comment in comments:
-            if "updated" not in comment and "published" in comment:
-                # we don't want an automatic update date
-                comment["updated"] = comment["published"]
-            yield self._m.send(client, comment, service, node)
-
-    def publish_item(self, client, ticket_data, service, node, session):
-        if node is None:
-            node = NS_TICKETS
-        id_ = ticket_data.pop("id", None)
-        log.debug(
-            "uploading item [{id}]: {title}".format(
-                id=id_, title=ticket_data.get("title", "")
-            )
-        )
-        return defer.ensureDeferred(
-            self._s.send_data_form_item(
-                client, service, node, ticket_data, session["schema"], id_
-            )
-        )
-
-    def item_filters(self, client, ticket_data, session, options):
-        mapping = options.get(OPT_MAPPING)
-        if mapping is not None:
-            if not isinstance(mapping, dict):
-                raise exceptions.DataError(_("mapping option must be a dictionary"))
-
-            for source, dest in mapping.items():
-                if not isinstance(source, str) or not isinstance(dest, str):
-                    raise exceptions.DataError(
-                        _(
-                            "keys and values of mapping must be sources and destinations ticket fields"
-                        )
-                    )
-                if source in ticket_data:
-                    value = ticket_data.pop(source)
-                    if dest in FIELDS_LIST:
-                        values = ticket_data[dest] = ticket_data.get(dest, [])
-                        values.append(value)
-                    else:
-                        if dest in ticket_data:
-                            ticket_data[dest] = ticket_data[dest] + "\n" + value
-                        else:
-                            ticket_data[dest] = value
--- a/sat/plugins/plugin_tickets_import_bugzilla.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,142 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT plugin for import external blogs
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core import exceptions
-
-# from twisted.internet import threads
-from twisted.internet import defer
-import os.path
-from lxml import etree
-from sat.tools.common import date_utils
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Bugzilla import",
-    C.PI_IMPORT_NAME: "IMPORT_BUGZILLA",
-    C.PI_TYPE: C.PLUG_TYPE_BLOG,
-    C.PI_DEPENDENCIES: ["TICKETS_IMPORT"],
-    C.PI_MAIN: "BugzillaImport",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Tickets importer for Bugzilla"""),
-}
-
-SHORT_DESC = D_("import tickets from Bugzilla xml export file")
-
-LONG_DESC = D_(
-    """This importer handle Bugzilla xml export file.
-
-To use it, you'll need to export tickets using XML.
-Tickets will be uploaded with the same ID as for Bugzilla, any existing ticket with this ID will be replaced.
-
-location: you must use the absolute path to your .xml file
-"""
-)
-
-STATUS_MAP = {
-    "NEW": "queued",
-    "ASSIGNED": "started",
-    "RESOLVED": "review",
-    "CLOSED": "closed",
-    "REOPENED": "started",  # we loose data here because there is no need on basic workflow to have a reopened status
-}
-
-
-class BugzillaParser(object):
-    # TODO: add a way to reassign values
-
-    def parse(self, file_path):
-        tickets = []
-        root = etree.parse(file_path)
-
-        for bug in root.xpath("bug"):
-            ticket = {}
-            ticket["id"] = bug.findtext("bug_id")
-            ticket["created"] = date_utils.date_parse(bug.findtext("creation_ts"))
-            ticket["updated"] = date_utils.date_parse(bug.findtext("delta_ts"))
-            ticket["title"] = bug.findtext("short_desc")
-            reporter_elt = bug.find("reporter")
-            ticket["author"] = reporter_elt.get("name")
-            if ticket["author"] is None:
-                if "@" in reporter_elt.text:
-                    ticket["author"] = reporter_elt.text[
-                        : reporter_elt.text.find("@")
-                    ].title()
-                else:
-                    ticket["author"] = "no name"
-            ticket["author_email"] = reporter_elt.text
-            assigned_to_elt = bug.find("assigned_to")
-            ticket["assigned_to_name"] = assigned_to_elt.get("name")
-            ticket["assigned_to_email"] = assigned_to_elt.text
-            ticket["cc_emails"] = [e.text for e in bug.findall("cc")]
-            ticket["priority"] = bug.findtext("priority").lower().strip()
-            ticket["severity"] = bug.findtext("bug_severity").lower().strip()
-            ticket["product"] = bug.findtext("product")
-            ticket["component"] = bug.findtext("component")
-            ticket["version"] = bug.findtext("version")
-            ticket["platform"] = bug.findtext("rep_platform")
-            ticket["os"] = bug.findtext("op_sys")
-            ticket["status"] = STATUS_MAP.get(bug.findtext("bug_status"), "queued")
-            ticket["milestone"] = bug.findtext("target_milestone")
-
-            body = None
-            comments = []
-            for longdesc in bug.findall("long_desc"):
-                if body is None:
-                    body = longdesc.findtext("thetext")
-                else:
-                    who = longdesc.find("who")
-                    comment = {
-                        "id": longdesc.findtext("commentid"),
-                        "author_email": who.text,
-                        "published": date_utils.date_parse(longdesc.findtext("bug_when")),
-                        "author": who.get("name", who.text),
-                        "content": longdesc.findtext("thetext"),
-                    }
-                    comments.append(comment)
-
-            ticket["body"] = body
-            ticket["comments"] = comments
-            tickets.append(ticket)
-
-        tickets.sort(key=lambda t: int(t["id"]))
-        return (tickets, len(tickets))
-
-
-class BugzillaImport(object):
-    def __init__(self, host):
-        log.info(_("Bugilla import plugin initialization"))
-        self.host = host
-        host.plugins["TICKETS_IMPORT"].register(
-            "bugzilla", self.import_, SHORT_DESC, LONG_DESC
-        )
-
-    def import_(self, client, location, options=None):
-        if not os.path.isabs(location):
-            raise exceptions.DataError(
-                "An absolute path to XML data need to be given as location"
-            )
-        bugzilla_parser = BugzillaParser()
-        # d = threads.deferToThread(bugzilla_parser.parse, location)
-        d = defer.maybeDeferred(bugzilla_parser.parse, location)
-        return d
--- a/sat/plugins/plugin_tmp_directory_subscription.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,75 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for directory subscription
-# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2015, 2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Directory subscription plugin",
-    C.PI_IMPORT_NAME: "DIRECTORY-SUBSCRIPTION",
-    C.PI_TYPE: "TMP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0050", "XEP-0055"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "DirectorySubscription",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of directory subscription"""),
-}
-
-
-NS_COMMANDS = "http://jabber.org/protocol/commands"
-CMD_UPDATE_SUBSCRIBTION = "update"
-
-
-class DirectorySubscription(object):
-    def __init__(self, host):
-        log.info(_("Directory subscription plugin initialization"))
-        self.host = host
-        host.import_menu(
-            (D_("Service"), D_("Directory subscription")),
-            self.subscribe,
-            security_limit=1,
-            help_string=D_("User directory subscription"),
-        )
-
-    def subscribe(self, raw_data, profile):
-        """Request available commands on the jabber search service associated to profile's host.
-
-        @param raw_data (dict): data received from the frontend
-        @param profile (unicode): %(doc_profile)s
-        @return: a deferred dict{unicode: unicode}
-        """
-        d = self.host.plugins["XEP-0055"]._get_host_services(profile)
-
-        def got_services(services):
-            service_jid = services[0]
-            session_id, session_data = self.host.plugins[
-                "XEP-0050"
-            ].requesting.new_session(profile=profile)
-            session_data["jid"] = service_jid
-            session_data["node"] = CMD_UPDATE_SUBSCRIBTION
-            data = {"session_id": session_id}
-            return self.host.plugins["XEP-0050"]._requesting_entity(data, profile)
-
-        return d.addCallback(got_services)
--- a/sat/plugins/plugin_xep_0020.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,166 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing xep-0020
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core import exceptions
-from twisted.words.xish import domish
-
-from zope.interface import implementer
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-
-from wokkel import disco, iwokkel, data_form
-
-NS_FEATURE_NEG = "http://jabber.org/protocol/feature-neg"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP 0020 Plugin",
-    C.PI_IMPORT_NAME: "XEP-0020",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0020"],
-    C.PI_MAIN: "XEP_0020",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Feature Negotiation"""),
-}
-
-
-class XEP_0020(object):
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0020 initialization"))
-
-    def get_handler(self, client):
-        return XEP_0020_handler()
-
-    def get_feature_elt(self, elt):
-        """Check element's children to find feature elements
-
-        @param elt(domish.Element): parent element of the feature element
-        @return: feature elements
-        @raise exceptions.NotFound: no feature element found
-        """
-        try:
-            feature_elt = next(elt.elements(NS_FEATURE_NEG, "feature"))
-        except StopIteration:
-            raise exceptions.NotFound
-        return feature_elt
-
-    def _get_form(self, elt, namespace):
-        """Return the first child data form
-
-        @param elt(domish.Element): parent of the data form
-        @param namespace (None, unicode): form namespace or None to ignore
-        @return (None, data_form.Form): data form or None is nothing is found
-        """
-        if namespace is None:
-            try:
-                form_elt = next(elt.elements(data_form.NS_X_DATA))
-            except StopIteration:
-                return None
-            else:
-                return data_form.Form.fromElement(form_elt)
-        else:
-            return data_form.findForm(elt, namespace)
-
-    def get_choosed_options(self, feature_elt, namespace):
-        """Return choosed feature for feature element
-
-        @param feature_elt(domish.Element): feature domish element
-        @param namespace (None, unicode): form namespace or None to ignore
-        @return (dict): feature name as key, and choosed option as value
-        @raise exceptions.NotFound: not data form is found
-        """
-        form = self._get_form(feature_elt, namespace)
-        if form is None:
-            raise exceptions.NotFound
-        result = {}
-        for field in form.fields:
-            values = form.fields[field].values
-            result[field] = values[0] if values else None
-            if len(values) > 1:
-                log.warning(
-                    _(
-                        "More than one value choosed for {}, keeping the first one"
-                    ).format(field)
-                )
-        return result
-
-    def negotiate(self, feature_elt, name, negotiable_values, namespace):
-        """Negotiate the feature options
-
-        @param feature_elt(domish.Element): feature element
-        @param name: the option name (i.e. field's var attribute) to negotiate
-        @param negotiable_values(iterable): acceptable values for this negotiation
-            first corresponding value will be returned
-        @param namespace (None, unicode): form namespace or None to ignore
-        @raise KeyError: name is not found in data form fields
-        """
-        form = self._get_form(feature_elt, namespace)
-        options = [option.value for option in form.fields[name].options]
-        for value in negotiable_values:
-            if value in options:
-                return value
-        return None
-
-    def choose_option(self, options, namespace):
-        """Build a feature element with choosed options
-
-        @param options(dict): dict with feature as key and choosed option as value
-        @param namespace (None, unicode): form namespace or None to ignore
-        """
-        feature_elt = domish.Element((NS_FEATURE_NEG, "feature"))
-        x_form = data_form.Form("submit", formNamespace=namespace)
-        x_form.makeFields(options)
-        feature_elt.addChild(x_form.toElement())
-        return feature_elt
-
-    def propose_features(self, options_dict, namespace):
-        """Build a feature element with options to propose
-
-        @param options_dict(dict): dict with feature as key and iterable of acceptable options as value
-        @param namespace(None, unicode): feature namespace
-        """
-        feature_elt = domish.Element((NS_FEATURE_NEG, "feature"))
-        x_form = data_form.Form("form", formNamespace=namespace)
-        for field in options_dict:
-            x_form.addField(
-                data_form.Field(
-                    "list-single",
-                    field,
-                    options=[data_form.Option(option) for option in options_dict[field]],
-                )
-            )
-        feature_elt.addChild(x_form.toElement())
-        return feature_elt
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0020_handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_FEATURE_NEG)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0033.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,243 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Extended Stanza Addressing (xep-0033)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core import exceptions
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-from twisted.words.protocols.jabber.jid import JID
-from twisted.python import failure
-import copy
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-from twisted.words.xish import domish
-from twisted.internet import defer
-
-from sat.tools import trigger
-from time import time
-
-# TODO: fix Prosody "addressing" plugin to leave the concerned bcc according to the spec:
-#
-# http://xmpp.org/extensions/xep-0033.html#addr-type-bcc
-# "This means that the server MUST remove these addresses before the stanza is delivered to anyone other than the given bcc addressee or the multicast service of the bcc addressee."
-#
-# http://xmpp.org/extensions/xep-0033.html#multicast
-# "Each 'bcc' recipient MUST receive only the <address type='bcc'/> associated with that addressee."
-
-# TODO: fix Prosody "addressing" plugin to determine itself if remote servers supports this XEP
-
-
-NS_XMPP_CLIENT = "jabber:client"
-NS_ADDRESS = "http://jabber.org/protocol/address"
-ATTRIBUTES = ["jid", "uri", "node", "desc", "delivered", "type"]
-ADDRESS_TYPES = ["to", "cc", "bcc", "replyto", "replyroom", "noreply"]
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Extended Stanza Addressing Protocol Plugin",
-    C.PI_IMPORT_NAME: "XEP-0033",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0033"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "XEP_0033",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Extended Stanza Addressing"""),
-}
-
-
-class XEP_0033(object):
-    """
-    Implementation for XEP 0033
-    """
-
-    def __init__(self, host):
-        log.info(_("Extended Stanza Addressing plugin initialization"))
-        self.host = host
-        self.internal_data = {}
-        host.trigger.add(
-            "sendMessage", self.send_message_trigger, trigger.TriggerManager.MIN_PRIORITY
-        )
-        host.trigger.add("message_received", self.message_received_trigger)
-
-    def send_message_trigger(
-        self, client, mess_data, pre_xml_treatments, post_xml_treatments
-    ):
-        """Process the XEP-0033 related data to be sent"""
-        profile = client.profile
-
-        def treatment(mess_data):
-            if not "address" in mess_data["extra"]:
-                return mess_data
-
-            def disco_callback(entities):
-                if not entities:
-                    log.warning(
-                        _("XEP-0033 is being used but the server doesn't support it!")
-                    )
-                    raise failure.Failure(
-                        exceptions.CancelError("Cancelled by XEP-0033")
-                    )
-                if mess_data["to"] not in entities:
-                    expected = _(" or ").join([entity.userhost() for entity in entities])
-                    log.warning(
-                        _(
-                            "Stanzas using XEP-0033 should be addressed to %(expected)s, not %(current)s!"
-                        )
-                        % {"expected": expected, "current": mess_data["to"]}
-                    )
-                    log.warning(
-                        _(
-                            "TODO: addressing has been fixed by the backend... fix it in the frontend!"
-                        )
-                    )
-                    mess_data["to"] = list(entities)[0].userhostJID()
-                element = mess_data["xml"].addElement("addresses", NS_ADDRESS)
-                entries = [
-                    entry.split(":")
-                    for entry in mess_data["extra"]["address"].split("\n")
-                    if entry != ""
-                ]
-                for type_, jid_ in entries:
-                    element.addChild(
-                        domish.Element(
-                            (None, "address"), None, {"type": type_, "jid": jid_}
-                        )
-                    )
-                # when the prosody plugin is completed, we can immediately return mess_data from here
-                self.send_and_store_message(mess_data, entries, profile)
-                log.debug("XEP-0033 took over")
-                raise failure.Failure(exceptions.CancelError("Cancelled by XEP-0033"))
-
-            d = self.host.find_features_set(client, [NS_ADDRESS])
-            d.addCallbacks(disco_callback, lambda __: disco_callback(None))
-            return d
-
-        post_xml_treatments.addCallback(treatment)
-        return True
-
-    def send_and_store_message(self, mess_data, entries, profile):
-        """Check if target servers support XEP-0033, send and store the messages
-        @return: a friendly failure to let the core know that we sent the message already
-
-        Later we should be able to remove this method because:
-        # XXX: sending the messages should be done by the local server
-        # FIXME: for now we duplicate the messages in the history for each recipient, this should change
-        # FIXME: for now we duplicate the echoes to the sender, this should also change
-        Ideas:
-        - fix Prosody plugin to check if target server support the feature
-        - redesign the database to save only one entry to the database
-        - change the message_new signal to eventually pass more than one recipient
-        """
-        client = self.host.get_client(profile)
-
-        def send(mess_data, skip_send=False):
-            d = defer.Deferred()
-            if not skip_send:
-                d.addCallback(
-                    lambda ret: defer.ensureDeferred(client.send_message_data(ret))
-                )
-            d.addCallback(
-                lambda ret: defer.ensureDeferred(client.message_add_to_history(ret))
-            )
-            d.addCallback(client.message_send_to_bridge)
-            d.addErrback(lambda failure: failure.trap(exceptions.CancelError))
-            return d.callback(mess_data)
-
-        def disco_callback(entities, to_jid_s):
-            history_data = copy.deepcopy(mess_data)
-            history_data["to"] = JID(to_jid_s)
-            history_data["xml"]["to"] = to_jid_s
-            if entities:
-                if entities not in self.internal_data[timestamp]:
-                    sent_data = copy.deepcopy(mess_data)
-                    sent_data["to"] = JID(JID(to_jid_s).host)
-                    sent_data["xml"]["to"] = JID(to_jid_s).host
-                    send(sent_data)
-                    self.internal_data[timestamp].append(entities)
-                # we still need to fill the history and signal the echo...
-                send(history_data, skip_send=True)
-            else:
-                # target server misses the addressing feature
-                send(history_data)
-
-        def errback(failure, to_jid):
-            disco_callback(None, to_jid)
-
-        timestamp = time()
-        self.internal_data[timestamp] = []
-        defer_list = []
-        for type_, jid_ in entries:
-            d = defer.Deferred()
-            d.addCallback(
-                self.host.find_features_set, client=client, jid_=JID(JID(jid_).host)
-            )
-            d.addCallbacks(
-                disco_callback, errback, callbackArgs=[jid_], errbackArgs=[jid_]
-            )
-            d.callback([NS_ADDRESS])
-            defer_list.append(d)
-        d = defer.Deferred().addCallback(lambda __: self.internal_data.pop(timestamp))
-        defer.DeferredList(defer_list).chainDeferred(d)
-
-    def message_received_trigger(self, client, message, post_treat):
-        """In order to save the addressing information in the history"""
-
-        def post_treat_addr(data, addresses):
-            data["extra"]["addresses"] = ""
-            for address in addresses:
-                # Depending how message has been constructed, we could get here
-                # some noise like "\n        " instead of an address element.
-                if isinstance(address, domish.Element):
-                    data["extra"]["addresses"] += "%s:%s\n" % (
-                        address["type"],
-                        address["jid"],
-                    )
-            return data
-
-        try:
-            addresses = next(message.elements(NS_ADDRESS, "addresses"))
-        except StopIteration:
-            pass  # no addresses
-        else:
-            post_treat.addCallback(post_treat_addr, addresses.children)
-        return True
-
-    def get_handler(self, client):
-        return XEP_0033_handler(self, client.profile)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0033_handler(XMPPHandler):
-
-    def __init__(self, plugin_parent, profile):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-        self.profile = profile
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_ADDRESS)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0045.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1483 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing xep-0045
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import time
-from typing import Optional
-import uuid
-
-from twisted.internet import defer
-from twisted.python import failure
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber import error as xmpp_error
-from wokkel import disco, iwokkel, muc
-from wokkel import rsm
-from wokkel import mam
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.core_types import SatXMPPEntity
-from sat.core.constants import Const as C
-from sat.core.i18n import D_, _
-from sat.core.log import getLogger
-from sat.memory import memory
-from sat.tools import xml_tools, utils
-
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP-0045 Plugin",
-    C.PI_IMPORT_NAME: "XEP-0045",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0045"],
-    C.PI_DEPENDENCIES: ["XEP-0359"],
-    C.PI_RECOMMENDATIONS: [C.TEXT_CMDS, "XEP-0313"],
-    C.PI_MAIN: "XEP_0045",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Multi-User Chat""")
-}
-
-NS_MUC = 'http://jabber.org/protocol/muc'
-AFFILIATIONS = ('owner', 'admin', 'member', 'none', 'outcast')
-ROOM_USER_JOINED = 'ROOM_USER_JOINED'
-ROOM_USER_LEFT = 'ROOM_USER_LEFT'
-OCCUPANT_KEYS = ('nick', 'entity', 'affiliation', 'role')
-ROOM_STATE_OCCUPANTS = "occupants"
-ROOM_STATE_SELF_PRESENCE = "self-presence"
-ROOM_STATE_LIVE = "live"
-ROOM_STATES = (ROOM_STATE_OCCUPANTS, ROOM_STATE_SELF_PRESENCE, ROOM_STATE_LIVE)
-HISTORY_LEGACY = "legacy"
-HISTORY_MAM = "mam"
-
-
-CONFIG_SECTION = 'plugin muc'
-
-default_conf = {"default_muc": 'sat@chat.jabberfr.org'}
-
-
-class AlreadyJoined(exceptions.ConflictError):
-
-    def __init__(self, room):
-        super(AlreadyJoined, self).__init__()
-        self.room = room
-
-
-class XEP_0045(object):
-    # TODO: handle invitations
-    # FIXME: this plugin need a good cleaning, join method is messy
-
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0045 initialization"))
-        self.host = host
-        self._sessions = memory.Sessions()
-        # return same arguments as muc_room_joined + a boolean set to True is the room was
-        # already joined (first argument)
-        host.bridge.add_method(
-            "muc_join", ".plugin", in_sign='ssa{ss}s', out_sign='(bsa{sa{ss}}ssass)',
-            method=self._join, async_=True)
-        host.bridge.add_method(
-            "muc_nick", ".plugin", in_sign='sss', out_sign='', method=self._nick)
-        host.bridge.add_method(
-            "muc_nick_get", ".plugin", in_sign='ss', out_sign='s', method=self._get_room_nick)
-        host.bridge.add_method(
-            "muc_leave", ".plugin", in_sign='ss', out_sign='', method=self._leave,
-            async_=True)
-        host.bridge.add_method(
-            "muc_occupants_get", ".plugin", in_sign='ss', out_sign='a{sa{ss}}',
-            method=self._get_room_occupants)
-        host.bridge.add_method(
-            "muc_subject", ".plugin", in_sign='sss', out_sign='', method=self._subject)
-        host.bridge.add_method(
-            "muc_get_rooms_joined", ".plugin", in_sign='s', out_sign='a(sa{sa{ss}}ssas)',
-            method=self._get_rooms_joined)
-        host.bridge.add_method(
-            "muc_get_unique_room_name", ".plugin", in_sign='ss', out_sign='s',
-            method=self._get_unique_name)
-        host.bridge.add_method(
-            "muc_configure_room", ".plugin", in_sign='ss', out_sign='s',
-            method=self._configure_room, async_=True)
-        host.bridge.add_method(
-            "muc_get_default_service", ".plugin", in_sign='', out_sign='s',
-            method=self.get_default_muc)
-        host.bridge.add_method(
-            "muc_get_service", ".plugin", in_sign='ss', out_sign='s',
-            method=self._get_muc_service, async_=True)
-        # called when a room will be joined but must be locked until join is received
-        # (room is prepared, history is getting retrieved)
-        # args: room_jid, profile
-        host.bridge.add_signal(
-            "muc_room_prepare_join", ".plugin", signature='ss')
-        # args: room_jid, occupants, user_nick, subject, profile
-        host.bridge.add_signal(
-            "muc_room_joined", ".plugin", signature='sa{sa{ss}}ssass')
-        # args: room_jid, profile
-        host.bridge.add_signal(
-            "muc_room_left", ".plugin", signature='ss')
-        # args: room_jid, old_nick, new_nick, profile
-        host.bridge.add_signal(
-            "muc_room_user_changed_nick", ".plugin", signature='ssss')
-        # args: room_jid, subject, profile
-        host.bridge.add_signal(
-            "muc_room_new_subject", ".plugin", signature='sss')
-        self.__submit_conf_id = host.register_callback(
-            self._submit_configuration, with_data=True)
-        self._room_join_id = host.register_callback(self._ui_room_join_cb, with_data=True)
-        host.import_menu(
-            (D_("MUC"), D_("configure")), self._configure_room_menu, security_limit=0,
-            help_string=D_("Configure Multi-User Chat room"), type_=C.MENU_ROOM)
-        try:
-            self.text_cmds = self.host.plugins[C.TEXT_CMDS]
-        except KeyError:
-            log.info(_("Text commands not available"))
-        else:
-            self.text_cmds.register_text_commands(self)
-            self.text_cmds.add_who_is_cb(self._whois, 100)
-
-        self._mam = self.host.plugins.get("XEP-0313")
-        self._si = self.host.plugins["XEP-0359"]
-
-        host.trigger.add("presence_available", self.presence_trigger)
-        host.trigger.add("presence_received", self.presence_received_trigger)
-        host.trigger.add("message_received", self.message_received_trigger, priority=1000000)
-        host.trigger.add("message_parse", self._message_parse_trigger)
-
-    async def profile_connected(self, client):
-        client.muc_service = await self.get_muc_service(client)
-
-    def _message_parse_trigger(self, client, message_elt, data):
-        """Add stanza-id from the room if present"""
-        if message_elt.getAttribute("type") != C.MESS_TYPE_GROUPCHAT:
-            return True
-
-        # stanza_id will not be filled by parse_message because the emitter
-        # is the room and not our server, so we have to parse it here
-        room_jid = data["from"].userhostJID()
-        stanza_id = self._si.get_stanza_id(message_elt, room_jid)
-        if stanza_id:
-            data["extra"]["stanza_id"] = stanza_id
-
-    def message_received_trigger(self, client, message_elt, post_treat):
-        if message_elt.getAttribute("type") == C.MESS_TYPE_GROUPCHAT:
-            if message_elt.subject or message_elt.delay:
-                return False
-            from_jid = jid.JID(message_elt['from'])
-            room_jid = from_jid.userhostJID()
-            if room_jid in client._muc_client.joined_rooms:
-                room = client._muc_client.joined_rooms[room_jid]
-                if room.state != ROOM_STATE_LIVE:
-                    if getattr(room, "_history_type", HISTORY_LEGACY) == HISTORY_LEGACY:
-                        # With MAM history, order is different, and we can get live
-                        # messages before history is complete, so this is not a warning
-                        # but an expected case.
-                        # On the other hand, with legacy history, it's not normal.
-                        log.warning(_(
-                            "Received non delayed message in a room before its "
-                            "initialisation: state={state}, msg={msg}").format(
-                        state=room.state,
-                        msg=message_elt.toXml()))
-                    room._cache.append(message_elt)
-                    return False
-            else:
-                log.warning("Received groupchat message for a room which has not been "
-                            "joined, ignoring it: {}".format(message_elt.toXml()))
-                return False
-        return True
-
-    def get_room(self, client: SatXMPPEntity, room_jid: jid.JID) -> muc.Room:
-        """Retrieve Room instance from its jid
-
-        @param room_jid: jid of the room
-        @raise exceptions.NotFound: the room has not been joined
-        """
-        try:
-            return client._muc_client.joined_rooms[room_jid]
-        except KeyError:
-            raise exceptions.NotFound(_("This room has not been joined"))
-
-    def check_room_joined(self, client, room_jid):
-        """Check that given room has been joined in current session
-
-        @param room_jid (JID): room JID
-        """
-        if room_jid not in client._muc_client.joined_rooms:
-            raise exceptions.NotFound(_("This room has not been joined"))
-
-    def is_joined_room(self, client: SatXMPPEntity, room_jid: jid.JID) -> bool:
-        """Tell if a jid is a known and joined room
-
-        @room_jid: jid of the room
-        """
-        try:
-            self.check_room_joined(client, room_jid)
-        except exceptions.NotFound:
-            return False
-        else:
-            return True
-
-    def is_room(self, client, entity_jid):
-        """Tell if a jid is a joined MUC
-
-        similar to is_joined_room but returns a boolean
-        @param entity_jid(jid.JID): full or bare jid of the entity check
-        @return (bool): True if the bare jid of the entity is a room jid
-        """
-        try:
-            self.check_room_joined(client, entity_jid.userhostJID())
-        except exceptions.NotFound:
-            return False
-        else:
-            return True
-
-    def get_bare_or_full(self, client, peer_jid):
-        """use full jid if peer_jid is an occupant of a room, bare jid else
-
-        @param peer_jid(jid.JID): entity to test
-        @return (jid.JID): bare or full jid
-        """
-        if peer_jid.resource:
-            if not self.is_room(client, peer_jid):
-                return peer_jid.userhostJID()
-        return peer_jid
-
-    def _get_room_joined_args(self, room, profile):
-        return [
-            room.roomJID.userhost(),
-            XEP_0045._get_occupants(room),
-            room.nick,
-            room.subject,
-            [s.name for s in room.statuses],
-            profile
-            ]
-
-    def _ui_room_join_cb(self, data, profile):
-        room_jid = jid.JID(data['index'])
-        client = self.host.get_client(profile)
-        self.join(client, room_jid)
-        return {}
-
-    def _password_ui_cb(self, data, client, room_jid, nick):
-        """Called when the user has given room password (or cancelled)"""
-        if C.bool(data.get(C.XMLUI_DATA_CANCELLED, "false")):
-            log.info("room join for {} is cancelled".format(room_jid.userhost()))
-            raise failure.Failure(exceptions.CancelError(D_("Room joining cancelled by user")))
-        password = data[xml_tools.form_escape('password')]
-        return client._muc_client.join(room_jid, nick, password).addCallbacks(self._join_cb, self._join_eb, (client, room_jid, nick), errbackArgs=(client, room_jid, nick, password))
-
-    def _show_list_ui(self, items, client, service):
-        xmlui = xml_tools.XMLUI(title=D_('Rooms in {}'.format(service.full())))
-        adv_list = xmlui.change_container('advanced_list', columns=1, selectable='single', callback_id=self._room_join_id)
-        items = sorted(items, key=lambda i: i.name.lower())
-        for item in items:
-            adv_list.set_row_index(item.entity.full())
-            xmlui.addText(item.name)
-        adv_list.end()
-        self.host.action_new({'xmlui': xmlui.toXml()}, profile=client.profile)
-
-    def _join_cb(self, room, client, room_jid, nick):
-        """Called when the user is in the requested room"""
-        if room.locked:
-            # FIXME: the current behaviour is to create an instant room
-            # and send the signal only when the room is unlocked
-            # a proper configuration management should be done
-            log.debug(_("room locked !"))
-            d = client._muc_client.configure(room.roomJID, {})
-            d.addErrback(self.host.log_errback,
-                         msg=_('Error while configuring the room: {failure_}'))
-        return room.fully_joined
-
-    def _join_eb(self, failure_, client, room_jid, nick, password):
-        """Called when something is going wrong when joining the room"""
-        try:
-            condition = failure_.value.condition
-        except AttributeError:
-            msg_suffix = f': {failure_}'
-        else:
-            if condition == 'conflict':
-                # we have a nickname conflict, we try again with "_" suffixed to current nickname
-                nick += '_'
-                return client._muc_client.join(room_jid, nick, password).addCallbacks(self._join_cb, self._join_eb, (client, room_jid, nick), errbackArgs=(client, room_jid, nick, password))
-            elif condition == 'not-allowed':
-                # room is restricted, we need a password
-                password_ui = xml_tools.XMLUI("form", title=D_('Room {} is restricted').format(room_jid.userhost()), submit_id='')
-                password_ui.addText(D_("This room is restricted, please enter the password"))
-                password_ui.addPassword('password')
-                d = xml_tools.defer_xmlui(self.host, password_ui, profile=client.profile)
-                d.addCallback(self._password_ui_cb, client, room_jid, nick)
-                return d
-
-            msg_suffix = ' with condition "{}"'.format(failure_.value.condition)
-
-        mess = D_("Error while joining the room {room}{suffix}".format(
-            room = room_jid.userhost(), suffix = msg_suffix))
-        log.warning(mess)
-        xmlui = xml_tools.note(mess, D_("Group chat error"), level=C.XMLUI_DATA_LVL_ERROR)
-        self.host.action_new({'xmlui': xmlui.toXml()}, profile=client.profile)
-
-    @staticmethod
-    def _get_occupants(room):
-        """Get occupants of a room in a form suitable for bridge"""
-        return {u.nick: {k:str(getattr(u,k) or '') for k in OCCUPANT_KEYS} for u in list(room.roster.values())}
-
-    def _get_room_occupants(self, room_jid_s, profile_key):
-        client = self.host.get_client(profile_key)
-        room_jid = jid.JID(room_jid_s)
-        return self.get_room_occupants(client, room_jid)
-
-    def get_room_occupants(self, client, room_jid):
-        room = self.get_room(client, room_jid)
-        return self._get_occupants(room)
-
-    def _get_rooms_joined(self, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        return self.get_rooms_joined(client)
-
-    def get_rooms_joined(self, client):
-        """Return rooms where user is"""
-        result = []
-        for room in list(client._muc_client.joined_rooms.values()):
-            if room.state == ROOM_STATE_LIVE:
-                result.append(
-                    (room.roomJID.userhost(),
-                     self._get_occupants(room),
-                     room.nick,
-                     room.subject,
-                     [s.name for s in room.statuses],
-                    )
-                )
-        return result
-
-    def _get_room_nick(self, room_jid_s, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        return self.get_room_nick(client, jid.JID(room_jid_s))
-
-    def get_room_nick(self, client, room_jid):
-        """return nick used in room by user
-
-        @param room_jid (jid.JID): JID of the room
-        @profile_key: profile
-        @return: nick or empty string in case of error
-        @raise exceptions.Notfound: use has not joined the room
-        """
-        self.check_room_joined(client, room_jid)
-        return client._muc_client.joined_rooms[room_jid].nick
-
-    def _configure_room(self, room_jid_s, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        d = self.configure_room(client, jid.JID(room_jid_s))
-        d.addCallback(lambda xmlui: xmlui.toXml())
-        return d
-
-    def _configure_room_menu(self, menu_data, profile):
-        """Return room configuration form
-
-        @param menu_data: %(menu_data)s
-        @param profile: %(doc_profile)s
-        """
-        client = self.host.get_client(profile)
-        try:
-            room_jid = jid.JID(menu_data['room_jid'])
-        except KeyError:
-            log.error(_("room_jid key is not present !"))
-            return defer.fail(exceptions.DataError)
-
-        def xmlui_received(xmlui):
-            if not xmlui:
-                msg = D_("No configuration available for this room")
-                return {"xmlui": xml_tools.note(msg).toXml()}
-            return {"xmlui": xmlui.toXml()}
-        return self.configure_room(client, room_jid).addCallback(xmlui_received)
-
-    def configure_room(self, client, room_jid):
-        """return the room configuration form
-
-        @param room: jid of the room to configure
-        @return: configuration form as XMLUI
-        """
-        self.check_room_joined(client, room_jid)
-
-        def config_2_xmlui(result):
-            if not result:
-                return ""
-            session_id, session_data = self._sessions.new_session(profile=client.profile)
-            session_data["room_jid"] = room_jid
-            xmlui = xml_tools.data_form_2_xmlui(result, submit_id=self.__submit_conf_id)
-            xmlui.session_id = session_id
-            return xmlui
-
-        d = client._muc_client.getConfiguration(room_jid)
-        d.addCallback(config_2_xmlui)
-        return d
-
-    def _submit_configuration(self, raw_data, profile):
-        cancelled = C.bool(raw_data.get("cancelled", C.BOOL_FALSE))
-        if cancelled:
-            return defer.succeed({})
-        client = self.host.get_client(profile)
-        try:
-            session_data = self._sessions.profile_get(raw_data["session_id"], profile)
-        except KeyError:
-            log.warning(D_("Session ID doesn't exist, session has probably expired."))
-            _dialog = xml_tools.XMLUI('popup', title=D_('Room configuration failed'))
-            _dialog.addText(D_("Session ID doesn't exist, session has probably expired."))
-            return defer.succeed({'xmlui': _dialog.toXml()})
-
-        data = xml_tools.xmlui_result_2_data_form_result(raw_data)
-        d = client._muc_client.configure(session_data['room_jid'], data)
-        _dialog = xml_tools.XMLUI('popup', title=D_('Room configuration succeed'))
-        _dialog.addText(D_("The new settings have been saved."))
-        d.addCallback(lambda ignore: {'xmlui': _dialog.toXml()})
-        del self._sessions[raw_data["session_id"]]
-        return d
-
-    def is_nick_in_room(self, client, room_jid, nick):
-        """Tell if a nick is currently present in a room"""
-        self.check_room_joined(client, room_jid)
-        return client._muc_client.joined_rooms[room_jid].inRoster(muc.User(nick))
-
-    def _get_muc_service(self, jid_=None, profile=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile)
-        d = defer.ensureDeferred(self.get_muc_service(client, jid_ or None))
-        d.addCallback(lambda service_jid: service_jid.full() if service_jid is not None else '')
-        return d
-
-    async def get_muc_service(
-        self,
-        client: SatXMPPEntity,
-        jid_: Optional[jid.JID] = None) -> Optional[jid.JID]:
-        """Return first found MUC service of an entity
-
-        @param jid_: entity which may have a MUC service, or None for our own server
-        @return: found service jid or None
-        """
-        if jid_ is None:
-            try:
-                muc_service = client.muc_service
-            except AttributeError:
-                pass
-            else:
-                # we have a cached value, we return it
-                return muc_service
-        services = await self.host.find_service_entities(client, "conference", "text", jid_)
-        for service in services:
-            if ".irc." not in service.userhost():
-                # FIXME:
-                # This ugly hack is here to avoid an issue with openfire: the IRC gateway
-                # use "conference/text" identity (instead of "conference/irc")
-                muc_service = service
-                break
-        else:
-            muc_service = None
-        return muc_service
-
-    def _get_unique_name(self, muc_service="", profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        return self.get_unique_name(client, muc_service or None).full()
-
-    def get_unique_name(self, client, muc_service=None):
-        """Return unique name for a room, avoiding collision
-
-        @param muc_service (jid.JID) : leave empty string to use the default service
-        @return: jid.JID (unique room bare JID)
-        """
-        # TODO: we should use #RFC-0045 10.1.4 when available here
-        room_name = str(uuid.uuid4())
-        if muc_service is None:
-            try:
-                muc_service = client.muc_service
-            except AttributeError:
-                raise exceptions.NotReady("Main server MUC service has not been checked yet")
-            if muc_service is None:
-                log.warning(_("No MUC service found on main server"))
-                raise exceptions.FeatureNotFound
-
-        muc_service = muc_service.userhost()
-        return jid.JID("{}@{}".format(room_name, muc_service))
-
-    def get_default_muc(self):
-        """Return the default MUC.
-
-        @return: unicode
-        """
-        return self.host.memory.config_get(CONFIG_SECTION, 'default_muc', default_conf['default_muc'])
-
-    def _join_eb(self, failure_, client):
-        failure_.trap(AlreadyJoined)
-        room = failure_.value.room
-        return [True] + self._get_room_joined_args(room, client.profile)
-
-    def _join(self, room_jid_s, nick, options, profile_key=C.PROF_KEY_NONE):
-        """join method used by bridge
-
-        @return (tuple): already_joined boolean + room joined arguments (see [_get_room_joined_args])
-        """
-        client = self.host.get_client(profile_key)
-        if room_jid_s:
-            muc_service = client.muc_service
-            try:
-                room_jid = jid.JID(room_jid_s)
-            except (RuntimeError, jid.InvalidFormat, AttributeError):
-                return defer.fail(jid.InvalidFormat(_("Invalid room identifier: {room_id}'. Please give a room short or full identifier like 'room' or 'room@{muc_service}'.").format(
-                    room_id=room_jid_s,
-                    muc_service=str(muc_service))))
-            if not room_jid.user:
-                room_jid.user, room_jid.host = room_jid.host, muc_service
-        else:
-            room_jid = self.get_unique_name(profile_key=client.profile)
-        # TODO: error management + signal in bridge
-        d = self.join(client, room_jid, nick, options or None)
-        d.addCallback(lambda room: [False] + self._get_room_joined_args(room, client.profile))
-        d.addErrback(self._join_eb, client)
-        return d
-
-    async def join(
-        self,
-        client: SatXMPPEntity,
-        room_jid: jid.JID,
-        nick: Optional[str] = None,
-        options: Optional[dict] = None
-    ) -> Optional[muc.Room]:
-        if not nick:
-            nick = client.jid.user
-        if options is None:
-            options = {}
-        if room_jid in client._muc_client.joined_rooms:
-            room = client._muc_client.joined_rooms[room_jid]
-            log.info(_('{profile} is already in room {room_jid}').format(
-                profile=client.profile, room_jid = room_jid.userhost()))
-            raise AlreadyJoined(room)
-        log.info(_("[{profile}] is joining room {room} with nick {nick}").format(
-            profile=client.profile, room=room_jid.userhost(), nick=nick))
-        self.host.bridge.muc_room_prepare_join(room_jid.userhost(), client.profile)
-
-        password = options.get("password")
-
-        try:
-            room = await client._muc_client.join(room_jid, nick, password)
-        except Exception as e:
-            room = await utils.as_deferred(
-                self._join_eb(failure.Failure(e), client, room_jid, nick, password)
-            )
-        else:
-            await defer.ensureDeferred(
-                self._join_cb(room, client, room_jid, nick)
-            )
-        return room
-
-    def pop_rooms(self, client):
-        """Remove rooms and return data needed to re-join them
-
-        This methods is to be called before a hot reconnection
-        @return (list[(jid.JID, unicode)]): arguments needed to re-join the rooms
-            This list can be used directly (unpacked) with self.join
-        """
-        args_list = []
-        for room in list(client._muc_client.joined_rooms.values()):
-            client._muc_client._removeRoom(room.roomJID)
-            args_list.append((client, room.roomJID, room.nick))
-        return args_list
-
-    def _nick(self, room_jid_s, nick, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        return self.nick(client, jid.JID(room_jid_s), nick)
-
-    def nick(self, client, room_jid, nick):
-        """Change nickname in a room"""
-        self.check_room_joined(client, room_jid)
-        return client._muc_client.nick(room_jid, nick)
-
-    def _leave(self, room_jid, profile_key):
-        client = self.host.get_client(profile_key)
-        return self.leave(client, jid.JID(room_jid))
-
-    def leave(self, client, room_jid):
-        self.check_room_joined(client, room_jid)
-        return client._muc_client.leave(room_jid)
-
-    def _subject(self, room_jid_s, new_subject, profile_key):
-        client = self.host.get_client(profile_key)
-        return self.subject(client, jid.JID(room_jid_s), new_subject)
-
-    def subject(self, client, room_jid, subject):
-        self.check_room_joined(client, room_jid)
-        return client._muc_client.subject(room_jid, subject)
-
-    def get_handler(self, client):
-        # create a MUC client and associate it with profile' session
-        muc_client = client._muc_client = LiberviaMUCClient(self)
-        return muc_client
-
-    def kick(self, client, nick, room_jid, options=None):
-        """Kick a participant from the room
-
-        @param nick (str): nick of the user to kick
-        @param room_jid_s (JID): jid of the room
-        @param options (dict): attribute with extra info (reason, password) as in #XEP-0045
-        """
-        if options is None:
-            options = {}
-        self.check_room_joined(client, room_jid)
-        return client._muc_client.kick(room_jid, nick, reason=options.get('reason', None))
-
-    def ban(self, client, entity_jid, room_jid, options=None):
-        """Ban an entity from the room
-
-        @param entity_jid (JID): bare jid of the entity to be banned
-        @param room_jid (JID): jid of the room
-        @param options: attribute with extra info (reason, password) as in #XEP-0045
-        """
-        self.check_room_joined(client, room_jid)
-        if options is None:
-            options = {}
-        assert not entity_jid.resource
-        assert not room_jid.resource
-        return client._muc_client.ban(room_jid, entity_jid, reason=options.get('reason', None))
-
-    def affiliate(self, client, entity_jid, room_jid, options):
-        """Change the affiliation of an entity
-
-        @param entity_jid (JID): bare jid of the entity
-        @param room_jid_s (JID): jid of the room
-        @param options: attribute with extra info (reason, nick) as in #XEP-0045
-        """
-        self.check_room_joined(client, room_jid)
-        assert not entity_jid.resource
-        assert not room_jid.resource
-        assert 'affiliation' in options
-        # TODO: handles reason and nick
-        return client._muc_client.modifyAffiliationList(room_jid, [entity_jid], options['affiliation'])
-
-    # Text commands #
-
-    def cmd_nick(self, client, mess_data):
-        """change nickname
-
-        @command (group): new_nick
-            - new_nick: new nick to use
-        """
-        nick = mess_data["unparsed"].strip()
-        if nick:
-            room = mess_data["to"]
-            self.nick(client, room, nick)
-
-        return False
-
-    def cmd_join(self, client, mess_data):
-        """join a new room
-
-        @command (all): JID
-            - JID: room to join (on the same service if full jid is not specified)
-        """
-        room_raw = mess_data["unparsed"].strip()
-        if room_raw:
-            if self.is_joined_room(client, mess_data["to"]):
-                # we use the same service as the one from the room where the command has
-                # been entered if full jid is not entered
-                muc_service = mess_data["to"].host
-                nick = self.get_room_nick(client, mess_data["to"]) or client.jid.user
-            else:
-                # the command has been entered in a one2one conversation, so we use
-                # our server MUC service as default service
-                muc_service = client.muc_service or ""
-                nick = client.jid.user
-            room_jid = self.text_cmds.get_room_jid(room_raw, muc_service)
-            self.join(client, room_jid, nick, {})
-
-        return False
-
-    def cmd_leave(self, client, mess_data):
-        """quit a room
-
-        @command (group): [ROOM_JID]
-            - ROOM_JID: jid of the room to live (current room if not specified)
-        """
-        room_raw = mess_data["unparsed"].strip()
-        if room_raw:
-            room = self.text_cmds.get_room_jid(room_raw, mess_data["to"].host)
-        else:
-            room = mess_data["to"]
-
-        self.leave(client, room)
-
-        return False
-
-    def cmd_part(self, client, mess_data):
-        """just a synonym of /leave
-
-        @command (group): [ROOM_JID]
-            - ROOM_JID: jid of the room to live (current room if not specified)
-        """
-        return self.cmd_leave(client, mess_data)
-
-    def cmd_kick(self, client, mess_data):
-        """kick a room member
-
-        @command (group): ROOM_NICK
-            - ROOM_NICK: the nick of the person to kick
-        """
-        options = mess_data["unparsed"].strip().split()
-        try:
-            nick = options[0]
-            assert self.is_nick_in_room(client, mess_data["to"], nick)
-        except (IndexError, AssertionError):
-            feedback = _("You must provide a member's nick to kick.")
-            self.text_cmds.feed_back(client, feedback, mess_data)
-            return False
-
-        reason = ' '.join(options[1:]) if len(options) > 1 else None
-
-        d = self.kick(client, nick, mess_data["to"], {"reason": reason})
-
-        def cb(__):
-            feedback_msg = _('You have kicked {}').format(nick)
-            if reason is not None:
-                feedback_msg += _(' for the following reason: {reason}').format(
-                    reason=reason
-                )
-            self.text_cmds.feed_back(client, feedback_msg, mess_data)
-            return True
-        d.addCallback(cb)
-        return d
-
-    def cmd_ban(self, client, mess_data):
-        """ban an entity from the room
-
-        @command (group): (JID) [reason]
-            - JID: the JID of the entity to ban
-            - reason: the reason why this entity is being banned
-        """
-        options = mess_data["unparsed"].strip().split()
-        try:
-            jid_s = options[0]
-            entity_jid = jid.JID(jid_s).userhostJID()
-            assert(entity_jid.user)
-            assert(entity_jid.host)
-        except (RuntimeError, jid.InvalidFormat, AttributeError, IndexError,
-                AssertionError):
-            feedback = _(
-                "You must provide a valid JID to ban, like in '/ban contact@example.net'"
-            )
-            self.text_cmds.feed_back(client, feedback, mess_data)
-            return False
-
-        reason = ' '.join(options[1:]) if len(options) > 1 else None
-
-        d = self.ban(client, entity_jid, mess_data["to"], {"reason": reason})
-
-        def cb(__):
-            feedback_msg = _('You have banned {}').format(entity_jid)
-            if reason is not None:
-                feedback_msg += _(' for the following reason: {reason}').format(
-                    reason=reason
-                )
-            self.text_cmds.feed_back(client, feedback_msg, mess_data)
-            return True
-        d.addCallback(cb)
-        return d
-
-    def cmd_affiliate(self, client, mess_data):
-        """affiliate an entity to the room
-
-        @command (group): (JID) [owner|admin|member|none|outcast]
-            - JID: the JID of the entity to affiliate
-            - owner: grant owner privileges
-            - admin: grant admin privileges
-            - member: grant member privileges
-            - none: reset entity privileges
-            - outcast: ban entity
-        """
-        options = mess_data["unparsed"].strip().split()
-        try:
-            jid_s = options[0]
-            entity_jid = jid.JID(jid_s).userhostJID()
-            assert(entity_jid.user)
-            assert(entity_jid.host)
-        except (RuntimeError, jid.InvalidFormat, AttributeError, IndexError, AssertionError):
-            feedback = _("You must provide a valid JID to affiliate, like in '/affiliate contact@example.net member'")
-            self.text_cmds.feed_back(client, feedback, mess_data)
-            return False
-
-        affiliation = options[1] if len(options) > 1 else 'none'
-        if affiliation not in AFFILIATIONS:
-            feedback = _("You must provide a valid affiliation: %s") % ' '.join(AFFILIATIONS)
-            self.text_cmds.feed_back(client, feedback, mess_data)
-            return False
-
-        d = self.affiliate(client, entity_jid, mess_data["to"], {'affiliation': affiliation})
-
-        def cb(__):
-            feedback_msg = _('New affiliation for {entity}: {affiliation}').format(
-                entity=entity_jid, affiliation=affiliation)
-            self.text_cmds.feed_back(client, feedback_msg, mess_data)
-            return True
-        d.addCallback(cb)
-        return d
-
-    def cmd_title(self, client, mess_data):
-        """change room's subject
-
-        @command (group): title
-            - title: new room subject
-        """
-        subject = mess_data["unparsed"].strip()
-
-        if subject:
-            room = mess_data["to"]
-            self.subject(client, room, subject)
-
-        return False
-
-    def cmd_topic(self, client, mess_data):
-        """just a synonym of /title
-
-        @command (group): title
-            - title: new room subject
-        """
-        return self.cmd_title(client, mess_data)
-
-    def cmd_list(self, client, mess_data):
-        """list available rooms in a muc server
-
-        @command (all): [MUC_SERVICE]
-            - MUC_SERVICE: service to request
-               empty value will request room's service for a room,
-               or user's server default MUC service in a one2one chat
-        """
-        unparsed = mess_data["unparsed"].strip()
-        try:
-            service = jid.JID(unparsed)
-        except RuntimeError:
-            if mess_data['type'] == C.MESS_TYPE_GROUPCHAT:
-                room_jid = mess_data["to"]
-                service = jid.JID(room_jid.host)
-            elif client.muc_service is not None:
-                service = client.muc_service
-            else:
-                msg = D_("No known default MUC service {unparsed}").format(
-                    unparsed=unparsed)
-                self.text_cmds.feed_back(client, msg, mess_data)
-                return False
-        except jid.InvalidFormat:
-            msg = D_("{} is not a valid JID!".format(unparsed))
-            self.text_cmds.feed_back(client, msg, mess_data)
-            return False
-        d = self.host.getDiscoItems(client, service)
-        d.addCallback(self._show_list_ui, client, service)
-
-        return False
-
-    def _whois(self, client, whois_msg, mess_data, target_jid):
-        """ Add MUC user information to whois """
-        if mess_data['type'] != "groupchat":
-            return
-        if target_jid.userhostJID() not in client._muc_client.joined_rooms:
-            log.warning(_("This room has not been joined"))
-            return
-        if not target_jid.resource:
-            return
-        user = client._muc_client.joined_rooms[target_jid.userhostJID()].getUser(target_jid.resource)
-        whois_msg.append(_("Nickname: %s") % user.nick)
-        if user.entity:
-            whois_msg.append(_("Entity: %s") % user.entity)
-        if user.affiliation != 'none':
-            whois_msg.append(_("Affiliation: %s") % user.affiliation)
-        if user.role != 'none':
-            whois_msg.append(_("Role: %s") % user.role)
-        if user.status:
-            whois_msg.append(_("Status: %s") % user.status)
-        if user.show:
-            whois_msg.append(_("Show: %s") % user.show)
-
-    def presence_trigger(self, presence_elt, client):
-        # FIXME: should we add a privacy parameters in settings to activate before
-        #        broadcasting the presence to all MUC rooms ?
-        muc_client = client._muc_client
-        for room_jid, room in muc_client.joined_rooms.items():
-            elt = xml_tools.element_copy(presence_elt)
-            elt['to'] = room_jid.userhost() + '/' + room.nick
-            client.presence.send(elt)
-        return True
-
-    def presence_received_trigger(self, client, entity, show, priority, statuses):
-        entity_bare = entity.userhostJID()
-        muc_client = client._muc_client
-        if entity_bare in muc_client.joined_rooms:
-            # presence is already handled in (un)availableReceived
-            return False
-        return True
-
-
-@implementer(iwokkel.IDisco)
-class LiberviaMUCClient(muc.MUCClient):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-        muc.MUCClient.__init__(self)
-        self._changing_nicks = set()  # used to keep trace of who is changing nick,
-                                      # and to discard userJoinedRoom signal in this case
-        print("init SatMUCClient OK")
-
-    @property
-    def joined_rooms(self):
-        return self._rooms
-
-    @property
-    def host(self):
-        return self.plugin_parent.host
-
-    @property
-    def client(self):
-        return self.parent
-
-    @property
-    def _mam(self):
-        return self.plugin_parent._mam
-
-    @property
-    def _si(self):
-        return self.plugin_parent._si
-
-    def change_room_state(self, room, new_state):
-        """Check that room is in expected state, and change it
-
-        @param new_state: one of ROOM_STATE_*
-        """
-        new_state_idx = ROOM_STATES.index(new_state)
-        if new_state_idx == -1:
-            raise exceptions.InternalError("unknown room state")
-        if new_state_idx < 1:
-            raise exceptions.InternalError("unexpected new room state ({room}): {state}".format(
-                room=room.userhost(),
-                state=new_state))
-        expected_state = ROOM_STATES[new_state_idx-1]
-        if room.state != expected_state:
-            log.error(_(
-                "room {room} is not in expected state: room is in state {current_state} "
-                "while we were expecting {expected_state}").format(
-                room=room.roomJID.userhost(),
-                current_state=room.state,
-                expected_state=expected_state))
-        room.state = new_state
-
-    def _addRoom(self, room):
-        super(LiberviaMUCClient, self)._addRoom(room)
-        room._roster_ok = False  # True when occupants list has been fully received
-        room.state = ROOM_STATE_OCCUPANTS
-        # FIXME: check if history_d is not redundant with fully_joined
-        room.fully_joined = defer.Deferred()  # called when everything is OK
-        # cache data until room is ready
-        # list of elements which will be re-injected in stream
-        room._cache = []
-        # we only need to keep last presence status for each jid, so a dict is suitable
-        room._cache_presence = {}
-
-    async def _join_legacy(
-        self,
-        client: SatXMPPEntity,
-        room_jid: jid.JID,
-        nick: str,
-        password: Optional[str]
-    ) -> muc.Room:
-        """Join room an retrieve history with legacy method"""
-        mess_data_list = await self.host.memory.history_get(
-            room_jid,
-            client.jid.userhostJID(),
-            limit=1,
-            between=True,
-            profile=client.profile
-        )
-        if mess_data_list:
-            timestamp = mess_data_list[0][1]
-            # we use seconds since last message to get backlog without duplicates
-            # and we remove 1 second to avoid getting the last message again
-            seconds = int(time.time() - timestamp) - 1
-        else:
-            seconds = None
-
-        room = await super(LiberviaMUCClient, self).join(
-            room_jid, nick, muc.HistoryOptions(seconds=seconds), password)
-        # used to send bridge signal once backlog are written in history
-        room._history_type = HISTORY_LEGACY
-        room._history_d = defer.Deferred()
-        room._history_d.callback(None)
-        return room
-
-    async def _get_mam_history(
-        self,
-        client: SatXMPPEntity,
-        room: muc.Room,
-        room_jid: jid.JID
-    ) -> None:
-        """Retrieve history for rooms handling MAM"""
-        history_d = room._history_d = defer.Deferred()
-        # we trigger now the deferred so all callback are processed as soon as possible
-        # and in order
-        history_d.callback(None)
-
-        last_mess = await self.host.memory.history_get(
-            room_jid,
-            None,
-            limit=1,
-            between=False,
-            filters={
-                'types': C.MESS_TYPE_GROUPCHAT,
-                'last_stanza_id': True},
-            profile=client.profile)
-        if last_mess:
-            stanza_id = last_mess[0][-1]['stanza_id']
-            rsm_req = rsm.RSMRequest(max_=20, after=stanza_id)
-            no_loop=False
-        else:
-            log.info("We have no MAM archive for room {room_jid}.".format(
-                room_jid=room_jid))
-            # we don't want the whole archive if we have no archive yet
-            # as it can be huge
-            rsm_req = rsm.RSMRequest(max_=50, before='')
-            no_loop=True
-
-        mam_req = mam.MAMRequest(rsm_=rsm_req)
-        complete = False
-        count = 0
-        while not complete:
-            try:
-                mam_data = await self._mam.get_archives(client, mam_req,
-                                                       service=room_jid)
-            except xmpp_error.StanzaError as e:
-                if last_mess and e.condition == 'item-not-found':
-                    log.info(
-                        f"requested item (with id {stanza_id!r}) can't be found in "
-                        f"history of {room_jid}, history has probably been purged on "
-                        f"server.")
-                    # we get last items like for a new room
-                    rsm_req = rsm.RSMRequest(max_=50, before='')
-                    mam_req = mam.MAMRequest(rsm_=rsm_req)
-                    no_loop=True
-                    continue
-                else:
-                    raise e
-            elt_list, rsm_response, mam_response = mam_data
-            complete = True if no_loop else mam_response["complete"]
-            # we update MAM request for next iteration
-            mam_req.rsm.after = rsm_response.last
-
-            if not elt_list:
-                break
-            else:
-                count += len(elt_list)
-
-                for mess_elt in elt_list:
-                    try:
-                        fwd_message_elt = self._mam.get_message_from_result(
-                            client, mess_elt, mam_req, service=room_jid)
-                    except exceptions.DataError:
-                        continue
-                    if fwd_message_elt.getAttribute("to"):
-                        log.warning(
-                            'Forwarded message element has a "to" attribute while it is '
-                            'forbidden by specifications')
-                    fwd_message_elt["to"] = client.jid.full()
-                    try:
-                        mess_data = client.messageProt.parse_message(fwd_message_elt)
-                    except Exception as e:
-                        log.error(
-                            f"Can't parse message, ignoring it: {e}\n"
-                            f"{fwd_message_elt.toXml()}"
-                        )
-                        continue
-                    # we attache parsed message data to element, to avoid parsing
-                    # again in _add_to_history
-                    fwd_message_elt._mess_data = mess_data
-                    # and we inject to MUC workflow
-                    client._muc_client._onGroupChat(fwd_message_elt)
-
-        if not count:
-            log.info(_("No message received while offline in {room_jid}".format(
-                room_jid=room_jid)))
-        else:
-            log.info(
-                _("We have received {num_mess} message(s) in {room_jid} while "
-                  "offline.")
-                .format(num_mess=count, room_jid=room_jid))
-
-        # for legacy history, the following steps are done in receivedSubject but for MAM
-        # the order is different (we have to join then get MAM archive, so subject
-        # is received before archive), so we change state and add the callbacks here.
-        self.change_room_state(room, ROOM_STATE_LIVE)
-        history_d.addCallbacks(self._history_cb, self._history_eb, [room],
-                                     errbackArgs=[room])
-
-        # we wait for all callbacks to be processed
-        await history_d
-
-    async def _join_mam(
-        self,
-        client: SatXMPPEntity,
-        room_jid: jid.JID,
-        nick: str,
-        password: Optional[str]
-    ) -> muc.Room:
-        """Join room and retrieve history using MAM"""
-        room = await super(LiberviaMUCClient, self).join(
-            # we don't want any history from room as we'll get it with MAM
-            room_jid, nick, muc.HistoryOptions(maxStanzas=0), password=password
-        )
-        room._history_type = HISTORY_MAM
-        # MAM history retrieval can be very long, and doesn't need to be sync, so we don't
-        # wait for it
-        defer.ensureDeferred(self._get_mam_history(client, room, room_jid))
-        room.fully_joined.callback(room)
-
-        return room
-
-    async def join(self, room_jid, nick, password=None):
-        room_service = jid.JID(room_jid.host)
-        has_mam = await self.host.hasFeature(self.client, mam.NS_MAM, room_service)
-        if not self._mam or not has_mam:
-            return await self._join_legacy(self.client, room_jid, nick, password)
-        else:
-            return await self._join_mam(self.client, room_jid, nick, password)
-
-    ## presence/roster ##
-
-    def availableReceived(self, presence):
-        """
-        Available presence was received.
-        """
-        # XXX: we override MUCClient.availableReceived to fix bugs
-        # (affiliation and role are not set)
-
-        room, user = self._getRoomUser(presence)
-
-        if room is None:
-            return
-
-        if user is None:
-            nick = presence.sender.resource
-            if not nick:
-                log.warning(_("missing nick in presence: {xml}").format(
-                    xml = presence.toElement().toXml()))
-                return
-            user = muc.User(nick, presence.entity)
-
-        # we want to keep statuses with room
-        # XXX: presence if broadcasted, and we won't have status code
-        #      like 110 (REALJID_PUBLIC) after first <presence/> received
-        #      so we keep only the initial <presence> (with SELF_PRESENCE),
-        #      thus we check if attribute already exists
-        if (not hasattr(room, 'statuses')
-            and muc.STATUS_CODE.SELF_PRESENCE in presence.mucStatuses):
-            room.statuses = presence.mucStatuses
-
-        # Update user data
-        user.role = presence.role
-        user.affiliation = presence.affiliation
-        user.status = presence.status
-        user.show = presence.show
-
-        if room.inRoster(user):
-            self.userUpdatedStatus(room, user, presence.show, presence.status)
-        else:
-            room.addUser(user)
-            self.userJoinedRoom(room, user)
-
-    def unavailableReceived(self, presence):
-        # XXX: we override this method to manage nickname change
-        """
-        Unavailable presence was received.
-
-        If this was received from a MUC room occupant JID, that occupant has
-        left the room.
-        """
-        room, user = self._getRoomUser(presence)
-
-        if room is None or user is None:
-            return
-
-        room.removeUser(user)
-
-        if muc.STATUS_CODE.NEW_NICK in presence.mucStatuses:
-            self._changing_nicks.add(presence.nick)
-            self.user_changed_nick(room, user, presence.nick)
-        else:
-            self._changing_nicks.discard(presence.nick)
-            self.userLeftRoom(room, user)
-
-    def userJoinedRoom(self, room, user):
-        if user.nick == room.nick:
-            # we have received our own nick,
-            # this mean that the full room roster was received
-            self.change_room_state(room, ROOM_STATE_SELF_PRESENCE)
-            log.debug("room {room} joined with nick {nick}".format(
-                room=room.occupantJID.userhost(), nick=user.nick))
-            # we set type so we don't have to use a deferred
-            # with disco to check entity type
-            self.host.memory.update_entity_data(
-                self.client, room.roomJID, C.ENTITY_TYPE, C.ENTITY_TYPE_MUC
-            )
-        elif room.state not in (ROOM_STATE_OCCUPANTS, ROOM_STATE_LIVE):
-            log.warning(
-                "Received user presence data in a room before its initialisation "
-                "(current state: {state}),"
-                "this is not standard! Ignoring it: {room} ({nick})".format(
-                    state=room.state,
-                    room=room.roomJID.userhost(),
-                    nick=user.nick))
-            return
-        else:
-            if not room.fully_joined.called:
-                return
-            try:
-                self._changing_nicks.remove(user.nick)
-            except KeyError:
-                # this is a new user
-                log.debug(_("user {nick} has joined room {room_id}").format(
-                    nick=user.nick, room_id=room.occupantJID.userhost()))
-                if not self.host.trigger.point(
-                        "MUC user joined", room, user, self.client.profile):
-                    return
-
-                extra = {'info_type': ROOM_USER_JOINED,
-                         'user_affiliation': user.affiliation,
-                         'user_role': user.role,
-                         'user_nick': user.nick
-                         }
-                if user.entity is not None:
-                    extra['user_entity'] = user.entity.full()
-                mess_data = {  # dict is similar to the one used in client.onMessage
-                    "from": room.roomJID,
-                    "to": self.client.jid,
-                    "uid": str(uuid.uuid4()),
-                    "message": {'': D_("=> {} has joined the room").format(user.nick)},
-                    "subject": {},
-                    "type": C.MESS_TYPE_INFO,
-                    "extra": extra,
-                    "timestamp": time.time(),
-                }
-                # FIXME: we disable presence in history as it's taking a lot of space
-                #        while not indispensable. In the future an option may allow
-                #        to re-enable it
-                # self.client.message_add_to_history(mess_data)
-                self.client.message_send_to_bridge(mess_data)
-
-
-    def userLeftRoom(self, room, user):
-        if not self.host.trigger.point("MUC user left", room, user, self.client.profile):
-            return
-        if user.nick == room.nick:
-            # we left the room
-            room_jid_s = room.roomJID.userhost()
-            log.info(_("Room ({room}) left ({profile})").format(
-                room = room_jid_s, profile = self.client.profile))
-            self.host.memory.del_entity_cache(room.roomJID, profile_key=self.client.profile)
-            self.host.bridge.muc_room_left(room.roomJID.userhost(), self.client.profile)
-        elif room.state != ROOM_STATE_LIVE:
-            log.warning("Received user presence data in a room before its initialisation (current state: {state}),"
-                "this is not standard! Ignoring it: {room} ({nick})".format(
-                state=room.state,
-                room=room.roomJID.userhost(),
-                nick=user.nick))
-            return
-        else:
-            if not room.fully_joined.called:
-                return
-            log.debug(_("user {nick} left room {room_id}").format(nick=user.nick, room_id=room.occupantJID.userhost()))
-            extra = {'info_type': ROOM_USER_LEFT,
-                     'user_affiliation': user.affiliation,
-                     'user_role': user.role,
-                     'user_nick': user.nick
-                     }
-            if user.entity is not None:
-                extra['user_entity'] = user.entity.full()
-            mess_data = {  # dict is similar to the one used in client.onMessage
-                "from": room.roomJID,
-                "to": self.client.jid,
-                "uid": str(uuid.uuid4()),
-                "message": {'': D_("<= {} has left the room").format(user.nick)},
-                "subject": {},
-                "type": C.MESS_TYPE_INFO,
-                "extra": extra,
-                "timestamp": time.time(),
-            }
-            # FIXME: disable history, see userJoinRoom comment
-            # self.client.message_add_to_history(mess_data)
-            self.client.message_send_to_bridge(mess_data)
-
-    def user_changed_nick(self, room, user, new_nick):
-        self.host.bridge.muc_room_user_changed_nick(room.roomJID.userhost(), user.nick, new_nick, self.client.profile)
-
-    def userUpdatedStatus(self, room, user, show, status):
-        entity = jid.JID(tuple=(room.roomJID.user, room.roomJID.host, user.nick))
-        if hasattr(room, "_cache_presence"):
-            # room has a cache for presence, meaning it has not been fully
-            # joined yet. So we put presence in cache, and stop workflow.
-            # Or delete current presence and continue workflow if it's an
-            # "unavailable" presence
-            cache = room._cache_presence
-            cache[entity] = {
-                "room": room,
-                "user": user,
-                "show": show,
-                "status": status,
-                }
-            return
-        statuses = {C.PRESENCE_STATUSES_DEFAULT: status or ''}
-        self.host.bridge.presence_update(
-            entity.full(), show or '', 0, statuses, self.client.profile)
-
-    ## messages ##
-
-    def receivedGroupChat(self, room, user, body):
-        log.debug('receivedGroupChat: room=%s user=%s body=%s' % (room.roomJID.full(), user, body))
-
-    def _add_to_history(self, __, user, message):
-        try:
-            # message can be already parsed (with MAM), in this case mess_data
-            # it attached to the element
-            mess_data = message.element._mess_data
-        except AttributeError:
-            mess_data = self.client.messageProt.parse_message(message.element)
-        if mess_data['message'] or mess_data['subject']:
-            return defer.ensureDeferred(
-                self.host.memory.add_to_history(self.client, mess_data)
-            )
-        else:
-            return defer.succeed(None)
-
-    def _add_to_history_eb(self, failure):
-        failure.trap(exceptions.CancelError)
-
-    def receivedHistory(self, room, user, message):
-        """Called when history (backlog) message are received
-
-        we check if message is not already in our history
-        and add it if needed
-        @param room(muc.Room): room instance
-        @param user(muc.User, None): the user that sent the message
-            None if the message come from the room
-        @param message(muc.GroupChat): the parsed message
-        """
-        if room.state != ROOM_STATE_SELF_PRESENCE:
-            log.warning(_(
-                "received history in unexpected state in room {room} (state: "
-                "{state})").format(room = room.roomJID.userhost(),
-                                    state = room.state))
-            if not hasattr(room, "_history_d"):
-                # XXX: this hack is due to buggy behaviour seen in the wild because of the
-                #      "mod_delay" prosody module being activated. This module add an
-                #      unexpected <delay> elements which break our workflow.
-                log.warning(_("storing the unexpected message anyway, to avoid loss"))
-                # we have to restore URI which are stripped by wokkel parsing
-                for c in message.element.elements():
-                    if c.uri is None:
-                        c.uri = C.NS_CLIENT
-                mess_data = self.client.messageProt.parse_message(message.element)
-                message.element._mess_data = mess_data
-                self._add_to_history(None, user, message)
-                if mess_data['message'] or mess_data['subject']:
-                    self.host.bridge.message_new(
-                        *self.client.message_get_bridge_args(mess_data),
-                        profile=self.client.profile
-                    )
-                return
-        room._history_d.addCallback(self._add_to_history, user, message)
-        room._history_d.addErrback(self._add_to_history_eb)
-
-    ## subject ##
-
-    def groupChatReceived(self, message):
-        """
-        A group chat message has been received from a MUC room.
-
-        There are a few event methods that may get called here.
-        L{receivedGroupChat}, L{receivedSubject} or L{receivedHistory}.
-        """
-        # We override this method to fix subject handling (empty strings were discarded)
-        # FIXME: remove this once fixed upstream
-        room, user = self._getRoomUser(message)
-
-        if room is None:
-            log.warning("No room found for message: {message}"
-                        .format(message=message.toElement().toXml()))
-            return
-
-        if message.subject is not None:
-            self.receivedSubject(room, user, message.subject)
-        elif message.delay is None:
-            self.receivedGroupChat(room, user, message)
-        else:
-            self.receivedHistory(room, user, message)
-
-    def subject(self, room, subject):
-        return muc.MUCClientProtocol.subject(self, room, subject)
-
-    def _history_cb(self, __, room):
-        """Called when history have been written to database and subject is received
-
-        this method will finish joining by:
-            - sending message to bridge
-            - calling fully_joined deferred (for legacy history)
-            - sending stanza put in cache
-            - cleaning variables not needed anymore
-        """
-        args = self.plugin_parent._get_room_joined_args(room, self.client.profile)
-        self.host.bridge.muc_room_joined(*args)
-        if room._history_type == HISTORY_LEGACY:
-            room.fully_joined.callback(room)
-        del room._history_d
-        del room._history_type
-        cache = room._cache
-        del room._cache
-        cache_presence = room._cache_presence
-        del room._cache_presence
-        for elem in cache:
-            self.client.xmlstream.dispatch(elem)
-        for presence_data in cache_presence.values():
-            if not presence_data['show'] and not presence_data['status']:
-                # occupants are already sent in muc_room_joined, so if we don't have
-                # extra information like show or statuses, we can discard the signal
-                continue
-            else:
-                self.userUpdatedStatus(**presence_data)
-
-    def _history_eb(self, failure_, room):
-        log.error("Error while managing history: {}".format(failure_))
-        self._history_cb(None, room)
-
-    def receivedSubject(self, room, user, subject):
-        # when subject is received, we know that we have whole roster and history
-        # cf. http://xmpp.org/extensions/xep-0045.html#enter-subject
-        room.subject = subject  # FIXME: subject doesn't handle xml:lang
-        if room.state != ROOM_STATE_LIVE:
-            if room._history_type == HISTORY_LEGACY:
-                self.change_room_state(room, ROOM_STATE_LIVE)
-                room._history_d.addCallbacks(self._history_cb, self._history_eb, [room], errbackArgs=[room])
-        else:
-            # the subject has been changed
-            log.debug(_("New subject for room ({room_id}): {subject}").format(room_id = room.roomJID.full(), subject = subject))
-            self.host.bridge.muc_room_new_subject(room.roomJID.userhost(), subject, self.client.profile)
-
-    ## disco ##
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
-        return [disco.DiscoFeature(NS_MUC)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=''):
-        # TODO: manage room queries ? Bad for privacy, must be disabled by default
-        #       see XEP-0045 § 6.7
-        return []
--- a/sat/plugins/plugin_xep_0047.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,385 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing gateways (xep-0047)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.protocols.jabber import error
-from twisted.internet import reactor
-from twisted.internet import defer
-from twisted.python import failure
-
-from wokkel import disco, iwokkel
-
-from zope.interface import implementer
-
-import base64
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-
-MESSAGE = "/message"
-IQ_SET = '/iq[@type="set"]'
-NS_IBB = "http://jabber.org/protocol/ibb"
-IBB_OPEN = IQ_SET + '/open[@xmlns="' + NS_IBB + '"]'
-IBB_CLOSE = IQ_SET + '/close[@xmlns="' + NS_IBB + '" and @sid="{}"]'
-IBB_IQ_DATA = IQ_SET + '/data[@xmlns="' + NS_IBB + '" and @sid="{}"]'
-IBB_MESSAGE_DATA = MESSAGE + '/data[@xmlns="' + NS_IBB + '" and @sid="{}"]'
-TIMEOUT = 120  # timeout for workflow
-DEFER_KEY = "finished"  # key of the deferred used to track session end
-
-PLUGIN_INFO = {
-    C.PI_NAME: "In-Band Bytestream Plugin",
-    C.PI_IMPORT_NAME: "XEP-0047",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0047"],
-    C.PI_MAIN: "XEP_0047",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of In-Band Bytestreams"""),
-}
-
-
-class XEP_0047(object):
-    NAMESPACE = NS_IBB
-    BLOCK_SIZE = 4096
-
-    def __init__(self, host):
-        log.info(_("In-Band Bytestreams plugin initialization"))
-        self.host = host
-
-    def get_handler(self, client):
-        return XEP_0047_handler(self)
-
-    def profile_connected(self, client):
-        client.xep_0047_current_stream = {}  # key: stream_id, value: data(dict)
-
-    def _time_out(self, sid, client):
-        """Delete current_stream id, called after timeout
-
-        @param sid(unicode): session id of client.xep_0047_current_stream
-        @param client: %(doc_client)s
-        """
-        log.info(
-            "In-Band Bytestream: TimeOut reached for id {sid} [{profile}]".format(
-                sid=sid, profile=client.profile
-            )
-        )
-        self._kill_session(sid, client, "TIMEOUT")
-
-    def _kill_session(self, sid, client, failure_reason=None):
-        """Delete a current_stream id, clean up associated observers
-
-        @param sid(unicode): session id
-        @param client: %(doc_client)s
-        @param failure_reason(None, unicode): if None the session is successful
-            else, will be used to call failure_cb
-        """
-        try:
-            session = client.xep_0047_current_stream[sid]
-        except KeyError:
-            log.warning("kill id called on a non existant id")
-            return
-
-        try:
-            observer_cb = session["observer_cb"]
-        except KeyError:
-            pass
-        else:
-            client.xmlstream.removeObserver(session["event_data"], observer_cb)
-
-        if session["timer"].active():
-            session["timer"].cancel()
-
-        del client.xep_0047_current_stream[sid]
-
-        success = failure_reason is None
-        stream_d = session[DEFER_KEY]
-
-        if success:
-            stream_d.callback(None)
-        else:
-            stream_d.errback(failure.Failure(exceptions.DataError(failure_reason)))
-
-    def create_session(self, *args, **kwargs):
-        """like [_create_session] but return the session deferred instead of the whole session
-
-        session deferred is fired when transfer is finished
-        """
-        return self._create_session(*args, **kwargs)[DEFER_KEY]
-
-    def _create_session(self, client, stream_object, local_jid, to_jid, sid):
-        """Called when a bytestream is imminent
-
-        @param stream_object(IConsumer): stream object where data will be written
-        @param local_jid(jid.JID): same as [start_stream]
-        @param to_jid(jid.JId): jid of the other peer
-        @param sid(unicode): session id
-        @return (dict): session data
-        """
-        if sid in client.xep_0047_current_stream:
-            raise exceptions.ConflictError("A session with this id already exists !")
-        session_data = client.xep_0047_current_stream[sid] = {
-            "id": sid,
-            DEFER_KEY: defer.Deferred(),
-            "local_jid": local_jid,
-            "to": to_jid,
-            "stream_object": stream_object,
-            "seq": -1,
-            "timer": reactor.callLater(TIMEOUT, self._time_out, sid, client),
-        }
-
-        return session_data
-
-    def _on_ibb_open(self, iq_elt, client):
-        """"Called when an IBB <open> element is received
-
-        @param iq_elt(domish.Element): the whole <iq> stanza
-        """
-        log.debug(_("IBB stream opening"))
-        iq_elt.handled = True
-        open_elt = next(iq_elt.elements(NS_IBB, "open"))
-        block_size = open_elt.getAttribute("block-size")
-        sid = open_elt.getAttribute("sid")
-        stanza = open_elt.getAttribute("stanza", "iq")
-        if not sid or not block_size or int(block_size) > 65535:
-            return self._sendError("not-acceptable", sid or None, iq_elt, client)
-        if not sid in client.xep_0047_current_stream:
-            log.warning(_("Ignoring unexpected IBB transfer: %s" % sid))
-            return self._sendError("not-acceptable", sid or None, iq_elt, client)
-        session_data = client.xep_0047_current_stream[sid]
-        if session_data["to"] != jid.JID(iq_elt["from"]):
-            log.warning(
-                _("sended jid inconsistency (man in the middle attack attempt ?)")
-            )
-            return self._sendError("not-acceptable", sid, iq_elt, client)
-
-        # at this stage, the session looks ok and will be accepted
-
-        # we reset the timeout:
-        session_data["timer"].reset(TIMEOUT)
-
-        # we save the xmlstream, events and observer data to allow observer removal
-        session_data["event_data"] = event_data = (
-            IBB_MESSAGE_DATA if stanza == "message" else IBB_IQ_DATA
-        ).format(sid)
-        session_data["observer_cb"] = observer_cb = self._on_ibb_data
-        event_close = IBB_CLOSE.format(sid)
-        # we now set the stream observer to look after data packet
-        # FIXME: if we never get the events, the observers stay.
-        #        would be better to have generic observer and check id once triggered
-        client.xmlstream.addObserver(event_data, observer_cb, client=client)
-        client.xmlstream.addOnetimeObserver(event_close, self._on_ibb_close, client=client)
-        # finally, we send the accept stanza
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        client.send(iq_result_elt)
-
-    def _on_ibb_close(self, iq_elt, client):
-        """"Called when an IBB <close> element is received
-
-        @param iq_elt(domish.Element): the whole <iq> stanza
-        """
-        iq_elt.handled = True
-        log.debug(_("IBB stream closing"))
-        close_elt = next(iq_elt.elements(NS_IBB, "close"))
-        # XXX: this observer is only triggered on valid sid, so we don't need to check it
-        sid = close_elt["sid"]
-
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        client.send(iq_result_elt)
-        self._kill_session(sid, client)
-
-    def _on_ibb_data(self, element, client):
-        """Observer called on <iq> or <message> stanzas with data element
-
-        Manage the data elelement (check validity and write to the stream_object)
-        @param element(domish.Element): <iq> or <message> stanza
-        """
-        element.handled = True
-        data_elt = next(element.elements(NS_IBB, "data"))
-        sid = data_elt["sid"]
-
-        try:
-            session_data = client.xep_0047_current_stream[sid]
-        except KeyError:
-            log.warning(_("Received data for an unknown session id"))
-            return self._sendError("item-not-found", None, element, client)
-
-        from_jid = session_data["to"]
-        stream_object = session_data["stream_object"]
-
-        if from_jid.full() != element["from"]:
-            log.warning(
-                _(
-                    "sended jid inconsistency (man in the middle attack attempt ?)\ninitial={initial}\ngiven={given}"
-                ).format(initial=from_jid, given=element["from"])
-            )
-            if element.name == "iq":
-                self._sendError("not-acceptable", sid, element, client)
-            return
-
-        session_data["seq"] = (session_data["seq"] + 1) % 65535
-        if int(data_elt.getAttribute("seq", -1)) != session_data["seq"]:
-            log.warning(_("Sequence error"))
-            if element.name == "iq":
-                reason = "not-acceptable"
-                self._sendError(reason, sid, element, client)
-            self.terminate_stream(session_data, client, reason)
-            return
-
-        # we reset the timeout:
-        session_data["timer"].reset(TIMEOUT)
-
-        # we can now decode the data
-        try:
-            stream_object.write(base64.b64decode(str(data_elt)))
-        except TypeError:
-            # The base64 data is invalid
-            log.warning(_("Invalid base64 data"))
-            if element.name == "iq":
-                self._sendError("not-acceptable", sid, element, client)
-            self.terminate_stream(session_data, client, reason)
-            return
-
-        # we can now ack success
-        if element.name == "iq":
-            iq_result_elt = xmlstream.toResponse(element, "result")
-            client.send(iq_result_elt)
-
-    def _sendError(self, error_condition, sid, iq_elt, client):
-        """Send error stanza
-
-        @param error_condition: one of twisted.words.protocols.jabber.error.STANZA_CONDITIONS keys
-        @param sid(unicode,None): jingle session id, or None, if session must not be destroyed
-        @param iq_elt(domish.Element): full <iq> stanza
-        @param client: %(doc_client)s
-        """
-        iq_elt = error.StanzaError(error_condition).toResponse(iq_elt)
-        log.warning(
-            "Error while managing in-band bytestream session, cancelling: {}".format(
-                error_condition
-            )
-        )
-        if sid is not None:
-            self._kill_session(sid, client, error_condition)
-        client.send(iq_elt)
-
-    def start_stream(self, client, stream_object, local_jid, to_jid, sid, block_size=None):
-        """Launch the stream workflow
-
-        @param stream_object(ifaces.IStreamProducer): stream object to send
-        @param local_jid(jid.JID): jid to use as local jid
-            This is needed for client which can be addressed with a different jid than
-            client.jid if a local part is used (e.g. piotr@file.example.net where
-            client.jid would be file.example.net)
-        @param to_jid(jid.JID): JID of the recipient
-        @param sid(unicode): Stream session id
-        @param block_size(int, None): size of the block (or None for default)
-        """
-        session_data = self._create_session(client, stream_object, local_jid, to_jid, sid)
-
-        if block_size is None:
-            block_size = XEP_0047.BLOCK_SIZE
-        assert block_size <= 65535
-        session_data["block_size"] = block_size
-
-        iq_elt = client.IQ()
-        iq_elt["from"] = local_jid.full()
-        iq_elt["to"] = to_jid.full()
-        open_elt = iq_elt.addElement((NS_IBB, "open"))
-        open_elt["block-size"] = str(block_size)
-        open_elt["sid"] = sid
-        open_elt["stanza"] = "iq"  # TODO: manage <message> stanza ?
-        args = [session_data, client]
-        d = iq_elt.send()
-        d.addCallbacks(self._iq_data_stream_cb, self._iq_data_stream_eb, args, None, args)
-        return session_data[DEFER_KEY]
-
-    def _iq_data_stream_cb(self, iq_elt, session_data, client):
-        """Called during the whole data streaming
-
-        @param iq_elt(domish.Element): iq result
-        @param session_data(dict): data of this streaming session
-        @param client: %(doc_client)s
-        """
-        session_data["timer"].reset(TIMEOUT)
-
-        # FIXME: producer/consumer mechanism is not used properly here
-        buffer_ = session_data["stream_object"].file_obj.read(session_data["block_size"])
-        if buffer_:
-            next_iq_elt = client.IQ()
-            next_iq_elt["from"] = session_data["local_jid"].full()
-            next_iq_elt["to"] = session_data["to"].full()
-            data_elt = next_iq_elt.addElement((NS_IBB, "data"))
-            seq = session_data["seq"] = (session_data["seq"] + 1) % 65535
-            data_elt["seq"] = str(seq)
-            data_elt["sid"] = session_data["id"]
-            data_elt.addContent(base64.b64encode(buffer_).decode())
-            args = [session_data, client]
-            d = next_iq_elt.send()
-            d.addCallbacks(self._iq_data_stream_cb, self._iq_data_stream_eb, args, None, args)
-        else:
-            self.terminate_stream(session_data, client)
-
-    def _iq_data_stream_eb(self, failure, session_data, client):
-        if failure.check(error.StanzaError):
-            log.warning("IBB transfer failed: {}".format(failure.value))
-        else:
-            log.error("IBB transfer failed: {}".format(failure.value))
-        self.terminate_stream(session_data, client, "IQ_ERROR")
-
-    def terminate_stream(self, session_data, client, failure_reason=None):
-        """Terminate the stream session
-
-        @param session_data(dict): data of this streaming session
-        @param client: %(doc_client)s
-        @param failure_reason(unicode, None): reason of the failure, or None if steam was successful
-        """
-        iq_elt = client.IQ()
-        iq_elt["from"] = session_data["local_jid"].full()
-        iq_elt["to"] = session_data["to"].full()
-        close_elt = iq_elt.addElement((NS_IBB, "close"))
-        close_elt["sid"] = session_data["id"]
-        iq_elt.send()
-        self._kill_session(session_data["id"], client, failure_reason)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0047_handler(XMPPHandler):
-
-    def __init__(self, parent):
-        self.plugin_parent = parent
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(
-            IBB_OPEN, self.plugin_parent._on_ibb_open, client=self.parent
-        )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_IBB)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0048.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,523 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Bookmarks (xep-0048)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.memory.persistent import PersistentBinaryDict
-from sat.tools import xml_tools
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.words.xish import domish
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber.error import StanzaError
-
-from twisted.internet import defer
-
-NS_BOOKMARKS = "storage:bookmarks"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Bookmarks",
-    C.PI_IMPORT_NAME: "XEP-0048",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0048"],
-    C.PI_DEPENDENCIES: ["XEP-0045"],
-    C.PI_RECOMMENDATIONS: ["XEP-0049"],
-    C.PI_MAIN: "XEP_0048",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of bookmarks"""),
-}
-
-
-class XEP_0048(object):
-    MUC_TYPE = "muc"
-    URL_TYPE = "url"
-    MUC_KEY = "jid"
-    URL_KEY = "url"
-    MUC_ATTRS = ("autojoin", "name")
-    URL_ATTRS = ("name",)
-
-    def __init__(self, host):
-        log.info(_("Bookmarks plugin initialization"))
-        self.host = host
-        # self.__menu_id = host.register_callback(self._bookmarks_menu, with_data=True)
-        self.__bm_save_id = host.register_callback(self._bookmarks_save_cb, with_data=True)
-        host.import_menu(
-            (D_("Groups"), D_("Bookmarks")),
-            self._bookmarks_menu,
-            security_limit=0,
-            help_string=D_("Use and manage bookmarks"),
-        )
-        self.__selected_id = host.register_callback(
-            self._bookmark_selected_cb, with_data=True
-        )
-        host.bridge.add_method(
-            "bookmarks_list",
-            ".plugin",
-            in_sign="sss",
-            out_sign="a{sa{sa{ss}}}",
-            method=self._bookmarks_list,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "bookmarks_remove",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="",
-            method=self._bookmarks_remove,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "bookmarks_add",
-            ".plugin",
-            in_sign="ssa{ss}ss",
-            out_sign="",
-            method=self._bookmarks_add,
-            async_=True,
-        )
-        try:
-            self.private_plg = self.host.plugins["XEP-0049"]
-        except KeyError:
-            self.private_plg = None
-        try:
-            self.host.plugins[C.TEXT_CMDS].register_text_commands(self)
-        except KeyError:
-            log.info(_("Text commands not available"))
-
-    async def profile_connected(self, client):
-        local = client.bookmarks_local = PersistentBinaryDict(
-            NS_BOOKMARKS, client.profile
-        )
-        await local.load()
-        if not local:
-            local[XEP_0048.MUC_TYPE] = dict()
-            local[XEP_0048.URL_TYPE] = dict()
-        private = await self._get_server_bookmarks("private", client.profile)
-        pubsub = client.bookmarks_pubsub = None
-
-        for bookmarks in (local, private, pubsub):
-            if bookmarks is not None:
-                for (room_jid, data) in list(bookmarks[XEP_0048.MUC_TYPE].items()):
-                    if data.get("autojoin", "false") == "true":
-                        nick = data.get("nick", client.jid.user)
-                        defer.ensureDeferred(
-                            self.host.plugins["XEP-0045"].join(client, room_jid, nick, {})
-                        )
-
-        # we don't use a DeferredList to gather result here, as waiting for all room would
-        # slow down a lot the connection process, and result in a bad user experience.
-
-    @defer.inlineCallbacks
-    def _get_server_bookmarks(self, storage_type, profile):
-        """Get distants bookmarks
-
-        update also the client.bookmarks_[type] key, with None if service is not available
-        @param storage_type: storage type, can be:
-            - 'private': XEP-0049 storage
-            - 'pubsub': XEP-0223 storage
-        @param profile: %(doc_profile)s
-        @return: data dictionary, or None if feature is not available
-        """
-        client = self.host.get_client(profile)
-        if storage_type == "private":
-            try:
-                bookmarks_private_xml = yield self.private_plg.private_xml_get(
-                    "storage", NS_BOOKMARKS, profile
-                )
-                data = client.bookmarks_private = self._bookmark_elt_2_dict(
-                    bookmarks_private_xml
-                )
-            except (StanzaError, AttributeError):
-                log.info(_("Private XML storage not available"))
-                data = client.bookmarks_private = None
-        elif storage_type == "pubsub":
-            raise NotImplementedError
-        else:
-            raise ValueError("storage_type must be 'private' or 'pubsub'")
-        defer.returnValue(data)
-
-    @defer.inlineCallbacks
-    def _set_server_bookmarks(self, storage_type, bookmarks_elt, profile):
-        """Save bookmarks on server
-
-        @param storage_type: storage type, can be:
-            - 'private': XEP-0049 storage
-            - 'pubsub': XEP-0223 storage
-        @param bookmarks_elt (domish.Element): bookmarks XML
-        @param profile: %(doc_profile)s
-        """
-        if storage_type == "private":
-            yield self.private_plg.private_xml_store(bookmarks_elt, profile)
-        elif storage_type == "pubsub":
-            raise NotImplementedError
-        else:
-            raise ValueError("storage_type must be 'private' or 'pubsub'")
-
-    def _bookmark_elt_2_dict(self, storage_elt):
-        """Parse bookmarks to get dictionary
-        @param storage_elt (domish.Element): bookmarks storage
-        @return (dict): bookmark data (key: bookmark type, value: list) where key can be:
-            - XEP_0048.MUC_TYPE
-            - XEP_0048.URL_TYPE
-            - value (dict): data as for add_bookmark
-        """
-        conf_data = {}
-        url_data = {}
-
-        conference_elts = storage_elt.elements(NS_BOOKMARKS, "conference")
-        for conference_elt in conference_elts:
-            try:
-                room_jid = jid.JID(conference_elt[XEP_0048.MUC_KEY])
-            except KeyError:
-                log.warning(
-                    "invalid bookmark found, igoring it:\n%s" % conference_elt.toXml()
-                )
-                continue
-
-            data = conf_data[room_jid] = {}
-
-            for attr in XEP_0048.MUC_ATTRS:
-                if conference_elt.hasAttribute(attr):
-                    data[attr] = conference_elt[attr]
-            try:
-                data["nick"] = str(
-                    next(conference_elt.elements(NS_BOOKMARKS, "nick"))
-                )
-            except StopIteration:
-                pass
-            # TODO: manage password (need to be secured, see XEP-0049 §4)
-
-        url_elts = storage_elt.elements(NS_BOOKMARKS, "url")
-        for url_elt in url_elts:
-            try:
-                url = url_elt[XEP_0048.URL_KEY]
-            except KeyError:
-                log.warning("invalid bookmark found, igoring it:\n%s" % url_elt.toXml())
-                continue
-            data = url_data[url] = {}
-            for attr in XEP_0048.URL_ATTRS:
-                if url_elt.hasAttribute(attr):
-                    data[attr] = url_elt[attr]
-
-        return {XEP_0048.MUC_TYPE: conf_data, XEP_0048.URL_TYPE: url_data}
-
-    def _dict_2_bookmark_elt(self, type_, data):
-        """Construct a bookmark element from a data dict
-        @param data (dict): bookmark data (key: bookmark type, value: list) where key can be:
-            - XEP_0048.MUC_TYPE
-            - XEP_0048.URL_TYPE
-            - value (dict): data as for add_bookmark
-        @return (domish.Element): bookmark element
-        """
-        rooms_data = data.get(XEP_0048.MUC_TYPE, {})
-        urls_data = data.get(XEP_0048.URL_TYPE, {})
-        storage_elt = domish.Element((NS_BOOKMARKS, "storage"))
-        for room_jid in rooms_data:
-            conference_elt = storage_elt.addElement("conference")
-            conference_elt[XEP_0048.MUC_KEY] = room_jid.full()
-            for attr in XEP_0048.MUC_ATTRS:
-                try:
-                    conference_elt[attr] = rooms_data[room_jid][attr]
-                except KeyError:
-                    pass
-            try:
-                conference_elt.addElement("nick", content=rooms_data[room_jid]["nick"])
-            except KeyError:
-                pass
-
-        for url, url_data in urls_data.items():
-            url_elt = storage_elt.addElement("url")
-            url_elt[XEP_0048.URL_KEY] = url
-            for attr in XEP_0048.URL_ATTRS:
-                try:
-                    url_elt[attr] = url_data[attr]
-                except KeyError:
-                    pass
-
-        return storage_elt
-
-    def _bookmark_selected_cb(self, data, profile):
-        try:
-            room_jid_s, nick = data["index"].split(" ", 1)
-            room_jid = jid.JID(room_jid_s)
-        except (KeyError, RuntimeError):
-            log.warning(_("No room jid selected"))
-            return {}
-
-        client = self.host.get_client(profile)
-        d = self.host.plugins["XEP-0045"].join(client, room_jid, nick, {})
-
-        def join_eb(failure):
-            log.warning("Error while trying to join room: {}".format(failure))
-            # FIXME: failure are badly managed in plugin XEP-0045. Plugin XEP-0045 need to be fixed before managing errors correctly here
-            return {}
-
-        d.addCallbacks(lambda __: {}, join_eb)
-        return d
-
-    def _bookmarks_menu(self, data, profile):
-        """ XMLUI activated by menu: return Gateways UI
-        @param profile: %(doc_profile)s
-
-        """
-        client = self.host.get_client(profile)
-        xmlui = xml_tools.XMLUI(title=_("Bookmarks manager"))
-        adv_list = xmlui.change_container(
-            "advanced_list",
-            columns=3,
-            selectable="single",
-            callback_id=self.__selected_id,
-        )
-        for bookmarks in (
-            client.bookmarks_local,
-            client.bookmarks_private,
-            client.bookmarks_pubsub,
-        ):
-            if bookmarks is None:
-                continue
-            for (room_jid, data) in sorted(
-                list(bookmarks[XEP_0048.MUC_TYPE].items()),
-                key=lambda item: item[1].get("name", item[0].user),
-            ):
-                room_jid_s = room_jid.full()
-                adv_list.set_row_index(
-                    "%s %s" % (room_jid_s, data.get("nick") or client.jid.user)
-                )
-                xmlui.addText(data.get("name", ""))
-                xmlui.addJid(room_jid)
-                if C.bool(data.get("autojoin", C.BOOL_FALSE)):
-                    xmlui.addText("autojoin")
-                else:
-                    xmlui.addEmpty()
-        adv_list.end()
-        xmlui.addDivider("dash")
-        xmlui.addText(_("add a bookmark"))
-        xmlui.change_container("pairs")
-        xmlui.addLabel(_("Name"))
-        xmlui.addString("name")
-        xmlui.addLabel(_("jid"))
-        xmlui.addString("jid")
-        xmlui.addLabel(_("Nickname"))
-        xmlui.addString("nick", client.jid.user)
-        xmlui.addLabel(_("Autojoin"))
-        xmlui.addBool("autojoin")
-        xmlui.change_container("vertical")
-        xmlui.addButton(self.__bm_save_id, _("Save"), ("name", "jid", "nick", "autojoin"))
-        return {"xmlui": xmlui.toXml()}
-
-    def _bookmarks_save_cb(self, data, profile):
-        bm_data = xml_tools.xmlui_result_2_data_form_result(data)
-        try:
-            location = jid.JID(bm_data.pop("jid"))
-        except KeyError:
-            raise exceptions.InternalError("Can't find mandatory key")
-        d = self.add_bookmark(XEP_0048.MUC_TYPE, location, bm_data, profile_key=profile)
-        d.addCallback(lambda __: {})
-        return d
-
-    @defer.inlineCallbacks
-    def add_bookmark(
-        self, type_, location, data, storage_type="auto", profile_key=C.PROF_KEY_NONE
-    ):
-        """Store a new bookmark
-
-        @param type_: bookmark type, one of:
-            - XEP_0048.MUC_TYPE: Multi-User chat room
-            - XEP_0048.URL_TYPE: web page URL
-        @param location: dependeding on type_, can be a MUC room jid or an url
-        @param data (dict): depending on type_, can contains the following keys:
-            - name: human readable name of the bookmark
-            - nick: user preferred room nick (default to user part of profile's jid)
-            - autojoin: "true" if room must be automatically joined on connection
-            - password: unused yet TODO
-        @param storage_type: where the bookmark will be stored, can be:
-            - "auto": find best available option: pubsub, private, local in that order
-            - "pubsub": PubSub private storage (XEP-0223)
-            - "private": Private XML storage (XEP-0049)
-            - "local": Store in SàT database
-        @param profile_key: %(doc_profile_key)s
-        """
-        assert storage_type in ("auto", "pubsub", "private", "local")
-        if type_ == XEP_0048.URL_TYPE and {"autojoin", "nick"}.intersection(list(data.keys())):
-            raise ValueError("autojoin or nick can't be used with URLs")
-        client = self.host.get_client(profile_key)
-        if storage_type == "auto":
-            if client.bookmarks_pubsub is not None:
-                storage_type = "pubsub"
-            elif client.bookmarks_private is not None:
-                storage_type = "private"
-            else:
-                storage_type = "local"
-                log.warning(_("Bookmarks will be local only"))
-            log.info(_('Type selected for "auto" storage: %s') % storage_type)
-
-        if storage_type == "local":
-            client.bookmarks_local[type_][location] = data
-            yield client.bookmarks_local.force(type_)
-        else:
-            bookmarks = yield self._get_server_bookmarks(storage_type, client.profile)
-            bookmarks[type_][location] = data
-            bookmark_elt = self._dict_2_bookmark_elt(type_, bookmarks)
-            yield self._set_server_bookmarks(storage_type, bookmark_elt, client.profile)
-
-    @defer.inlineCallbacks
-    def remove_bookmark(
-        self, type_, location, storage_type="all", profile_key=C.PROF_KEY_NONE
-    ):
-        """Remove a stored bookmark
-
-        @param type_: bookmark type, one of:
-            - XEP_0048.MUC_TYPE: Multi-User chat room
-            - XEP_0048.URL_TYPE: web page URL
-        @param location: dependeding on type_, can be a MUC room jid or an url
-        @param storage_type: where the bookmark is stored, can be:
-            - "all": remove from everywhere
-            - "pubsub": PubSub private storage (XEP-0223)
-            - "private": Private XML storage (XEP-0049)
-            - "local": Store in SàT database
-        @param profile_key: %(doc_profile_key)s
-        """
-        assert storage_type in ("all", "pubsub", "private", "local")
-        client = self.host.get_client(profile_key)
-
-        if storage_type in ("all", "local"):
-            try:
-                del client.bookmarks_local[type_][location]
-                yield client.bookmarks_local.force(type_)
-            except KeyError:
-                log.debug("Bookmark is not present in local storage")
-
-        if storage_type in ("all", "private"):
-            bookmarks = yield self._get_server_bookmarks("private", client.profile)
-            try:
-                del bookmarks[type_][location]
-                bookmark_elt = self._dict_2_bookmark_elt(type_, bookmarks)
-                yield self._set_server_bookmarks("private", bookmark_elt, client.profile)
-            except KeyError:
-                log.debug("Bookmark is not present in private storage")
-
-        if storage_type == "pubsub":
-            raise NotImplementedError
-
-    def _bookmarks_list(self, type_, storage_location, profile_key=C.PROF_KEY_NONE):
-        """Return stored bookmarks
-
-        @param type_: bookmark type, one of:
-            - XEP_0048.MUC_TYPE: Multi-User chat room
-            - XEP_0048.URL_TYPE: web page URL
-        @param storage_location: can be:
-            - 'all'
-            - 'local'
-            - 'private'
-            - 'pubsub'
-        @param profile_key: %(doc_profile_key)s
-        @param return (dict): (key: storage_location, value dict) with:
-            - value (dict): (key: bookmark_location, value: bookmark data)
-        """
-        client = self.host.get_client(profile_key)
-        ret = {}
-        ret_d = defer.succeed(ret)
-
-        def fill_bookmarks(__, _storage_location):
-            bookmarks_ori = getattr(client, "bookmarks_" + _storage_location)
-            if bookmarks_ori is None:
-                return ret
-            data = bookmarks_ori[type_]
-            for bookmark in data:
-                if type_ == XEP_0048.MUC_TYPE:
-                    ret[_storage_location][bookmark.full()] = data[bookmark].copy()
-                else:
-                    ret[_storage_location][bookmark] = data[bookmark].copy()
-            return ret
-
-        for _storage_location in ("local", "private", "pubsub"):
-            if storage_location in ("all", _storage_location):
-                ret[_storage_location] = {}
-                if _storage_location in ("private",):
-                    # we update distant bookmarks, just in case an other client added something
-                    d = self._get_server_bookmarks(_storage_location, client.profile)
-                else:
-                    d = defer.succeed(None)
-                d.addCallback(fill_bookmarks, _storage_location)
-                ret_d.addCallback(lambda __: d)
-
-        return ret_d
-
-    def _bookmarks_remove(
-        self, type_, location, storage_location, profile_key=C.PROF_KEY_NONE
-    ):
-        """Return stored bookmarks
-
-        @param type_: bookmark type, one of:
-            - XEP_0048.MUC_TYPE: Multi-User chat room
-            - XEP_0048.URL_TYPE: web page URL
-        @param location: dependeding on type_, can be a MUC room jid or an url
-        @param storage_location: can be:
-            - "all": remove from everywhere
-            - "pubsub": PubSub private storage (XEP-0223)
-            - "private": Private XML storage (XEP-0049)
-            - "local": Store in SàT database
-        @param profile_key: %(doc_profile_key)s
-        """
-        if type_ == XEP_0048.MUC_TYPE:
-            location = jid.JID(location)
-        return self.remove_bookmark(type_, location, storage_location, profile_key)
-
-    def _bookmarks_add(
-        self, type_, location, data, storage_type="auto", profile_key=C.PROF_KEY_NONE
-    ):
-        if type_ == XEP_0048.MUC_TYPE:
-            location = jid.JID(location)
-        return self.add_bookmark(type_, location, data, storage_type, profile_key)
-
-    def cmd_bookmark(self, client, mess_data):
-        """(Un)bookmark a MUC room
-
-        @command (group): [autojoin | remove]
-            - autojoin: join room automatically on connection
-            - remove: remove bookmark(s) for this room
-        """
-        txt_cmd = self.host.plugins[C.TEXT_CMDS]
-
-        options = mess_data["unparsed"].strip().split()
-        if options and options[0] not in ("autojoin", "remove"):
-            txt_cmd.feed_back(client, _("Bad arguments"), mess_data)
-            return False
-
-        room_jid = mess_data["to"].userhostJID()
-
-        if "remove" in options:
-            self.remove_bookmark(XEP_0048.MUC_TYPE, room_jid, profile_key=client.profile)
-            txt_cmd.feed_back(
-                client,
-                _("All [%s] bookmarks are being removed") % room_jid.full(),
-                mess_data,
-            )
-            return False
-
-        data = {
-            "name": room_jid.user,
-            "nick": client.jid.user,
-            "autojoin": "true" if "autojoin" in options else "false",
-        }
-        self.add_bookmark(XEP_0048.MUC_TYPE, room_jid, data, profile_key=client.profile)
-        txt_cmd.feed_back(client, _("Bookmark added"), mess_data)
-
-        return False
--- a/sat/plugins/plugin_xep_0049.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,82 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing xep-0049
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from wokkel import compat
-from twisted.words.xish import domish
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP-0049 Plugin",
-    C.PI_IMPORT_NAME: "XEP-0049",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0049"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "XEP_0049",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of private XML storage"""),
-}
-
-
-class XEP_0049(object):
-    NS_PRIVATE = "jabber:iq:private"
-
-    def __init__(self, host):
-        log.info(_("Plugin XEP-0049 initialization"))
-        self.host = host
-
-    def private_xml_store(self, element, profile_key):
-        """Store private data
-        @param element: domish.Element to store (must have a namespace)
-        @param profile_key: %(doc_profile_key)s
-
-        """
-        assert isinstance(element, domish.Element)
-        client = self.host.get_client(profile_key)
-        # XXX: feature announcement in disco#info is not mandatory in XEP-0049, so we have to try to use private XML, and react according to the answer
-        iq_elt = compat.IQ(client.xmlstream)
-        query_elt = iq_elt.addElement("query", XEP_0049.NS_PRIVATE)
-        query_elt.addChild(element)
-        return iq_elt.send()
-
-    def private_xml_get(self, node_name, namespace, profile_key):
-        """Store private data
-        @param node_name: name of the node to get
-        @param namespace: namespace of the node to get
-        @param profile_key: %(doc_profile_key)s
-        @return (domish.Element): a deferred which fire the stored data
-
-        """
-        client = self.host.get_client(profile_key)
-        # XXX: see private_xml_store note about feature checking
-        iq_elt = compat.IQ(client.xmlstream, "get")
-        query_elt = iq_elt.addElement("query", XEP_0049.NS_PRIVATE)
-        query_elt.addElement(node_name, namespace)
-
-        def get_cb(answer_iq_elt):
-            answer_query_elt = next(answer_iq_elt.elements(XEP_0049.NS_PRIVATE, "query"))
-            return answer_query_elt.firstChildElement()
-
-        d = iq_elt.send()
-        d.addCallback(get_cb)
-        return d
--- a/sat/plugins/plugin_xep_0050.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,835 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin for Ad-Hoc Commands (XEP-0050)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-from collections import namedtuple
-from uuid import uuid4
-from typing import List, Optional
-
-from zope.interface import implementer
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols import jabber
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from twisted.internet import defer
-from wokkel import disco, iwokkel, data_form
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.core.xmpp import SatXMPPEntity
-from sat.core import exceptions
-from sat.memory.memory import Sessions
-from sat.tools import xml_tools, utils
-from sat.tools.common import data_format
-
-
-log = getLogger(__name__)
-
-
-IQ_SET = '/iq[@type="set"]'
-NS_COMMANDS = "http://jabber.org/protocol/commands"
-ID_CMD_LIST = disco.DiscoIdentity("automation", "command-list")
-ID_CMD_NODE = disco.DiscoIdentity("automation", "command-node")
-CMD_REQUEST = IQ_SET + '/command[@xmlns="' + NS_COMMANDS + '"]'
-
-SHOWS = {
-    "default": _("Online"),
-    "away": _("Away"),
-    "chat": _("Free for chat"),
-    "dnd": _("Do not disturb"),
-    "xa": _("Left"),
-    "disconnect": _("Disconnect"),
-}
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Ad-Hoc Commands",
-    C.PI_IMPORT_NAME: "XEP-0050",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0050"],
-    C.PI_MAIN: "XEP_0050",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Ad-Hoc Commands"""),
-}
-
-
-class AdHocError(Exception):
-    def __init__(self, error_const):
-        """ Error to be used from callback
-        @param error_const: one of XEP_0050.ERROR
-        """
-        assert error_const in XEP_0050.ERROR
-        self.callback_error = error_const
-
-
-@implementer(iwokkel.IDisco)
-class AdHocCommand(XMPPHandler):
-
-    def __init__(self, callback, label, node, features, timeout,
-                 allowed_jids, allowed_groups, allowed_magics, forbidden_jids,
-                forbidden_groups):
-        XMPPHandler.__init__(self)
-        self.callback = callback
-        self.label = label
-        self.node = node
-        self.features = [disco.DiscoFeature(feature) for feature in features]
-        self.allowed_jids = allowed_jids
-        self.allowed_groups = allowed_groups
-        self.allowed_magics = allowed_magics
-        self.forbidden_jids = forbidden_jids
-        self.forbidden_groups = forbidden_groups
-        self.sessions = Sessions(timeout=timeout)
-
-    @property
-    def client(self):
-        return self.parent
-
-    def getName(self, xml_lang=None):
-        return self.label
-
-    def is_authorised(self, requestor):
-        if "@ALL@" in self.allowed_magics:
-            return True
-        forbidden = set(self.forbidden_jids)
-        for group in self.forbidden_groups:
-            forbidden.update(self.client.roster.get_jids_from_group(group))
-        if requestor.userhostJID() in forbidden:
-            return False
-        allowed = set(self.allowed_jids)
-        for group in self.allowed_groups:
-            try:
-                allowed.update(self.client.roster.get_jids_from_group(group))
-            except exceptions.UnknownGroupError:
-                log.warning(_("The groups [{group}] is unknown for profile [{profile}])")
-                            .format(group=group, profile=self.client.profile))
-        if requestor.userhostJID() in allowed:
-            return True
-        return False
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        if (
-            nodeIdentifier != NS_COMMANDS
-        ):  # FIXME: we should manage other disco nodes here
-            return []
-        # identities = [ID_CMD_LIST if self.node == NS_COMMANDS else ID_CMD_NODE] # FIXME
-        return [disco.DiscoFeature(NS_COMMANDS)] + self.features
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
-
-    def _sendAnswer(self, callback_data, session_id, request):
-        """ Send result of the command
-
-        @param callback_data: tuple (payload, status, actions, note) with:
-            - payload (domish.Element, None) usualy containing data form
-            - status: current status, see XEP_0050.STATUS
-            - actions(list[str], None): list of allowed actions (see XEP_0050.ACTION).
-                       First action is the default one. Default to EXECUTE
-            - note(tuple[str, unicode]): optional additional note: either None or a
-                tuple with (note type, human readable string), "note type" being in
-                XEP_0050.NOTE
-        @param session_id: current session id
-        @param request: original request (domish.Element)
-        @return: deferred
-        """
-        payload, status, actions, note = callback_data
-        assert isinstance(payload, domish.Element) or payload is None
-        assert status in XEP_0050.STATUS
-        if not actions:
-            actions = [XEP_0050.ACTION.EXECUTE]
-        result = domish.Element((None, "iq"))
-        result["type"] = "result"
-        result["id"] = request["id"]
-        result["to"] = request["from"]
-        command_elt = result.addElement("command", NS_COMMANDS)
-        command_elt["sessionid"] = session_id
-        command_elt["node"] = self.node
-        command_elt["status"] = status
-
-        if status != XEP_0050.STATUS.CANCELED:
-            if status != XEP_0050.STATUS.COMPLETED:
-                actions_elt = command_elt.addElement("actions")
-                actions_elt["execute"] = actions[0]
-                for action in actions:
-                    actions_elt.addElement(action)
-
-            if note is not None:
-                note_type, note_mess = note
-                note_elt = command_elt.addElement("note", content=note_mess)
-                note_elt["type"] = note_type
-
-            if payload is not None:
-                command_elt.addChild(payload)
-
-        self.client.send(result)
-        if status in (XEP_0050.STATUS.COMPLETED, XEP_0050.STATUS.CANCELED):
-            del self.sessions[session_id]
-
-    def _sendError(self, error_constant, session_id, request):
-        """ Send error stanza
-
-        @param error_constant: one of XEP_OO50.ERROR
-        @param request: original request (domish.Element)
-        """
-        xmpp_condition, cmd_condition = error_constant
-        iq_elt = jabber.error.StanzaError(xmpp_condition).toResponse(request)
-        if cmd_condition:
-            error_elt = next(iq_elt.elements(None, "error"))
-            error_elt.addElement(cmd_condition, NS_COMMANDS)
-        self.client.send(iq_elt)
-        del self.sessions[session_id]
-
-    def _request_eb(self, failure_, request, session_id):
-        if failure_.check(AdHocError):
-            error_constant = failure_.value.callback_error
-        else:
-            log.error(f"unexpected error while handling request: {failure_}")
-            error_constant = XEP_0050.ERROR.INTERNAL
-
-        self._sendError(error_constant, session_id, request)
-
-    def on_request(self, command_elt, requestor, action, session_id):
-        if not self.is_authorised(requestor):
-            return self._sendError(
-                XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent
-            )
-        if session_id:
-            try:
-                session_data = self.sessions[session_id]
-            except KeyError:
-                return self._sendError(
-                    XEP_0050.ERROR.SESSION_EXPIRED, session_id, command_elt.parent
-                )
-            if session_data["requestor"] != requestor:
-                return self._sendError(
-                    XEP_0050.ERROR.FORBIDDEN, session_id, command_elt.parent
-                )
-        else:
-            session_id, session_data = self.sessions.new_session()
-            session_data["requestor"] = requestor
-        if action == XEP_0050.ACTION.CANCEL:
-            d = defer.succeed((None, XEP_0050.STATUS.CANCELED, None, None))
-        else:
-            d = utils.as_deferred(
-                self.callback,
-                self.client,
-                command_elt,
-                session_data,
-                action,
-                self.node,
-            )
-        d.addCallback(self._sendAnswer, session_id, command_elt.parent)
-        d.addErrback(self._request_eb, command_elt.parent, session_id)
-
-
-class XEP_0050(object):
-    STATUS = namedtuple("Status", ("EXECUTING", "COMPLETED", "CANCELED"))(
-        "executing", "completed", "canceled"
-    )
-    ACTION = namedtuple("Action", ("EXECUTE", "CANCEL", "NEXT", "PREV"))(
-        "execute", "cancel", "next", "prev"
-    )
-    NOTE = namedtuple("Note", ("INFO", "WARN", "ERROR"))("info", "warn", "error")
-    ERROR = namedtuple(
-        "Error",
-        (
-            "MALFORMED_ACTION",
-            "BAD_ACTION",
-            "BAD_LOCALE",
-            "BAD_PAYLOAD",
-            "BAD_SESSIONID",
-            "SESSION_EXPIRED",
-            "FORBIDDEN",
-            "ITEM_NOT_FOUND",
-            "FEATURE_NOT_IMPLEMENTED",
-            "INTERNAL",
-        ),
-    )(
-        ("bad-request", "malformed-action"),
-        ("bad-request", "bad-action"),
-        ("bad-request", "bad-locale"),
-        ("bad-request", "bad-payload"),
-        ("bad-request", "bad-sessionid"),
-        ("not-allowed", "session-expired"),
-        ("forbidden", None),
-        ("item-not-found", None),
-        ("feature-not-implemented", None),
-        ("internal-server-error", None),
-    )  # XEP-0050 §4.4 Table 5
-
-    def __init__(self, host):
-        log.info(_("plugin XEP-0050 initialization"))
-        self.host = host
-        self.requesting = Sessions()
-        host.bridge.add_method(
-            "ad_hoc_run",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=self._run,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ad_hoc_list",
-            ".plugin",
-            in_sign="ss",
-            out_sign="s",
-            method=self._list_ui,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ad_hoc_sequence",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="s",
-            method=self._sequence,
-            async_=True,
-        )
-        self.__requesting_id = host.register_callback(
-            self._requesting_entity, with_data=True
-        )
-        host.import_menu(
-            (D_("Service"), D_("Commands")),
-            self._commands_menu,
-            security_limit=2,
-            help_string=D_("Execute ad-hoc commands"),
-        )
-        host.register_namespace('commands', NS_COMMANDS)
-
-    def get_handler(self, client):
-        return XEP_0050_handler(self)
-
-    def profile_connected(self, client):
-        # map from node to AdHocCommand instance
-        client._XEP_0050_commands = {}
-        if not client.is_component:
-            self.add_ad_hoc_command(client, self._status_callback, _("Status"))
-
-    def do(self, client, entity, node, action=ACTION.EXECUTE, session_id=None,
-           form_values=None, timeout=30):
-        """Do an Ad-Hoc Command
-
-        @param entity(jid.JID): entity which will execture the command
-        @param node(unicode): node of the command
-        @param action(unicode): one of XEP_0050.ACTION
-        @param session_id(unicode, None): id of the ad-hoc session
-            None if no session is involved
-        @param form_values(dict, None): values to use to create command form
-            values will be passed to data_form.Form.makeFields
-        @return: iq result element
-        """
-        iq_elt = client.IQ(timeout=timeout)
-        iq_elt["to"] = entity.full()
-        command_elt = iq_elt.addElement("command", NS_COMMANDS)
-        command_elt["node"] = node
-        command_elt["action"] = action
-        if session_id is not None:
-            command_elt["sessionid"] = session_id
-
-        if form_values:
-            # We add the XMLUI result to the command payload
-            form = data_form.Form("submit")
-            form.makeFields(form_values)
-            command_elt.addChild(form.toElement())
-        d = iq_elt.send()
-        return d
-
-    def get_command_elt(self, iq_elt):
-        try:
-            return next(iq_elt.elements(NS_COMMANDS, "command"))
-        except StopIteration:
-            raise exceptions.NotFound(_("Missing command element"))
-
-    def ad_hoc_error(self, error_type):
-        """Shortcut to raise an AdHocError
-
-        @param error_type(unicode): one of XEP_0050.ERROR
-        """
-        raise AdHocError(error_type)
-
-    def _items_2_xmlui(self, items, no_instructions):
-        """Convert discovery items to XMLUI dialog """
-        # TODO: manage items on different jids
-        form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id)
-
-        if not no_instructions:
-            form_ui.addText(_("Please select a command"), "instructions")
-
-        options = [(item.nodeIdentifier, item.name) for item in items]
-        form_ui.addList("node", options)
-        return form_ui
-
-    def _get_data_lvl(self, type_):
-        """Return the constant corresponding to <note/> type attribute value
-
-        @param type_: note type (see XEP-0050 §4.3)
-        @return: a C.XMLUI_DATA_LVL_* constant
-        """
-        if type_ == "error":
-            return C.XMLUI_DATA_LVL_ERROR
-        elif type_ == "warn":
-            return C.XMLUI_DATA_LVL_WARNING
-        else:
-            if type_ != "info":
-                log.warning(_("Invalid note type [%s], using info") % type_)
-            return C.XMLUI_DATA_LVL_INFO
-
-    def _merge_notes(self, notes):
-        """Merge notes with level prefix (e.g. "ERROR: the message")
-
-        @param notes (list): list of tuple (level, message)
-        @return: list of messages
-        """
-        lvl_map = {
-            C.XMLUI_DATA_LVL_INFO: "",
-            C.XMLUI_DATA_LVL_WARNING: "%s: " % _("WARNING"),
-            C.XMLUI_DATA_LVL_ERROR: "%s: " % _("ERROR"),
-        }
-        return ["%s%s" % (lvl_map[lvl], msg) for lvl, msg in notes]
-
-    def parse_command_answer(self, iq_elt):
-        command_elt = self.get_command_elt(iq_elt)
-        data = {}
-        data["status"] = command_elt.getAttribute("status", XEP_0050.STATUS.EXECUTING)
-        data["session_id"] = command_elt.getAttribute("sessionid")
-        data["notes"] = notes = []
-        for note_elt in command_elt.elements(NS_COMMANDS, "note"):
-            notes.append(
-                (
-                    self._get_data_lvl(note_elt.getAttribute("type", "info")),
-                    str(note_elt),
-                )
-            )
-
-        return command_elt, data
-
-
-    def _commands_answer_2_xmlui(self, iq_elt, session_id, session_data):
-        """Convert command answer to an ui for frontend
-
-        @param iq_elt: command result
-        @param session_id: id of the session used with the frontend
-        @param profile_key: %(doc_profile_key)s
-        """
-        command_elt, answer_data = self.parse_command_answer(iq_elt)
-        status = answer_data["status"]
-        if status in [XEP_0050.STATUS.COMPLETED, XEP_0050.STATUS.CANCELED]:
-            # the command session is finished, we purge our session
-            del self.requesting[session_id]
-            if status == XEP_0050.STATUS.COMPLETED:
-                session_id = None
-            else:
-                return None
-        remote_session_id = answer_data["session_id"]
-        if remote_session_id:
-            session_data["remote_id"] = remote_session_id
-        notes = answer_data["notes"]
-        for data_elt in command_elt.elements(data_form.NS_X_DATA, "x"):
-            if data_elt["type"] in ("form", "result"):
-                break
-        else:
-            # no matching data element found
-            if status != XEP_0050.STATUS.COMPLETED:
-                log.warning(
-                    _("No known payload found in ad-hoc command result, aborting")
-                )
-                del self.requesting[session_id]
-                return xml_tools.XMLUI(
-                    C.XMLUI_DIALOG,
-                    dialog_opt={
-                        C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE,
-                        C.XMLUI_DATA_MESS: _("No payload found"),
-                        C.XMLUI_DATA_LVL: C.XMLUI_DATA_LVL_ERROR,
-                    },
-                )
-            if not notes:
-                # the status is completed, and we have no note to show
-                return None
-
-            # if we have only one note, we show a dialog with the level of the note
-            # if we have more, we show a dialog with "info" level, and all notes merged
-            dlg_level = notes[0][0] if len(notes) == 1 else C.XMLUI_DATA_LVL_INFO
-            return xml_tools.XMLUI(
-                C.XMLUI_DIALOG,
-                dialog_opt={
-                    C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE,
-                    C.XMLUI_DATA_MESS: "\n".join(self._merge_notes(notes)),
-                    C.XMLUI_DATA_LVL: dlg_level,
-                },
-                session_id=session_id,
-            )
-
-        if session_id is None:
-            xmlui = xml_tools.data_form_elt_result_2_xmlui(data_elt)
-            if notes:
-                for level, note in notes:
-                    if level != "info":
-                        note = f"[{level}] {note}"
-                    xmlui.add_widget("text", note)
-            return xmlui
-
-        form = data_form.Form.fromElement(data_elt)
-        # we add any present note to the instructions
-        form.instructions.extend(self._merge_notes(notes))
-        return xml_tools.data_form_2_xmlui(form, self.__requesting_id, session_id=session_id)
-
-    def _requesting_entity(self, data, profile):
-        def serialise(ret_data):
-            if "xmlui" in ret_data:
-                ret_data["xmlui"] = ret_data["xmlui"].toXml()
-            return ret_data
-
-        d = self.requesting_entity(data, profile)
-        d.addCallback(serialise)
-        return d
-
-    def requesting_entity(self, data, profile):
-        """Request and entity and create XMLUI accordingly.
-
-        @param data: data returned by previous XMLUI (first one must come from
-                     self._commands_menu)
-        @param profile: %(doc_profile)s
-        @return: callback dict result (with "xmlui" corresponding to the answering
-                 dialog, or empty if it's finished without error)
-        """
-        if C.bool(data.get("cancelled", C.BOOL_FALSE)):
-            return defer.succeed({})
-        data_form_values = xml_tools.xmlui_result_2_data_form_result(data)
-        client = self.host.get_client(profile)
-        # TODO: cancel, prev and next are not managed
-        # TODO: managed answerer errors
-        # TODO: manage nodes with a non data form payload
-        if "session_id" not in data:
-            # we just had the jid, we now request it for the available commands
-            session_id, session_data = self.requesting.new_session(profile=client.profile)
-            entity = jid.JID(data[xml_tools.SAT_FORM_PREFIX + "jid"])
-            session_data["jid"] = entity
-            d = self.list_ui(client, entity)
-
-            def send_items(xmlui):
-                xmlui.session_id = session_id  # we need to keep track of the session
-                return {"xmlui": xmlui}
-
-            d.addCallback(send_items)
-        else:
-            # we have started a several forms sessions
-            try:
-                session_data = self.requesting.profile_get(
-                    data["session_id"], client.profile
-                )
-            except KeyError:
-                log.warning("session id doesn't exist, session has probably expired")
-                # TODO: send error dialog
-                return defer.succeed({})
-            session_id = data["session_id"]
-            entity = session_data["jid"]
-            try:
-                session_data["node"]
-                # node has already been received
-            except KeyError:
-                # it's the first time we know the node, we save it in session data
-                session_data["node"] = data_form_values.pop("node")
-
-            # remote_id is the XEP_0050 sessionid used by answering command
-            # while session_id is our own session id used with the frontend
-            remote_id = session_data.get("remote_id")
-
-            # we request execute node's command
-            d = self.do(client, entity, session_data["node"], action=XEP_0050.ACTION.EXECUTE,
-                        session_id=remote_id, form_values=data_form_values)
-            d.addCallback(self._commands_answer_2_xmlui, session_id, session_data)
-            d.addCallback(lambda xmlui: {"xmlui": xmlui} if xmlui is not None else {})
-
-        return d
-
-    def _commands_menu(self, menu_data, profile):
-        """First XMLUI activated by menu: ask for target jid
-
-        @param profile: %(doc_profile)s
-        """
-        form_ui = xml_tools.XMLUI("form", submit_id=self.__requesting_id)
-        form_ui.addText(_("Please enter target jid"), "instructions")
-        form_ui.change_container("pairs")
-        form_ui.addLabel("jid")
-        form_ui.addString("jid", value=self.host.get_client(profile).jid.host)
-        return {"xmlui": form_ui.toXml()}
-
-    def _status_callback(self, client, command_elt, session_data, action, node):
-        """Ad-hoc command used to change the "show" part of status"""
-        actions = session_data.setdefault("actions", [])
-        actions.append(action)
-
-        if len(actions) == 1:
-            # it's our first request, we ask the desired new status
-            status = XEP_0050.STATUS.EXECUTING
-            form = data_form.Form("form", title=_("status selection"))
-            show_options = [
-                data_form.Option(name, label) for name, label in list(SHOWS.items())
-            ]
-            field = data_form.Field(
-                "list-single", "show", options=show_options, required=True
-            )
-            form.addField(field)
-
-            payload = form.toElement()
-            note = None
-
-        elif len(actions) == 2:
-            # we should have the answer here
-            try:
-                x_elt = next(command_elt.elements(data_form.NS_X_DATA, "x"))
-                answer_form = data_form.Form.fromElement(x_elt)
-                show = answer_form["show"]
-            except (KeyError, StopIteration):
-                self.ad_hoc_error(XEP_0050.ERROR.BAD_PAYLOAD)
-            if show not in SHOWS:
-                self.ad_hoc_error(XEP_0050.ERROR.BAD_PAYLOAD)
-            if show == "disconnect":
-                self.host.disconnect(client.profile)
-            else:
-                self.host.presence_set(show=show, profile_key=client.profile)
-
-            # job done, we can end the session
-            status = XEP_0050.STATUS.COMPLETED
-            payload = None
-            note = (self.NOTE.INFO, _("Status updated"))
-        else:
-            self.ad_hoc_error(XEP_0050.ERROR.INTERNAL)
-
-        return (payload, status, None, note)
-
-    def _run(self, service_jid_s="", node="", profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        service_jid = jid.JID(service_jid_s) if service_jid_s else None
-        d = defer.ensureDeferred(self.run(client, service_jid, node or None))
-        d.addCallback(lambda xmlui: xmlui.toXml())
-        return d
-
-    async def run(self, client, service_jid=None, node=None):
-        """Run an ad-hoc command
-
-        @param service_jid(jid.JID, None): jid of the ad-hoc service
-            None to use profile's server
-        @param node(unicode, None): node of the ad-hoc commnad
-            None to get initial list
-        @return(unicode): command page XMLUI
-        """
-        if service_jid is None:
-            service_jid = jid.JID(client.jid.host)
-        session_id, session_data = self.requesting.new_session(profile=client.profile)
-        session_data["jid"] = service_jid
-        if node is None:
-            xmlui = await self.list_ui(client, service_jid)
-        else:
-            session_data["node"] = node
-            cb_data = await self.requesting_entity(
-                {"session_id": session_id}, client.profile
-            )
-            xmlui = cb_data["xmlui"]
-
-        xmlui.session_id = session_id
-        return xmlui
-
-    def list(self, client, to_jid):
-        """Request available commands
-
-        @param to_jid(jid.JID, None): the entity answering the commands
-            None to use profile's server
-        @return D(disco.DiscoItems): found commands
-        """
-        d = self.host.getDiscoItems(client, to_jid, NS_COMMANDS)
-        return d
-
-    def _list_ui(self, to_jid_s, profile_key):
-        client = self.host.get_client(profile_key)
-        to_jid = jid.JID(to_jid_s) if to_jid_s else None
-        d = self.list_ui(client, to_jid, no_instructions=True)
-        d.addCallback(lambda xmlui: xmlui.toXml())
-        return d
-
-    def list_ui(self, client, to_jid, no_instructions=False):
-        """Request available commands and generate XMLUI
-
-        @param to_jid(jid.JID, None): the entity answering the commands
-            None to use profile's server
-        @param no_instructions(bool): if True, don't add instructions widget
-        @return D(xml_tools.XMLUI): UI with the commands
-        """
-        d = self.list(client, to_jid)
-        d.addCallback(self._items_2_xmlui, no_instructions)
-        return d
-
-    def _sequence(self, sequence, node, service_jid_s="", profile_key=C.PROF_KEY_NONE):
-        sequence = data_format.deserialise(sequence, type_check=list)
-        client = self.host.get_client(profile_key)
-        service_jid = jid.JID(service_jid_s) if service_jid_s else None
-        d = defer.ensureDeferred(self.sequence(client, sequence, node, service_jid))
-        d.addCallback(lambda data: data_format.serialise(data))
-        return d
-
-    async def sequence(
-        self,
-        client: SatXMPPEntity,
-        sequence: List[dict],
-        node: str,
-        service_jid: Optional[jid.JID] = None,
-    ) -> dict:
-        """Send a series of data to an ad-hoc service
-
-        @param sequence: list of values to send
-            value are specified by a dict mapping var name to value.
-        @param node: node of the ad-hoc commnad
-        @param service_jid: jid of the ad-hoc service
-            None to use profile's server
-        @return: data received in final answer
-        """
-        if service_jid is None:
-            service_jid = jid.JID(client.jid.host)
-
-        session_id = None
-
-        for data_to_send in sequence:
-            iq_result_elt = await self.do(
-                client,
-                service_jid,
-                node,
-                session_id=session_id,
-                form_values=data_to_send,
-            )
-            __, answer_data = self.parse_command_answer(iq_result_elt)
-            session_id = answer_data.pop("session_id")
-
-        return answer_data
-
-    def add_ad_hoc_command(self, client, callback, label, node=None, features=None,
-                        timeout=600, allowed_jids=None, allowed_groups=None,
-                        allowed_magics=None, forbidden_jids=None, forbidden_groups=None,
-                        ):
-        """Add an ad-hoc command for the current profile
-
-        @param callback: method associated with this ad-hoc command which return the
-                         payload data (see AdHocCommand._sendAnswer), can return a
-                         deferred
-        @param label: label associated with this command on the main menu
-        @param node: disco item node associated with this command. None to use
-                     autogenerated node
-        @param features: features associated with the payload (list of strings), usualy
-                         data form
-        @param timeout: delay between two requests before canceling the session (in
-                        seconds)
-        @param allowed_jids: list of allowed entities
-        @param allowed_groups: list of allowed roster groups
-        @param allowed_magics: list of allowed magic keys, can be:
-                               @ALL@: allow everybody
-                               @PROFILE_BAREJID@: allow only the jid of the profile
-        @param forbidden_jids: black list of entities which can't access this command
-        @param forbidden_groups: black list of groups which can't access this command
-        @return: node of the added command, useful to remove the command later
-        """
-        # FIXME: "@ALL@" for profile_key seems useless and dangerous
-
-        if node is None:
-            node = "%s_%s" % ("COMMANDS", uuid4())
-
-        if features is None:
-            features = [data_form.NS_X_DATA]
-
-        if allowed_jids is None:
-            allowed_jids = []
-        if allowed_groups is None:
-            allowed_groups = []
-        if allowed_magics is None:
-            allowed_magics = ["@PROFILE_BAREJID@"]
-        if forbidden_jids is None:
-            forbidden_jids = []
-        if forbidden_groups is None:
-            forbidden_groups = []
-
-        # TODO: manage newly created/removed profiles
-        _allowed_jids = (
-            (allowed_jids + [client.jid.userhostJID()])
-            if "@PROFILE_BAREJID@" in allowed_magics
-            else allowed_jids
-        )
-        ad_hoc_command = AdHocCommand(
-            callback,
-            label,
-            node,
-            features,
-            timeout,
-            _allowed_jids,
-            allowed_groups,
-            allowed_magics,
-            forbidden_jids,
-            forbidden_groups,
-        )
-        ad_hoc_command.setHandlerParent(client)
-        commands = client._XEP_0050_commands
-        commands[node] = ad_hoc_command
-
-    def on_cmd_request(self, request, client):
-        request.handled = True
-        requestor = jid.JID(request["from"])
-        command_elt = next(request.elements(NS_COMMANDS, "command"))
-        action = command_elt.getAttribute("action", self.ACTION.EXECUTE)
-        node = command_elt.getAttribute("node")
-        if not node:
-            client.sendError(request, "bad-request")
-            return
-        sessionid = command_elt.getAttribute("sessionid")
-        commands = client._XEP_0050_commands
-        try:
-            command = commands[node]
-        except KeyError:
-            client.sendError(request, "item-not-found")
-            return
-        command.on_request(command_elt, requestor, action, sessionid)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0050_handler(XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-
-    @property
-    def client(self):
-        return self.parent
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(
-            CMD_REQUEST, self.plugin_parent.on_cmd_request, client=self.parent
-        )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        identities = []
-        if nodeIdentifier == NS_COMMANDS and self.client._XEP_0050_commands:
-            # we only add the identity if we have registred commands
-            identities.append(ID_CMD_LIST)
-        return [disco.DiscoFeature(NS_COMMANDS)] + identities
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        ret = []
-        if nodeIdentifier == NS_COMMANDS:
-            commands = self.client._XEP_0050_commands
-            for command in list(commands.values()):
-                if command.is_authorised(requestor):
-                    ret.append(
-                        disco.DiscoItem(self.parent.jid, command.node, command.getName())
-                    )  # TODO: manage name language
-        return ret
--- a/sat/plugins/plugin_xep_0054.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,475 +0,0 @@
-#!/usr/bin/env python3
-
-# SAT plugin for managing xep-0054
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import io
-from base64 import b64decode, b64encode
-from hashlib import sha1
-from pathlib import Path
-from typing import Optional
-from zope.interface import implementer
-from twisted.internet import threads, defer
-from twisted.words.protocols.jabber import jid, error
-from twisted.words.xish import domish
-from twisted.python.failure import Failure
-from wokkel import disco, iwokkel
-from sat.core import exceptions
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.core.xmpp import SatXMPPEntity
-from sat.memory import persistent
-from sat.tools import image
-
-log = getLogger(__name__)
-
-try:
-    from PIL import Image
-except:
-    raise exceptions.MissingModule(
-        "Missing module pillow, please download/install it from https://python-pillow.github.io"
-    )
-
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-
-IMPORT_NAME = "XEP-0054"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP 0054 Plugin",
-    C.PI_IMPORT_NAME: IMPORT_NAME,
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"],
-    C.PI_DEPENDENCIES: ["IDENTITY"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "XEP_0054",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of vcard-temp"""),
-}
-
-IQ_GET = '/iq[@type="get"]'
-NS_VCARD = "vcard-temp"
-VCARD_REQUEST = IQ_GET + '/vCard[@xmlns="' + NS_VCARD + '"]'  # TODO: manage requests
-
-PRESENCE = "/presence"
-NS_VCARD_UPDATE = "vcard-temp:x:update"
-VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]'
-
-HASH_SHA1_EMPTY = "da39a3ee5e6b4b0d3255bfef95601890afd80709"
-
-
-class XEP_0054(object):
-
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0054 initialization"))
-        self.host = host
-        self._i = host.plugins['IDENTITY']
-        self._i.register(IMPORT_NAME, 'avatar', self.get_avatar, self.set_avatar)
-        self._i.register(IMPORT_NAME, 'nicknames', self.get_nicknames, self.set_nicknames)
-        host.trigger.add("presence_available", self.presence_available_trigger)
-
-    def get_handler(self, client):
-        return XEP_0054_handler(self)
-
-    def presence_available_trigger(self, presence_elt, client):
-        try:
-            avatar_hash = client._xep_0054_avatar_hashes[client.jid.userhost()]
-        except KeyError:
-            log.info(
-                _("No avatar in cache for {profile}")
-                .format(profile=client.profile))
-            return True
-        x_elt = domish.Element((NS_VCARD_UPDATE, "x"))
-        x_elt.addElement("photo", content=avatar_hash)
-        presence_elt.addChild(x_elt)
-        return True
-
-    async def profile_connecting(self, client):
-        client._xep_0054_avatar_hashes = persistent.PersistentDict(
-            NS_VCARD, client.profile)
-        await client._xep_0054_avatar_hashes.load()
-
-    def save_photo(self, client, photo_elt, entity):
-        """Parse a <PHOTO> photo_elt and save the picture"""
-        # XXX: this method is launched in a separate thread
-        try:
-            mime_type = str(next(photo_elt.elements(NS_VCARD, "TYPE")))
-        except StopIteration:
-            mime_type = None
-        else:
-            if not mime_type:
-                # MIME type not known, we'll try autodetection below
-                mime_type = None
-            elif mime_type == "image/x-png":
-                # XXX: this old MIME type is still used by some clients
-                mime_type = "image/png"
-
-        try:
-            buf = str(next(photo_elt.elements(NS_VCARD, "BINVAL")))
-        except StopIteration:
-            log.warning("BINVAL element not found")
-            raise Failure(exceptions.NotFound())
-
-        if not buf:
-            log.warning("empty avatar for {jid}".format(jid=entity.full()))
-            raise Failure(exceptions.NotFound())
-
-        log.debug(_("Decoding binary"))
-        decoded = b64decode(buf)
-        del buf
-
-        if mime_type is None:
-            log.debug(
-                f"no media type found specified for {entity}'s avatar, trying to "
-                f"guess")
-
-            try:
-                mime_type = image.guess_type(io.BytesIO(decoded))
-            except IOError as e:
-                log.warning(f"Can't open avatar buffer: {e}")
-
-            if mime_type is None:
-                msg = f"Can't find media type for {entity}'s avatar"
-                log.warning(msg)
-                raise Failure(exceptions.DataError(msg))
-
-        image_hash = sha1(decoded).hexdigest()
-        with self.host.common_cache.cache_data(
-            PLUGIN_INFO["import_name"],
-            image_hash,
-            mime_type,
-        ) as f:
-            f.write(decoded)
-        return image_hash
-
-    async def v_card_2_dict(self, client, vcard_elt, entity_jid):
-        """Convert a VCard_elt to a dict, and save binaries"""
-        log.debug(("parsing vcard_elt"))
-        vcard_dict = {}
-
-        for elem in vcard_elt.elements():
-            if elem.name == "FN":
-                vcard_dict["fullname"] = str(elem)
-            elif elem.name == "NICKNAME":
-                nickname = vcard_dict["nickname"] = str(elem)
-                await self._i.update(
-                    client,
-                    IMPORT_NAME,
-                    "nicknames",
-                    [nickname],
-                    entity_jid
-                )
-            elif elem.name == "URL":
-                vcard_dict["website"] = str(elem)
-            elif elem.name == "EMAIL":
-                vcard_dict["email"] = str(elem)
-            elif elem.name == "BDAY":
-                vcard_dict["birthday"] = str(elem)
-            elif elem.name == "PHOTO":
-                # TODO: handle EXTVAL
-                try:
-                    avatar_hash = await threads.deferToThread(
-                        self.save_photo, client, elem, entity_jid
-                    )
-                except (exceptions.DataError, exceptions.NotFound):
-                    avatar_hash = ""
-                    vcard_dict["avatar"] = avatar_hash
-                except Exception as e:
-                    log.error(f"avatar saving error: {e}")
-                    avatar_hash = None
-                else:
-                    vcard_dict["avatar"] = avatar_hash
-                if avatar_hash is not None:
-                    await client._xep_0054_avatar_hashes.aset(
-                        entity_jid.full(), avatar_hash)
-
-                    if avatar_hash:
-                        avatar_cache = self.host.common_cache.get_metadata(avatar_hash)
-                        await self._i.update(
-                            client,
-                            IMPORT_NAME,
-                            "avatar",
-                            {
-                                'path': avatar_cache['path'],
-                                'filename': avatar_cache['filename'],
-                                'media_type': avatar_cache['mime_type'],
-                                'cache_uid': avatar_hash
-                            },
-                            entity_jid
-                        )
-                    else:
-                        await self._i.update(
-                            client, IMPORT_NAME, "avatar", None, entity_jid)
-            else:
-                log.debug("FIXME: [{}] VCard_elt tag is not managed yet".format(elem.name))
-
-        return vcard_dict
-
-    async def get_vcard_element(self, client, entity_jid):
-        """Retrieve domish.Element of a VCard
-
-        @param entity_jid(jid.JID): entity from who we need the vCard
-        @raise DataError: we got an invalid answer
-        """
-        iq_elt = client.IQ("get")
-        iq_elt["from"] = client.jid.full()
-        iq_elt["to"] = entity_jid.full()
-        iq_elt.addElement("vCard", NS_VCARD)
-        iq_ret_elt = await iq_elt.send(entity_jid.full())
-        try:
-            return next(iq_ret_elt.elements(NS_VCARD, "vCard"))
-        except StopIteration:
-            log.warning(_(
-                "vCard element not found for {entity_jid}: {xml}"
-                ).format(entity_jid=entity_jid, xml=iq_ret_elt.toXml()))
-            raise exceptions.DataError(f"no vCard element found for {entity_jid}")
-
-    async def update_vcard_elt(self, client, entity_jid, to_replace):
-        """Create a vcard element to replace some metadata
-
-        @param to_replace(list[str]): list of vcard element names to remove
-        """
-        try:
-            # we first check if a vcard already exists, to keep data
-            vcard_elt = await self.get_vcard_element(client, entity_jid)
-        except error.StanzaError as e:
-            if e.condition == "item-not-found":
-                vcard_elt = domish.Element((NS_VCARD, "vCard"))
-            else:
-                raise e
-        except exceptions.DataError:
-            vcard_elt = domish.Element((NS_VCARD, "vCard"))
-        else:
-            # the vcard exists, we need to remove elements that we'll replace
-            for elt_name in to_replace:
-                try:
-                    elt = next(vcard_elt.elements(NS_VCARD, elt_name))
-                except StopIteration:
-                    pass
-                else:
-                    vcard_elt.children.remove(elt)
-
-        return vcard_elt
-
-    async def get_card(self, client, entity_jid):
-        """Ask server for VCard
-
-        @param entity_jid(jid.JID): jid from which we want the VCard
-        @result(dict): vCard data
-        """
-        entity_jid = self._i.get_identity_jid(client, entity_jid)
-        log.debug(f"Asking for {entity_jid}'s VCard")
-        try:
-            vcard_elt = await self.get_vcard_element(client, entity_jid)
-        except exceptions.DataError:
-            self._i.update(client, IMPORT_NAME, "avatar", None, entity_jid)
-        except Exception as e:
-            log.warning(_(
-                "Can't get vCard for {entity_jid}: {e}"
-                ).format(entity_jid=entity_jid, e=e))
-        else:
-            log.debug(_("VCard found"))
-            return await self.v_card_2_dict(client, vcard_elt, entity_jid)
-
-    async def get_avatar(
-            self,
-            client: SatXMPPEntity,
-            entity_jid: jid.JID
-        ) -> Optional[dict]:
-        """Get avatar data
-
-        @param entity: entity to get avatar from
-        @return: avatar metadata, or None if no avatar has been found
-        """
-        entity_jid = self._i.get_identity_jid(client, entity_jid)
-        hashes_cache = client._xep_0054_avatar_hashes
-        vcard = await self.get_card(client, entity_jid)
-        if vcard is None:
-            return None
-        try:
-            avatar_hash = hashes_cache[entity_jid.full()]
-        except KeyError:
-            if 'avatar' in vcard:
-                raise exceptions.InternalError(
-                    "No avatar hash while avatar is found in vcard")
-            return None
-
-        if not avatar_hash:
-            return None
-
-        avatar_cache = self.host.common_cache.get_metadata(avatar_hash)
-        return self._i.avatar_build_metadata(
-                avatar_cache['path'], avatar_cache['mime_type'], avatar_hash)
-
-    async def set_avatar(self, client, avatar_data, entity):
-        """Set avatar of the profile
-
-        @param avatar_data(dict): data of the image to use as avatar, as built by
-            IDENTITY plugin.
-        @param entity(jid.JID): entity whose avatar must be changed
-        """
-        vcard_elt = await self.update_vcard_elt(client, entity, ['PHOTO'])
-
-        iq_elt = client.IQ()
-        iq_elt.addChild(vcard_elt)
-        # metadata with encoded image are now filled at the right size/format
-        photo_elt = vcard_elt.addElement("PHOTO")
-        photo_elt.addElement("TYPE", content=avatar_data["media_type"])
-        photo_elt.addElement("BINVAL", content=avatar_data["base64"])
-
-        await iq_elt.send()
-
-        # FIXME: should send the current presence, not always "available" !
-        await client.presence.available()
-
-    async def get_nicknames(self, client, entity):
-        """get nick from cache, or check vCard
-
-        @param entity(jid.JID): entity to get nick from
-        @return(list[str]): nicknames found
-        """
-        vcard_data = await self.get_card(client, entity)
-        try:
-            return [vcard_data['nickname']]
-        except (KeyError, TypeError):
-            return []
-
-    async def set_nicknames(self, client, nicknames, entity):
-        """Update our vCard and set a nickname
-
-        @param nicknames(list[str]): new nicknames to use
-            only first item of this list will be used here
-        """
-        nick = nicknames[0].strip()
-
-        vcard_elt = await self.update_vcard_elt(client, entity, ['NICKNAME'])
-
-        if nick:
-            vcard_elt.addElement((NS_VCARD, "NICKNAME"), content=nick)
-        iq_elt = client.IQ()
-        iq_elt.addChild(vcard_elt)
-        await iq_elt.send()
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0054_handler(XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(VCARD_UPDATE, self._update)
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_VCARD)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
-
-    async def update(self, presence):
-        """Called on <presence/> stanza with vcard data
-
-        Check for avatar information, and get VCard if needed
-        @param presence(domish.Element): <presence/> stanza
-        """
-        client = self.parent
-        entity_jid = self.plugin_parent._i.get_identity_jid(
-            client, jid.JID(presence["from"]))
-
-        try:
-            x_elt = next(presence.elements(NS_VCARD_UPDATE, "x"))
-        except StopIteration:
-            return
-
-        try:
-            photo_elt = next(x_elt.elements(NS_VCARD_UPDATE, "photo"))
-        except StopIteration:
-            return
-
-        given_hash = str(photo_elt).strip()
-        if given_hash == HASH_SHA1_EMPTY:
-            given_hash = ""
-
-        hashes_cache = client._xep_0054_avatar_hashes
-
-        old_hash = hashes_cache.get(entity_jid.full())
-
-        if old_hash == given_hash:
-            # no change, we can return…
-            if given_hash:
-                # …but we double check that avatar is in cache
-                avatar_cache = self.host.common_cache.get_metadata(given_hash)
-                if avatar_cache is None:
-                    log.debug(
-                        f"Avatar for [{entity_jid}] is known but not in cache, we get "
-                        f"it"
-                    )
-                    # get_card will put the avatar in cache
-                    await self.plugin_parent.get_card(client, entity_jid)
-                else:
-                    log.debug(f"avatar for {entity_jid} is already in cache")
-            return
-
-        if given_hash is None:
-            # XXX: we use empty string to indicate that there is no avatar
-            given_hash = ""
-
-        await hashes_cache.aset(entity_jid.full(), given_hash)
-
-        if not given_hash:
-            await self.plugin_parent._i.update(
-                client, IMPORT_NAME, "avatar", None, entity_jid)
-            # the avatar has been removed, no need to go further
-            return
-
-        avatar_cache = self.host.common_cache.get_metadata(given_hash)
-        if avatar_cache is not None:
-            log.debug(
-                f"New avatar found for [{entity_jid}], it's already in cache, we use it"
-            )
-            await self.plugin_parent._i.update(
-                client,
-                IMPORT_NAME, "avatar",
-                {
-                    'path': avatar_cache['path'],
-                    'filename': avatar_cache['filename'],
-                    'media_type': avatar_cache['mime_type'],
-                    'cache_uid': given_hash,
-                },
-                entity_jid
-            )
-        else:
-            log.debug(
-                "New avatar found for [{entity_jid}], requesting vcard"
-            )
-            vcard = await self.plugin_parent.get_card(client, entity_jid)
-            if vcard is None:
-                log.warning(f"Unexpected empty vCard for {entity_jid}")
-                return
-            computed_hash = client._xep_0054_avatar_hashes[entity_jid.full()]
-            if computed_hash != given_hash:
-                log.warning(
-                    "computed hash differs from given hash for {entity}:\n"
-                    "computed: {computed}\ngiven: {given}".format(
-                        entity=entity_jid, computed=computed_hash, given=given_hash
-                    )
-                )
-
-    def _update(self, presence):
-        defer.ensureDeferred(self.update(presence))
--- a/sat/plugins/plugin_xep_0055.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,526 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Jabber Search (xep-0055)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-from twisted.words.protocols.jabber.xmlstream import IQ
-from twisted.words.protocols.jabber import jid
-from twisted.internet import defer
-from wokkel import data_form
-from sat.core.constants import Const as C
-from sat.core.exceptions import DataError
-from sat.tools import xml_tools
-
-from wokkel import disco, iwokkel
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-from zope.interface import implementer
-
-
-NS_SEARCH = "jabber:iq:search"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Jabber Search",
-    C.PI_IMPORT_NAME: "XEP-0055",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0055"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_RECOMMENDATIONS: ["XEP-0059"],
-    C.PI_MAIN: "XEP_0055",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of Jabber Search"""),
-}
-
-# config file parameters
-CONFIG_SECTION = "plugin search"
-CONFIG_SERVICE_LIST = "service_list"
-
-DEFAULT_SERVICE_LIST = ["salut.libervia.org"]
-
-FIELD_SINGLE = "field_single"  # single text field for the simple search
-FIELD_CURRENT_SERVICE = (
-    "current_service_jid"
-)  # read-only text field for the advanced search
-
-
-class XEP_0055(object):
-    def __init__(self, host):
-        log.info(_("Jabber search plugin initialization"))
-        self.host = host
-
-        # default search services (config file + hard-coded lists)
-        self.services = [
-            jid.JID(entry)
-            for entry in host.memory.config_get(
-                CONFIG_SECTION, CONFIG_SERVICE_LIST, DEFAULT_SERVICE_LIST
-            )
-        ]
-
-        host.bridge.add_method(
-            "search_fields_ui_get",
-            ".plugin",
-            in_sign="ss",
-            out_sign="s",
-            method=self._get_fields_ui,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "search_request",
-            ".plugin",
-            in_sign="sa{ss}s",
-            out_sign="s",
-            method=self._search_request,
-            async_=True,
-        )
-
-        self.__search_menu_id = host.register_callback(self._get_main_ui, with_data=True)
-        host.import_menu(
-            (D_("Contacts"), D_("Search directory")),
-            self._get_main_ui,
-            security_limit=1,
-            help_string=D_("Search user directory"),
-        )
-
-    def _get_host_services(self, profile):
-        """Return the jabber search services associated to the user host.
-
-        @param profile (unicode): %(doc_profile)s
-        @return: list[jid.JID]
-        """
-        client = self.host.get_client(profile)
-        d = self.host.find_features_set(client, [NS_SEARCH])
-        return d.addCallback(lambda set_: list(set_))
-
-    ## Main search UI (menu item callback) ##
-
-    def _get_main_ui(self, raw_data, profile):
-        """Get the XMLUI for selecting a service and searching the directory.
-
-        @param raw_data (dict): data received from the frontend
-        @param profile (unicode): %(doc_profile)s
-        @return: a deferred XMLUI string representation
-        """
-        # check if the user's server offers some search services
-        d = self._get_host_services(profile)
-        return d.addCallback(lambda services: self.get_main_ui(services, raw_data, profile))
-
-    def get_main_ui(self, services, raw_data, profile):
-        """Get the XMLUI for selecting a service and searching the directory.
-
-        @param services (list[jid.JID]): search services offered by the user server
-        @param raw_data (dict): data received from the frontend
-        @param profile (unicode): %(doc_profile)s
-        @return: a deferred XMLUI string representation
-        """
-        # extend services offered by user's server with the default services
-        services.extend([service for service in self.services if service not in services])
-        data = xml_tools.xmlui_result_2_data_form_result(raw_data)
-        main_ui = xml_tools.XMLUI(
-            C.XMLUI_WINDOW,
-            container="tabs",
-            title=_("Search users"),
-            submit_id=self.__search_menu_id,
-        )
-
-        d = self._add_simple_search_ui(services, main_ui, data, profile)
-        d.addCallback(
-            lambda __: self._add_advanced_search_ui(services, main_ui, data, profile)
-        )
-        return d.addCallback(lambda __: {"xmlui": main_ui.toXml()})
-
-    def _add_simple_search_ui(self, services, main_ui, data, profile):
-        """Add to the main UI a tab for the simple search.
-
-        Display a single input field and search on the main service (it actually does one search per search field and then compile the results).
-
-        @param services (list[jid.JID]): search services offered by the user server
-        @param main_ui (XMLUI): the main XMLUI instance
-        @param data (dict): form data without SAT_FORM_PREFIX
-        @param profile (unicode): %(doc_profile)s
-
-        @return: a __ Deferred
-        """
-        service_jid = services[
-            0
-        ]  # TODO: search on all the given services, not only the first one
-
-        form = data_form.Form("form", formNamespace=NS_SEARCH)
-        form.addField(
-            data_form.Field(
-                "text-single",
-                FIELD_SINGLE,
-                label=_("Search for"),
-                value=data.get(FIELD_SINGLE, ""),
-            )
-        )
-
-        sub_cont = main_ui.main_container.add_tab(
-            "simple_search",
-            label=_("Simple search"),
-            container=xml_tools.VerticalContainer,
-        )
-        main_ui.change_container(sub_cont.append(xml_tools.PairsContainer(main_ui)))
-        xml_tools.data_form_2_widgets(main_ui, form)
-
-        # FIXME: add colspan attribute to divider? (we are in a PairsContainer)
-        main_ui.addDivider("blank")
-        main_ui.addDivider("blank")  # here we added a blank line before the button
-        main_ui.addDivider("blank")
-        main_ui.addButton(self.__search_menu_id, _("Search"), (FIELD_SINGLE,))
-        main_ui.addDivider("blank")
-        main_ui.addDivider("blank")  # a blank line again after the button
-
-        simple_data = {
-            key: value for key, value in data.items() if key in (FIELD_SINGLE,)
-        }
-        if simple_data:
-            log.debug("Simple search with %s on %s" % (simple_data, service_jid))
-            sub_cont.parent.set_selected(True)
-            main_ui.change_container(sub_cont.append(xml_tools.VerticalContainer(main_ui)))
-            main_ui.addDivider("dash")
-            d = self.search_request(service_jid, simple_data, profile)
-            d.addCallbacks(
-                lambda elt: self._display_search_result(main_ui, elt),
-                lambda failure: main_ui.addText(failure.getErrorMessage()),
-            )
-            return d
-
-        return defer.succeed(None)
-
-    def _add_advanced_search_ui(self, services, main_ui, data, profile):
-        """Add to the main UI a tab for the advanced search.
-
-        Display a service selector and allow to search on all the fields that are implemented by the selected service.
-
-        @param services (list[jid.JID]): search services offered by the user server
-        @param main_ui (XMLUI): the main XMLUI instance
-        @param data (dict): form data without SAT_FORM_PREFIX
-        @param profile (unicode): %(doc_profile)s
-
-        @return: a __ Deferred
-        """
-        sub_cont = main_ui.main_container.add_tab(
-            "advanced_search",
-            label=_("Advanced search"),
-            container=xml_tools.VerticalContainer,
-        )
-        service_selection_fields = ["service_jid", "service_jid_extra"]
-
-        if "service_jid_extra" in data:
-            # refresh button has been pushed, select the tab
-            sub_cont.parent.set_selected(True)
-            # get the selected service
-            service_jid_s = data.get("service_jid_extra", "")
-            if not service_jid_s:
-                service_jid_s = data.get("service_jid", str(services[0]))
-            log.debug("Refreshing search fields for %s" % service_jid_s)
-        else:
-            service_jid_s = data.get(FIELD_CURRENT_SERVICE, str(services[0]))
-        services_s = [str(service) for service in services]
-        if service_jid_s not in services_s:
-            services_s.append(service_jid_s)
-
-        main_ui.change_container(sub_cont.append(xml_tools.PairsContainer(main_ui)))
-        main_ui.addLabel(_("Search on"))
-        main_ui.addList("service_jid", options=services_s, selected=service_jid_s)
-        main_ui.addLabel(_("Other service"))
-        main_ui.addString(name="service_jid_extra")
-
-        # FIXME: add colspan attribute to divider? (we are in a PairsContainer)
-        main_ui.addDivider("blank")
-        main_ui.addDivider("blank")  # here we added a blank line before the button
-        main_ui.addDivider("blank")
-        main_ui.addButton(
-            self.__search_menu_id, _("Refresh fields"), service_selection_fields
-        )
-        main_ui.addDivider("blank")
-        main_ui.addDivider("blank")  # a blank line again after the button
-        main_ui.addLabel(_("Displaying the search form for"))
-        main_ui.addString(name=FIELD_CURRENT_SERVICE, value=service_jid_s, read_only=True)
-        main_ui.addDivider("dash")
-        main_ui.addDivider("dash")
-
-        main_ui.change_container(sub_cont.append(xml_tools.VerticalContainer(main_ui)))
-        service_jid = jid.JID(service_jid_s)
-        d = self.get_fields_ui(service_jid, profile)
-        d.addCallbacks(
-            self._add_advanced_form,
-            lambda failure: main_ui.addText(failure.getErrorMessage()),
-            [service_jid, main_ui, sub_cont, data, profile],
-        )
-        return d
-
-    def _add_advanced_form(self, form_elt, service_jid, main_ui, sub_cont, data, profile):
-        """Add the search form and the search results (if there is some to display).
-
-        @param form_elt (domish.Element): form element listing the fields
-        @param service_jid (jid.JID): current search service
-        @param main_ui (XMLUI): the main XMLUI instance
-        @param sub_cont (Container): the container of the current tab
-        @param data (dict): form data without SAT_FORM_PREFIX
-        @param profile (unicode): %(doc_profile)s
-
-        @return: a __ Deferred
-        """
-        field_list = data_form.Form.fromElement(form_elt).fieldList
-        adv_fields = [field.var for field in field_list if field.var]
-        adv_data = {key: value for key, value in data.items() if key in adv_fields}
-
-        xml_tools.data_form_2_widgets(main_ui, data_form.Form.fromElement(form_elt))
-
-        # refill the submitted values
-        # FIXME: wokkel's data_form.Form.fromElement doesn't parse the values, so we do it directly in XMLUI for now
-        for widget in main_ui.current_container.elem.childNodes:
-            name = widget.getAttribute("name")
-            if adv_data.get(name):
-                widget.setAttribute("value", adv_data[name])
-
-        # FIXME: add colspan attribute to divider? (we are in a PairsContainer)
-        main_ui.addDivider("blank")
-        main_ui.addDivider("blank")  # here we added a blank line before the button
-        main_ui.addDivider("blank")
-        main_ui.addButton(
-            self.__search_menu_id, _("Search"), adv_fields + [FIELD_CURRENT_SERVICE]
-        )
-        main_ui.addDivider("blank")
-        main_ui.addDivider("blank")  # a blank line again after the button
-
-        if adv_data:  # display the search results
-            log.debug("Advanced search with %s on %s" % (adv_data, service_jid))
-            sub_cont.parent.set_selected(True)
-            main_ui.change_container(sub_cont.append(xml_tools.VerticalContainer(main_ui)))
-            main_ui.addDivider("dash")
-            d = self.search_request(service_jid, adv_data, profile)
-            d.addCallbacks(
-                lambda elt: self._display_search_result(main_ui, elt),
-                lambda failure: main_ui.addText(failure.getErrorMessage()),
-            )
-            return d
-
-        return defer.succeed(None)
-
-    def _display_search_result(self, main_ui, elt):
-        """Display the search results.
-
-        @param main_ui (XMLUI): the main XMLUI instance
-        @param elt (domish.Element):  form result element
-        """
-        if [child for child in elt.children if child.name == "item"]:
-            headers, xmlui_data = xml_tools.data_form_elt_result_2_xmlui_data(elt)
-            if "jid" in headers:  # use XMLUI JidsListWidget to display the results
-                values = {}
-                for i in range(len(xmlui_data)):
-                    header = list(headers.keys())[i % len(headers)]
-                    widget_type, widget_args, widget_kwargs = xmlui_data[i]
-                    value = widget_args[0]
-                    values.setdefault(header, []).append(
-                        jid.JID(value) if header == "jid" else value
-                    )
-                main_ui.addJidsList(jids=values["jid"], name=D_("Search results"))
-                # TODO: also display the values other than JID
-            else:
-                xml_tools.xmlui_data_2_advanced_list(main_ui, headers, xmlui_data)
-        else:
-            main_ui.addText(D_("The search gave no result"))
-
-    ## Retrieve the  search fields ##
-
-    def _get_fields_ui(self, to_jid_s, profile_key):
-        """Ask a service to send us the list of the form fields it manages.
-
-        @param to_jid_s (unicode): XEP-0055 compliant search entity
-        @param profile_key (unicode): %(doc_profile_key)s
-        @return: a deferred XMLUI instance
-        """
-        d = self.get_fields_ui(jid.JID(to_jid_s), profile_key)
-        d.addCallback(lambda form: xml_tools.data_form_elt_result_2_xmlui(form).toXml())
-        return d
-
-    def get_fields_ui(self, to_jid, profile_key):
-        """Ask a service to send us the list of the form fields it manages.
-
-        @param to_jid (jid.JID): XEP-0055 compliant search entity
-        @param profile_key (unicode): %(doc_profile_key)s
-        @return: a deferred domish.Element
-        """
-        client = self.host.get_client(profile_key)
-        fields_request = IQ(client.xmlstream, "get")
-        fields_request["from"] = client.jid.full()
-        fields_request["to"] = to_jid.full()
-        fields_request.addElement("query", NS_SEARCH)
-        d = fields_request.send(to_jid.full())
-        d.addCallbacks(self._get_fields_ui_cb, self._get_fields_ui_eb)
-        return d
-
-    def _get_fields_ui_cb(self, answer):
-        """Callback for self.get_fields_ui.
-
-        @param answer (domish.Element): search query element
-        @return: domish.Element
-        """
-        try:
-            query_elts = next(answer.elements("jabber:iq:search", "query"))
-        except StopIteration:
-            log.info(_("No query element found"))
-            raise DataError  # FIXME: StanzaError is probably more appropriate, check the RFC
-        try:
-            form_elt = next(query_elts.elements(data_form.NS_X_DATA, "x"))
-        except StopIteration:
-            log.info(_("No data form found"))
-            raise NotImplementedError(
-                "Only search through data form is implemented so far"
-            )
-        return form_elt
-
-    def _get_fields_ui_eb(self, failure):
-        """Errback to self.get_fields_ui.
-
-        @param failure (defer.failure.Failure): twisted failure
-        @raise: the unchanged defer.failure.Failure
-        """
-        log.info(_("Fields request failure: %s") % str(failure.getErrorMessage()))
-        raise failure
-
-    ## Do the search ##
-
-    def _search_request(self, to_jid_s, search_data, profile_key):
-        """Actually do a search, according to filled data.
-
-        @param to_jid_s (unicode): XEP-0055 compliant search entity
-        @param search_data (dict): filled data, corresponding to the form obtained in get_fields_ui
-        @param profile_key (unicode): %(doc_profile_key)s
-        @return: a deferred XMLUI string representation
-        """
-        d = self.search_request(jid.JID(to_jid_s), search_data, profile_key)
-        d.addCallback(lambda form: xml_tools.data_form_elt_result_2_xmlui(form).toXml())
-        return d
-
-    def search_request(self, to_jid, search_data, profile_key):
-        """Actually do a search, according to filled data.
-
-        @param to_jid (jid.JID): XEP-0055 compliant search entity
-        @param search_data (dict): filled data, corresponding to the form obtained in get_fields_ui
-        @param profile_key (unicode): %(doc_profile_key)s
-        @return: a deferred domish.Element
-        """
-        if FIELD_SINGLE in search_data:
-            value = search_data[FIELD_SINGLE]
-            d = self.get_fields_ui(to_jid, profile_key)
-            d.addCallback(
-                lambda elt: self.search_request_multi(to_jid, value, elt, profile_key)
-            )
-            return d
-
-        client = self.host.get_client(profile_key)
-        search_request = IQ(client.xmlstream, "set")
-        search_request["from"] = client.jid.full()
-        search_request["to"] = to_jid.full()
-        query_elt = search_request.addElement("query", NS_SEARCH)
-        x_form = data_form.Form("submit", formNamespace=NS_SEARCH)
-        x_form.makeFields(search_data)
-        query_elt.addChild(x_form.toElement())
-        # TODO: XEP-0059 could be used here (with the needed new method attributes)
-        d = search_request.send(to_jid.full())
-        d.addCallbacks(self._search_ok, self._search_err)
-        return d
-
-    def search_request_multi(self, to_jid, value, form_elt, profile_key):
-        """Search for a value simultaneously in all fields, returns the results compilation.
-
-        @param to_jid (jid.JID): XEP-0055 compliant search entity
-        @param value (unicode): value to search
-        @param form_elt (domish.Element): form element listing the fields
-        @param profile_key (unicode): %(doc_profile_key)s
-        @return: a deferred domish.Element
-        """
-        form = data_form.Form.fromElement(form_elt)
-        d_list = []
-
-        for field in [field.var for field in form.fieldList if field.var]:
-            d_list.append(self.search_request(to_jid, {field: value}, profile_key))
-
-        def cb(result):  # return the results compiled in one domish element
-            result_elt = None
-            for success, form_elt in result:
-                if not success:
-                    continue
-                if (
-                    result_elt is None
-                ):  # the result element is built over the first answer
-                    result_elt = form_elt
-                    continue
-                for item_elt in form_elt.elements("jabber:x:data", "item"):
-                    result_elt.addChild(item_elt)
-            if result_elt is None:
-                raise defer.failure.Failure(
-                    DataError(_("The search could not be performed"))
-                )
-            return result_elt
-
-        return defer.DeferredList(d_list).addCallback(cb)
-
-    def _search_ok(self, answer):
-        """Callback for self.search_request.
-
-        @param answer (domish.Element): search query element
-        @return: domish.Element
-        """
-        try:
-            query_elts = next(answer.elements("jabber:iq:search", "query"))
-        except StopIteration:
-            log.info(_("No query element found"))
-            raise DataError  # FIXME: StanzaError is probably more appropriate, check the RFC
-        try:
-            form_elt = next(query_elts.elements(data_form.NS_X_DATA, "x"))
-        except StopIteration:
-            log.info(_("No data form found"))
-            raise NotImplementedError(
-                "Only search through data form is implemented so far"
-            )
-        return form_elt
-
-    def _search_err(self, failure):
-        """Errback to self.search_request.
-
-        @param failure (defer.failure.Failure): twisted failure
-        @raise: the unchanged defer.failure.Failure
-        """
-        log.info(_("Search request failure: %s") % str(failure.getErrorMessage()))
-        raise failure
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0055_handler(XMPPHandler):
-
-    def __init__(self, plugin_parent, profile):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-        self.profile = profile
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_SEARCH)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0059.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,159 +0,0 @@
-#!/usr/bin/env python3
-
-# Result Set Management (XEP-0059)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional
-from zope.interface import implementer
-from twisted.words.protocols.jabber import xmlstream
-from wokkel import disco
-from wokkel import iwokkel
-from wokkel import rsm
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Result Set Management",
-    C.PI_IMPORT_NAME: "XEP-0059",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0059"],
-    C.PI_MAIN: "XEP_0059",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Result Set Management"""),
-}
-
-RSM_PREFIX = "rsm_"
-
-
-class XEP_0059(object):
-    # XXX: RSM management is done directly in Wokkel.
-
-    def __init__(self, host):
-        log.info(_("Result Set Management plugin initialization"))
-
-    def get_handler(self, client):
-        return XEP_0059_handler()
-
-    def parse_extra(self, extra):
-        """Parse extra dictionnary to retrieve RSM arguments
-
-        @param extra(dict): data for parse
-        @return (rsm.RSMRequest, None): request with parsed arguments
-            or None if no RSM arguments have been found
-        """
-        if int(extra.get(RSM_PREFIX + 'max', 0)) < 0:
-            raise ValueError(_("rsm_max can't be negative"))
-
-        rsm_args = {}
-        for arg in ("max", "after", "before", "index"):
-            try:
-                argname = "max_" if arg == "max" else arg
-                rsm_args[argname] = extra.pop(RSM_PREFIX + arg)
-            except KeyError:
-                continue
-
-        if rsm_args:
-            return rsm.RSMRequest(**rsm_args)
-        else:
-            return None
-
-    def response2dict(self, rsm_response, data=None):
-        """Return a dict with RSM response
-
-        Key set in data can be:
-            - rsm_first: first item id in the page
-            - rsm_last: last item id in the page
-            - rsm_index: position of the first item in the full set (may be approximate)
-            - rsm_count: total number of items in the full set (may be approximage)
-        If a value doesn't exists, it's not set.
-        All values are set as strings.
-        @param rsm_response(rsm.RSMResponse): response to parse
-        @param data(dict, None): dict to update with rsm_* data.
-            If None, a new dict is created
-        @return (dict): data dict
-        """
-        if data is None:
-            data = {}
-        if rsm_response.first is not None:
-            data["first"] = rsm_response.first
-        if rsm_response.last is not None:
-            data["last"] = rsm_response.last
-        if rsm_response.index is not None:
-            data["index"] = rsm_response.index
-        return data
-
-    def get_next_request(
-        self,
-        rsm_request: rsm.RSMRequest,
-        rsm_response: rsm.RSMResponse,
-        log_progress: bool = True,
-    ) -> Optional[rsm.RSMRequest]:
-        """Generate next request to paginate through all items
-
-        Page will be retrieved forward
-        @param rsm_request: last request used
-        @param rsm_response: response from the last request
-        @return: request to retrive next page, or None if we are at the end
-            or if pagination is not possible
-        """
-        if rsm_request.max == 0:
-            log.warning("Can't do pagination if max is 0")
-            return None
-        if rsm_response is None:
-            # may happen if result set it empty, or we are at the end
-            return None
-        if (
-            rsm_response.count is not None
-            and rsm_response.index is not None
-        ):
-            next_index = rsm_response.index + rsm_request.max
-            if next_index >= rsm_response.count:
-                # we have reached the last page
-                return None
-
-            if log_progress:
-                log.debug(
-                    f"retrieving items {next_index} to "
-                    f"{min(next_index+rsm_request.max, rsm_response.count)} on "
-                    f"{rsm_response.count} ({next_index/rsm_response.count*100:.2f}%)"
-                )
-
-        if rsm_response.last is None:
-            if rsm_response.count:
-                log.warning("Can't do pagination, no \"last\" received")
-            return None
-
-        return rsm.RSMRequest(
-            max_=rsm_request.max,
-            after=rsm_response.last
-        )
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0059_handler(xmlstream.XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(rsm.NS_RSM)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0060.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1820 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin for Publish-Subscribe (xep-0060)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-from collections import namedtuple
-from functools import reduce
-from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union
-import urllib.error
-import urllib.parse
-import urllib.request
-
-from twisted.internet import defer, reactor
-from twisted.words.protocols.jabber import error, jid
-from twisted.words.xish import domish
-from wokkel import disco
-from wokkel import data_form
-from wokkel import pubsub
-from wokkel import rsm
-from wokkel import mam
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.xmpp import SatXMPPClient
-from sat.tools import utils
-from sat.tools import sat_defer
-from sat.tools import xml_tools
-from sat.tools.common import data_format
-
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Publish-Subscribe",
-    C.PI_IMPORT_NAME: "XEP-0060",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0060"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_RECOMMENDATIONS: ["XEP-0059", "XEP-0313"],
-    C.PI_MAIN: "XEP_0060",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of PubSub Protocol"""),
-}
-
-UNSPECIFIED = "unspecified error"
-
-
-Extra = namedtuple("Extra", ("rsm_request", "extra"))
-# rsm_request is the rsm.RSMRequest build with rsm_ prefixed keys, or None
-# extra is a potentially empty dict
-TIMEOUT = 30
-# minimum features that a pubsub service must have to be selectable as default
-DEFAULT_PUBSUB_MIN_FEAT = {
- 'http://jabber.org/protocol/pubsub#persistent-items',
- 'http://jabber.org/protocol/pubsub#publish',
- 'http://jabber.org/protocol/pubsub#retract-items',
-}
-
-class XEP_0060(object):
-    OPT_ACCESS_MODEL = "pubsub#access_model"
-    OPT_PERSIST_ITEMS = "pubsub#persist_items"
-    OPT_MAX_ITEMS = "pubsub#max_items"
-    OPT_DELIVER_PAYLOADS = "pubsub#deliver_payloads"
-    OPT_SEND_ITEM_SUBSCRIBE = "pubsub#send_item_subscribe"
-    OPT_NODE_TYPE = "pubsub#node_type"
-    OPT_SUBSCRIPTION_TYPE = "pubsub#subscription_type"
-    OPT_SUBSCRIPTION_DEPTH = "pubsub#subscription_depth"
-    OPT_ROSTER_GROUPS_ALLOWED = "pubsub#roster_groups_allowed"
-    OPT_PUBLISH_MODEL = "pubsub#publish_model"
-    OPT_OVERWRITE_POLICY = "pubsub#overwrite_policy"
-    ACCESS_OPEN = "open"
-    ACCESS_PRESENCE = "presence"
-    ACCESS_ROSTER = "roster"
-    ACCESS_PUBLISHER_ROSTER = "publisher-roster"
-    ACCESS_AUTHORIZE = "authorize"
-    ACCESS_WHITELIST = "whitelist"
-    PUBLISH_MODEL_PUBLISHERS = "publishers"
-    PUBLISH_MODEL_SUBSCRIBERS = "subscribers"
-    PUBLISH_MODEL_OPEN = "open"
-    OWPOL_ORIGINAL = "original_publisher"
-    OWPOL_ANY_PUB = "any_publisher"
-    ID_SINGLETON = "current"
-    EXTRA_PUBLISH_OPTIONS = "publish_options"
-    EXTRA_ON_PRECOND_NOT_MET = "on_precondition_not_met"
-    # extra disco needed for RSM, cf. XEP-0060 § 6.5.4
-    DISCO_RSM = "http://jabber.org/protocol/pubsub#rsm"
-
-    def __init__(self, host):
-        log.info(_("PubSub plugin initialization"))
-        self.host = host
-        self._rsm = host.plugins.get("XEP-0059")
-        self._mam = host.plugins.get("XEP-0313")
-        self._node_cb = {}  # dictionnary of callbacks for node (key: node, value: list of callbacks)
-        self.rt_sessions = sat_defer.RTDeferredSessions()
-        host.bridge.add_method(
-            "ps_node_create",
-            ".plugin",
-            in_sign="ssa{ss}s",
-            out_sign="s",
-            method=self._create_node,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_node_configuration_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="a{ss}",
-            method=self._get_node_configuration,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_node_configuration_set",
-            ".plugin",
-            in_sign="ssa{ss}s",
-            out_sign="",
-            method=self._set_node_configuration,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_node_affiliations_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="a{ss}",
-            method=self._get_node_affiliations,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_node_affiliations_set",
-            ".plugin",
-            in_sign="ssa{ss}s",
-            out_sign="",
-            method=self._set_node_affiliations,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_node_subscriptions_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="a{ss}",
-            method=self._get_node_subscriptions,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_node_subscriptions_set",
-            ".plugin",
-            in_sign="ssa{ss}s",
-            out_sign="",
-            method=self._set_node_subscriptions,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_node_purge",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._purge_node,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_node_delete",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._delete_node,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_node_watch_add",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._addWatch,
-            async_=False,
-        )
-        host.bridge.add_method(
-            "ps_node_watch_remove",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._remove_watch,
-            async_=False,
-        )
-        host.bridge.add_method(
-            "ps_affiliations_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="a{ss}",
-            method=self._get_affiliations,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_items_get",
-            ".plugin",
-            in_sign="ssiassss",
-            out_sign="s",
-            method=self._get_items,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_item_send",
-            ".plugin",
-            in_sign="ssssss",
-            out_sign="s",
-            method=self._send_item,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_items_send",
-            ".plugin",
-            in_sign="ssasss",
-            out_sign="as",
-            method=self._send_items,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_item_retract",
-            ".plugin",
-            in_sign="sssbs",
-            out_sign="",
-            method=self._retract_item,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_items_retract",
-            ".plugin",
-            in_sign="ssasbs",
-            out_sign="",
-            method=self._retract_items,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_item_rename",
-            ".plugin",
-            in_sign="sssss",
-            out_sign="",
-            method=self._rename_item,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_subscribe",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="s",
-            method=self._subscribe,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_unsubscribe",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._unsubscribe,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_subscriptions_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=self._subscriptions,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_subscribe_to_many",
-            ".plugin",
-            in_sign="a(ss)sa{ss}s",
-            out_sign="s",
-            method=self._subscribe_to_many,
-        )
-        host.bridge.add_method(
-            "ps_get_subscribe_rt_result",
-            ".plugin",
-            in_sign="ss",
-            out_sign="(ua(sss))",
-            method=self._many_subscribe_rt_result,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_get_from_many",
-            ".plugin",
-            in_sign="a(ss)iss",
-            out_sign="s",
-            method=self._get_from_many,
-        )
-        host.bridge.add_method(
-            "ps_get_from_many_rt_result",
-            ".plugin",
-            in_sign="ss",
-            out_sign="(ua(sssasa{ss}))",
-            method=self._get_from_many_rt_result,
-            async_=True,
-        )
-
-        #  high level observer method
-        host.bridge.add_signal(
-            "ps_event", ".plugin", signature="ssssss"
-        )  # args: category, service(jid), node, type (C.PS_ITEMS, C.PS_DELETE), data, profile
-
-        # low level observer method, used if service/node is in watching list (see psNodeWatch* methods)
-        host.bridge.add_signal(
-            "ps_event_raw", ".plugin", signature="sssass"
-        )  # args: service(jid), node, type (C.PS_ITEMS, C.PS_DELETE), list of item_xml, profile
-
-    def get_handler(self, client):
-        client.pubsub_client = SatPubSubClient(self.host, self)
-        return client.pubsub_client
-
-    async def profile_connected(self, client):
-        client.pubsub_watching = set()
-        try:
-            client.pubsub_service = jid.JID(
-                self.host.memory.config_get("", "pubsub_service")
-            )
-        except RuntimeError:
-            log.info(
-                _(
-                    "Can't retrieve pubsub_service from conf, we'll use first one that "
-                    "we find"
-                )
-            )
-            pubsub_services = await self.host.find_service_entities(
-                client, "pubsub", "service"
-            )
-            for service_jid in pubsub_services:
-                infos = await self.host.memory.disco.get_infos(client, service_jid)
-                if not DEFAULT_PUBSUB_MIN_FEAT.issubset(infos.features):
-                    continue
-                names = {(n or "").lower() for n in infos.identities.values()}
-                if "libervia pubsub service" in names:
-                    # this is the name of Libervia's side project pubsub service, we know
-                    # that it is a suitable default pubsub service
-                    client.pubsub_service = service_jid
-                    break
-                categories = {(i[0] or "").lower() for i in infos.identities.keys()}
-                if "gateway" in categories or "gateway" in names:
-                    # we don't want to use a gateway as default pubsub service
-                    continue
-                if "jabber:iq:register" in infos.features:
-                    # may be present on gateways, and we don't want a service
-                    # where registration is needed
-                    continue
-                client.pubsub_service = service_jid
-                break
-            else:
-                client.pubsub_service = None
-            pubsub_service_str = (
-                client.pubsub_service.full() if client.pubsub_service else "PEP"
-            )
-            log.info(f"default pubsub service: {pubsub_service_str}")
-
-    def features_get(self, profile):
-        try:
-            client = self.host.get_client(profile)
-        except exceptions.ProfileNotSetError:
-            return {}
-        try:
-            return {
-                "service": client.pubsub_service.full()
-                if client.pubsub_service is not None
-                else ""
-            }
-        except AttributeError:
-            if self.host.is_connected(profile):
-                log.debug("Profile is not connected, service is not checked yet")
-            else:
-                log.error("Service should be available !")
-            return {}
-
-    def parse_extra(self, extra):
-        """Parse extra dictionnary
-
-        used bridge's extra dictionnaries
-        @param extra(dict): extra data used to configure request
-        @return(Extra): filled Extra instance
-        """
-        if extra is None:
-            rsm_request = None
-            extra = {}
-        else:
-            # order-by
-            if C.KEY_ORDER_BY in extra:
-                # FIXME: we temporarily manage only one level of ordering
-                #        we need to switch to a fully serialised extra data
-                #        to be able to encode a whole ordered list
-                extra[C.KEY_ORDER_BY] = [extra.pop(C.KEY_ORDER_BY)]
-
-            # rsm
-            if self._rsm is None:
-                rsm_request = None
-            else:
-                rsm_request = self._rsm.parse_extra(extra)
-
-            # mam
-            if self._mam is None:
-                mam_request = None
-            else:
-                mam_request = self._mam.parse_extra(extra, with_rsm=False)
-
-            if mam_request is not None:
-                assert "mam" not in extra
-                extra["mam"] = mam_request
-
-        return Extra(rsm_request, extra)
-
-    def add_managed_node(
-        self,
-        node: str,
-        priority: int = 0,
-        **kwargs: Callable
-    ):
-        """Add a handler for a node
-
-        @param node: node to monitor
-            all node *prefixed* with this one will be triggered
-        @param priority: priority of the callback. Callbacks with higher priority will be
-            called first.
-        @param **kwargs: method(s) to call when the node is found
-            the method must be named after PubSub constants in lower case
-            and suffixed with "_cb"
-            e.g.: "items_cb" for C.PS_ITEMS, "delete_cb" for C.PS_DELETE
-            note: only C.PS_ITEMS and C.PS_DELETE are implemented so far
-        """
-        assert node is not None
-        assert kwargs
-        callbacks = self._node_cb.setdefault(node, {})
-        for event, cb in kwargs.items():
-            event_name = event[:-3]
-            assert event_name in C.PS_EVENTS
-            cb_list = callbacks.setdefault(event_name, [])
-            cb_list.append((cb, priority))
-            cb_list.sort(key=lambda c: c[1], reverse=True)
-
-    def remove_managed_node(self, node, *args):
-        """Add a handler for a node
-
-        @param node(unicode): node to monitor
-        @param *args: callback(s) to remove
-        """
-        assert args
-        try:
-            registred_cb = self._node_cb[node]
-        except KeyError:
-            pass
-        else:
-            removed = False
-            for callback in args:
-                for event, cb_list in registred_cb.items():
-                    to_remove = []
-                    for cb in cb_list:
-                        if cb[0] == callback:
-                            to_remove.append(cb)
-                            for cb in to_remove:
-                                cb_list.remove(cb)
-                            if not cb_list:
-                                del registred_cb[event]
-                            if not registred_cb:
-                                del self._node_cb[node]
-                            removed = True
-                            break
-
-            if not removed:
-                log.error(
-                    f"Trying to remove inexistant callback {callback} for node {node}"
-                )
-
-    # def listNodes(self, service, nodeIdentifier='', profile=C.PROF_KEY_NONE):
-    #     """Retrieve the name of the nodes that are accessible on the target service.
-
-    #     @param service (JID): target service
-    #     @param nodeIdentifier (str): the parent node name (leave empty to retrieve first-level nodes)
-    #     @param profile (str): %(doc_profile)s
-    #     @return: deferred which fire a list of nodes
-    #     """
-    #     client = self.host.get_client(profile)
-    #     d = self.host.getDiscoItems(client, service, nodeIdentifier)
-    #     d.addCallback(lambda result: [item.getAttribute('node') for item in result.toElement().children if item.hasAttribute('node')])
-    #     return d
-
-    # def listSubscribedNodes(self, service, nodeIdentifier='', filter_='subscribed', profile=C.PROF_KEY_NONE):
-    #     """Retrieve the name of the nodes to which the profile is subscribed on the target service.
-
-    #     @param service (JID): target service
-    #     @param nodeIdentifier (str): the parent node name (leave empty to retrieve all subscriptions)
-    #     @param filter_ (str): filter the result according to the given subscription type:
-    #         - None: do not filter
-    #         - 'pending': subscription has not been approved yet by the node owner
-    #         - 'unconfigured': subscription options have not been configured yet
-    #         - 'subscribed': subscription is complete
-    #     @param profile (str): %(doc_profile)s
-    #     @return: Deferred list[str]
-    #     """
-    #     d = self.subscriptions(service, nodeIdentifier, profile_key=profile)
-    #     d.addCallback(lambda subs: [sub.getAttribute('node') for sub in subs if sub.getAttribute('subscription') == filter_])
-    #     return d
-
-    def _send_item(self, service, nodeIdentifier, payload, item_id=None, extra_ser="",
-                  profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        service = None if not service else jid.JID(service)
-        payload = xml_tools.parse(payload)
-        extra = data_format.deserialise(extra_ser)
-        d = defer.ensureDeferred(self.send_item(
-            client, service, nodeIdentifier, payload, item_id or None, extra
-        ))
-        d.addCallback(lambda ret: ret or "")
-        return d
-
-    def _send_items(self, service, nodeIdentifier, items, extra_ser=None,
-                  profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        service = None if not service else jid.JID(service)
-        try:
-            items = [xml_tools.parse(item) for item in items]
-        except Exception as e:
-            raise exceptions.DataError(_("Can't parse items: {msg}").format(
-                msg=e))
-        extra = data_format.deserialise(extra_ser)
-        return defer.ensureDeferred(self.send_items(
-            client, service, nodeIdentifier, items, extra=extra
-        ))
-
-    async def send_item(
-        self,
-        client: SatXMPPClient,
-        service: Union[jid.JID, None],
-        nodeIdentifier: str,
-        payload: domish.Element,
-        item_id: Optional[str] = None,
-        extra: Optional[Dict[str, Any]] = None
-    ) -> Optional[str]:
-        """High level method to send one item
-
-        @param service: service to send the item to None to use PEP
-        @param NodeIdentifier: PubSub node to use
-        @param payload: payload of the item to send
-        @param item_id: id to use or None to create one
-        @param extra: extra options
-        @return: id of the created item
-        """
-        assert isinstance(payload, domish.Element)
-        item_elt = domish.Element((pubsub.NS_PUBSUB, 'item'))
-        if item_id is not None:
-            item_elt['id'] = item_id
-        item_elt.addChild(payload)
-        published_ids = await self.send_items(
-            client,
-            service,
-            nodeIdentifier,
-            [item_elt],
-            extra=extra
-        )
-        try:
-            return published_ids[0]
-        except IndexError:
-            return item_id
-
-    async def send_items(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        nodeIdentifier: str,
-        items: List[domish.Element],
-        sender: Optional[jid.JID] = None,
-        extra: Optional[Dict[str, Any]] = None
-    ) -> List[str]:
-        """High level method to send several items at once
-
-        @param service: service to send the item to
-            None to use PEP
-        @param NodeIdentifier: PubSub node to use
-        @param items: whole item elements to send,
-            "id" will be used if set
-        @param extra: extra options. Key can be:
-            - self.EXTRA_PUBLISH_OPTIONS(dict): publish options, cf. XEP-0060 § 7.1.5
-                the dict maps option name to value(s)
-            - self.EXTRA_ON_PRECOND_NOT_MET(str): policy to have when publishing is
-                failing du to failing precondition. Value can be:
-                * raise (default): raise the exception
-                * publish_without_options: re-publish without the publish-options.
-                    A warning will be logged showing that the publish-options could not
-                    be used
-        @return: ids of the created items
-        """
-        if extra is None:
-            extra = {}
-        if service is None:
-            service = client.jid.userhostJID()
-        parsed_items = []
-        for item in items:
-            if item.name != 'item':
-                raise exceptions.DataError(_("Invalid item: {xml}").format(item.toXml()))
-            item_id = item.getAttribute("id")
-            parsed_items.append(pubsub.Item(id=item_id, payload=item.firstChildElement()))
-        publish_options = extra.get(self.EXTRA_PUBLISH_OPTIONS)
-        try:
-            iq_result = await self.publish(
-                client, service, nodeIdentifier, parsed_items, options=publish_options,
-                sender=sender
-            )
-        except error.StanzaError as e:
-            if ((e.condition == 'conflict' and e.appCondition
-                 and e.appCondition.name == 'precondition-not-met'
-                 and publish_options is not None)):
-                # this usually happens when publish-options can't be set
-                policy = extra.get(self.EXTRA_ON_PRECOND_NOT_MET, 'raise')
-                if policy == 'raise':
-                    raise e
-                elif policy == 'publish_without_options':
-                    log.warning(_(
-                        "Can't use publish-options ({options}) on node {node}, "
-                        "re-publishing without them: {reason}").format(
-                            options=', '.join(f'{k} = {v}'
-                                    for k,v in publish_options.items()),
-                            node=nodeIdentifier,
-                            reason=e,
-                        )
-                    )
-                    iq_result = await self.publish(
-                        client, service, nodeIdentifier, parsed_items)
-                else:
-                    raise exceptions.InternalError(
-                        f"Invalid policy in extra's {self.EXTRA_ON_PRECOND_NOT_MET!r}: "
-                        f"{policy}"
-                    )
-            else:
-                raise e
-        try:
-            return [
-                item['id']
-                for item in iq_result.pubsub.publish.elements(pubsub.NS_PUBSUB, 'item')
-            ]
-        except AttributeError:
-            return []
-
-    async def publish(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        nodeIdentifier: str,
-        items: Optional[List[domish.Element]] = None,
-        options: Optional[dict] = None,
-        sender: Optional[jid.JID] = None,
-        extra: Optional[Dict[str, Any]] = None
-    ) -> domish.Element:
-        """Publish pubsub items
-
-        @param sender: sender of the request,
-            client.jid will be used if nto set
-        @param extra: extra data
-            not used directly by ``publish``, but may be used in triggers
-        @return: IQ result stanza
-        @trigger XEP-0060_publish: called just before publication.
-            if it returns False, extra must have a "iq_result_elt" key set with
-            domish.Element to return.
-        """
-        if sender is None:
-            sender = client.jid
-        if extra is None:
-            extra = {}
-        if not await self.host.trigger.async_point(
-            "XEP-0060_publish", client, service, nodeIdentifier, items, options, sender,
-            extra
-        ):
-            return extra["iq_result_elt"]
-        iq_result_elt = await client.pubsub_client.publish(
-            service, nodeIdentifier, items, sender,
-            options=options
-        )
-        return iq_result_elt
-
-    def _unwrap_mam_message(self, message_elt):
-        try:
-            item_elt = reduce(
-                lambda elt, ns_name: next(elt.elements(*ns_name)),
-                (message_elt,
-                 (mam.NS_MAM, "result"),
-                 (C.NS_FORWARD, "forwarded"),
-                 (C.NS_CLIENT, "message"),
-                 ("http://jabber.org/protocol/pubsub#event", "event"),
-                 ("http://jabber.org/protocol/pubsub#event", "items"),
-                 ("http://jabber.org/protocol/pubsub#event", "item"),
-                ))
-        except StopIteration:
-            raise exceptions.DataError("Can't find Item in MAM message element")
-        return item_elt
-
-    def serialise_items(self, items_data):
-        items, metadata = items_data
-        metadata['items'] = items
-        return data_format.serialise(metadata)
-
-    def _get_items(self, service="", node="", max_items=10, item_ids=None, sub_id=None,
-                  extra="", profile_key=C.PROF_KEY_NONE):
-        """Get items from pubsub node
-
-        @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
-        """
-        client = self.host.get_client(profile_key)
-        service = jid.JID(service) if service else None
-        max_items = None if max_items == C.NO_LIMIT else max_items
-        extra = self.parse_extra(data_format.deserialise(extra))
-        d = defer.ensureDeferred(self.get_items(
-            client,
-            service,
-            node,
-            max_items,
-            item_ids,
-            sub_id or None,
-            extra.rsm_request,
-            extra.extra,
-        ))
-        d.addCallback(self.trans_items_data)
-        d.addCallback(self.serialise_items)
-        return d
-
-    async def get_items(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: str,
-        max_items: Optional[int] = None,
-        item_ids: Optional[List[str]] = None,
-        sub_id: Optional[str] = None,
-        rsm_request: Optional[rsm.RSMRequest] = None,
-        extra: Optional[dict] = None
-    ) -> Tuple[List[dict], dict]:
-        """Retrieve pubsub items from a node.
-
-        @param service (JID, None): pubsub service.
-        @param node (str): node id.
-        @param max_items (int): optional limit on the number of retrieved items.
-        @param item_ids (list[str]): identifiers of the items to be retrieved (can't be
-             used with rsm_request). If requested items don't exist, they won't be
-             returned, meaning that we can have an empty list as result (NotFound
-             exception is NOT raised).
-        @param sub_id (str): optional subscription identifier.
-        @param rsm_request (rsm.RSMRequest): RSM request data
-        @return: a deferred couple (list[dict], dict) containing:
-            - list of items
-            - metadata with the following keys:
-                - rsm_first, rsm_last, rsm_count, rsm_index: first, last, count and index
-                    value of RSMResponse
-                - service, node: service and node used
-        """
-        if item_ids and max_items is not None:
-            max_items = None
-        if rsm_request and item_ids:
-            raise ValueError("items_id can't be used with rsm")
-        if extra is None:
-            extra = {}
-        cont, ret = await self.host.trigger.async_return_point(
-            "XEP-0060_getItems", client, service, node, max_items, item_ids, sub_id,
-            rsm_request, extra
-        )
-        if not cont:
-            return ret
-        try:
-            mam_query = extra["mam"]
-        except KeyError:
-            d = defer.ensureDeferred(client.pubsub_client.items(
-                service = service,
-                nodeIdentifier = node,
-                maxItems = max_items,
-                subscriptionIdentifier = sub_id,
-                sender = None,
-                itemIdentifiers = item_ids,
-                orderBy = extra.get(C.KEY_ORDER_BY),
-                rsm_request = rsm_request,
-                extra = extra
-            ))
-            # we have no MAM data here, so we add None
-            d.addErrback(sat_defer.stanza_2_not_found)
-            d.addTimeout(TIMEOUT, reactor)
-            items, rsm_response = await d
-            mam_response = None
-        else:
-            # if mam is requested, we have to do a totally different query
-            if self._mam is None:
-                raise exceptions.NotFound("MAM (XEP-0313) plugin is not available")
-            if max_items is not None:
-                raise exceptions.DataError("max_items parameter can't be used with MAM")
-            if item_ids:
-                raise exceptions.DataError("items_ids parameter can't be used with MAM")
-            if mam_query.node is None:
-                mam_query.node = node
-            elif mam_query.node != node:
-                raise exceptions.DataError(
-                    "MAM query node is incoherent with get_items's node"
-                )
-            if mam_query.rsm is None:
-                mam_query.rsm = rsm_request
-            else:
-                if mam_query.rsm != rsm_request:
-                    raise exceptions.DataError(
-                        "Conflict between RSM request and MAM's RSM request"
-                    )
-            items, rsm_response, mam_response = await self._mam.get_archives(
-                client, mam_query, service, self._unwrap_mam_message
-            )
-
-        try:
-            subscribe = C.bool(extra["subscribe"])
-        except KeyError:
-            subscribe = False
-
-        if subscribe:
-            try:
-                await self.subscribe(client, service, node)
-            except error.StanzaError as e:
-                log.warning(
-                    f"Could not subscribe to node {node} on service {service}: {e}"
-                )
-
-        # TODO: handle mam_response
-        service_jid = service if service else client.jid.userhostJID()
-        metadata = {
-            "service": service_jid,
-            "node": node,
-            "uri": self.get_node_uri(service_jid, node),
-        }
-        if mam_response is not None:
-            # mam_response is a dict with "complete" and "stable" keys
-            # we can put them directly in metadata
-            metadata.update(mam_response)
-        if rsm_request is not None and rsm_response is not None:
-            metadata['rsm'] = rsm_response.toDict()
-            if mam_response is None:
-                index = rsm_response.index
-                count = rsm_response.count
-                if index is None or count is None:
-                    # we don't have enough information to know if the data is complete
-                    # or not
-                    metadata["complete"] = None
-                else:
-                    # normally we have a strict equality here but XEP-0059 states
-                    # that index MAY be approximative, so just in case…
-                    metadata["complete"] = index + len(items) >= count
-        # encrypted metadata can be added by plugins in XEP-0060_items trigger
-        if "encrypted" in extra:
-            metadata["encrypted"] = extra["encrypted"]
-
-        return (items, metadata)
-
-    # @defer.inlineCallbacks
-    # def getItemsFromMany(self, service, data, max_items=None, sub_id=None, rsm=None, profile_key=C.PROF_KEY_NONE):
-    #     """Massively retrieve pubsub items from many nodes.
-    #     @param service (JID): target service.
-    #     @param data (dict): dictionnary binding some arbitrary keys to the node identifiers.
-    #     @param max_items (int): optional limit on the number of retrieved items *per node*.
-    #     @param sub_id (str): optional subscription identifier.
-    #     @param rsm (dict): RSM request data
-    #     @param profile_key (str): %(doc_profile_key)s
-    #     @return: a deferred dict with:
-    #         - key: a value in (a subset of) data.keys()
-    #         - couple (list[dict], dict) containing:
-    #             - list of items
-    #             - RSM response data
-    #     """
-    #     client = self.host.get_client(profile_key)
-    #     found_nodes = yield self.listNodes(service, profile=client.profile)
-    #     d_dict = {}
-    #     for publisher, node in data.items():
-    #         if node not in found_nodes:
-    #             log.debug(u"Skip the items retrieval for [{node}]: node doesn't exist".format(node=node))
-    #             continue  # avoid pubsub "item-not-found" error
-    #         d_dict[publisher] = self.get_items(service, node, max_items, None, sub_id, rsm, client.profile)
-    #     defer.returnValue(d_dict)
-
-    def getOptions(self, service, nodeIdentifier, subscriber, subscriptionIdentifier=None,
-                   profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        return client.pubsub_client.getOptions(
-            service, nodeIdentifier, subscriber, subscriptionIdentifier
-        )
-
-    def setOptions(self, service, nodeIdentifier, subscriber, options,
-                   subscriptionIdentifier=None, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        return client.pubsub_client.setOptions(
-            service, nodeIdentifier, subscriber, options, subscriptionIdentifier
-        )
-
-    def _create_node(self, service_s, nodeIdentifier, options, profile_key):
-        client = self.host.get_client(profile_key)
-        return self.createNode(
-            client, jid.JID(service_s) if service_s else None, nodeIdentifier, options
-        )
-
-    def createNode(
-        self,
-        client: SatXMPPClient,
-        service: jid.JID,
-        nodeIdentifier: Optional[str] = None,
-        options: Optional[Dict[str, str]] = None
-    ) -> str:
-        """Create a new node
-
-        @param service: PubSub service,
-        @param NodeIdentifier: node name use None to create instant node (identifier will
-            be returned by this method)
-        @param option: node configuration options
-        @return: identifier of the created node (may be different from requested name)
-        """
-        # TODO: if pubsub service doesn't hande publish-options, configure it in a second time
-        return client.pubsub_client.createNode(service, nodeIdentifier, options)
-
-    @defer.inlineCallbacks
-    def create_if_new_node(self, client, service, nodeIdentifier, options=None):
-        """Helper method similar to createNode, but will not fail in case of conflict"""
-        try:
-            yield self.createNode(client, service, nodeIdentifier, options)
-        except error.StanzaError as e:
-            if e.condition == "conflict":
-                pass
-            else:
-                raise e
-
-    def _get_node_configuration(self, service_s, nodeIdentifier, profile_key):
-        client = self.host.get_client(profile_key)
-        d = self.getConfiguration(
-            client, jid.JID(service_s) if service_s else None, nodeIdentifier
-        )
-
-        def serialize(form):
-            # FIXME: better more generic dataform serialisation should be available in SàT
-            return {f.var: str(f.value) for f in list(form.fields.values())}
-
-        d.addCallback(serialize)
-        return d
-
-    def getConfiguration(self, client, service, nodeIdentifier):
-        request = pubsub.PubSubRequest("configureGet")
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-
-        def cb(iq):
-            form = data_form.findForm(iq.pubsub.configure, pubsub.NS_PUBSUB_NODE_CONFIG)
-            form.typeCheck()
-            return form
-
-        d = request.send(client.xmlstream)
-        d.addCallback(cb)
-        return d
-
-    def make_configuration_form(self, options: dict) -> data_form.Form:
-        """Build a configuration form"""
-        form = data_form.Form(
-            formType="submit", formNamespace=pubsub.NS_PUBSUB_NODE_CONFIG
-        )
-        form.makeFields(options)
-        return form
-
-    def _set_node_configuration(self, service_s, nodeIdentifier, options, profile_key):
-        client = self.host.get_client(profile_key)
-        d = self.setConfiguration(
-            client, jid.JID(service_s) if service_s else None, nodeIdentifier, options
-        )
-        return d
-
-    def setConfiguration(self, client, service, nodeIdentifier, options):
-        request = pubsub.PubSubRequest("configureSet")
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-
-        form = self.make_configuration_form(options)
-        request.options = form
-
-        d = request.send(client.xmlstream)
-        return d
-
-    def _get_affiliations(self, service_s, nodeIdentifier, profile_key):
-        client = self.host.get_client(profile_key)
-        d = self.get_affiliations(
-            client, jid.JID(service_s) if service_s else None, nodeIdentifier or None
-        )
-        return d
-
-    def get_affiliations(self, client, service, nodeIdentifier=None):
-        """Retrieve affiliations of an entity
-
-        @param nodeIdentifier(unicode, None): node to get affiliation from
-            None to get all nodes affiliations for this service
-        """
-        request = pubsub.PubSubRequest("affiliations")
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-
-        def cb(iq_elt):
-            try:
-                affiliations_elt = next(
-                    iq_elt.pubsub.elements(pubsub.NS_PUBSUB, "affiliations")
-                )
-            except StopIteration:
-                raise ValueError(
-                    _("Invalid result: missing <affiliations> element: {}").format(
-                        iq_elt.toXml
-                    )
-                )
-            try:
-                return {
-                    e["node"]: e["affiliation"]
-                    for e in affiliations_elt.elements(pubsub.NS_PUBSUB, "affiliation")
-                }
-            except KeyError:
-                raise ValueError(
-                    _("Invalid result: bad <affiliation> element: {}").format(
-                        iq_elt.toXml
-                    )
-                )
-
-        d = request.send(client.xmlstream)
-        d.addCallback(cb)
-        return d
-
-    def _get_node_affiliations(self, service_s, nodeIdentifier, profile_key):
-        client = self.host.get_client(profile_key)
-        d = self.get_node_affiliations(
-            client, jid.JID(service_s) if service_s else None, nodeIdentifier
-        )
-        d.addCallback(
-            lambda affiliations: {j.full(): a for j, a in affiliations.items()}
-        )
-        return d
-
-    def get_node_affiliations(self, client, service, nodeIdentifier):
-        """Retrieve affiliations of a node owned by profile"""
-        request = pubsub.PubSubRequest("affiliationsGet")
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-
-        def cb(iq_elt):
-            try:
-                affiliations_elt = next(
-                    iq_elt.pubsub.elements(pubsub.NS_PUBSUB_OWNER, "affiliations")
-                )
-            except StopIteration:
-                raise ValueError(
-                    _("Invalid result: missing <affiliations> element: {}").format(
-                        iq_elt.toXml
-                    )
-                )
-            try:
-                return {
-                    jid.JID(e["jid"]): e["affiliation"]
-                    for e in affiliations_elt.elements(
-                        (pubsub.NS_PUBSUB_OWNER, "affiliation")
-                    )
-                }
-            except KeyError:
-                raise ValueError(
-                    _("Invalid result: bad <affiliation> element: {}").format(
-                        iq_elt.toXml
-                    )
-                )
-
-        d = request.send(client.xmlstream)
-        d.addCallback(cb)
-        return d
-
-    def _set_node_affiliations(
-        self, service_s, nodeIdentifier, affiliations, profile_key=C.PROF_KEY_NONE
-    ):
-        client = self.host.get_client(profile_key)
-        affiliations = {
-            jid.JID(jid_): affiliation for jid_, affiliation in affiliations.items()
-        }
-        d = self.set_node_affiliations(
-            client,
-            jid.JID(service_s) if service_s else None,
-            nodeIdentifier,
-            affiliations,
-        )
-        return d
-
-    def set_node_affiliations(self, client, service, nodeIdentifier, affiliations):
-        """Update affiliations of a node owned by profile
-
-        @param affiliations(dict[jid.JID, unicode]): affiliations to set
-            check https://xmpp.org/extensions/xep-0060.html#affiliations for a list of possible affiliations
-        """
-        request = pubsub.PubSubRequest("affiliationsSet")
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-        request.affiliations = affiliations
-        d = request.send(client.xmlstream)
-        return d
-
-    def _purge_node(self, service_s, nodeIdentifier, profile_key):
-        client = self.host.get_client(profile_key)
-        return self.purge_node(
-            client, jid.JID(service_s) if service_s else None, nodeIdentifier
-        )
-
-    def purge_node(self, client, service, nodeIdentifier):
-        return client.pubsub_client.purge_node(service, nodeIdentifier)
-
-    def _delete_node(self, service_s, nodeIdentifier, profile_key):
-        client = self.host.get_client(profile_key)
-        return self.deleteNode(
-            client, jid.JID(service_s) if service_s else None, nodeIdentifier
-        )
-
-    def deleteNode(
-        self,
-        client: SatXMPPClient,
-        service: jid.JID,
-        nodeIdentifier: str
-    ) -> defer.Deferred:
-        return client.pubsub_client.deleteNode(service, nodeIdentifier)
-
-    def _addWatch(self, service_s, node, profile_key):
-        """watch modifications on a node
-
-        This method should only be called from bridge
-        """
-        client = self.host.get_client(profile_key)
-        service = jid.JID(service_s) if service_s else client.jid.userhostJID()
-        client.pubsub_watching.add((service, node))
-
-    def _remove_watch(self, service_s, node, profile_key):
-        """remove a node watch
-
-        This method should only be called from bridge
-        """
-        client = self.host.get_client(profile_key)
-        service = jid.JID(service_s) if service_s else client.jid.userhostJID()
-        client.pubsub_watching.remove((service, node))
-
-    def _retract_item(
-        self, service_s, nodeIdentifier, itemIdentifier, notify, profile_key
-    ):
-        return self._retract_items(
-            service_s, nodeIdentifier, (itemIdentifier,), notify, profile_key
-        )
-
-    def _retract_items(
-        self, service_s, nodeIdentifier, itemIdentifiers, notify, profile_key
-    ):
-        client = self.host.get_client(profile_key)
-        return self.retract_items(
-            client,
-            jid.JID(service_s) if service_s else None,
-            nodeIdentifier,
-            itemIdentifiers,
-            notify,
-        )
-
-    def retract_items(
-        self,
-        client: SatXMPPClient,
-        service: jid.JID,
-        nodeIdentifier: str,
-        itemIdentifiers: Iterable[str],
-        notify: bool = True,
-    ) -> defer.Deferred:
-        return client.pubsub_client.retractItems(
-            service, nodeIdentifier, itemIdentifiers, notify=notify
-        )
-
-    def _rename_item(
-        self,
-        service,
-        node,
-        item_id,
-        new_id,
-        profile_key=C.PROF_KEY_NONE,
-    ):
-        client = self.host.get_client(profile_key)
-        service = jid.JID(service) if service else None
-        return defer.ensureDeferred(self.rename_item(
-            client, service, node, item_id, new_id
-        ))
-
-    async def rename_item(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: str,
-        item_id: str,
-        new_id: str
-    ) -> None:
-        """Rename an item by recreating it then deleting it
-
-        we have to recreate then delete because there is currently no rename operation
-        with PubSub
-        """
-        if not item_id or not new_id:
-            raise ValueError("item_id and new_id must not be empty")
-        # retract must be done last, so if something goes wrong, the exception will stop
-        # the workflow and no accidental delete should happen
-        item_elt = (await self.get_items(client, service, node, item_ids=[item_id]))[0][0]
-        await self.send_item(client, service, node, item_elt.firstChildElement(), new_id)
-        await self.retract_items(client, service, node, [item_id])
-
-    def _subscribe(self, service, nodeIdentifier, options, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        service = None if not service else jid.JID(service)
-        d = defer.ensureDeferred(
-            self.subscribe(
-                client,
-                service,
-                nodeIdentifier,
-                options=data_format.deserialise(options)
-            )
-        )
-        d.addCallback(lambda subscription: subscription.subscriptionIdentifier or "")
-        return d
-
-    async def subscribe(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        nodeIdentifier: str,
-        sub_jid: Optional[jid.JID] = None,
-        options: Optional[dict] = None
-    ) -> pubsub.Subscription:
-        # TODO: reimplement a subscribtion cache, checking that we have not subscription before trying to subscribe
-        if service is None:
-            service = client.jid.userhostJID()
-        cont, trigger_sub = await self.host.trigger.async_return_point(
-            "XEP-0060_subscribe", client, service, nodeIdentifier, sub_jid, options,
-        )
-        if not cont:
-            return trigger_sub
-        try:
-            subscription = await client.pubsub_client.subscribe(
-                service, nodeIdentifier, sub_jid or client.jid.userhostJID(),
-                options=options, sender=client.jid.userhostJID()
-            )
-        except error.StanzaError as e:
-            if e.condition == 'item-not-found':
-                raise exceptions.NotFound(e.text or e.condition)
-            else:
-                raise e
-        return subscription
-
-    def _unsubscribe(self, service, nodeIdentifier, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        service = None if not service else jid.JID(service)
-        return defer.ensureDeferred(self.unsubscribe(client, service, nodeIdentifier))
-
-    async def unsubscribe(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        nodeIdentifier: str,
-        sub_jid: Optional[jid.JID] = None,
-        subscriptionIdentifier: Optional[str] = None,
-        sender: Optional[jid.JID] = None,
-    ) -> None:
-        if not await self.host.trigger.async_point(
-            "XEP-0060_unsubscribe", client, service, nodeIdentifier, sub_jid,
-            subscriptionIdentifier, sender
-        ):
-            return
-        try:
-            await client.pubsub_client.unsubscribe(
-            service,
-            nodeIdentifier,
-            sub_jid or client.jid.userhostJID(),
-            subscriptionIdentifier,
-            sender,
-        )
-        except error.StanzaError as e:
-            try:
-                next(e.getElement().elements(pubsub.NS_PUBSUB_ERRORS, "not-subscribed"))
-            except StopIteration:
-                raise e
-            else:
-                log.info(
-                    f"{sender.full() if sender else client.jid.full()} was not "
-                    f"subscribed to node {nodeIdentifier!s} at {service.full()}"
-                )
-
-    @utils.ensure_deferred
-    async def _subscriptions(
-        self,
-        service="",
-        nodeIdentifier="",
-        profile_key=C.PROF_KEY_NONE
-    ) -> str:
-        client = self.host.get_client(profile_key)
-        service = None if not service else jid.JID(service)
-        subs = await self.subscriptions(client, service, nodeIdentifier or None)
-        return data_format.serialise(subs)
-
-    async def subscriptions(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID] = None,
-        node: Optional[str] = None
-    ) -> List[Dict[str, Union[str, bool]]]:
-        """Retrieve subscriptions from a service
-
-        @param service(jid.JID): PubSub service
-        @param nodeIdentifier(unicode, None): node to check
-            None to get all subscriptions
-        """
-        cont, ret = await self.host.trigger.async_return_point(
-            "XEP-0060_subscriptions", client, service, node
-        )
-        if not cont:
-            return ret
-        subs = await client.pubsub_client.subscriptions(service, node)
-        ret = []
-        for sub in subs:
-            sub_dict = {
-                "service": service.host if service else client.jid.host,
-                "node": sub.nodeIdentifier,
-                "subscriber": sub.subscriber.full(),
-                "state": sub.state,
-            }
-            if sub.subscriptionIdentifier is not None:
-                sub_dict["id"] = sub.subscriptionIdentifier
-            ret.append(sub_dict)
-        return ret
-
-    ## misc tools ##
-
-    def get_node_uri(self, service, node, item=None):
-        """Return XMPP URI of a PubSub node
-
-        @param service(jid.JID): PubSub service
-        @param node(unicode): node
-        @return (unicode): URI of the node
-        """
-        # FIXME: deprecated, use sat.tools.common.uri instead
-        assert service is not None
-        # XXX: urllib.urlencode use "&" to separate value, while XMPP URL (cf. RFC 5122)
-        #      use ";" as a separator. So if more than one value is used in query_data,
-        #      urlencode MUST NOT BE USED.
-        query_data = [("node", node.encode("utf-8"))]
-        if item is not None:
-            query_data.append(("item", item.encode("utf-8")))
-        return "xmpp:{service}?;{query}".format(
-            service=service.userhost(), query=urllib.parse.urlencode(query_data)
-        )
-
-    ## methods to manage several stanzas/jids at once ##
-
-    # generic #
-
-    def get_rt_results(
-        self, session_id, on_success=None, on_error=None, profile=C.PROF_KEY_NONE
-    ):
-        return self.rt_sessions.get_results(session_id, on_success, on_error, profile)
-
-    def trans_items_data(self, items_data, item_cb=lambda item: item.toXml()):
-        """Helper method to transform result from [get_items]
-
-        the items_data must be a tuple(list[domish.Element], dict[unicode, unicode])
-        as returned by [get_items].
-        @param items_data(tuple): tuple returned by [get_items]
-        @param item_cb(callable): method to transform each item
-        @return (tuple): a serialised form ready to go throught bridge
-        """
-        items, metadata = items_data
-        items = [item_cb(item) for item in items]
-
-        return (items, metadata)
-
-    def trans_items_data_d(self, items_data, item_cb):
-        """Helper method to transform result from [get_items], deferred version
-
-        the items_data must be a tuple(list[domish.Element], dict[unicode, unicode])
-        as returned by [get_items]. metadata values are then casted to unicode and
-        each item is passed to items_cb.
-        An errback is added to item_cb, and when it is fired the value is filtered from
-            final items
-        @param items_data(tuple): tuple returned by [get_items]
-        @param item_cb(callable): method to transform each item (must return a deferred)
-        @return (tuple): a deferred which fire a dict which can be serialised to go
-            throught bridge
-        """
-        items, metadata = items_data
-
-        def eb(failure_):
-            log.warning(f"Error while parsing item: {failure_.value}")
-
-        d = defer.gatherResults([item_cb(item).addErrback(eb) for item in items])
-        d.addCallback(lambda parsed_items: (
-            [i for i in parsed_items if i is not None],
-            metadata
-        ))
-        return d
-
-    def ser_d_list(self, results, failure_result=None):
-        """Serialise a DeferredList result
-
-        @param results: DeferredList results
-        @param failure_result: value to use as value for failed Deferred
-            (default: empty tuple)
-        @return (list): list with:
-            - failure: empty in case of success, else error message
-            - result
-        """
-        if failure_result is None:
-            failure_result = ()
-        return [
-            ("", result)
-            if success
-            else (str(result.result) or UNSPECIFIED, failure_result)
-            for success, result in results
-        ]
-
-    # subscribe #
-
-    @utils.ensure_deferred
-    async def _get_node_subscriptions(
-        self,
-        service: str,
-        node: str,
-        profile_key: str
-    ) -> Dict[str, str]:
-        client = self.host.get_client(profile_key)
-        subs = await self.get_node_subscriptions(
-            client, jid.JID(service) if service else None, node
-        )
-        return {j.full(): a for j, a in subs.items()}
-
-    async def get_node_subscriptions(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        nodeIdentifier: str
-    ) -> Dict[jid.JID, str]:
-        """Retrieve subscriptions to a node
-
-        @param nodeIdentifier(unicode): node to get subscriptions from
-        """
-        if not nodeIdentifier:
-            raise exceptions.DataError("node identifier can't be empty")
-        request = pubsub.PubSubRequest("subscriptionsGet")
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-
-        iq_elt = await request.send(client.xmlstream)
-        try:
-            subscriptions_elt = next(
-                iq_elt.pubsub.elements(pubsub.NS_PUBSUB_OWNER, "subscriptions")
-            )
-        except StopIteration:
-            raise ValueError(
-                _("Invalid result: missing <subscriptions> element: {}").format(
-                    iq_elt.toXml
-                )
-            )
-        except AttributeError as e:
-            raise ValueError(_("Invalid result: {}").format(e))
-        try:
-            return {
-                jid.JID(s["jid"]): s["subscription"]
-                for s in subscriptions_elt.elements(
-                    (pubsub.NS_PUBSUB, "subscription")
-                )
-            }
-        except KeyError:
-            raise ValueError(
-                _("Invalid result: bad <subscription> element: {}").format(
-                    iq_elt.toXml
-                )
-            )
-
-    def _set_node_subscriptions(
-        self, service_s, nodeIdentifier, subscriptions, profile_key=C.PROF_KEY_NONE
-    ):
-        client = self.host.get_client(profile_key)
-        subscriptions = {
-            jid.JID(jid_): subscription
-            for jid_, subscription in subscriptions.items()
-        }
-        d = self.set_node_subscriptions(
-            client,
-            jid.JID(service_s) if service_s else None,
-            nodeIdentifier,
-            subscriptions,
-        )
-        return d
-
-    def set_node_subscriptions(self, client, service, nodeIdentifier, subscriptions):
-        """Set or update subscriptions of a node owned by profile
-
-        @param subscriptions(dict[jid.JID, unicode]): subscriptions to set
-            check https://xmpp.org/extensions/xep-0060.html#substates for a list of possible subscriptions
-        """
-        request = pubsub.PubSubRequest("subscriptionsSet")
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-        request.subscriptions = {
-            pubsub.Subscription(nodeIdentifier, jid_, state)
-            for jid_, state in subscriptions.items()
-        }
-        d = request.send(client.xmlstream)
-        return d
-
-    def _many_subscribe_rt_result(self, session_id, profile_key=C.PROF_KEY_DEFAULT):
-        """Get real-time results for subcribeToManu session
-
-        @param session_id: id of the real-time deferred session
-        @param return (tuple): (remaining, results) where:
-            - remaining is the number of still expected results
-            - results is a list of tuple(unicode, unicode, bool, unicode) with:
-                - service: pubsub service
-                - and node: pubsub node
-                - failure(unicode): empty string in case of success, error message else
-        @param profile_key: %(doc_profile_key)s
-        """
-        profile = self.host.get_client(profile_key).profile
-        d = self.rt_sessions.get_results(
-            session_id,
-            on_success=lambda result: "",
-            on_error=lambda failure: str(failure.value),
-            profile=profile,
-        )
-        # we need to convert jid.JID to unicode with full() to serialise it for the bridge
-        d.addCallback(
-            lambda ret: (
-                ret[0],
-                [
-                    (service.full(), node, "" if success else failure or UNSPECIFIED)
-                    for (service, node), (success, failure) in ret[1].items()
-                ],
-            )
-        )
-        return d
-
-    def _subscribe_to_many(
-        self, node_data, subscriber=None, options=None, profile_key=C.PROF_KEY_NONE
-    ):
-        return self.subscribe_to_many(
-            [(jid.JID(service), str(node)) for service, node in node_data],
-            jid.JID(subscriber),
-            options,
-            profile_key,
-        )
-
-    def subscribe_to_many(
-        self, node_data, subscriber, options=None, profile_key=C.PROF_KEY_NONE
-    ):
-        """Subscribe to several nodes at once.
-
-        @param node_data (iterable[tuple]): iterable of tuple (service, node) where:
-            - service (jid.JID) is the pubsub service
-            - node (unicode) is the node to subscribe to
-        @param subscriber (jid.JID): optional subscription identifier.
-        @param options (dict): subscription options
-        @param profile_key (str): %(doc_profile_key)s
-        @return (str): RT Deferred session id
-        """
-        client = self.host.get_client(profile_key)
-        deferreds = {}
-        for service, node in node_data:
-            deferreds[(service, node)] = defer.ensureDeferred(
-                client.pubsub_client.subscribe(
-                    service, node, subscriber, options=options
-                )
-            )
-        return self.rt_sessions.new_session(deferreds, client.profile)
-        # found_nodes = yield self.listNodes(service, profile=client.profile)
-        # subscribed_nodes = yield self.listSubscribedNodes(service, profile=client.profile)
-        # d_list = []
-        # for nodeIdentifier in (set(nodeIdentifiers) - set(subscribed_nodes)):
-        #     if nodeIdentifier not in found_nodes:
-        #         log.debug(u"Skip the subscription to [{node}]: node doesn't exist".format(node=nodeIdentifier))
-        #         continue  # avoid sat-pubsub "SubscriptionExists" error
-        #     d_list.append(client.pubsub_client.subscribe(service, nodeIdentifier, sub_jid or client.pubsub_client.parent.jid.userhostJID(), options=options))
-        # defer.returnValue(d_list)
-
-    # get #
-
-    def _get_from_many_rt_result(self, session_id, profile_key=C.PROF_KEY_DEFAULT):
-        """Get real-time results for get_from_many session
-
-        @param session_id: id of the real-time deferred session
-        @param profile_key: %(doc_profile_key)s
-        @param return (tuple): (remaining, results) where:
-            - remaining is the number of still expected results
-            - results is a list of tuple with
-                - service (unicode): pubsub service
-                - node (unicode): pubsub node
-                - failure (unicode): empty string in case of success, error message else
-                - items (list[s]): raw XML of items
-                - metadata(dict): serialised metadata
-        """
-        profile = self.host.get_client(profile_key).profile
-        d = self.rt_sessions.get_results(
-            session_id,
-            on_success=lambda result: ("", self.trans_items_data(result)),
-            on_error=lambda failure: (str(failure.value) or UNSPECIFIED, ([], {})),
-            profile=profile,
-        )
-        d.addCallback(
-            lambda ret: (
-                ret[0],
-                [
-                    (service.full(), node, failure, items, metadata)
-                    for (service, node), (success, (failure, (items, metadata))) in ret[
-                        1
-                    ].items()
-                ],
-            )
-        )
-        return d
-
-    def _get_from_many(
-        self, node_data, max_item=10, extra="", profile_key=C.PROF_KEY_NONE
-    ):
-        """
-        @param max_item(int): maximum number of item to get, C.NO_LIMIT for no limit
-        """
-        max_item = None if max_item == C.NO_LIMIT else max_item
-        extra = self.parse_extra(data_format.deserialise(extra))
-        return self.get_from_many(
-            [(jid.JID(service), str(node)) for service, node in node_data],
-            max_item,
-            extra.rsm_request,
-            extra.extra,
-            profile_key,
-        )
-
-    def get_from_many(self, node_data, max_item=None, rsm_request=None, extra=None,
-                    profile_key=C.PROF_KEY_NONE):
-        """Get items from many nodes at once
-
-        @param node_data (iterable[tuple]): iterable of tuple (service, node) where:
-            - service (jid.JID) is the pubsub service
-            - node (unicode) is the node to get items from
-        @param max_items (int): optional limit on the number of retrieved items.
-        @param rsm_request (RSMRequest): RSM request data
-        @param profile_key (unicode): %(doc_profile_key)s
-        @return (str): RT Deferred session id
-        """
-        client = self.host.get_client(profile_key)
-        deferreds = {}
-        for service, node in node_data:
-            deferreds[(service, node)] = defer.ensureDeferred(self.get_items(
-                client, service, node, max_item, rsm_request=rsm_request, extra=extra
-            ))
-        return self.rt_sessions.new_session(deferreds, client.profile)
-
-
-@implementer(disco.IDisco)
-class SatPubSubClient(rsm.PubSubClient):
-
-    def __init__(self, host, parent_plugin):
-        self.host = host
-        self.parent_plugin = parent_plugin
-        rsm.PubSubClient.__init__(self)
-
-    def connectionInitialized(self):
-        rsm.PubSubClient.connectionInitialized(self)
-
-    async def items(
-        self,
-        service: Optional[jid.JID],
-        nodeIdentifier: str,
-        maxItems: Optional[int] = None,
-        subscriptionIdentifier: Optional[str] = None,
-        sender: Optional[jid.JID] = None,
-        itemIdentifiers: Optional[Set[str]] = None,
-        orderBy: Optional[List[str]] = None,
-        rsm_request: Optional[rsm.RSMRequest] = None,
-        extra: Optional[Dict[str, Any]] = None,
-    ):
-        if extra is None:
-            extra = {}
-        items, rsm_response = await super().items(
-            service, nodeIdentifier, maxItems, subscriptionIdentifier, sender,
-            itemIdentifiers, orderBy, rsm_request
-        )
-        # items must be returned, thus this async point can't stop the workflow (but it
-        # can modify returned items)
-        await self.host.trigger.async_point(
-            "XEP-0060_items", self.parent, service, nodeIdentifier, items, rsm_response,
-            extra
-        )
-        return items, rsm_response
-
-    def _get_node_callbacks(self, node, event):
-        """Generate callbacks from given node and event
-
-        @param node(unicode): node used for the item
-            any registered node which prefix the node will match
-        @param event(unicode): one of C.PS_ITEMS, C.PS_RETRACT, C.PS_DELETE
-        @return (iterator[callable]): callbacks for this node/event
-        """
-        for registered_node, callbacks_dict in self.parent_plugin._node_cb.items():
-            if not node.startswith(registered_node):
-                continue
-            try:
-                for callback_data in callbacks_dict[event]:
-                    yield callback_data[0]
-            except KeyError:
-                continue
-
-    async def _call_node_callbacks(self, client, event: pubsub.ItemsEvent) -> None:
-        """Call sequencially event callbacks of a node
-
-        Callbacks are called sequencially and not in parallel to be sure to respect
-        priority (notably for plugin needing to get old items before they are modified or
-        deleted from cache).
-        """
-        for callback in self._get_node_callbacks(event.nodeIdentifier, C.PS_ITEMS):
-            try:
-                await utils.as_deferred(callback, client, event)
-            except Exception as e:
-                log.error(
-                    f"Error while running items event callback {callback}: {e}"
-                )
-
-    def itemsReceived(self, event):
-        log.debug("Pubsub items received")
-        client = self.parent
-        defer.ensureDeferred(self._call_node_callbacks(client, event))
-        if (event.sender, event.nodeIdentifier) in client.pubsub_watching:
-            raw_items = [i.toXml() for i in event.items]
-            self.host.bridge.ps_event_raw(
-                event.sender.full(),
-                event.nodeIdentifier,
-                C.PS_ITEMS,
-                raw_items,
-                client.profile,
-            )
-
-    def deleteReceived(self, event):
-        log.debug(("Publish node deleted"))
-        for callback in self._get_node_callbacks(event.nodeIdentifier, C.PS_DELETE):
-            d = utils.as_deferred(callback, self.parent, event)
-            d.addErrback(lambda f: log.error(
-                f"Error while running delete event callback {callback}: {f}"
-            ))
-        client = self.parent
-        if (event.sender, event.nodeIdentifier) in client.pubsub_watching:
-            self.host.bridge.ps_event_raw(
-                event.sender.full(), event.nodeIdentifier, C.PS_DELETE, [], client.profile
-            )
-
-    def purgeReceived(self, event):
-        log.debug(("Publish node purged"))
-        for callback in self._get_node_callbacks(event.nodeIdentifier, C.PS_PURGE):
-            d = utils.as_deferred(callback, self.parent, event)
-            d.addErrback(lambda f: log.error(
-                f"Error while running purge event callback {callback}: {f}"
-            ))
-        client = self.parent
-        if (event.sender, event.nodeIdentifier) in client.pubsub_watching:
-            self.host.bridge.ps_event_raw(
-                event.sender.full(), event.nodeIdentifier, C.PS_PURGE, [], client.profile
-            )
-
-    def subscriptions(self, service, nodeIdentifier, sender=None):
-        """Return the list of subscriptions to the given service and node.
-
-        @param service: The publish subscribe service to retrieve the subscriptions from.
-        @type service: L{JID<twisted.words.protocols.jabber.jid.JID>}
-        @param nodeIdentifier: The identifier of the node (leave empty to retrieve all subscriptions).
-        @type nodeIdentifier: C{unicode}
-        @return (list[pubsub.Subscription]): list of subscriptions
-        """
-        request = pubsub.PubSubRequest("subscriptions")
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-        request.sender = sender
-        d = request.send(self.xmlstream)
-
-        def cb(iq):
-            subs = []
-            for subscription_elt in iq.pubsub.subscriptions.elements(
-                pubsub.NS_PUBSUB, "subscription"
-            ):
-                subscription = pubsub.Subscription(
-                    subscription_elt["node"],
-                    jid.JID(subscription_elt["jid"]),
-                    subscription_elt["subscription"],
-                    subscriptionIdentifier=subscription_elt.getAttribute("subid"),
-                )
-                subs.append(subscription)
-            return subs
-
-        return d.addCallback(cb)
-
-    def purge_node(self, service, nodeIdentifier):
-        """Purge a node (i.e. delete all items from it)
-
-        @param service(jid.JID, None): service to send the item to
-            None to use PEP
-        @param NodeIdentifier(unicode): PubSub node to use
-        """
-        # TODO: propose this upstream and remove it once merged
-        request = pubsub.PubSubRequest('purge')
-        request.recipient = service
-        request.nodeIdentifier = nodeIdentifier
-        return request.send(self.xmlstream)
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        disco_info = []
-        self.host.trigger.point("PubSub Disco Info", disco_info, self.parent.profile)
-        return disco_info
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0065.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1396 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing xep-0065
-
-# Copyright (C)
-# 2002, 2003, 2004   Dave Smith (dizzyd@jabber.org)
-# 2007, 2008         Fabio Forno (xmpp:ff@jabber.bluendo.com)
-# 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-# --
-
-# This module is based on proxy65 (http://code.google.com/p/proxy65),
-# originaly written by David Smith and modified by Fabio Forno.
-# It is sublicensed under AGPL v3 (or any later version) as allowed by the original
-# license.
-
-# --
-
-# Here is a copy of the original license:
-
-# Copyright (C)
-# 2002-2004   Dave Smith (dizzyd@jabber.org)
-# 2007-2008   Fabio Forno (xmpp:ff@jabber.bluendo.com)
-
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-
-# The above copyright notice and this permission notice shall be included in
-# all copies or substantial portions of the Software.
-
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-# THE SOFTWARE.
-
-import struct
-import hashlib
-import uuid
-from collections import namedtuple
-from zope.interface import implementer
-from twisted.internet import protocol
-from twisted.internet import reactor
-from twisted.internet import error as internet_error
-from twisted.words.protocols.jabber import error as jabber_error
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber import xmlstream
-from twisted.internet import defer
-from wokkel import disco, iwokkel
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.tools import sat_defer
-
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP 0065 Plugin",
-    C.PI_IMPORT_NAME: "XEP-0065",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0065"],
-    C.PI_DEPENDENCIES: ["IP"],
-    C.PI_RECOMMENDATIONS: ["NAT-PORT"],
-    C.PI_MAIN: "XEP_0065",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of SOCKS5 Bytestreams"""),
-}
-
-IQ_SET = '/iq[@type="set"]'
-NS_BS = "http://jabber.org/protocol/bytestreams"
-BS_REQUEST = IQ_SET + '/query[@xmlns="' + NS_BS + '"]'
-TIMER_KEY = "timer"
-DEFER_KEY = "finished"  # key of the deferred used to track session end
-SERVER_STARTING_PORT = (
-    0
-)  # starting number for server port search (0 to ask automatic attribution)
-
-# priorities are candidates local priorities, must be a int between 0 and 65535
-PRIORITY_BEST_DIRECT = 10000
-PRIORITY_DIRECT = 5000
-PRIORITY_ASSISTED = 1000
-PRIORITY_PROXY = 0.2  # proxy is the last option for s5b
-CANDIDATE_DELAY = 0.2  # see XEP-0260 §4
-CANDIDATE_DELAY_PROXY = 0.2  # additional time for proxy types (see XEP-0260 §4 note 3)
-
-TIMEOUT = 300  # maxium time between session creation and stream start
-
-# XXX: by default eveything is automatic
-# TODO: use these params to force use of specific proxy/port/IP
-# PARAMS = """
-#     <params>
-#     <general>
-#     <category name="File Transfer">
-#         <param name="Force IP" type="string" />
-#         <param name="Force Port" type="int" constraint="1;65535" />
-#     </category>
-#     </general>
-#     <individual>
-#     <category name="File Transfer">
-#         <param name="Force Proxy" value="" type="string" />
-#         <param name="Force Proxy host" value="" type="string" />
-#         <param name="Force Proxy port" value="" type="int" constraint="1;65535" />
-#     </category>
-#     </individual>
-#     </params>
-#     """
-
-(
-    STATE_INITIAL,
-    STATE_AUTH,
-    STATE_REQUEST,
-    STATE_READY,
-    STATE_AUTH_USERPASS,
-    STATE_CLIENT_INITIAL,
-    STATE_CLIENT_AUTH,
-    STATE_CLIENT_REQUEST,
-) = range(8)
-
-SOCKS5_VER = 0x05
-
-ADDR_IPV4 = 0x01
-ADDR_DOMAINNAME = 0x03
-ADDR_IPV6 = 0x04
-
-CMD_CONNECT = 0x01
-CMD_BIND = 0x02
-CMD_UDPASSOC = 0x03
-
-AUTHMECH_ANON = 0x00
-AUTHMECH_USERPASS = 0x02
-AUTHMECH_INVALID = 0xFF
-
-REPLY_SUCCESS = 0x00
-REPLY_GENERAL_FAILUR = 0x01
-REPLY_CONN_NOT_ALLOWED = 0x02
-REPLY_NETWORK_UNREACHABLE = 0x03
-REPLY_HOST_UNREACHABLE = 0x04
-REPLY_CONN_REFUSED = 0x05
-REPLY_TTL_EXPIRED = 0x06
-REPLY_CMD_NOT_SUPPORTED = 0x07
-REPLY_ADDR_NOT_SUPPORTED = 0x08
-
-
-ProxyInfos = namedtuple("ProxyInfos", ["host", "jid", "port"])
-
-
-class Candidate(object):
-    def __init__(self, host, port, type_, priority, jid_, id_=None, priority_local=False,
-                 factory=None,):
-        """
-        @param host(unicode): host IP or domain
-        @param port(int): port
-        @param type_(unicode): stream type (one of XEP_0065.TYPE_*)
-        @param priority(int): priority
-        @param jid_(jid.JID): jid
-        @param id_(None, id_): Candidate ID, or None to generate
-        @param priority_local(bool): if True, priority is used as local priority,
-            else priority is used as global one (and local priority is set to 0)
-        """
-        assert isinstance(jid_, jid.JID)
-        self.host, self.port, self.type, self.jid = (host, int(port), type_, jid_)
-        self.id = id_ if id_ is not None else str(uuid.uuid4())
-        if priority_local:
-            self._local_priority = int(priority)
-            self._priority = self.calculate_priority()
-        else:
-            self._local_priority = 0
-            self._priority = int(priority)
-        self.factory = factory
-
-    def discard(self):
-        """Disconnect a candidate if it is connected
-
-        Used to disconnect tryed client when they are discarded
-        """
-        log.debug("Discarding {}".format(self))
-        try:
-            self.factory.discard()
-        except AttributeError:
-            pass  # no discard for Socks5ServerFactory
-
-    @property
-    def local_priority(self):
-        return self._local_priority
-
-    @property
-    def priority(self):
-        return self._priority
-
-    def __str__(self):
-        return "Candidate ({0.priority}): host={0.host} port={0.port} jid={0.jid} type={0.type}{id}".format(
-            self, id=" id={}".format(self.id if self.id is not None else "")
-        )
-
-    def __eq__(self, other):
-        # self.id is is not used in __eq__ as the same candidate can have
-        # different ids if proposed by initiator or responder
-        try:
-            return (
-                self.host == other.host
-                and self.port == other.port
-                and self.jid == other.jid
-            )
-        except (AttributeError, TypeError):
-            return False
-
-    def __ne__(self, other):
-        return not self.__eq__(other)
-
-    def calculate_priority(self):
-        """Calculate candidate priority according to XEP-0260 §2.2
-
-
-        @return (int): priority
-        """
-        if self.type == XEP_0065.TYPE_DIRECT:
-            multiplier = 126
-        elif self.type == XEP_0065.TYPE_ASSISTED:
-            multiplier = 120
-        elif self.type == XEP_0065.TYPE_TUNEL:
-            multiplier = 110
-        elif self.type == XEP_0065.TYPE_PROXY:
-            multiplier = 10
-        else:
-            raise exceptions.InternalError("Unknown {} type !".format(self.type))
-        return 2 ** 16 * multiplier + self._local_priority
-
-    def activate(self, client, sid, peer_jid, local_jid):
-        """Activate the proxy candidate
-
-        Send activation request as explained in XEP-0065 § 6.3.5
-        Must only be used with proxy candidates
-        @param sid(unicode): session id (same as for get_session_hash)
-        @param peer_jid(jid.JID): jid of the other peer
-        @return (D(domish.Element)): IQ result (or error)
-        """
-        assert self.type == XEP_0065.TYPE_PROXY
-        iq_elt = client.IQ()
-        iq_elt["from"] = local_jid.full()
-        iq_elt["to"] = self.jid.full()
-        query_elt = iq_elt.addElement((NS_BS, "query"))
-        query_elt["sid"] = sid
-        query_elt.addElement("activate", content=peer_jid.full())
-        return iq_elt.send()
-
-    def start_transfer(self, session_hash=None):
-        if self.type == XEP_0065.TYPE_PROXY:
-            chunk_size = 4096  # Prosody's proxy reject bigger chunks by default
-        else:
-            chunk_size = None
-        self.factory.start_transfer(session_hash, chunk_size=chunk_size)
-
-
-def get_session_hash(requester_jid, target_jid, sid):
-    """Calculate SHA1 Hash according to XEP-0065 §5.3.2
-
-    @param requester_jid(jid.JID): jid of the requester (the one which activate the proxy)
-    @param target_jid(jid.JID): jid of the target
-    @param sid(unicode): session id
-    @return (str): hash
-    """
-    return hashlib.sha1(
-        (sid + requester_jid.full() + target_jid.full()).encode("utf-8")
-    ).hexdigest()
-
-
-class SOCKSv5(protocol.Protocol):
-    CHUNK_SIZE = 2 ** 16
-
-    def __init__(self, session_hash=None):
-        """
-        @param session_hash(str): hash of the session
-            must only be used in client mode
-        """
-        self.connection = defer.Deferred()  # called when connection/auth is done
-        if session_hash is not None:
-            assert isinstance(session_hash, str)
-            self.server_mode = False
-            self._session_hash = session_hash
-            self.state = STATE_CLIENT_INITIAL
-        else:
-            self.server_mode = True
-            self.state = STATE_INITIAL
-        self.buf = b""
-        self.supportedAuthMechs = [AUTHMECH_ANON]
-        self.supportedAddrs = [ADDR_DOMAINNAME]
-        self.enabledCommands = [CMD_CONNECT]
-        self.peersock = None
-        self.addressType = 0
-        self.requestType = 0
-        self._stream_object = None
-        self.active = False  # set to True when protocol is actually used for transfer
-        # used by factories to know when the finished Deferred can be triggered
-
-    @property
-    def stream_object(self):
-        if self._stream_object is None:
-            self._stream_object = self.getSession()["stream_object"]
-            if self.server_mode:
-                self._stream_object.registerProducer(self.transport, True)
-        return self._stream_object
-
-    def getSession(self):
-        """Return session associated with this candidate
-
-        @return (dict): session data
-        """
-        if self.server_mode:
-            return self.factory.getSession(self._session_hash)
-        else:
-            return self.factory.getSession()
-
-    def _start_negotiation(self):
-        log.debug("starting negotiation (client mode)")
-        self.state = STATE_CLIENT_AUTH
-        self.transport.write(struct.pack("!3B", SOCKS5_VER, 1, AUTHMECH_ANON))
-
-    def _parse_negotiation(self):
-        try:
-            # Parse out data
-            ver, nmethod = struct.unpack("!BB", self.buf[:2])
-            methods = struct.unpack("%dB" % nmethod, self.buf[2 : nmethod + 2])
-
-            # Ensure version is correct
-            if ver != 5:
-                self.transport.write(struct.pack("!BB", SOCKS5_VER, AUTHMECH_INVALID))
-                self.transport.loseConnection()
-                return
-
-            # Trim off front of the buffer
-            self.buf = self.buf[nmethod + 2 :]
-
-            # Check for supported auth mechs
-            for m in self.supportedAuthMechs:
-                if m in methods:
-                    # Update internal state, according to selected method
-                    if m == AUTHMECH_ANON:
-                        self.state = STATE_REQUEST
-                    elif m == AUTHMECH_USERPASS:
-                        self.state = STATE_AUTH_USERPASS
-                    # Complete negotiation w/ this method
-                    self.transport.write(struct.pack("!BB", SOCKS5_VER, m))
-                    return
-
-            # No supported mechs found, notify client and close the connection
-            log.warning("Unsupported authentication mechanism")
-            self.transport.write(struct.pack("!BB", SOCKS5_VER, AUTHMECH_INVALID))
-            self.transport.loseConnection()
-        except struct.error:
-            pass
-
-    def _parse_user_pass(self):
-        try:
-            # Parse out data
-            ver, ulen = struct.unpack("BB", self.buf[:2])
-            uname, = struct.unpack("%ds" % ulen, self.buf[2 : ulen + 2])
-            plen, = struct.unpack("B", self.buf[ulen + 2])
-            password, = struct.unpack("%ds" % plen, self.buf[ulen + 3 : ulen + 3 + plen])
-            # Trim off fron of the buffer
-            self.buf = self.buf[3 + ulen + plen :]
-            # Fire event to authenticate user
-            if self.authenticate_user_pass(uname, password):
-                # Signal success
-                self.state = STATE_REQUEST
-                self.transport.write(struct.pack("!BB", SOCKS5_VER, 0x00))
-            else:
-                # Signal failure
-                self.transport.write(struct.pack("!BB", SOCKS5_VER, 0x01))
-                self.transport.loseConnection()
-        except struct.error:
-            pass
-
-    def send_error_reply(self, errorcode):
-        # Any other address types are not supported
-        result = struct.pack("!BBBBIH", SOCKS5_VER, errorcode, 0, 1, 0, 0)
-        self.transport.write(result)
-        self.transport.loseConnection()
-
-    def _parseRequest(self):
-        try:
-            # Parse out data and trim buffer accordingly
-            ver, cmd, rsvd, self.addressType = struct.unpack("!BBBB", self.buf[:4])
-
-            # Ensure we actually support the requested address type
-            if self.addressType not in self.supportedAddrs:
-                self.send_error_reply(REPLY_ADDR_NOT_SUPPORTED)
-                return
-
-            # Deal with addresses
-            if self.addressType == ADDR_IPV4:
-                addr, port = struct.unpack("!IH", self.buf[4:10])
-                self.buf = self.buf[10:]
-            elif self.addressType == ADDR_DOMAINNAME:
-                nlen = self.buf[4]
-                addr, port = struct.unpack("!%dsH" % nlen, self.buf[5:])
-                self.buf = self.buf[7 + len(addr) :]
-            else:
-                # Any other address types are not supported
-                self.send_error_reply(REPLY_ADDR_NOT_SUPPORTED)
-                return
-
-            # Ensure command is supported
-            if cmd not in self.enabledCommands:
-                # Send a not supported error
-                self.send_error_reply(REPLY_CMD_NOT_SUPPORTED)
-                return
-
-            # Process the command
-            if cmd == CMD_CONNECT:
-                self.connect_requested(addr, port)
-            elif cmd == CMD_BIND:
-                self.bind_requested(addr, port)
-            else:
-                # Any other command is not supported
-                self.send_error_reply(REPLY_CMD_NOT_SUPPORTED)
-
-        except struct.error:
-            # The buffer is probably not complete, we need to wait more
-            return None
-
-    def _make_request(self):
-        hash_ = self._session_hash.encode('utf-8')
-        request = struct.pack(
-            "!5B%dsH" % len(hash_),
-            SOCKS5_VER,
-            CMD_CONNECT,
-            0,
-            ADDR_DOMAINNAME,
-            len(hash_),
-            hash_,
-            0,
-        )
-        self.transport.write(request)
-        self.state = STATE_CLIENT_REQUEST
-
-    def _parse_request_reply(self):
-        try:
-            ver, rep, rsvd, self.addressType = struct.unpack("!BBBB", self.buf[:4])
-            # Ensure we actually support the requested address type
-            if self.addressType not in self.supportedAddrs:
-                self.send_error_reply(REPLY_ADDR_NOT_SUPPORTED)
-                return
-
-            # Deal with addresses
-            if self.addressType == ADDR_IPV4:
-                addr, port = struct.unpack("!IH", self.buf[4:10])
-                self.buf = self.buf[10:]
-            elif self.addressType == ADDR_DOMAINNAME:
-                nlen = self.buf[4]
-                addr, port = struct.unpack("!%dsH" % nlen, self.buf[5:])
-                self.buf = self.buf[7 + len(addr) :]
-            else:
-                # Any other address types are not supported
-                self.send_error_reply(REPLY_ADDR_NOT_SUPPORTED)
-                return
-
-            # Ensure reply is OK
-            if rep != REPLY_SUCCESS:
-                self.loseConnection()
-                return
-
-            self.state = STATE_READY
-            self.connection.callback(None)
-
-        except struct.error:
-            # The buffer is probably not complete, we need to wait more
-            return None
-
-    def connectionMade(self):
-        log.debug(
-            "Socks5 connectionMade (mode = {})".format(
-                "server" if self.state == STATE_INITIAL else "client"
-            )
-        )
-        if self.state == STATE_CLIENT_INITIAL:
-            self._start_negotiation()
-
-    def connect_requested(self, addr, port):
-        # Check that this session is expected
-        if not self.factory.add_to_session(addr.decode('utf-8'), self):
-            log.warning(
-                "Unexpected connection request received from {host}".format(
-                    host=self.transport.getPeer().host
-                )
-            )
-            self.send_error_reply(REPLY_CONN_REFUSED)
-            return
-        self._session_hash = addr.decode('utf-8')
-        self.connect_completed(addr, 0)
-
-    def start_transfer(self, chunk_size):
-        """Callback called when the result iq is received
-
-        @param chunk_size(None, int): size of the buffer, or None for default
-        """
-        self.active = True
-        if chunk_size is not None:
-            self.CHUNK_SIZE = chunk_size
-        log.debug("Starting file transfer")
-        d = self.stream_object.start_stream(self.transport)
-        d.addCallback(self.stream_finished)
-
-    def stream_finished(self, d):
-        log.info(_("File transfer completed, closing connection"))
-        self.transport.loseConnection()
-
-    def connect_completed(self, remotehost, remoteport):
-        if self.addressType == ADDR_IPV4:
-            result = struct.pack(
-                "!BBBBIH", SOCKS5_VER, REPLY_SUCCESS, 0, 1, remotehost, remoteport
-            )
-        elif self.addressType == ADDR_DOMAINNAME:
-            result = struct.pack(
-                "!BBBBB%dsH" % len(remotehost),
-                SOCKS5_VER,
-                REPLY_SUCCESS,
-                0,
-                ADDR_DOMAINNAME,
-                len(remotehost),
-                remotehost,
-                remoteport,
-            )
-        self.transport.write(result)
-        self.state = STATE_READY
-
-    def bind_requested(self, addr, port):
-        pass
-
-    def authenticate_user_pass(self, user, passwd):
-        # FIXME: implement authentication and remove the debug printing a password
-        log.debug("User/pass: %s/%s" % (user, passwd))
-        return True
-
-    def dataReceived(self, buf):
-        if self.state == STATE_READY:
-            # Everything is set, we just have to write the incoming data
-            self.stream_object.write(buf)
-            if not self.active:
-                self.active = True
-                self.getSession()[TIMER_KEY].cancel()
-            return
-
-        self.buf = self.buf + buf
-        if self.state == STATE_INITIAL:
-            self._parse_negotiation()
-        if self.state == STATE_AUTH_USERPASS:
-            self._parse_user_pass()
-        if self.state == STATE_REQUEST:
-            self._parseRequest()
-        if self.state == STATE_CLIENT_REQUEST:
-            self._parse_request_reply()
-        if self.state == STATE_CLIENT_AUTH:
-            ver, method = struct.unpack("!BB", buf)
-            self.buf = self.buf[2:]
-            if ver != SOCKS5_VER or method != AUTHMECH_ANON:
-                self.transport.loseConnection()
-            else:
-                self._make_request()
-
-    def connectionLost(self, reason):
-        log.debug("Socks5 connection lost: {}".format(reason.value))
-        if self.state != STATE_READY:
-            self.connection.errback(reason)
-        if self.server_mode:
-            try:
-                session_hash = self._session_hash
-            except AttributeError:
-                log.debug("no session has been received yet")
-            else:
-                self.factory.remove_from_session(session_hash, self, reason)
-
-
-class Socks5ServerFactory(protocol.ServerFactory):
-    protocol = SOCKSv5
-
-    def __init__(self, parent):
-        """
-        @param parent(XEP_0065): XEP_0065 parent instance
-        """
-        self.parent = parent
-
-    def getSession(self, session_hash):
-        return self.parent.getSession(None, session_hash)
-
-    def start_transfer(self, session_hash, chunk_size=None):
-        session = self.getSession(session_hash)
-        try:
-            protocol = session["protocols"][0]
-        except (KeyError, IndexError):
-            log.error("Can't start file transfer, can't find protocol")
-        else:
-            session[TIMER_KEY].cancel()
-            protocol.start_transfer(chunk_size)
-
-    def add_to_session(self, session_hash, protocol):
-        """Check is session_hash is valid, and associate protocol with it
-
-        the session will be associated to the corresponding candidate
-        @param session_hash(str): hash of the session
-        @param protocol(SOCKSv5): protocol instance
-        @param return(bool): True if hash was valid (i.e. expected), False else
-        """
-        assert isinstance(session_hash, str)
-        try:
-            session_data = self.getSession(session_hash)
-        except KeyError:
-            return False
-        else:
-            session_data.setdefault("protocols", []).append(protocol)
-            return True
-
-    def remove_from_session(self, session_hash, protocol, reason):
-        """Remove a protocol from session_data
-
-        There can be several protocol instances while candidates are tried, they
-        have removed when candidate connection is closed
-        @param session_hash(str): hash of the session
-        @param protocol(SOCKSv5): protocol instance
-        @param reason(failure.Failure): reason of the removal
-        """
-        try:
-            protocols = self.getSession(session_hash)["protocols"]
-            protocols.remove(protocol)
-        except (KeyError, ValueError):
-            log.error("Protocol not found in session while it should be there")
-        else:
-            if protocol.active:
-                # The active protocol has been removed, session is finished
-                if reason.check(internet_error.ConnectionDone):
-                    self.getSession(session_hash)[DEFER_KEY].callback(None)
-                else:
-                    self.getSession(session_hash)[DEFER_KEY].errback(reason)
-
-
-class Socks5ClientFactory(protocol.ClientFactory):
-    protocol = SOCKSv5
-
-    def __init__(self, client, parent, session, session_hash):
-        """Init the Client Factory
-
-        @param session(dict): session data
-        @param session_hash(unicode): hash used for peer_connection
-            hash is the same as hostname computed in XEP-0065 § 5.3.2 #1
-        """
-        self.session = session
-        self.session_hash = session_hash
-        self.client = client
-        self.connection = defer.Deferred()
-        self._protocol_instance = None
-        self.connector = None
-
-    def discard(self):
-        """Disconnect the client
-
-        Also set a discarded flag, which avoid to call the session Deferred
-        """
-        self.connector.disconnect()
-
-    def getSession(self):
-        return self.session
-
-    def start_transfer(self, __=None, chunk_size=None):
-        self.session[TIMER_KEY].cancel()
-        self._protocol_instance.start_transfer(chunk_size)
-
-    def clientConnectionFailed(self, connector, reason):
-        log.debug("Connection failed")
-        self.connection.errback(reason)
-
-    def clientConnectionLost(self, connector, reason):
-        log.debug(_("Socks 5 client connection lost (reason: %s)") % reason.value)
-        if self._protocol_instance.active:
-            # This one was used for the transfer, than mean that
-            # the Socks5 session is finished
-            if reason.check(internet_error.ConnectionDone):
-                self.getSession()[DEFER_KEY].callback(None)
-            else:
-                self.getSession()[DEFER_KEY].errback(reason)
-        self._protocol_instance = None
-
-    def buildProtocol(self, addr):
-        log.debug(("Socks 5 client connection started"))
-        p = self.protocol(session_hash=self.session_hash)
-        p.factory = self
-        p.connection.chainDeferred(self.connection)
-        self._protocol_instance = p
-        return p
-
-
-class XEP_0065(object):
-    NAMESPACE = NS_BS
-    TYPE_DIRECT = "direct"
-    TYPE_ASSISTED = "assisted"
-    TYPE_TUNEL = "tunel"
-    TYPE_PROXY = "proxy"
-    Candidate = Candidate
-
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0065 initialization"))
-        self.host = host
-
-        # session data
-        self.hash_clients_map = {}  # key: hash of the transfer session, value: session data
-        self._cache_proxies = {}  # key: server jid, value: proxy data
-
-        # misc data
-        self._server_factory = None
-        self._external_port = None
-
-        # plugins shortcuts
-        self._ip = self.host.plugins["IP"]
-        try:
-            self._np = self.host.plugins["NAT-PORT"]
-        except KeyError:
-            log.debug("NAT Port plugin not available")
-            self._np = None
-
-        # parameters
-        # XXX: params are not used for now, but they may be used in the futur to force proxy/IP
-        # host.memory.update_params(PARAMS)
-
-    def get_handler(self, client):
-        return XEP_0065_handler(self)
-
-    def profile_connected(self, client):
-        client.xep_0065_sid_session = {}  # key: stream_id, value: session_data(dict)
-        client._s5b_sessions = {}
-
-    def get_session_hash(self, from_jid, to_jid, sid):
-        return get_session_hash(from_jid, to_jid, sid)
-
-    def get_socks_5_server_factory(self):
-        """Return server factory
-
-        The server is created if it doesn't exists yet
-        self._server_factory_port is set on server creation
-        """
-
-        if self._server_factory is None:
-            self._server_factory = Socks5ServerFactory(self)
-            for port in range(SERVER_STARTING_PORT, 65356):
-                try:
-                    listening_port = reactor.listenTCP(port, self._server_factory)
-                except internet_error.CannotListenError as e:
-                    log.debug(
-                        "Cannot listen on port {port}: {err_msg}{err_num}".format(
-                            port=port,
-                            err_msg=e.socketError.strerror,
-                            err_num=" (error code: {})".format(e.socketError.errno),
-                        )
-                    )
-                else:
-                    self._server_factory_port = listening_port.getHost().port
-                    break
-
-            log.info(
-                _("Socks5 Stream server launched on port {}").format(
-                    self._server_factory_port
-                )
-            )
-        return self._server_factory
-
-    @defer.inlineCallbacks
-    def get_proxy(self, client, local_jid):
-        """Return the proxy available for this profile
-
-        cache is used between clients using the same server
-        @param local_jid(jid.JID): same as for [get_candidates]
-        @return ((D)(ProxyInfos, None)): Found proxy infos,
-            or None if not acceptable proxy is found
-        @raise exceptions.NotFound: no Proxy found
-        """
-
-        def notFound(server):
-            log.info("No proxy found on this server")
-            self._cache_proxies[server] = None
-            raise exceptions.NotFound
-
-        server = client.host if client.is_component else client.jid.host
-        try:
-            defer.returnValue(self._cache_proxies[server])
-        except KeyError:
-            pass
-        try:
-            proxy = (
-                yield self.host.find_service_entities(client, "proxy", "bytestreams")
-            ).pop()
-        except (defer.CancelledError, StopIteration, KeyError):
-            notFound(server)
-        iq_elt = client.IQ("get")
-        iq_elt["from"] = local_jid.full()
-        iq_elt["to"] = proxy.full()
-        iq_elt.addElement((NS_BS, "query"))
-
-        try:
-            result_elt = yield iq_elt.send()
-        except jabber_error.StanzaError as failure:
-            log.warning(
-                "Error while requesting proxy info on {jid}: {error}".format(
-                    jid=proxy.full(), error=failure
-                )
-            )
-            notFound(server)
-
-        try:
-            query_elt = next(result_elt.elements(NS_BS, "query"))
-            streamhost_elt = next(query_elt.elements(NS_BS, "streamhost"))
-            host = streamhost_elt["host"]
-            jid_ = streamhost_elt["jid"]
-            port = streamhost_elt["port"]
-            if not all((host, jid, port)):
-                raise KeyError
-            jid_ = jid.JID(jid_)
-        except (StopIteration, KeyError, RuntimeError, jid.InvalidFormat, AttributeError):
-            log.warning("Invalid proxy data received from {}".format(proxy.full()))
-            notFound(server)
-
-        proxy_infos = self._cache_proxies[server] = ProxyInfos(host, jid_, port)
-        log.info("Proxy found: {}".format(proxy_infos))
-        defer.returnValue(proxy_infos)
-
-    @defer.inlineCallbacks
-    def _get_network_data(self, client):
-        """Retrieve information about network
-
-        @param client: %(doc_client)s
-        @return (D(tuple[local_port, external_port, local_ips, external_ip])): network data
-        """
-        self.get_socks_5_server_factory()
-        local_port = self._server_factory_port
-        external_ip = yield self._ip.get_external_ip(client)
-        local_ips = yield self._ip.get_local_i_ps(client)
-
-        if external_ip is not None and self._external_port is None:
-            if external_ip != local_ips[0]:
-                log.info("We are probably behind a NAT")
-                if self._np is None:
-                    log.warning("NAT port plugin not available, we can't map port")
-                else:
-                    ext_port = yield self._np.map_port(
-                        local_port, desc="SaT socks5 stream"
-                    )
-                    if ext_port is None:
-                        log.warning("Can't map NAT port")
-                    else:
-                        self._external_port = ext_port
-
-        defer.returnValue((local_port, self._external_port, local_ips, external_ip))
-
-    @defer.inlineCallbacks
-    def get_candidates(self, client, local_jid):
-        """Return a list of our stream candidates
-
-        @param local_jid(jid.JID): jid to use as local jid
-            This is needed for client which can be addressed with a different jid than
-            client.jid if a local part is used (e.g. piotr@file.example.net where
-            client.jid would be file.example.net)
-        @return (D(list[Candidate])): list of candidates, ordered by priority
-        """
-        server_factory = yield self.get_socks_5_server_factory()
-        local_port, ext_port, local_ips, external_ip = yield self._get_network_data(client)
-        try:
-            proxy = yield self.get_proxy(client, local_jid)
-        except exceptions.NotFound:
-            proxy = None
-
-        # its time to gather the candidates
-        candidates = []
-
-        # first the direct ones
-
-        # the preferred direct connection
-        ip = local_ips.pop(0)
-        candidates.append(
-            Candidate(
-                ip,
-                local_port,
-                XEP_0065.TYPE_DIRECT,
-                PRIORITY_BEST_DIRECT,
-                local_jid,
-                priority_local=True,
-                factory=server_factory,
-            )
-        )
-        for ip in local_ips:
-            candidates.append(
-                Candidate(
-                    ip,
-                    local_port,
-                    XEP_0065.TYPE_DIRECT,
-                    PRIORITY_DIRECT,
-                    local_jid,
-                    priority_local=True,
-                    factory=server_factory,
-                )
-            )
-
-        # then the assisted one
-        if ext_port is not None:
-            candidates.append(
-                Candidate(
-                    external_ip,
-                    ext_port,
-                    XEP_0065.TYPE_ASSISTED,
-                    PRIORITY_ASSISTED,
-                    local_jid,
-                    priority_local=True,
-                    factory=server_factory,
-                )
-            )
-
-        # finally the proxy
-        if proxy:
-            candidates.append(
-                Candidate(
-                    proxy.host,
-                    proxy.port,
-                    XEP_0065.TYPE_PROXY,
-                    PRIORITY_PROXY,
-                    proxy.jid,
-                    priority_local=True,
-                )
-            )
-
-        # should be already sorted, but just in case the priorities get weird
-        candidates.sort(key=lambda c: c.priority, reverse=True)
-        defer.returnValue(candidates)
-
-    def _add_connector(self, connector, candidate):
-        """Add connector used to connect to candidate, and return client factory's connection Deferred
-
-        the connector can be used to disconnect the candidate, and returning the factory's connection Deferred allow to wait for connection completion
-        @param connector: a connector implementing IConnector
-        @param candidate(Candidate): candidate linked to the connector
-        @return (D): Deferred fired when factory connection is done or has failed
-        """
-        candidate.factory.connector = connector
-        return candidate.factory.connection
-
-    def connect_candidate(
-        self, client, candidate, session_hash, peer_session_hash=None, delay=None
-    ):
-        """Connect to a candidate
-
-        Connection will be done with a Socks5ClientFactory
-        @param candidate(Candidate): candidate to connect to
-        @param session_hash(unicode): hash of the session
-            hash is the same as hostname computed in XEP-0065 § 5.3.2 #1
-        @param peer_session_hash(unicode, None): hash used with the peer
-            None to use session_hash.
-            None must be used in 2 cases:
-                - when XEP-0065 is used with XEP-0096
-                - when a peer connect to a proxy *he proposed himself*
-            in practice, peer_session_hash is only used by try_candidates
-        @param delay(None, float): optional delay to wait before connection, in seconds
-        @return (D): Deferred launched when TCP connection + Socks5 connection is done
-        """
-        if peer_session_hash is None:
-            # for XEP-0065, only one hash is needed
-            peer_session_hash = session_hash
-        session = self.getSession(client, session_hash)
-        factory = Socks5ClientFactory(client, self, session, peer_session_hash)
-        candidate.factory = factory
-        if delay is None:
-            d = defer.succeed(candidate.host)
-        else:
-            d = sat_defer.DelayedDeferred(delay, candidate.host)
-        d.addCallback(reactor.connectTCP, candidate.port, factory)
-        d.addCallback(self._add_connector, candidate)
-        return d
-
-    def try_candidates(
-        self,
-        client,
-        candidates,
-        session_hash,
-        peer_session_hash,
-        connection_cb=None,
-        connection_eb=None,
-    ):
-        defers_list = []
-
-        for candidate in candidates:
-            delay = CANDIDATE_DELAY * len(defers_list)
-            if candidate.type == XEP_0065.TYPE_PROXY:
-                delay += CANDIDATE_DELAY_PROXY
-            d = self.connect_candidate(
-                client, candidate, session_hash, peer_session_hash, delay
-            )
-            if connection_cb is not None:
-                d.addCallback(
-                    lambda __, candidate=candidate, client=client: connection_cb(
-                        client, candidate
-                    )
-                )
-            if connection_eb is not None:
-                d.addErrback(connection_eb, client, candidate)
-            defers_list.append(d)
-
-        return defers_list
-
-    def get_best_candidate(self, client, candidates, session_hash, peer_session_hash=None):
-        """Get best candidate (according to priority) which can connect
-
-        @param candidates(iterable[Candidate]): candidates to test
-        @param session_hash(unicode): hash of the session
-            hash is the same as hostname computed in XEP-0065 § 5.3.2 #1
-        @param peer_session_hash(unicode, None): hash of the other peer
-            only useful for XEP-0260, must be None for XEP-0065 streamhost candidates
-        @return (D(None, Candidate)): best candidate or None if none can connect
-        """
-        defer_candidates = None
-
-        def connection_cb(client, candidate):
-            log.info("Connection of {} successful".format(str(candidate)))
-            for idx, other_candidate in enumerate(candidates):
-                try:
-                    if other_candidate.priority < candidate.priority:
-                        log.debug("Cancelling {}".format(other_candidate))
-                        defer_candidates[idx].cancel()
-                except AttributeError:
-                    assert other_candidate is None
-
-        def connection_eb(failure, client, candidate):
-            if failure.check(defer.CancelledError):
-                log.debug("Connection of {} has been cancelled".format(candidate))
-            else:
-                log.info(
-                    "Connection of {candidate} Failed: {error}".format(
-                        candidate=candidate, error=failure.value
-                    )
-                )
-            candidates[candidates.index(candidate)] = None
-
-        def all_tested(__):
-            log.debug("All candidates have been tested")
-            good_candidates = [c for c in candidates if c]
-            return good_candidates[0] if good_candidates else None
-
-        defer_candidates = self.try_candidates(
-            client,
-            candidates,
-            session_hash,
-            peer_session_hash,
-            connection_cb,
-            connection_eb,
-        )
-        d_list = defer.DeferredList(defer_candidates)
-        d_list.addCallback(all_tested)
-        return d_list
-
-    def _time_out(self, session_hash, client):
-        """Called when stream was not started quickly enough
-
-        @param session_hash(str): hash as returned by get_session_hash
-        @param client: %(doc_client)s
-        """
-        log.info("Socks5 Bytestream: TimeOut reached")
-        session = self.getSession(client, session_hash)
-        session[DEFER_KEY].errback(exceptions.TimeOutError())
-
-    def kill_session(self, failure_, session_hash, sid, client):
-        """Clean the current session
-
-        @param session_hash(str): hash as returned by get_session_hash
-        @param sid(None, unicode): session id
-            or None if self.xep_0065_sid_session was not used
-        @param client: %(doc_client)s
-        @param failure_(None, failure.Failure): None if eveything was fine, a failure else
-        @return (None, failure.Failure): failure_ is returned
-        """
-        log.debug(
-            "Cleaning session with hash {hash}{id}: {reason}".format(
-                hash=session_hash,
-                reason="" if failure_ is None else failure_.value,
-                id="" if sid is None else " (id: {})".format(sid),
-            )
-        )
-
-        try:
-            assert self.hash_clients_map[session_hash] == client
-            del self.hash_clients_map[session_hash]
-        except KeyError:
-            pass
-
-        if sid is not None:
-            try:
-                del client.xep_0065_sid_session[sid]
-            except KeyError:
-                log.warning("Session id {} is unknown".format(sid))
-
-        try:
-            session_data = client._s5b_sessions[session_hash]
-        except KeyError:
-            log.warning("There is no session with this hash")
-            return
-        else:
-            del client._s5b_sessions[session_hash]
-
-        try:
-            session_data["timer"].cancel()
-        except (internet_error.AlreadyCalled, internet_error.AlreadyCancelled):
-            pass
-
-        return failure_
-
-    def start_stream(self, client, stream_object, local_jid, to_jid, sid):
-        """Launch the stream workflow
-
-        @param streamProducer: stream_object to use
-        @param local_jid(jid.JID): same as for [get_candidates]
-        @param to_jid: JID of the recipient
-        @param sid: Stream session id
-        @param successCb: method to call when stream successfuly finished
-        @param failureCb: method to call when something goes wrong
-        @return (D): Deferred fired when session is finished
-        """
-        session_data = self._create_session(
-            client, stream_object, local_jid, to_jid, sid, True)
-
-        session_data[client] = client
-
-        def got_candidates(candidates):
-            session_data["candidates"] = candidates
-            iq_elt = client.IQ()
-            iq_elt["from"] = local_jid.full()
-            iq_elt["to"] = to_jid.full()
-            query_elt = iq_elt.addElement((NS_BS, "query"))
-            query_elt["mode"] = "tcp"
-            query_elt["sid"] = sid
-
-            for candidate in candidates:
-                streamhost = query_elt.addElement("streamhost")
-                streamhost["host"] = candidate.host
-                streamhost["port"] = str(candidate.port)
-                streamhost["jid"] = candidate.jid.full()
-                log.debug("Candidate proposed: {}".format(candidate))
-
-            d = iq_elt.send()
-            args = [client, session_data, local_jid]
-            d.addCallbacks(self._iq_negotiation_cb, self._iq_negotiation_eb, args, None, args)
-
-        self.get_candidates(client, local_jid).addCallback(got_candidates)
-        return session_data[DEFER_KEY]
-
-    def _iq_negotiation_cb(self, iq_elt, client, session_data, local_jid):
-        """Called when the result of open iq is received
-
-        @param session_data(dict): data of the session
-        @param client: %(doc_client)s
-        @param iq_elt(domish.Element): <iq> result
-        """
-        try:
-            query_elt = next(iq_elt.elements(NS_BS, "query"))
-            streamhost_used_elt = next(query_elt.elements(NS_BS, "streamhost-used"))
-        except StopIteration:
-            log.warning("No streamhost found in stream query")
-            # FIXME: must clean session
-            return
-
-        streamhost_jid = jid.JID(streamhost_used_elt["jid"])
-        try:
-            candidate = next((
-                c for c in session_data["candidates"] if c.jid == streamhost_jid
-            ))
-        except StopIteration:
-            log.warning(
-                "Candidate [{jid}] is unknown !".format(jid=streamhost_jid.full())
-            )
-            return
-        else:
-            log.info("Candidate choosed by target: {}".format(candidate))
-
-        if candidate.type == XEP_0065.TYPE_PROXY:
-            log.info("A Socks5 proxy is used")
-            d = self.connect_candidate(client, candidate, session_data["hash"])
-            d.addCallback(
-                lambda __: candidate.activate(
-                    client, session_data["id"], session_data["peer_jid"], local_jid
-                )
-            )
-            d.addErrback(self._activation_eb)
-        else:
-            d = defer.succeed(None)
-
-        d.addCallback(lambda __: candidate.start_transfer(session_data["hash"]))
-
-    def _activation_eb(self, failure):
-        log.warning("Proxy activation error: {}".format(failure.value))
-
-    def _iq_negotiation_eb(self, stanza_err, client, session_data, local_jid):
-        log.warning("Socks5 transfer failed: {}".format(stanza_err.value))
-        # FIXME: must clean session
-
-    def create_session(self, *args, **kwargs):
-        """like [_create_session] but return the session deferred instead of the whole session
-
-        session deferred is fired when transfer is finished
-        """
-        return self._create_session(*args, **kwargs)[DEFER_KEY]
-
-    def _create_session(self, client, stream_object, local_jid, to_jid, sid,
-                       requester=False):
-        """Called when a bytestream is imminent
-
-        @param stream_object(iface.IStreamProducer): File object where data will be
-            written
-        @param to_jid(jid.JId): jid of the other peer
-        @param sid(unicode): session id
-        @param initiator(bool): if True, this session is create by initiator
-        @return (dict): session data
-        """
-        if sid in client.xep_0065_sid_session:
-            raise exceptions.ConflictError("A session with this id already exists !")
-        if requester:
-            session_hash = get_session_hash(local_jid, to_jid, sid)
-            session_data = self._register_hash(client, session_hash, stream_object)
-        else:
-            session_hash = get_session_hash(to_jid, local_jid, sid)
-            session_d = defer.Deferred()
-            session_d.addBoth(self.kill_session, session_hash, sid, client)
-            session_data = client._s5b_sessions[session_hash] = {
-                DEFER_KEY: session_d,
-                TIMER_KEY: reactor.callLater(
-                    TIMEOUT, self._time_out, session_hash, client
-                ),
-            }
-        client.xep_0065_sid_session[sid] = session_data
-        session_data.update(
-            {
-                "id": sid,
-                "local_jid": local_jid,
-                "peer_jid": to_jid,
-                "stream_object": stream_object,
-                "hash": session_hash,
-            }
-        )
-
-        return session_data
-
-    def getSession(self, client, session_hash):
-        """Return session data
-
-        @param session_hash(unicode): hash of the session
-            hash is the same as hostname computed in XEP-0065 § 5.3.2 #1
-        @param client(None, SatXMPPClient): client of the peer
-            None is used only if client is unknown (this is only the case
-            for incoming request received by Socks5ServerFactory). None must
-            only be used by Socks5ServerFactory.
-            See comments below for details
-        @return (dict): session data
-        """
-        assert isinstance(session_hash, str)
-        if client is None:
-            try:
-                client = self.hash_clients_map[session_hash]
-            except KeyError as e:
-                log.warning("The requested session doesn't exists !")
-                raise e
-        return client._s5b_sessions[session_hash]
-
-    def register_hash(self, *args, **kwargs):
-        """like [_register_hash] but return the session deferred instead of the whole session
-        session deferred is fired when transfer is finished
-        """
-        return self._register_hash(*args, **kwargs)[DEFER_KEY]
-
-    def _register_hash(self, client, session_hash, stream_object):
-        """Create a session_data associated to hash
-
-        @param session_hash(str): hash of the session
-        @param stream_object(iface.IStreamProducer, IConsumer, None): file-like object
-            None if it will be filled later
-        return (dict): session data
-        """
-        assert session_hash not in client._s5b_sessions
-        session_d = defer.Deferred()
-        session_d.addBoth(self.kill_session, session_hash, None, client)
-        session_data = client._s5b_sessions[session_hash] = {
-            DEFER_KEY: session_d,
-            TIMER_KEY: reactor.callLater(TIMEOUT, self._time_out, session_hash, client),
-        }
-
-        if stream_object is not None:
-            session_data["stream_object"] = stream_object
-
-        assert session_hash not in self.hash_clients_map
-        self.hash_clients_map[session_hash] = client
-
-        return session_data
-
-    def associate_stream_object(self, client, session_hash, stream_object):
-        """Associate a stream object with  a session"""
-        session_data = self.getSession(client, session_hash)
-        assert "stream_object" not in session_data
-        session_data["stream_object"] = stream_object
-
-    def stream_query(self, iq_elt, client):
-        log.debug("BS stream query")
-
-        iq_elt.handled = True
-
-        query_elt = next(iq_elt.elements(NS_BS, "query"))
-        try:
-            sid = query_elt["sid"]
-        except KeyError:
-            log.warning("Invalid bystreams request received")
-            return client.sendError(iq_elt, "bad-request")
-
-        streamhost_elts = list(query_elt.elements(NS_BS, "streamhost"))
-        if not streamhost_elts:
-            return client.sendError(iq_elt, "bad-request")
-
-        try:
-            session_data = client.xep_0065_sid_session[sid]
-        except KeyError:
-            log.warning("Ignoring unexpected BS transfer: {}".format(sid))
-            return client.sendError(iq_elt, "not-acceptable")
-
-        peer_jid = session_data["peer_jid"] = jid.JID(iq_elt["from"])
-
-        candidates = []
-        nb_sh = len(streamhost_elts)
-        for idx, sh_elt in enumerate(streamhost_elts):
-            try:
-                host, port, jid_ = sh_elt["host"], sh_elt["port"], jid.JID(sh_elt["jid"])
-            except KeyError:
-                log.warning("malformed streamhost element")
-                return client.sendError(iq_elt, "bad-request")
-            priority = nb_sh - idx
-            if jid_.userhostJID() != peer_jid.userhostJID():
-                type_ = XEP_0065.TYPE_PROXY
-            else:
-                type_ = XEP_0065.TYPE_DIRECT
-            candidates.append(Candidate(host, port, type_, priority, jid_))
-
-        for candidate in candidates:
-            log.info("Candidate proposed: {}".format(candidate))
-
-        d = self.get_best_candidate(client, candidates, session_data["hash"])
-        d.addCallback(self._ack_stream, iq_elt, session_data, client)
-
-    def _ack_stream(self, candidate, iq_elt, session_data, client):
-        if candidate is None:
-            log.info("No streamhost candidate worked, we have to end negotiation")
-            return client.sendError(iq_elt, "item-not-found")
-        log.info("We choose: {}".format(candidate))
-        result_elt = xmlstream.toResponse(iq_elt, "result")
-        query_elt = result_elt.addElement((NS_BS, "query"))
-        query_elt["sid"] = session_data["id"]
-        streamhost_used_elt = query_elt.addElement("streamhost-used")
-        streamhost_used_elt["jid"] = candidate.jid.full()
-        client.send(result_elt)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0065_handler(xmlstream.XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(
-            BS_REQUEST, self.plugin_parent.stream_query, client=self.parent
-        )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_BS)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0070.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,156 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing xep-0070
-# Copyright (C) 2009-2016 Geoffrey POUZET (chteufleur@kingpenguin.tk)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.protocols import jabber
-
-log = getLogger(__name__)
-from sat.tools import xml_tools
-
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-
-
-NS_HTTP_AUTH = "http://jabber.org/protocol/http-auth"
-
-IQ = "iq"
-IQ_GET = "/" + IQ + '[@type="get"]'
-IQ_HTTP_AUTH_REQUEST = IQ_GET + '/confirm[@xmlns="' + NS_HTTP_AUTH + '"]'
-
-MSG = "message"
-MSG_GET = "/" + MSG + '[@type="normal"]'
-MSG_HTTP_AUTH_REQUEST = MSG_GET + '/confirm[@xmlns="' + NS_HTTP_AUTH + '"]'
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP-0070 Plugin",
-    C.PI_IMPORT_NAME: "XEP-0070",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0070"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "XEP_0070",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of HTTP Requests via XMPP"""),
-}
-
-
-class XEP_0070(object):
-    """
-    Implementation for XEP 0070.
-    """
-
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0070 initialization"))
-        self.host = host
-        self._dictRequest = dict()
-
-    def get_handler(self, client):
-        return XEP_0070_handler(self, client.profile)
-
-    def on_http_auth_request_iq(self, iq_elt, client):
-        """This method is called on confirmation request received (XEP-0070 #4.5)
-
-        @param iq_elt: IQ element
-        @param client: %(doc_client)s
-        """
-        log.info(_("XEP-0070 Verifying HTTP Requests via XMPP (iq)"))
-        self._treat_http_auth_request(iq_elt, IQ, client)
-
-    def on_http_auth_request_msg(self, msg_elt, client):
-        """This method is called on confirmation request received (XEP-0070 #4.5)
-
-        @param msg_elt: message element
-        @param client: %(doc_client)s
-        """
-        log.info(_("XEP-0070 Verifying HTTP Requests via XMPP (message)"))
-        self._treat_http_auth_request(msg_elt, MSG, client)
-
-    def _treat_http_auth_request(self, elt, stanzaType, client):
-        elt.handled = True
-        auth_elt = next(elt.elements(NS_HTTP_AUTH, "confirm"))
-        auth_id = auth_elt["id"]
-        auth_method = auth_elt["method"]
-        auth_url = auth_elt["url"]
-        self._dictRequest[client] = (auth_id, auth_method, auth_url, stanzaType, elt)
-        title = D_("Auth confirmation")
-        message = D_("{auth_url} needs to validate your identity, do you agree?\n"
-                     "Validation code : {auth_id}\n\n"
-                     "Please check that this code is the same as on {auth_url}"
-                    ).format(auth_url=auth_url, auth_id=auth_id)
-        d = xml_tools.defer_confirm(self.host, message=message, title=title,
-            profile=client.profile)
-        d.addCallback(self._auth_request_callback, client)
-
-    def _auth_request_callback(self, authorized, client):
-        try:
-            auth_id, auth_method, auth_url, stanzaType, elt = self._dictRequest.pop(
-                client)
-        except KeyError:
-            authorized = False
-
-        if authorized:
-            if stanzaType == IQ:
-                # iq
-                log.debug(_("XEP-0070 reply iq"))
-                iq_result_elt = xmlstream.toResponse(elt, "result")
-                client.send(iq_result_elt)
-            elif stanzaType == MSG:
-                # message
-                log.debug(_("XEP-0070 reply message"))
-                msg_result_elt = xmlstream.toResponse(elt, "result")
-                msg_result_elt.addChild(next(elt.elements(NS_HTTP_AUTH, "confirm")))
-                client.send(msg_result_elt)
-        else:
-            log.debug(_("XEP-0070 reply error"))
-            result_elt = jabber.error.StanzaError("not-authorized").toResponse(elt)
-            client.send(result_elt)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0070_handler(XMPPHandler):
-
-    def __init__(self, plugin_parent, profile):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-        self.profile = profile
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(
-            IQ_HTTP_AUTH_REQUEST,
-            self.plugin_parent.on_http_auth_request_iq,
-            client=self.parent,
-        )
-        self.xmlstream.addObserver(
-            MSG_HTTP_AUTH_REQUEST,
-            self.plugin_parent.on_http_auth_request_msg,
-            client=self.parent,
-        )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_HTTP_AUTH)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0071.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,309 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Publish-Subscribe (xep-0071)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.tools.common import data_format
-
-from twisted.internet import defer
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-# from lxml import etree
-try:
-    from lxml import html
-except ImportError:
-    raise exceptions.MissingModule(
-        "Missing module lxml, please download/install it from http://lxml.de/"
-    )
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-
-NS_XHTML_IM = "http://jabber.org/protocol/xhtml-im"
-NS_XHTML = "http://www.w3.org/1999/xhtml"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XHTML-IM Plugin",
-    C.PI_IMPORT_NAME: "XEP-0071",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0071"],
-    C.PI_DEPENDENCIES: ["TEXT_SYNTAXES"],
-    C.PI_MAIN: "XEP_0071",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of XHTML-IM"""),
-}
-
-allowed = {
-    "a": set(["href", "style", "type"]),
-    "blockquote": set(["style"]),
-    "body": set(["style"]),
-    "br": set([]),
-    "cite": set(["style"]),
-    "em": set([]),
-    "img": set(["alt", "height", "src", "style", "width"]),
-    "li": set(["style"]),
-    "ol": set(["style"]),
-    "p": set(["style"]),
-    "span": set(["style"]),
-    "strong": set([]),
-    "ul": set(["style"]),
-}
-
-styles_allowed = [
-    "background-color",
-    "color",
-    "font-family",
-    "font-size",
-    "font-style",
-    "font-weight",
-    "margin-left",
-    "margin-right",
-    "text-align",
-    "text-decoration",
-]
-
-blacklist = ["script"]  # tag that we have to kill (we don't keep content)
-
-
-class XEP_0071(object):
-    SYNTAX_XHTML_IM = "XHTML-IM"
-
-    def __init__(self, host):
-        log.info(_("XHTML-IM plugin initialization"))
-        self.host = host
-        self._s = self.host.plugins["TEXT_SYNTAXES"]
-        self._s.add_syntax(
-            self.SYNTAX_XHTML_IM,
-            lambda xhtml: xhtml,
-            self.XHTML2XHTML_IM,
-            [self._s.OPT_HIDDEN],
-        )
-        host.trigger.add("message_received", self.message_received_trigger)
-        host.trigger.add("sendMessage", self.send_message_trigger)
-
-    def get_handler(self, client):
-        return XEP_0071_handler(self)
-
-    def _message_post_treat(self, data, message_elt, body_elts, client):
-        """Callback which manage the post treatment of the message in case of XHTML-IM found
-
-        @param data: data send by message_received trigger through post_treat deferred
-        @param message_elt: whole <message> stanza
-        @param body_elts: XHTML-IM body elements found
-        @return: the data with the extra parameter updated
-        """
-        # TODO: check if text only body is empty, then try to convert XHTML-IM to pure text and show a warning message
-        def converted(xhtml, lang):
-            if lang:
-                data["extra"]["xhtml_{}".format(lang)] = xhtml
-            else:
-                data["extra"]["xhtml"] = xhtml
-
-        defers = []
-        for body_elt in body_elts:
-            lang = body_elt.getAttribute((C.NS_XML, "lang"), "")
-            treat_d = defer.succeed(None)  #  deferred used for treatments
-            if self.host.trigger.point(
-                "xhtml_post_treat", client, message_elt, body_elt, lang, treat_d
-            ):
-                continue
-            treat_d.addCallback(
-                lambda __: self._s.convert(
-                    body_elt.toXml(), self.SYNTAX_XHTML_IM, safe=True
-                )
-            )
-            treat_d.addCallback(converted, lang)
-            defers.append(treat_d)
-
-        d_list = defer.DeferredList(defers)
-        d_list.addCallback(lambda __: data)
-        return d_list
-
-    def _fill_body_text(self, text, data, lang):
-        data["message"][lang or ""] = text
-        message_elt = data["xml"]
-        body_elt = message_elt.addElement("body", content=text)
-        if lang:
-            body_elt[(C.NS_XML, "lang")] = lang
-
-    def _check_body_text(self, data, lang, markup, syntax, defers):
-        """check if simple text message exists, and fill if needed"""
-        if not (lang or "") in data["message"]:
-            d = self._s.convert(markup, syntax, self._s.SYNTAX_TEXT)
-            d.addCallback(self._fill_body_text, data, lang)
-            defers.append(d)
-
-    def _send_message_add_rich(self, data, client):
-        """ Construct XHTML-IM node and add it XML element
-
-        @param data: message data as sended by sendMessage callback
-        """
-        # at this point, either ['extra']['rich'] or ['extra']['xhtml'] exists
-        # but both can't exist at the same time
-        message_elt = data["xml"]
-        html_elt = message_elt.addElement((NS_XHTML_IM, "html"))
-
-        def syntax_converted(xhtml_im, lang):
-            body_elt = html_elt.addElement((NS_XHTML, "body"))
-            if lang:
-                body_elt[(C.NS_XML, "lang")] = lang
-                data["extra"]["xhtml_{}".format(lang)] = xhtml_im
-            else:
-                data["extra"]["xhtml"] = xhtml_im
-            body_elt.addRawXml(xhtml_im)
-
-        syntax = self._s.get_current_syntax(client.profile)
-        defers = []
-        if "xhtml" in data["extra"]:
-            # we have directly XHTML
-            for lang, xhtml in data_format.get_sub_dict("xhtml", data["extra"]):
-                self._check_body_text(data, lang, xhtml, self._s.SYNTAX_XHTML, defers)
-                d = self._s.convert(xhtml, self._s.SYNTAX_XHTML, self.SYNTAX_XHTML_IM)
-                d.addCallback(syntax_converted, lang)
-                defers.append(d)
-        elif "rich" in data["extra"]:
-            # we have rich syntax to convert
-            for lang, rich_data in data_format.get_sub_dict("rich", data["extra"]):
-                self._check_body_text(data, lang, rich_data, syntax, defers)
-                d = self._s.convert(rich_data, syntax, self.SYNTAX_XHTML_IM)
-                d.addCallback(syntax_converted, lang)
-                defers.append(d)
-        else:
-            exceptions.InternalError("xhtml or rich should be present at this point")
-        d_list = defer.DeferredList(defers)
-        d_list.addCallback(lambda __: data)
-        return d_list
-
-    def message_received_trigger(self, client, message, post_treat):
-        """ Check presence of XHTML-IM in message
-        """
-        try:
-            html_elt = next(message.elements(NS_XHTML_IM, "html"))
-        except StopIteration:
-            # No XHTML-IM
-            pass
-        else:
-            body_elts = html_elt.elements(NS_XHTML, "body")
-            post_treat.addCallback(self._message_post_treat, message, body_elts, client)
-        return True
-
-    def send_message_trigger(self, client, data, pre_xml_treatments, post_xml_treatments):
-        """ Check presence of rich text in extra """
-        rich = {}
-        xhtml = {}
-        for key, value in data["extra"].items():
-            if key.startswith("rich"):
-                rich[key[5:]] = value
-            elif key.startswith("xhtml"):
-                xhtml[key[6:]] = value
-        if rich and xhtml:
-            raise exceptions.DataError(
-                _("Can't have XHTML and rich content at the same time")
-            )
-        if rich or xhtml:
-            if rich:
-                data["rich"] = rich
-            else:
-                data["xhtml"] = xhtml
-            post_xml_treatments.addCallback(self._send_message_add_rich, client)
-        return True
-
-    def _purge_style(self, styles_raw):
-        """ Remove unauthorised styles according to the XEP-0071
-        @param styles_raw: raw styles (value of the style attribute)
-        """
-        purged = []
-
-        styles = [style.strip().split(":") for style in styles_raw.split(";")]
-
-        for style_tuple in styles:
-            if len(style_tuple) != 2:
-                continue
-            name, value = style_tuple
-            name = name.strip()
-            if name not in styles_allowed:
-                continue
-            purged.append((name, value.strip()))
-
-        return "; ".join(["%s: %s" % data for data in purged])
-
-    def XHTML2XHTML_IM(self, xhtml):
-        """ Convert XHTML document to XHTML_IM subset
-        @param xhtml: raw xhtml to convert
-        """
-        # TODO: more clever tag replacement (replace forbidden tags with equivalents when possible)
-
-        parser = html.HTMLParser(remove_comments=True, encoding="utf-8")
-        root = html.fromstring(xhtml, parser=parser)
-        body_elt = root.find("body")
-        if body_elt is None:
-            # we use the whole XML as body if no body element is found
-            body_elt = html.Element("body")
-            body_elt.append(root)
-        else:
-            body_elt.attrib.clear()
-
-        allowed_tags = list(allowed.keys())
-        to_strip = []
-        for elem in body_elt.iter():
-            if elem.tag not in allowed_tags:
-                to_strip.append(elem)
-            else:
-                # we remove unallowed attributes
-                attrib = elem.attrib
-                att_to_remove = set(attrib).difference(allowed[elem.tag])
-                for att in att_to_remove:
-                    del (attrib[att])
-                if "style" in attrib:
-                    attrib["style"] = self._purge_style(attrib["style"])
-
-        for elem in to_strip:
-            if elem.tag in blacklist:
-                # we need to remove the element and all descendants
-                log.debug("removing black listed tag: %s" % (elem.tag))
-                elem.drop_tree()
-            else:
-                elem.drop_tag()
-        if len(body_elt) != 1:
-            root_elt = body_elt
-            body_elt.tag = "p"
-        else:
-            root_elt = body_elt[0]
-
-        return html.tostring(root_elt, encoding="unicode", method="xml")
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0071_handler(XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_XHTML_IM)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0077.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,312 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing xep-0077
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from twisted.words.protocols.jabber import jid, xmlstream, client, error as jabber_error
-from twisted.internet import defer, reactor, ssl
-from wokkel import data_form
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-from sat.core.xmpp import SatXMPPEntity
-from sat.tools import xml_tools
-
-log = getLogger(__name__)
-
-NS_REG = "jabber:iq:register"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP 0077 Plugin",
-    C.PI_IMPORT_NAME: "XEP-0077",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0077"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "XEP_0077",
-    C.PI_DESCRIPTION: _("""Implementation of in-band registration"""),
-}
-
-# FIXME: this implementation is incomplete
-
-
-class RegisteringAuthenticator(xmlstream.ConnectAuthenticator):
-    # FIXME: request IQ is not send to check available fields,
-    #        while XEP recommand to use it
-    # FIXME: doesn't handle data form or oob
-    namespace = 'jabber:client'
-
-    def __init__(self, jid_, password, email=None, check_certificate=True):
-        log.debug(_("Registration asked for {jid}").format(jid=jid_))
-        xmlstream.ConnectAuthenticator.__init__(self, jid_.host)
-        self.jid = jid_
-        self.password = password
-        self.email = email
-        self.check_certificate = check_certificate
-        self.registered = defer.Deferred()
-
-    def associateWithStream(self, xs):
-        xmlstream.ConnectAuthenticator.associateWithStream(self, xs)
-        xs.addObserver(xmlstream.STREAM_AUTHD_EVENT, self.register)
-
-        xs.initializers = [client.CheckVersionInitializer(xs)]
-        if self.check_certificate:
-            tls_required, configurationForTLS = True, None
-        else:
-            tls_required = False
-            configurationForTLS = ssl.CertificateOptions(trustRoot=None)
-        tls_init = xmlstream.TLSInitiatingInitializer(
-            xs, required=tls_required, configurationForTLS=configurationForTLS)
-
-        xs.initializers.append(tls_init)
-
-    def register(self, xmlstream):
-        log.debug(_("Stream started with {server}, now registering"
-                    .format(server=self.jid.host)))
-        iq = XEP_0077.build_register_iq(self.xmlstream, self.jid, self.password, self.email)
-        d = iq.send(self.jid.host).addCallbacks(self.registration_cb, self.registration_eb)
-        d.chainDeferred(self.registered)
-
-    def registration_cb(self, answer):
-        log.debug(_("Registration answer: {}").format(answer.toXml()))
-        self.xmlstream.sendFooter()
-
-    def registration_eb(self, failure_):
-        log.info(_("Registration failure: {}").format(str(failure_.value)))
-        self.xmlstream.sendFooter()
-        raise failure_
-
-
-class ServerRegister(xmlstream.XmlStreamFactory):
-
-    def __init__(self, *args, **kwargs):
-        xmlstream.XmlStreamFactory.__init__(self, *args, **kwargs)
-        self.addBootstrap(xmlstream.STREAM_END_EVENT, self._disconnected)
-
-    def clientConnectionLost(self, connector, reason):
-        connector.disconnect()
-
-    def _disconnected(self, reason):
-        if not self.authenticator.registered.called:
-            err = jabber_error.StreamError("Server unexpectedly closed the connection")
-            try:
-                if reason.value.args[0][0][2] == "certificate verify failed":
-                    err = exceptions.InvalidCertificate()
-            except (IndexError, TypeError):
-                pass
-            self.authenticator.registered.errback(err)
-
-
-class XEP_0077(object):
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0077 initialization"))
-        self.host = host
-        host.bridge.add_method(
-            "in_band_register",
-            ".plugin",
-            in_sign="ss",
-            out_sign="",
-            method=self._in_band_register,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "in_band_account_new",
-            ".plugin",
-            in_sign="ssssi",
-            out_sign="",
-            method=self._register_new_account,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "in_band_unregister",
-            ".plugin",
-            in_sign="ss",
-            out_sign="",
-            method=self._unregister,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "in_band_password_change",
-            ".plugin",
-            in_sign="ss",
-            out_sign="",
-            method=self._change_password,
-            async_=True,
-        )
-
-    @staticmethod
-    def build_register_iq(xmlstream_, jid_, password, email=None):
-        iq_elt = xmlstream.IQ(xmlstream_, "set")
-        iq_elt["to"] = jid_.host
-        query_elt = iq_elt.addElement(("jabber:iq:register", "query"))
-        username_elt = query_elt.addElement("username")
-        username_elt.addContent(jid_.user)
-        password_elt = query_elt.addElement("password")
-        password_elt.addContent(password)
-        if email is not None:
-            email_elt = query_elt.addElement("email")
-            email_elt.addContent(email)
-        return iq_elt
-
-    def _reg_cb(self, answer, client, post_treat_cb):
-        """Called after the first get IQ"""
-        try:
-            query_elt = next(answer.elements(NS_REG, "query"))
-        except StopIteration:
-            raise exceptions.DataError("Can't find expected query element")
-
-        try:
-            x_elem = next(query_elt.elements(data_form.NS_X_DATA, "x"))
-        except StopIteration:
-            # XXX: it seems we have an old service which doesn't manage data forms
-            log.warning(_("Can't find data form"))
-            raise exceptions.DataError(
-                _("This gateway can't be managed by SàT, sorry :(")
-            )
-
-        def submit_form(data, profile):
-            form_elt = xml_tools.xmlui_result_to_elt(data)
-
-            iq_elt = client.IQ()
-            iq_elt["id"] = answer["id"]
-            iq_elt["to"] = answer["from"]
-            query_elt = iq_elt.addElement("query", NS_REG)
-            query_elt.addChild(form_elt)
-            d = iq_elt.send()
-            d.addCallback(self._reg_success, client, post_treat_cb)
-            d.addErrback(self._reg_failure, client)
-            return d
-
-        form = data_form.Form.fromElement(x_elem)
-        submit_reg_id = self.host.register_callback(
-            submit_form, with_data=True, one_shot=True
-        )
-        return xml_tools.data_form_2_xmlui(form, submit_reg_id)
-
-    def _reg_eb(self, failure, client):
-        """Called when something is wrong with registration"""
-        log.info(_("Registration failure: %s") % str(failure.value))
-        raise failure
-
-    def _reg_success(self, answer, client, post_treat_cb):
-        log.debug(_("registration answer: %s") % answer.toXml())
-        if post_treat_cb is not None:
-            post_treat_cb(jid.JID(answer["from"]), client.profile)
-        return {}
-
-    def _reg_failure(self, failure, client):
-        log.info(_("Registration failure: %s") % str(failure.value))
-        if failure.value.condition == "conflict":
-            raise exceptions.ConflictError(
-                _("Username already exists, please choose an other one")
-            )
-        raise failure
-
-    def _in_band_register(self, to_jid_s, profile_key=C.PROF_KEY_NONE):
-        return self.in_band_register, jid.JID(to_jid_s, profile_key)
-
-    def in_band_register(self, to_jid, post_treat_cb=None, profile_key=C.PROF_KEY_NONE):
-        """register to a service
-
-        @param to_jid(jid.JID): jid of the service to register to
-        """
-        # FIXME: this post_treat_cb arguments seems wrong, check it
-        client = self.host.get_client(profile_key)
-        log.debug(_("Asking registration for {}").format(to_jid.full()))
-        reg_request = client.IQ("get")
-        reg_request["from"] = client.jid.full()
-        reg_request["to"] = to_jid.full()
-        reg_request.addElement("query", NS_REG)
-        d = reg_request.send(to_jid.full()).addCallbacks(
-            self._reg_cb,
-            self._reg_eb,
-            callbackArgs=[client, post_treat_cb],
-            errbackArgs=[client],
-        )
-        return d
-
-    def _register_new_account(self, jid_, password, email, host, port):
-        kwargs = {}
-        if email:
-            kwargs["email"] = email
-        if host:
-            kwargs["host"] = host
-        if port:
-            kwargs["port"] = port
-        return self.register_new_account(jid.JID(jid_), password, **kwargs)
-
-    def register_new_account(
-        self, jid_, password, email=None, host=None, port=C.XMPP_C2S_PORT
-    ):
-        """register a new account on a XMPP server
-
-        @param jid_(jid.JID): request jid to register
-        @param password(unicode): password of the account
-        @param email(unicode): email of the account
-        @param host(None, unicode): host of the server to register to
-        @param port(int): port of the server to register to
-        """
-        if host is None:
-           host = self.host.memory.config_get("", "xmpp_domain", "127.0.0.1")
-        check_certificate = host != "127.0.0.1"
-        authenticator = RegisteringAuthenticator(
-            jid_, password, email, check_certificate=check_certificate)
-        registered_d = authenticator.registered
-        server_register = ServerRegister(authenticator)
-        reactor.connectTCP(host, port, server_register)
-        return registered_d
-
-    def _change_password(self, new_password, profile_key):
-        client = self.host.get_client(profile_key)
-        return self.change_password(client, new_password)
-
-    def change_password(self, client, new_password):
-        iq_elt = self.build_register_iq(client.xmlstream, client.jid, new_password)
-        d = iq_elt.send(client.jid.host)
-        d.addCallback(
-            lambda __: self.host.memory.param_set(
-                "Password", new_password, "Connection", profile_key=client.profile
-            )
-        )
-        return d
-
-    def _unregister(self, to_jid_s, profile_key):
-        client = self.host.get_client(profile_key)
-        return self.unregister(client, jid.JID(to_jid_s))
-
-    def unregister(
-            self,
-            client: SatXMPPEntity,
-            to_jid: jid.JID
-    ) -> defer.Deferred:
-        """remove registration from a server/service
-
-        BEWARE! if you remove registration from profile own server, this will
-        DELETE THE XMPP ACCOUNT WITHOUT WARNING
-        @param to_jid: jid of the service or server
-            None to delete client's account (DANGEROUS!)
-        """
-        iq_elt = client.IQ()
-        if to_jid is not None:
-            iq_elt["to"] = to_jid.full()
-        query_elt = iq_elt.addElement((NS_REG, "query"))
-        query_elt.addElement("remove")
-        d = iq_elt.send()
-        if not to_jid or to_jid == jid.JID(client.jid.host):
-            d.addCallback(lambda __: client.entity_disconnect())
-        return d
-
--- a/sat/plugins/plugin_xep_0080.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,151 +0,0 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Dict, Any
-
-from twisted.words.xish import domish
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.tools import utils
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "User Location",
-    C.PI_IMPORT_NAME: "XEP-0080",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0080"],
-    C.PI_MAIN: "XEP_0080",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of XEP-0080 (User Location)"""),
-}
-
-NS_GEOLOC = "http://jabber.org/protocol/geoloc"
-KEYS_TYPES = {
-    "accuracy": float,
-    "alt": float,
-    "altaccuracy": float,
-    "area": str,
-    "bearing": float,
-    "building": str,
-    "country": str,
-    "countrycode": str,
-    "datum": str,
-    "description": str,
-    "error": float,
-    "floor": str,
-    "lat": float,
-    "locality": str,
-    "lon": float,
-    "postalcode": str,
-    "region": str,
-    "room": str,
-    "speed": float,
-    "street": str,
-    "text": str,
-    "timestamp": "datetime",
-    "tzo": str,
-    "uri": str
-}
-
-
-class XEP_0080:
-
-    def __init__(self, host):
-        log.info(_("XEP-0080 (User Location) plugin initialization"))
-        host.register_namespace("geoloc", NS_GEOLOC)
-
-    def get_geoloc_elt(
-        self,
-        location_data: Dict[str, Any],
-    ) -> domish.Element:
-        """Generate the element describing the location
-
-        @param geoloc: metadata description the location
-            Keys correspond to ones found at
-            https://xmpp.org/extensions/xep-0080.html#format, with following additional
-            keys:
-                - id (str): Identifier for this location
-                - language (str): language of the human readable texts
-            All keys are optional.
-        @return: ``<geoloc/>`` element
-        """
-        geoloc_elt = domish.Element((NS_GEOLOC, "geoloc"))
-        for key, value in location_data.items():
-            try:
-                key_type = KEYS_TYPES[key]
-            except KeyError:
-                if key == "id":
-                    # "id" attribute is not specified for XEP-0080's <geoloc/> element,
-                    # but it may be used in a parent element (that's the case for events)
-                    pass
-                elif key == "language":
-                    geoloc_elt["xml:lang"] = value
-                else:
-                    log.warning(f"Unknown location key {key}: {location_data}")
-                continue
-            if key_type == "datetime":
-                content = utils.xmpp_date(value)
-            else:
-                content = str(value)
-            geoloc_elt.addElement(key, content=content)
-
-        return geoloc_elt
-
-    def parse_geoloc_elt(
-        self,
-        geoloc_elt: domish.Element
-    ) -> Dict[str, Any]:
-        """Parse <geoloc/> element
-
-        @param geoloc_elt: <geoloc/> element
-            a parent element can also be used
-        @return: geoloc data. It's a dict whose keys correspond to
-            [get_geoloc_elt] parameters
-        @raise exceptions.NotFound: no <geoloc/> element has been found
-        """
-
-        if geoloc_elt.name != "geoloc":
-            try:
-                geoloc_elt = next(geoloc_elt.elements(NS_GEOLOC, "geoloc"))
-            except StopIteration:
-                raise exceptions.NotFound
-        data: Dict[str, Any] = {}
-        for elt in geoloc_elt.elements():
-            if elt.uri != NS_GEOLOC:
-                log.warning(f"unmanaged geoloc element: {elt.toXml()}")
-                continue
-            try:
-                data_type = KEYS_TYPES[elt.name]
-            except KeyError:
-                log.warning(f"unknown geoloc element: {elt.toXml()}")
-                continue
-            try:
-                if data_type == "datetime":
-                    data[elt.name] = utils.parse_xmpp_date(str(elt))
-                else:
-                    data[elt.name] = data_type(str(elt))
-            except Exception as e:
-                log.warning(f"can't parse element: {elt.toXml()}")
-                continue
-
-        return data
--- a/sat/plugins/plugin_xep_0082.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,65 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for XMPP Date and Time Profile formatting and parsing with Python's
-# datetime package
-# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.constants import Const as C
-from sat.core.i18n import D_
-from sat.core.sat_main import SAT
-from sat.tools import xmpp_datetime
-
-
-__all__ = [  # pylint: disable=unused-variable
-    "PLUGIN_INFO",
-    "XEP_0082"
-]
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XMPP Date and Time Profiles",
-    C.PI_IMPORT_NAME: "XEP-0082",
-    C.PI_TYPE: C.PLUG_TYPE_MISC,
-    C.PI_PROTOCOLS: [ "XEP-0082" ],
-    C.PI_DEPENDENCIES: [],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "XEP_0082",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: D_("Date and Time Profiles for XMPP"),
-}
-
-
-class XEP_0082:  # pylint: disable=invalid-name
-    """
-    Implementation of the date and time profiles specified in XEP-0082 using Python's
-    datetime module. The legacy format described in XEP-0082 section "4. Migration" is not
-    supported. Reexports of the functions in :mod:`sat.tools.xmpp_datetime`.
-
-    This is a passive plugin, i.e. it doesn't hook into any triggers to process stanzas
-    actively, but offers API for other plugins to use.
-    """
-
-    def __init__(self, sat: SAT) -> None:
-        """
-        @param sat: The SAT instance.
-        """
-
-    format_date = staticmethod(xmpp_datetime.format_date)
-    parse_date = staticmethod(xmpp_datetime.parse_date)
-    format_datetime = staticmethod(xmpp_datetime.format_datetime)
-    parse_datetime = staticmethod(xmpp_datetime.parse_datetime)
-    format_time = staticmethod(xmpp_datetime.format_time)
-    parse_time = staticmethod(xmpp_datetime.parse_time)
--- a/sat/plugins/plugin_xep_0084.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,268 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for XEP-0084
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional, Dict, Any
-from pathlib import Path
-from base64 import b64decode, b64encode
-
-from twisted.internet import defer
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.protocols.jabber import jid, error
-from twisted.words.xish import domish
-from zope.interface import implementer
-from wokkel import disco, iwokkel, pubsub
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.core_types import SatXMPPEntity
-from sat.core import exceptions
-
-
-log = getLogger(__name__)
-
-IMPORT_NAME = "XEP-0084"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "User Avatar",
-    C.PI_IMPORT_NAME: IMPORT_NAME,
-    C.PI_TYPE: C.PLUG_TYPE_XEP,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0084"],
-    C.PI_DEPENDENCIES: ["IDENTITY", "XEP-0060", "XEP-0163"],
-    C.PI_MAIN: "XEP_0084",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""XEP-0084 (User Avatar) implementation"""),
-}
-
-NS_AVATAR = "urn:xmpp:avatar"
-NS_AVATAR_METADATA = f"{NS_AVATAR}:metadata"
-NS_AVATAR_DATA = f"{NS_AVATAR}:data"
-
-
-class XEP_0084:
-    namespace_metadata = NS_AVATAR_METADATA
-    namespace_data = NS_AVATAR_DATA
-
-    def __init__(self, host):
-        log.info(_("XEP-0084 (User Avatar) plugin initialization"))
-        host.register_namespace("avatar_metadata", NS_AVATAR_METADATA)
-        host.register_namespace("avatar_data", NS_AVATAR_DATA)
-        self.host = host
-        self._p = host.plugins["XEP-0060"]
-        self._i = host.plugins['IDENTITY']
-        self._i.register(
-            IMPORT_NAME,
-            "avatar",
-            self.get_avatar,
-            self.set_avatar,
-            priority=2000
-        )
-        host.plugins["XEP-0163"].add_pep_event(
-            None, NS_AVATAR_METADATA, self._on_metadata_update
-        )
-
-    def get_handler(self, client):
-        return XEP_0084_Handler()
-
-    def _on_metadata_update(self, itemsEvent, profile):
-        client = self.host.get_client(profile)
-        defer.ensureDeferred(self.on_metadata_update(client, itemsEvent))
-
-    async def on_metadata_update(
-        self,
-        client: SatXMPPEntity,
-        itemsEvent: pubsub.ItemsEvent
-    ) -> None:
-        entity = client.jid.userhostJID()
-        avatar_metadata = await self.get_avatar(client, entity)
-        await self._i.update(client, IMPORT_NAME, "avatar", avatar_metadata, entity)
-
-    async def get_avatar(
-            self,
-            client: SatXMPPEntity,
-            entity_jid: jid.JID
-        ) -> Optional[dict]:
-        """Get avatar data
-
-        @param entity: entity to get avatar from
-        @return: avatar metadata, or None if no avatar has been found
-        """
-        service = entity_jid.userhostJID()
-        # metadata
-        try:
-            items, __ = await self._p.get_items(
-                client,
-                service,
-                NS_AVATAR_METADATA,
-                max_items=1
-            )
-        except exceptions.NotFound:
-            return None
-
-        if not items:
-            return None
-
-        item_elt = items[0]
-        try:
-            metadata_elt = next(item_elt.elements(NS_AVATAR_METADATA, "metadata"))
-        except StopIteration:
-            log.warning(f"missing metadata element: {item_elt.toXml()}")
-            return None
-
-        for info_elt in metadata_elt.elements(NS_AVATAR_METADATA, "info"):
-            try:
-                metadata = {
-                    "id": str(info_elt["id"]),
-                    "size": int(info_elt["bytes"]),
-                    "media_type": str(info_elt["type"])
-                }
-                avatar_id = metadata["id"]
-                if not avatar_id:
-                    raise ValueError
-            except (KeyError, ValueError):
-                log.warning(f"invalid <info> element: {item_elt.toXml()}")
-                return None
-            # FIXME: to simplify, we only handle image/png for now
-            if metadata["media_type"] == "image/png":
-                break
-        else:
-            # mandatory image/png is missing, or avatar is disabled
-            # (https://xmpp.org/extensions/xep-0084.html#pub-disable)
-            return None
-
-        cache_data = self.host.common_cache.get_metadata(avatar_id)
-        if not cache_data:
-            try:
-                data_items, __ = await self._p.get_items(
-                    client,
-                    service,
-                    NS_AVATAR_DATA,
-                    item_ids=[avatar_id]
-                )
-                data_item_elt = data_items[0]
-            except (error.StanzaError, IndexError) as e:
-                log.warning(
-                    f"Can't retrieve avatar of {service.full()} with ID {avatar_id!r}: "
-                    f"{e}"
-                )
-                return None
-            try:
-                avatar_buf = b64decode(
-                    str(next(data_item_elt.elements(NS_AVATAR_DATA, "data")))
-                )
-            except Exception as e:
-                log.warning(
-                    f"invalid data element for {service.full()} with avatar ID "
-                    f"{avatar_id!r}: {e}\n{data_item_elt.toXml()}"
-                )
-                return None
-            with self.host.common_cache.cache_data(
-                IMPORT_NAME,
-                avatar_id,
-                metadata["media_type"]
-            ) as f:
-                f.write(avatar_buf)
-                cache_data = {
-                    "path": Path(f.name),
-                    "mime_type": metadata["media_type"]
-                }
-
-        return self._i.avatar_build_metadata(
-                cache_data['path'], cache_data['mime_type'], avatar_id
-        )
-
-    def build_item_data_elt(self, avatar_data: Dict[str, Any]) -> domish.Element:
-        """Generate the item for the data node
-
-        @param avatar_data: data as build by identity plugin (need to be filled with
-            "cache_uid" and "base64" keys)
-        """
-        data_elt = domish.Element((NS_AVATAR_DATA, "data"))
-        data_elt.addContent(avatar_data["base64"])
-        return pubsub.Item(id=avatar_data["cache_uid"], payload=data_elt)
-
-    def build_item_metadata_elt(self, avatar_data: Dict[str, Any]) -> domish.Element:
-        """Generate the item for the metadata node
-
-        @param avatar_data: data as build by identity plugin (need to be filled with
-            "cache_uid", "path", and "media_type" keys)
-        """
-        metadata_elt = domish.Element((NS_AVATAR_METADATA, "metadata"))
-        info_elt = metadata_elt.addElement("info")
-        # FIXME: we only fill required elements for now (see
-        #        https://xmpp.org/extensions/xep-0084.html#table-1)
-        info_elt["id"] = avatar_data["cache_uid"]
-        info_elt["type"] = avatar_data["media_type"]
-        info_elt["bytes"] = str(avatar_data["path"].stat().st_size)
-        return pubsub.Item(id=self._p.ID_SINGLETON, payload=metadata_elt)
-
-    async def set_avatar(
-        self,
-        client: SatXMPPEntity,
-        avatar_data: Dict[str, Any],
-        entity: jid.JID
-    ) -> None:
-        """Set avatar of the profile
-
-        @param avatar_data(dict): data of the image to use as avatar, as built by
-            IDENTITY plugin.
-        @param entity(jid.JID): entity whose avatar must be changed
-        """
-        service = entity.userhostJID()
-
-        # Data
-        await self._p.create_if_new_node(
-            client,
-            service,
-            NS_AVATAR_DATA,
-            options={
-                self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
-                self._p.OPT_PERSIST_ITEMS: 1,
-                self._p.OPT_MAX_ITEMS: 1,
-            }
-        )
-        item_data_elt = self.build_item_data_elt(avatar_data)
-        await self._p.send_items(client, service, NS_AVATAR_DATA, [item_data_elt])
-
-        # Metadata
-        await self._p.create_if_new_node(
-            client,
-            service,
-            NS_AVATAR_METADATA,
-            options={
-                self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
-                self._p.OPT_PERSIST_ITEMS: 1,
-                self._p.OPT_MAX_ITEMS: 1,
-            }
-        )
-        item_metadata_elt = self.build_item_metadata_elt(avatar_data)
-        await self._p.send_items(client, service, NS_AVATAR_METADATA, [item_metadata_elt])
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0084_Handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [
-            disco.DiscoFeature(NS_AVATAR_METADATA),
-            disco.DiscoFeature(NS_AVATAR_DATA)
-        ]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0085.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,433 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Chat State Notifications Protocol (xep-0085)
-# Copyright (C) 2009-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-from twisted.words.protocols.jabber.jid import JID
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-from twisted.words.xish import domish
-from twisted.internet import reactor
-from twisted.internet import error as internet_error
-
-NS_XMPP_CLIENT = "jabber:client"
-NS_CHAT_STATES = "http://jabber.org/protocol/chatstates"
-CHAT_STATES = ["active", "inactive", "gone", "composing", "paused"]
-MESSAGE_TYPES = ["chat", "groupchat"]
-PARAM_KEY = "Notifications"
-PARAM_NAME = "Enable chat state notifications"
-ENTITY_KEY = PARAM_KEY + "_" + PARAM_NAME
-DELETE_VALUE = "DELETE"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Chat State Notifications Protocol Plugin",
-    C.PI_IMPORT_NAME: "XEP-0085",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0085"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "XEP_0085",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Chat State Notifications Protocol"""),
-}
-
-
-# Describe the internal transitions that are triggered
-# by a timer. Beside that, external transitions can be
-# runned to target the states "active" or "composing".
-# Delay is specified here in seconds.
-TRANSITIONS = {
-    "active": {"next_state": "inactive", "delay": 120},
-    "inactive": {"next_state": "gone", "delay": 480},
-    "gone": {"next_state": "", "delay": 0},
-    "composing": {"next_state": "paused", "delay": 30},
-    "paused": {"next_state": "inactive", "delay": 450},
-}
-
-
-class UnknownChatStateException(Exception):
-    """
-    This error is raised when an unknown chat state is used.
-    """
-
-    pass
-
-
-class XEP_0085(object):
-    """
-    Implementation for XEP 0085
-    """
-
-    params = """
-    <params>
-    <individual>
-    <category name="%(category_name)s" label="%(category_label)s">
-        <param name="%(param_name)s" label="%(param_label)s" value="true" type="bool" security="0"/>
-     </category>
-    </individual>
-    </params>
-    """ % {
-        "category_name": PARAM_KEY,
-        "category_label": _(PARAM_KEY),
-        "param_name": PARAM_NAME,
-        "param_label": _("Enable chat state notifications"),
-    }
-
-    def __init__(self, host):
-        log.info(_("Chat State Notifications plugin initialization"))
-        self.host = host
-        self.map = {}  # FIXME: would be better to use client instead of mapping profile to data
-
-        # parameter value is retrieved before each use
-        host.memory.update_params(self.params)
-
-        # triggers from core
-        host.trigger.add("message_received", self.message_received_trigger)
-        host.trigger.add("sendMessage", self.send_message_trigger)
-        host.trigger.add("param_update_trigger", self.param_update_trigger)
-
-        # args: to_s (jid as string), profile
-        host.bridge.add_method(
-            "chat_state_composing",
-            ".plugin",
-            in_sign="ss",
-            out_sign="",
-            method=self.chat_state_composing,
-        )
-
-        # args: from (jid as string), state in CHAT_STATES, profile
-        host.bridge.add_signal("chat_state_received", ".plugin", signature="sss")
-
-    def get_handler(self, client):
-        return XEP_0085_handler(self, client.profile)
-
-    def profile_disconnected(self, client):
-        """Eventually send a 'gone' state to all one2one contacts."""
-        profile = client.profile
-        if profile not in self.map:
-            return
-        for to_jid in self.map[profile]:
-            # FIXME: the "unavailable" presence stanza is received by to_jid
-            # before the chat state, so it will be ignored... find a way to
-            # actually defer the disconnection
-            self.map[profile][to_jid]._onEvent("gone")
-        del self.map[profile]
-
-    def update_cache(self, entity_jid, value, profile):
-        """Update the entity data of the given profile for one or all contacts.
-        Reset the chat state(s) display if the notification has been disabled.
-
-        @param entity_jid: contact's JID, or C.ENTITY_ALL to update all contacts.
-        @param value: True, False or DELETE_VALUE to delete the entity data
-        @param profile: current profile
-        """
-        client = self.host.get_client(profile)
-        if value == DELETE_VALUE:
-            self.host.memory.del_entity_datum(client, entity_jid, ENTITY_KEY)
-        else:
-            self.host.memory.update_entity_data(
-                client, entity_jid, ENTITY_KEY, value
-            )
-        if not value or value == DELETE_VALUE:
-            # reinit chat state UI for this or these contact(s)
-            self.host.bridge.chat_state_received(entity_jid.full(), "", profile)
-
-    def param_update_trigger(self, name, value, category, type_, profile):
-        """Reset all the existing chat state entity data associated with this profile after a parameter modification.
-
-        @param name: parameter name
-        @param value: "true" to activate the notifications, or any other value to delete it
-        @param category: parameter category
-        @param type_: parameter type
-        """
-        if (category, name) == (PARAM_KEY, PARAM_NAME):
-            self.update_cache(
-                C.ENTITY_ALL, True if C.bool(value) else DELETE_VALUE, profile=profile
-            )
-            return False
-        return True
-
-    def message_received_trigger(self, client, message, post_treat):
-        """
-        Update the entity cache when we receive a message with body.
-        Check for a chat state in the message and signal frontends.
-        """
-        profile = client.profile
-        if not self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile):
-            return True
-
-        from_jid = JID(message.getAttribute("from"))
-        if self._is_muc(from_jid, profile):
-            from_jid = from_jid.userhostJID()
-        else:  # update entity data for one2one chat
-            # assert from_jid.resource # FIXME: assert doesn't work on normal message from server (e.g. server announce), because there is no resource
-            try:
-                next(domish.generateElementsNamed(message.elements(), name="body"))
-                try:
-                    next(domish.generateElementsNamed(message.elements(), name="active"))
-                    # contact enabled Chat State Notifications
-                    self.update_cache(from_jid, True, profile=profile)
-                except StopIteration:
-                    if message.getAttribute("type") == "chat":
-                        # contact didn't enable Chat State Notifications
-                        self.update_cache(from_jid, False, profile=profile)
-                        return True
-            except StopIteration:
-                pass
-
-        # send our next "composing" states to any MUC and to the contacts who enabled the feature
-        self._chat_state_init(from_jid, message.getAttribute("type"), profile)
-
-        state_list = [
-            child.name
-            for child in message.elements()
-            if message.getAttribute("type") in MESSAGE_TYPES
-            and child.name in CHAT_STATES
-            and child.defaultUri == NS_CHAT_STATES
-        ]
-        for state in state_list:
-            # there must be only one state according to the XEP
-            if state != "gone" or message.getAttribute("type") != "groupchat":
-                self.host.bridge.chat_state_received(
-                    message.getAttribute("from"), state, profile
-                )
-            break
-        return True
-
-    def send_message_trigger(
-        self, client, mess_data, pre_xml_treatments, post_xml_treatments
-    ):
-        """
-        Eventually add the chat state to the message and initiate
-        the state machine when sending an "active" state.
-        """
-        profile = client.profile
-
-        def treatment(mess_data):
-            message = mess_data["xml"]
-            to_jid = JID(message.getAttribute("to"))
-            if not self._check_activation(to_jid, forceEntityData=True, profile=profile):
-                return mess_data
-            try:
-                # message with a body always mean active state
-                next(domish.generateElementsNamed(message.elements(), name="body"))
-                message.addElement("active", NS_CHAT_STATES)
-                # launch the chat state machine (init the timer)
-                if self._is_muc(to_jid, profile):
-                    to_jid = to_jid.userhostJID()
-                self._chat_state_active(to_jid, mess_data["type"], profile)
-            except StopIteration:
-                if "chat_state" in mess_data["extra"]:
-                    state = mess_data["extra"].pop("chat_state")
-                    assert state in CHAT_STATES
-                    message.addElement(state, NS_CHAT_STATES)
-            return mess_data
-
-        post_xml_treatments.addCallback(treatment)
-        return True
-
-    def _is_muc(self, to_jid, profile):
-        """Tell if that JID is a MUC or not
-
-        @param to_jid (JID): full or bare JID to check
-        @param profile (str): %(doc_profile)s
-        @return: bool
-        """
-        client = self.host.get_client(profile)
-        try:
-            type_ = self.host.memory.get_entity_datum(
-                client, to_jid.userhostJID(), C.ENTITY_TYPE)
-            if type_ == C.ENTITY_TYPE_MUC:
-                return True
-        except (exceptions.UnknownEntityError, KeyError):
-            pass
-        return False
-
-    def _check_activation(self, to_jid, forceEntityData, profile):
-        """
-        @param to_jid: the contact's full JID (or bare if you know it's a MUC)
-        @param forceEntityData: if set to True, a non-existing
-        entity data will be considered to be True (and initialized)
-        @param: current profile
-        @return: True if the notifications should be sent to this JID.
-        """
-        client = self.host.get_client(profile)
-        # check if the parameter is active
-        if not self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile):
-            return False
-        # check if notifications should be sent to this contact
-        if self._is_muc(to_jid, profile):
-            return True
-        # FIXME: this assertion crash when we want to send a message to an online bare jid
-        # assert to_jid.resource or not self.host.memory.is_entity_available(to_jid, profile) # must either have a resource, or talk to an offline contact
-        try:
-            return self.host.memory.get_entity_datum(client, to_jid, ENTITY_KEY)
-        except (exceptions.UnknownEntityError, KeyError):
-            if forceEntityData:
-                # enable it for the first time
-                self.update_cache(to_jid, True, profile=profile)
-                return True
-        # wait for the first message before sending states
-        return False
-
-    def _chat_state_init(self, to_jid, mess_type, profile):
-        """
-        Data initialization for the chat state machine.
-
-        @param to_jid (JID): full JID for one2one, bare JID for MUC
-        @param mess_type (str): "one2one" or "groupchat"
-        @param profile (str): %(doc_profile)s
-        """
-        if mess_type is None:
-            return
-        profile_map = self.map.setdefault(profile, {})
-        if to_jid not in profile_map:
-            machine = ChatStateMachine(self.host, to_jid, mess_type, profile)
-            self.map[profile][to_jid] = machine
-
-    def _chat_state_active(self, to_jid, mess_type, profile_key):
-        """
-        Launch the chat state machine on "active" state.
-
-        @param to_jid (JID): full JID for one2one, bare JID for MUC
-        @param mess_type (str): "one2one" or "groupchat"
-        @param profile (str): %(doc_profile)s
-        """
-        profile = self.host.memory.get_profile_name(profile_key)
-        if profile is None:
-            raise exceptions.ProfileUnknownError
-        self._chat_state_init(to_jid, mess_type, profile)
-        self.map[profile][to_jid]._onEvent("active")
-
-    def chat_state_composing(self, to_jid_s, profile_key):
-        """Move to the "composing" state when required.
-
-        Since this method is called from the front-end, it needs to check the
-        values of the parameter "Send chat state notifications" and the entity
-        data associated to the target JID.
-
-        @param to_jid_s (str): contact full JID as a string
-        @param profile_key (str): %(doc_profile_key)s
-        """
-        # TODO: try to optimize this method which is called often
-        client = self.host.get_client(profile_key)
-        to_jid = JID(to_jid_s)
-        if self._is_muc(to_jid, client.profile):
-            to_jid = to_jid.userhostJID()
-        elif not to_jid.resource:
-            to_jid.resource = self.host.memory.main_resource_get(client, to_jid)
-        if not self._check_activation(
-            to_jid, forceEntityData=False, profile=client.profile
-        ):
-            return
-        try:
-            self.map[client.profile][to_jid]._onEvent("composing")
-        except (KeyError, AttributeError):
-            # no message has been sent/received since the notifications
-            # have been enabled, it's better to wait for a first one
-            pass
-
-
-class ChatStateMachine(object):
-    """
-    This class represents a chat state, between one profile and
-    one target contact. A timer is used to move from one state
-    to the other. The initialization is done through the "active"
-    state which is internally set when a message is sent. The state
-    "composing" can be set externally (through the bridge by a
-    frontend). Other states are automatically set with the timer.
-    """
-
-    def __init__(self, host, to_jid, mess_type, profile):
-        """
-        Initialization need to store the target, message type
-        and a profile for sending later messages.
-        """
-        self.host = host
-        self.to_jid = to_jid
-        self.mess_type = mess_type
-        self.profile = profile
-        self.state = None
-        self.timer = None
-
-    def _onEvent(self, state):
-        """
-        Move to the specified state, eventually send the
-        notification to the contact (the "active" state is
-        automatically sent with each message) and set the timer.
-        """
-        assert state in TRANSITIONS
-        transition = TRANSITIONS[state]
-        assert "next_state" in transition and "delay" in transition
-
-        if state != self.state and state != "active":
-            if state != "gone" or self.mess_type != "groupchat":
-                # send a new message without body
-                log.debug(
-                    "sending state '{state}' to {jid}".format(
-                        state=state, jid=self.to_jid.full()
-                    )
-                )
-                client = self.host.get_client(self.profile)
-                mess_data = {
-                    "from": client.jid,
-                    "to": self.to_jid,
-                    "uid": "",
-                    "message": {},
-                    "type": self.mess_type,
-                    "subject": {},
-                    "extra": {},
-                }
-                client.generate_message_xml(mess_data)
-                mess_data["xml"].addElement(state, NS_CHAT_STATES)
-                client.send(mess_data["xml"])
-
-        self.state = state
-        try:
-            self.timer.cancel()
-        except (internet_error.AlreadyCalled, AttributeError):
-            pass
-
-        if transition["next_state"] and transition["delay"] > 0:
-            self.timer = reactor.callLater(
-                transition["delay"], self._onEvent, transition["next_state"]
-            )
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0085_handler(XMPPHandler):
-
-    def __init__(self, plugin_parent, profile):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-        self.profile = profile
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_CHAT_STATES)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0092.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,142 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT plugin for Software Version (XEP-0092)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Tuple
-
-from twisted.internet import defer, reactor
-from twisted.words.protocols.jabber import jid
-from wokkel import compat
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-NS_VERSION = "jabber:iq:version"
-TIMEOUT = 10
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Software Version Plugin",
-    C.PI_IMPORT_NAME: "XEP-0092",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0092"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_RECOMMENDATIONS: [C.TEXT_CMDS],
-    C.PI_MAIN: "XEP_0092",
-    C.PI_HANDLER: "no",  # version is already handler in core.xmpp module
-    C.PI_DESCRIPTION: _("""Implementation of Software Version"""),
-}
-
-
-class XEP_0092(object):
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0092 initialization"))
-        self.host = host
-        host.bridge.add_method(
-            "software_version_get",
-            ".plugin",
-            in_sign="ss",
-            out_sign="(sss)",
-            method=self._get_version,
-            async_=True,
-        )
-        try:
-            self.host.plugins[C.TEXT_CMDS].add_who_is_cb(self._whois, 50)
-        except KeyError:
-            log.info(_("Text commands not available"))
-
-    def _get_version(self, entity_jid_s, profile_key):
-        def prepare_for_bridge(data):
-            name, version, os = data
-            return (name or "", version or "", os or "")
-
-        client = self.host.get_client(profile_key)
-        d = self.version_get(client, jid.JID(entity_jid_s))
-        d.addCallback(prepare_for_bridge)
-        return d
-
-    def version_get(
-        self,
-        client: SatXMPPEntity,
-        jid_: jid.JID,
-    ) -> Tuple[str, str, str]:
-        """Ask version of the client that jid_ is running
-
-        @param jid_: jid from who we want to know client's version
-        @return: a defered which fire a tuple with the following data (None if not available):
-            - name: Natural language name of the software
-            - version: specific version of the software
-            - os: operating system of the queried entity
-        """
-
-        def do_version_get(__):
-            iq_elt = compat.IQ(client.xmlstream, "get")
-            iq_elt["to"] = jid_.full()
-            iq_elt.addElement("query", NS_VERSION)
-            d = iq_elt.send()
-            d.addCallback(self._got_version)
-            return d
-
-        d = self.host.check_feature(client, NS_VERSION, jid_)
-        d.addCallback(do_version_get)
-        reactor.callLater(
-            TIMEOUT, d.cancel
-        )  # XXX: timeout needed because some clients don't answer the IQ
-        return d
-
-    def _got_version(self, iq_elt):
-        try:
-            query_elt = next(iq_elt.elements(NS_VERSION, "query"))
-        except StopIteration:
-            raise exceptions.DataError
-        ret = []
-        for name in ("name", "version", "os"):
-            try:
-                data_elt = next(query_elt.elements(NS_VERSION, name))
-                ret.append(str(data_elt))
-            except StopIteration:
-                ret.append(None)
-
-        return tuple(ret)
-
-    def _whois(self, client, whois_msg, mess_data, target_jid):
-        """Add software/OS information to whois"""
-
-        def version_cb(version_data):
-            name, version, os = version_data
-            if name:
-                whois_msg.append(_("Client name: %s") % name)
-            if version:
-                whois_msg.append(_("Client version: %s") % version)
-            if os:
-                whois_msg.append(_("Operating system: %s") % os)
-
-        def version_eb(failure):
-            failure.trap(exceptions.FeatureNotFound, defer.CancelledError)
-            if failure.check(failure, exceptions.FeatureNotFound):
-                whois_msg.append(_("Software version not available"))
-            else:
-                whois_msg.append(_("Client software version request timeout"))
-
-        d = self.version_get(client, target_jid)
-        d.addCallbacks(version_cb, version_eb)
-        return d
--- a/sat/plugins/plugin_xep_0095.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,206 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing xep-0095
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core import exceptions
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.protocols.jabber import error
-from zope.interface import implementer
-from wokkel import disco
-from wokkel import iwokkel
-import uuid
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP 0095 Plugin",
-    C.PI_IMPORT_NAME: "XEP-0095",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0095"],
-    C.PI_MAIN: "XEP_0095",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Stream Initiation"""),
-}
-
-
-IQ_SET = '/iq[@type="set"]'
-NS_SI = "http://jabber.org/protocol/si"
-SI_REQUEST = IQ_SET + '/si[@xmlns="' + NS_SI + '"]'
-SI_PROFILE_HEADER = "http://jabber.org/protocol/si/profile/"
-SI_ERROR_CONDITIONS = ("bad-profile", "no-valid-streams")
-
-
-class XEP_0095(object):
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0095 initialization"))
-        self.host = host
-        self.si_profiles = {}  # key: SI profile, value: callback
-
-    def get_handler(self, client):
-        return XEP_0095_handler(self)
-
-    def register_si_profile(self, si_profile, callback):
-        """Add a callback for a SI Profile
-
-        @param si_profile(unicode): SI profile name (e.g. file-transfer)
-        @param callback(callable): method to call when the profile name is asked
-        """
-        self.si_profiles[si_profile] = callback
-
-    def unregister_si_profile(self, si_profile):
-        try:
-            del self.si_profiles[si_profile]
-        except KeyError:
-            log.error(
-                "Trying to unregister SI profile [{}] which was not registered".format(
-                    si_profile
-                )
-            )
-
-    def stream_init(self, iq_elt, client):
-        """This method is called on stream initiation (XEP-0095 #3.2)
-
-        @param iq_elt: IQ element
-        """
-        log.info(_("XEP-0095 Stream initiation"))
-        iq_elt.handled = True
-        si_elt = next(iq_elt.elements(NS_SI, "si"))
-        si_id = si_elt["id"]
-        si_mime_type = iq_elt.getAttribute("mime-type", "application/octet-stream")
-        si_profile = si_elt["profile"]
-        si_profile_key = (
-            si_profile[len(SI_PROFILE_HEADER) :]
-            if si_profile.startswith(SI_PROFILE_HEADER)
-            else si_profile
-        )
-        if si_profile_key in self.si_profiles:
-            # We know this SI profile, we call the callback
-            self.si_profiles[si_profile_key](client, iq_elt, si_id, si_mime_type, si_elt)
-        else:
-            # We don't know this profile, we send an error
-            self.sendError(client, iq_elt, "bad-profile")
-
-    def sendError(self, client, request, condition):
-        """Send IQ error as a result
-
-        @param request(domish.Element): original IQ request
-        @param condition(str): error condition
-        """
-        if condition in SI_ERROR_CONDITIONS:
-            si_condition = condition
-            condition = "bad-request"
-        else:
-            si_condition = None
-
-        iq_error_elt = error.StanzaError(condition).toResponse(request)
-        if si_condition is not None:
-            iq_error_elt.error.addElement((NS_SI, si_condition))
-
-        client.send(iq_error_elt)
-
-    def accept_stream(self, client, iq_elt, feature_elt, misc_elts=None):
-        """Send the accept stream initiation answer
-
-        @param iq_elt(domish.Element): initial SI request
-        @param feature_elt(domish.Element): 'feature' element containing stream method to use
-        @param misc_elts(list[domish.Element]): list of elements to add
-        """
-        log.info(_("sending stream initiation accept answer"))
-        if misc_elts is None:
-            misc_elts = []
-        result_elt = xmlstream.toResponse(iq_elt, "result")
-        si_elt = result_elt.addElement((NS_SI, "si"))
-        si_elt.addChild(feature_elt)
-        for elt in misc_elts:
-            si_elt.addChild(elt)
-        client.send(result_elt)
-
-    def _parse_offer_result(self, iq_elt):
-        try:
-            si_elt = next(iq_elt.elements(NS_SI, "si"))
-        except StopIteration:
-            log.warning("No <si/> element found in result while expected")
-            raise exceptions.DataError
-        return (iq_elt, si_elt)
-
-    def propose_stream(
-        self,
-        client,
-        to_jid,
-        si_profile,
-        feature_elt,
-        misc_elts,
-        mime_type="application/octet-stream",
-    ):
-        """Propose a stream initiation
-
-        @param to_jid(jid.JID): recipient
-        @param si_profile(unicode): Stream initiation profile (XEP-0095)
-        @param feature_elt(domish.Element): feature element, according to XEP-0020
-        @param misc_elts(list[domish.Element]): list of elements to add
-        @param mime_type(unicode): stream mime type
-        @return (tuple): tuple with:
-            - session id (unicode)
-            - (D(domish_elt, domish_elt): offer deferred which returl a tuple
-                with iq_elt and si_elt
-        """
-        offer = client.IQ()
-        sid = str(uuid.uuid4())
-        log.debug(_("Stream Session ID: %s") % offer["id"])
-
-        offer["from"] = client.jid.full()
-        offer["to"] = to_jid.full()
-        si = offer.addElement("si", NS_SI)
-        si["id"] = sid
-        si["mime-type"] = mime_type
-        si["profile"] = si_profile
-        for elt in misc_elts:
-            si.addChild(elt)
-        si.addChild(feature_elt)
-
-        offer_d = offer.send()
-        offer_d.addCallback(self._parse_offer_result)
-        return sid, offer_d
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0095_handler(xmlstream.XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(
-            SI_REQUEST, self.plugin_parent.stream_init, client=self.parent
-        )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_SI)] + [
-            disco.DiscoFeature(
-                "http://jabber.org/protocol/si/profile/{}".format(profile_name)
-            )
-            for profile_name in self.plugin_parent.si_profiles
-        ]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0096.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,406 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing xep-0096
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import os
-from twisted.words.xish import domish
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber import error
-from twisted.internet import defer
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.tools import xml_tools
-from sat.tools import stream
-
-log = getLogger(__name__)
-
-
-NS_SI_FT = "http://jabber.org/protocol/si/profile/file-transfer"
-IQ_SET = '/iq[@type="set"]'
-SI_PROFILE_NAME = "file-transfer"
-SI_PROFILE = "http://jabber.org/protocol/si/profile/" + SI_PROFILE_NAME
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP-0096 Plugin",
-    C.PI_IMPORT_NAME: "XEP-0096",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0096"],
-    C.PI_DEPENDENCIES: ["XEP-0020", "XEP-0095", "XEP-0065", "XEP-0047", "FILE"],
-    C.PI_MAIN: "XEP_0096",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of SI File Transfer"""),
-}
-
-
-class XEP_0096(object):
-    # TODO: call self._f.unregister when unloading order will be managing (i.e. when depenencies will be unloaded at the end)
-    name = PLUGIN_INFO[C.PI_NAME]
-    human_name = D_("Stream Initiation")
-
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0096 initialization"))
-        self.host = host
-        self.managed_stream_m = [
-            self.host.plugins["XEP-0065"].NAMESPACE,
-            self.host.plugins["XEP-0047"].NAMESPACE,
-        ]  # Stream methods managed
-        self._f = self.host.plugins["FILE"]
-        self._f.register(self)
-        self._si = self.host.plugins["XEP-0095"]
-        self._si.register_si_profile(SI_PROFILE_NAME, self._transfer_request)
-        host.bridge.add_method(
-            "si_file_send", ".plugin", in_sign="sssss", out_sign="s", method=self._file_send
-        )
-
-    async def can_handle_file_send(self, client, peer_jid, filepath):
-        return await self.host.hasFeature(client, NS_SI_FT, peer_jid)
-
-    def unload(self):
-        self._si.unregister_si_profile(SI_PROFILE_NAME)
-
-    def _bad_request(self, client, iq_elt, message=None):
-        """Send a bad-request error
-
-        @param iq_elt(domish.Element): initial <IQ> element of the SI request
-        @param message(None, unicode): informational message to display in the logs
-        """
-        if message is not None:
-            log.warning(message)
-        self._si.sendError(client, iq_elt, "bad-request")
-
-    def _parse_range(self, parent_elt, file_size):
-        """find and parse <range/> element
-
-        @param parent_elt(domish.Element): direct parent of the <range/> element
-        @return (tuple[bool, int, int]): a tuple with
-            - True if range is required
-            - range_offset
-            - range_length
-        """
-        try:
-            range_elt = next(parent_elt.elements(NS_SI_FT, "range"))
-        except StopIteration:
-            range_ = False
-            range_offset = None
-            range_length = None
-        else:
-            range_ = True
-
-            try:
-                range_offset = int(range_elt["offset"])
-            except KeyError:
-                range_offset = 0
-
-            try:
-                range_length = int(range_elt["length"])
-            except KeyError:
-                range_length = file_size
-
-            if range_offset != 0 or range_length != file_size:
-                raise NotImplementedError  # FIXME
-
-        return range_, range_offset, range_length
-
-    def _transfer_request(self, client, iq_elt, si_id, si_mime_type, si_elt):
-        """Called when a file transfer is requested
-
-        @param iq_elt(domish.Element): initial <IQ> element of the SI request
-        @param si_id(unicode): Stream Initiation session id
-        @param si_mime_type("unicode"): Mime type of the file (or default "application/octet-stream" if unknown)
-        @param si_elt(domish.Element): request
-        """
-        log.info(_("XEP-0096 file transfer requested"))
-        peer_jid = jid.JID(iq_elt["from"])
-
-        try:
-            file_elt = next(si_elt.elements(NS_SI_FT, "file"))
-        except StopIteration:
-            return self._bad_request(
-                client, iq_elt, "No <file/> element found in SI File Transfer request"
-            )
-
-        try:
-            feature_elt = self.host.plugins["XEP-0020"].get_feature_elt(si_elt)
-        except exceptions.NotFound:
-            return self._bad_request(
-                client, iq_elt, "No <feature/> element found in SI File Transfer request"
-            )
-
-        try:
-            filename = file_elt["name"]
-            file_size = int(file_elt["size"])
-        except (KeyError, ValueError):
-            return self._bad_request(client, iq_elt, "Malformed SI File Transfer request")
-
-        file_date = file_elt.getAttribute("date")
-        file_hash = file_elt.getAttribute("hash")
-
-        log.info(
-            "File proposed: name=[{name}] size={size}".format(
-                name=filename, size=file_size
-            )
-        )
-
-        try:
-            file_desc = str(next(file_elt.elements(NS_SI_FT, "desc")))
-        except StopIteration:
-            file_desc = ""
-
-        try:
-            range_, range_offset, range_length = self._parse_range(file_elt, file_size)
-        except ValueError:
-            return self._bad_request(client, iq_elt, "Malformed SI File Transfer request")
-
-        try:
-            stream_method = self.host.plugins["XEP-0020"].negotiate(
-                feature_elt, "stream-method", self.managed_stream_m, namespace=None
-            )
-        except KeyError:
-            return self._bad_request(client, iq_elt, "No stream method found")
-
-        if stream_method:
-            if stream_method == self.host.plugins["XEP-0065"].NAMESPACE:
-                plugin = self.host.plugins["XEP-0065"]
-            elif stream_method == self.host.plugins["XEP-0047"].NAMESPACE:
-                plugin = self.host.plugins["XEP-0047"]
-            else:
-                log.error(
-                    "Unknown stream method, this should not happen at this stage, cancelling transfer"
-                )
-        else:
-            log.warning("Can't find a valid stream method")
-            self._si.sendError(client, iq_elt, "not-acceptable")
-            return
-
-        # if we are here, the transfer can start, we just need user's agreement
-        data = {
-            "name": filename,
-            "peer_jid": peer_jid,
-            "size": file_size,
-            "date": file_date,
-            "hash": file_hash,
-            "desc": file_desc,
-            "range": range_,
-            "range_offset": range_offset,
-            "range_length": range_length,
-            "si_id": si_id,
-            "progress_id": si_id,
-            "stream_method": stream_method,
-            "stream_plugin": plugin,
-        }
-
-        d = defer.ensureDeferred(
-            self._f.get_dest_dir(client, peer_jid, data, data, stream_object=True)
-        )
-        d.addCallback(self.confirmation_cb, client, iq_elt, data)
-
-    def confirmation_cb(self, accepted, client, iq_elt, data):
-        """Called on confirmation answer
-
-        @param accepted(bool): True if file transfer is accepted
-        @param iq_elt(domish.Element): initial SI request
-        @param data(dict): session data
-        """
-        if not accepted:
-            log.info("File transfer declined")
-            self._si.sendError(client, iq_elt, "forbidden")
-            return
-        # data, timeout, stream_method, failed_methods = client._xep_0096_waiting_for_approval[sid]
-        # can_range = data['can_range'] == "True"
-        # range_offset = 0
-        # if timeout.active():
-        #     timeout.cancel()
-        # try:
-        #     dest_path = frontend_data['dest_path']
-        # except KeyError:
-        #     log.error(_('dest path not found in frontend_data'))
-        #     del client._xep_0096_waiting_for_approval[sid]
-        #     return
-        # if stream_method == self.host.plugins["XEP-0065"].NAMESPACE:
-        #     plugin = self.host.plugins["XEP-0065"]
-        # elif stream_method == self.host.plugins["XEP-0047"].NAMESPACE:
-        #     plugin = self.host.plugins["XEP-0047"]
-        # else:
-        #     log.error(_("Unknown stream method, this should not happen at this stage, cancelling transfer"))
-        #     del client._xep_0096_waiting_for_approval[sid]
-        #     return
-
-        # file_obj = self._getFileObject(dest_path, can_range)
-        # range_offset = file_obj.tell()
-        d = data["stream_plugin"].create_session(
-            client, data["stream_object"], client.jid, data["peer_jid"], data["si_id"]
-        )
-        d.addCallback(self._transfer_cb, client, data)
-        d.addErrback(self._transfer_eb, client, data)
-
-        # we can send the iq result
-        feature_elt = self.host.plugins["XEP-0020"].choose_option(
-            {"stream-method": data["stream_method"]}, namespace=None
-        )
-        misc_elts = []
-        misc_elts.append(domish.Element((SI_PROFILE, "file")))
-        # if can_range:
-        #     range_elt = domish.Element((None, "range"))
-        #     range_elt['offset'] = str(range_offset)
-        #     #TODO: manage range length
-        #     misc_elts.append(range_elt)
-        self._si.accept_stream(client, iq_elt, feature_elt, misc_elts)
-
-    def _transfer_cb(self, __, client, data):
-        """Called by the stream method when transfer successfuly finished
-
-        @param data: session data
-        """
-        # TODO: check hash
-        data["stream_object"].close()
-        log.info("Transfer {si_id} successfuly finished".format(**data))
-
-    def _transfer_eb(self, failure, client, data):
-        """Called when something went wrong with the transfer
-
-        @param id: stream id
-        @param data: session data
-        """
-        log.warning(
-            "Transfer {si_id} failed: {reason}".format(
-                reason=str(failure.value), **data
-            )
-        )
-        data["stream_object"].close()
-
-    def _file_send(self, peer_jid_s, filepath, name, desc, profile=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile)
-        return self.file_send(
-            client, jid.JID(peer_jid_s), filepath, name or None, desc or None
-        )
-
-    def file_send(self, client, peer_jid, filepath, name=None, desc=None, extra=None):
-        """Send a file using XEP-0096
-
-        @param peer_jid(jid.JID): recipient
-        @param filepath(str): absolute path to the file to send
-        @param name(unicode): name of the file to send
-            name must not contain "/" characters
-        @param desc: description of the file
-        @param extra: not used here
-        @return: an unique id to identify the transfer
-        """
-        feature_elt = self.host.plugins["XEP-0020"].propose_features(
-            {"stream-method": self.managed_stream_m}, namespace=None
-        )
-
-        file_transfer_elts = []
-
-        statinfo = os.stat(filepath)
-        file_elt = domish.Element((SI_PROFILE, "file"))
-        file_elt["name"] = name or os.path.basename(filepath)
-        assert "/" not in file_elt["name"]
-        size = statinfo.st_size
-        file_elt["size"] = str(size)
-        if desc:
-            file_elt.addElement("desc", content=desc)
-        file_transfer_elts.append(file_elt)
-
-        file_transfer_elts.append(domish.Element((None, "range")))
-
-        sid, offer_d = self._si.propose_stream(
-            client, peer_jid, SI_PROFILE, feature_elt, file_transfer_elts
-        )
-        args = [filepath, sid, size, client]
-        offer_d.addCallbacks(self._file_cb, self._file_eb, args, None, args)
-        return sid
-
-    def _file_cb(self, result_tuple, filepath, sid, size, client):
-        iq_elt, si_elt = result_tuple
-
-        try:
-            feature_elt = self.host.plugins["XEP-0020"].get_feature_elt(si_elt)
-        except exceptions.NotFound:
-            log.warning("No <feature/> element found in result while expected")
-            return
-
-        choosed_options = self.host.plugins["XEP-0020"].get_choosed_options(
-            feature_elt, namespace=None
-        )
-        try:
-            stream_method = choosed_options["stream-method"]
-        except KeyError:
-            log.warning("No stream method choosed")
-            return
-
-        try:
-            file_elt = next(si_elt.elements(NS_SI_FT, "file"))
-        except StopIteration:
-            pass
-        else:
-            range_, range_offset, range_length = self._parse_range(file_elt, size)
-
-        if stream_method == self.host.plugins["XEP-0065"].NAMESPACE:
-            plugin = self.host.plugins["XEP-0065"]
-        elif stream_method == self.host.plugins["XEP-0047"].NAMESPACE:
-            plugin = self.host.plugins["XEP-0047"]
-        else:
-            log.warning("Invalid stream method received")
-            return
-
-        stream_object = stream.FileStreamObject(
-            self.host, client, filepath, uid=sid, size=size
-        )
-        d = plugin.start_stream(client, stream_object, client.jid,
-                               jid.JID(iq_elt["from"]), sid)
-        d.addCallback(self._send_cb, client, sid, stream_object)
-        d.addErrback(self._send_eb, client, sid, stream_object)
-
-    def _file_eb(self, failure, filepath, sid, size, client):
-        if failure.check(error.StanzaError):
-            stanza_err = failure.value
-            if stanza_err.code == "403" and stanza_err.condition == "forbidden":
-                from_s = stanza_err.stanza["from"]
-                log.info("File transfer refused by {}".format(from_s))
-                msg = D_("The contact {} has refused your file").format(from_s)
-                title = D_("File refused")
-                xml_tools.quick_note(self.host, client, msg, title, C.XMLUI_DATA_LVL_INFO)
-            else:
-                log.warning(_("Error during file transfer"))
-                msg = D_(
-                    "Something went wrong during the file transfer session initialisation: {reason}"
-                ).format(reason=str(stanza_err))
-                title = D_("File transfer error")
-                xml_tools.quick_note(self.host, client, msg, title, C.XMLUI_DATA_LVL_ERROR)
-        elif failure.check(exceptions.DataError):
-            log.warning("Invalid stanza received")
-        else:
-            log.error("Error while proposing stream: {}".format(failure))
-
-    def _send_cb(self, __, client, sid, stream_object):
-        log.info(
-            _("transfer {sid} successfuly finished [{profile}]").format(
-                sid=sid, profile=client.profile
-            )
-        )
-        stream_object.close()
-
-    def _send_eb(self, failure, client, sid, stream_object):
-        log.warning(
-            _("transfer {sid} failed [{profile}]: {reason}").format(
-                sid=sid, profile=client.profile, reason=str(failure.value)
-            )
-        )
-        stream_object.close()
--- a/sat/plugins/plugin_xep_0100.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,265 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing gateways (xep-0100)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.tools import xml_tools
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.words.protocols.jabber import jid
-from twisted.internet import reactor, defer
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Gateways Plugin",
-    C.PI_IMPORT_NAME: "XEP-0100",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0100"],
-    C.PI_DEPENDENCIES: ["XEP-0077"],
-    C.PI_MAIN: "XEP_0100",
-    C.PI_DESCRIPTION: _("""Implementation of Gateways protocol"""),
-}
-
-WARNING_MSG = D_(
-    """Be careful ! Gateways allow you to use an external IM (legacy IM), so you can see your contact as XMPP contacts.
-But when you do this, all your messages go throught the external legacy IM server, it is a huge privacy issue (i.e.: all your messages throught the gateway can be monitored, recorded, analysed by the external server, most of time a private company)."""
-)
-
-GATEWAY_TIMEOUT = 10  # time to wait before cancelling a gateway disco info, in seconds
-
-TYPE_DESCRIPTIONS = {
-    "irc": D_("Internet Relay Chat"),
-    "xmpp": D_("XMPP"),
-    "qq": D_("Tencent QQ"),
-    "simple": D_("SIP/SIMPLE"),
-    "icq": D_("ICQ"),
-    "yahoo": D_("Yahoo! Messenger"),
-    "gadu-gadu": D_("Gadu-Gadu"),
-    "aim": D_("AOL Instant Messenger"),
-    "msn": D_("Windows Live Messenger"),
-}
-
-
-class XEP_0100(object):
-    def __init__(self, host):
-        log.info(_("Gateways plugin initialization"))
-        self.host = host
-        self.__gateways = {}  # dict used to construct the answer to gateways_find. Key = target jid
-        host.bridge.add_method(
-            "gateways_find",
-            ".plugin",
-            in_sign="ss",
-            out_sign="s",
-            method=self._find_gateways,
-        )
-        host.bridge.add_method(
-            "gateway_register",
-            ".plugin",
-            in_sign="ss",
-            out_sign="s",
-            method=self._gateway_register,
-        )
-        self.__menu_id = host.register_callback(self._gateways_menu, with_data=True)
-        self.__selected_id = host.register_callback(
-            self._gateway_selected_cb, with_data=True
-        )
-        host.import_menu(
-            (D_("Service"), D_("Gateways")),
-            self._gateways_menu,
-            security_limit=1,
-            help_string=D_("Find gateways"),
-        )
-
-    def _gateways_menu(self, data, profile):
-        """ XMLUI activated by menu: return Gateways UI
-
-        @param profile: %(doc_profile)s
-        """
-        client = self.host.get_client(profile)
-        try:
-            jid_ = jid.JID(
-                data.get(xml_tools.form_escape("external_jid"), client.jid.host)
-            )
-        except RuntimeError:
-            raise exceptions.DataError(_("Invalid JID"))
-        d = self.gateways_find(jid_, profile)
-        d.addCallback(self._gateways_result_2_xmlui, jid_)
-        d.addCallback(lambda xmlui: {"xmlui": xmlui.toXml()})
-        return d
-
-    def _gateways_result_2_xmlui(self, result, entity):
-        xmlui = xml_tools.XMLUI(title=_("Gateways manager (%s)") % entity.full())
-        xmlui.addText(_(WARNING_MSG))
-        xmlui.addDivider("dash")
-        adv_list = xmlui.change_container(
-            "advanced_list",
-            columns=3,
-            selectable="single",
-            callback_id=self.__selected_id,
-        )
-        for success, gateway_data in result:
-            if not success:
-                fail_cond, disco_item = gateway_data
-                xmlui.addJid(disco_item.entity)
-                xmlui.addText(_("Failed (%s)") % fail_cond)
-                xmlui.addEmpty()
-            else:
-                jid_, data = gateway_data
-                for datum in data:
-                    identity, name = datum
-                    adv_list.set_row_index(jid_.full())
-                    xmlui.addJid(jid_)
-                    xmlui.addText(name)
-                    xmlui.addText(self._get_identity_desc(identity))
-        adv_list.end()
-        xmlui.addDivider("blank")
-        xmlui.change_container("advanced_list", columns=3)
-        xmlui.addLabel(_("Use external XMPP server"))
-        xmlui.addString("external_jid")
-        xmlui.addButton(self.__menu_id, _("Go !"), fields_back=("external_jid",))
-        return xmlui
-
-    def _gateway_selected_cb(self, data, profile):
-        try:
-            target_jid = jid.JID(data["index"])
-        except (KeyError, RuntimeError):
-            log.warning(_("No gateway index selected"))
-            return {}
-
-        d = self.gateway_register(target_jid, profile)
-        d.addCallback(lambda xmlui: {"xmlui": xmlui.toXml()})
-        return d
-
-    def _get_identity_desc(self, identity):
-        """ Return a human readable description of identity
-        @param identity: tuple as returned by Disco identities (category, type)
-
-        """
-        category, type_ = identity
-        if category != "gateway":
-            log.error(
-                _(
-                    'INTERNAL ERROR: identity category should always be "gateway" in _getTypeString, got "%s"'
-                )
-                % category
-            )
-        try:
-            return _(TYPE_DESCRIPTIONS[type_])
-        except KeyError:
-            return _("Unknown IM")
-
-    def _registration_successful(self, jid_, profile):
-        """Called when in_band registration is ok, we must now follow the rest of procedure"""
-        log.debug(_("Registration successful, doing the rest"))
-        self.host.contact_add(jid_, profile_key=profile)
-        self.host.presence_set(jid_, profile_key=profile)
-
-    def _gateway_register(self, target_jid_s, profile_key=C.PROF_KEY_NONE):
-        d = self.gateway_register(jid.JID(target_jid_s), profile_key)
-        d.addCallback(lambda xmlui: xmlui.toXml())
-        return d
-
-    def gateway_register(self, target_jid, profile_key=C.PROF_KEY_NONE):
-        """Register gateway using in-band registration, then log-in to gateway"""
-        profile = self.host.memory.get_profile_name(profile_key)
-        assert profile
-        d = self.host.plugins["XEP-0077"].in_band_register(
-            target_jid, self._registration_successful, profile
-        )
-        return d
-
-    def _infos_received(self, dl_result, items, target, client):
-        """Find disco infos about entity, to check if it is a gateway"""
-
-        ret = []
-        for idx, (success, result) in enumerate(dl_result):
-            if not success:
-                if isinstance(result.value, defer.CancelledError):
-                    msg = _("Timeout")
-                else:
-                    try:
-                        msg = result.value.condition
-                    except AttributeError:
-                        msg = str(result)
-                ret.append((success, (msg, items[idx])))
-            else:
-                entity = items[idx].entity
-                gateways = [
-                    (identity, result.identities[identity])
-                    for identity in result.identities
-                    if identity[0] == "gateway"
-                ]
-                if gateways:
-                    log.info(
-                        _("Found gateway [%(jid)s]: %(identity_name)s")
-                        % {
-                            "jid": entity.full(),
-                            "identity_name": " - ".join(
-                                [gateway[1] for gateway in gateways]
-                            ),
-                        }
-                    )
-                    ret.append((success, (entity, gateways)))
-                else:
-                    log.info(
-                        _("Skipping [%(jid)s] which is not a gateway")
-                        % {"jid": entity.full()}
-                    )
-        return ret
-
-    def _items_received(self, disco, target, client):
-        """Look for items with disco protocol, and ask infos for each one"""
-
-        if len(disco._items) == 0:
-            log.debug(_("No gateway found"))
-            return []
-
-        _defers = []
-        for item in disco._items:
-            log.debug(_("item found: %s") % item.entity)
-            _defers.append(client.disco.requestInfo(item.entity))
-        dl = defer.DeferredList(_defers)
-        dl.addCallback(
-            self._infos_received, items=disco._items, target=target, client=client
-        )
-        reactor.callLater(GATEWAY_TIMEOUT, dl.cancel)
-        return dl
-
-    def _find_gateways(self, target_jid_s, profile_key):
-        target_jid = jid.JID(target_jid_s)
-        profile = self.host.memory.get_profile_name(profile_key)
-        if not profile:
-            raise exceptions.ProfileUnknownError
-        d = self.gateways_find(target_jid, profile)
-        d.addCallback(self._gateways_result_2_xmlui, target_jid)
-        d.addCallback(lambda xmlui: xmlui.toXml())
-        return d
-
-    def gateways_find(self, target, profile):
-        """Find gateways in the target JID, using discovery protocol
-        """
-        client = self.host.get_client(profile)
-        log.debug(
-            _("find gateways (target = %(target)s, profile = %(profile)s)")
-            % {"target": target.full(), "profile": profile}
-        )
-        d = client.disco.requestItems(target)
-        d.addCallback(self._items_received, target=target, client=client)
-        return d
--- a/sat/plugins/plugin_xep_0103.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,90 +0,0 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Dict, Any
-
-from twisted.words.xish import domish
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core import exceptions
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "URL Address Information",
-    C.PI_IMPORT_NAME: "XEP-0103",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0103"],
-    C.PI_MAIN: "XEP_0103",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of XEP-0103 (URL Address Information)"""),
-}
-
-NS_URL_DATA = "http://jabber.org/protocol/url-data"
-
-
-class XEP_0103:
-    namespace = NS_URL_DATA
-
-    def __init__(self, host):
-        log.info(_("XEP-0103 (URL Address Information) plugin initialization"))
-        host.register_namespace("url-data", NS_URL_DATA)
-
-    def get_url_data_elt(
-        self,
-        url: str,
-        **kwargs
-    ) -> domish.Element:
-        """Generate the element describing the URL
-
-        @param url: URL to use
-        @param extra: extra metadata describing how to access the URL
-        @return: ``<url-data/>`` element
-        """
-        url_data_elt = domish.Element((NS_URL_DATA, "url-data"))
-        url_data_elt["target"] = url
-        return url_data_elt
-
-    def parse_url_data_elt(
-        self,
-        url_data_elt: domish.Element
-    ) -> Dict[str, Any]:
-        """Parse <url-data/> element
-
-        @param url_data_elt: <url-data/> element
-            a parent element can also be used
-        @return: url-data data. It's a dict whose keys correspond to
-            [get_url_data_elt] parameters
-        @raise exceptions.NotFound: no <url-data/> element has been found
-        """
-        if url_data_elt.name != "url-data":
-            try:
-                url_data_elt = next(
-                    url_data_elt.elements(NS_URL_DATA, "url-data")
-                )
-            except StopIteration:
-                raise exceptions.NotFound
-        try:
-            data: Dict[str, Any] = {"url": url_data_elt["target"]}
-        except KeyError:
-            raise ValueError(f'"target" attribute is missing: {url_data_elt.toXml}')
-
-        return data
--- a/sat/plugins/plugin_xep_0106.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,111 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Explicit Message Encryption
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from twisted.words.protocols.jabber import xmlstream
-from zope.interface import implementer
-from wokkel import disco
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "JID Escaping",
-    C.PI_IMPORT_NAME: "XEP-0106",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0106"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "XEP_0106",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""(Un)escape JID to use disallowed chars in local parts"""),
-}
-
-NS_JID_ESCAPING = r"jid\20escaping"
-ESCAPE_MAP = {
-    ' ': r'\20',
-    '"': r'\22',
-    '&': r'\26',
-    "'": r'\27',
-    '/': r'\2f',
-    ':': r'\3a',
-    '<': r'\3c',
-    '>': r'\3e',
-    '@': r'\40',
-    '\\': r'\5c',
-}
-
-
-class XEP_0106(object):
-
-    def __init__(self, host):
-        self.reverse_map = {v:k for k,v in ESCAPE_MAP.items()}
-
-    def get_handler(self, client):
-        return XEP_0106_handler()
-
-    def escape(self, text):
-        """Escape text
-
-        @param text(unicode): text to escape
-        @return (unicode): escaped text
-        @raise ValueError: text can't be escaped
-        """
-        if not text or text[0] == ' ' or text[-1] == ' ':
-            raise ValueError("text must not be empty, or start or end with a whitespace")
-        escaped = []
-        for c in text:
-            if c in ESCAPE_MAP:
-                escaped.append(ESCAPE_MAP[c])
-            else:
-                escaped.append(c)
-        return ''.join(escaped)
-
-    def unescape(self, escaped):
-        """Unescape text
-
-        @param escaped(unicode): text to unescape
-        @return (unicode): unescaped text
-        @raise ValueError: text can't be unescaped
-        """
-        if not escaped or escaped.startswith(r'\27') or escaped.endswith(r'\27'):
-            raise ValueError("escaped value must not be empty, or start or end with a "
-                             f"whitespace: rejected value is {escaped!r}")
-        unescaped = []
-        idx = 0
-        while idx < len(escaped):
-            char_seq = escaped[idx:idx+3]
-            if char_seq in self.reverse_map:
-                unescaped.append(self.reverse_map[char_seq])
-                idx += 3
-            else:
-                unescaped.append(escaped[idx])
-                idx += 1
-        return ''.join(unescaped)
-
-
-@implementer(disco.IDisco)
-class XEP_0106_handler(xmlstream.XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_JID_ESCAPING)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0115.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,212 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing xep-0115
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.words.xish import domish
-from twisted.words.protocols.jabber import jid
-from twisted.internet import defer, error
-from zope.interface import implementer
-from wokkel import disco, iwokkel
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-
-PRESENCE = "/presence"
-NS_ENTITY_CAPABILITY = "http://jabber.org/protocol/caps"
-NS_CAPS_OPTIMIZE = "http://jabber.org/protocol/caps#optimize"
-CAPABILITY_UPDATE = PRESENCE + '/c[@xmlns="' + NS_ENTITY_CAPABILITY + '"]'
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP 0115 Plugin",
-    C.PI_IMPORT_NAME: "XEP-0115",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0115"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "XEP_0115",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of entity capabilities"""),
-}
-
-
-class XEP_0115(object):
-    cap_hash = None  # capabilities hash is class variable as it is common to all profiles
-
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0115 initialization"))
-        self.host = host
-        host.trigger.add("Presence send", self._presence_trigger)
-
-    def get_handler(self, client):
-        return XEP_0115_handler(self)
-
-    @defer.inlineCallbacks
-    def _prepare_caps(self, client):
-        # we have to calculate hash for client
-        # because disco infos/identities may change between clients
-
-        # optimize check
-        client._caps_optimize = yield self.host.hasFeature(client, NS_CAPS_OPTIMIZE)
-        if client._caps_optimize:
-            log.info(_("Caps optimisation enabled"))
-            client._caps_sent = False
-        else:
-            log.warning(_("Caps optimisation not available"))
-
-        # hash generation
-        _infos = yield client.discoHandler.info(client.jid, client.jid, "")
-        disco_infos = disco.DiscoInfo()
-        for item in _infos:
-            disco_infos.append(item)
-        cap_hash = client._caps_hash = self.host.memory.disco.generate_hash(disco_infos)
-        log.info(
-            "Our capability hash has been generated: [{cap_hash}]".format(
-                cap_hash=cap_hash
-            )
-        )
-        log.debug("Generating capability domish.Element")
-        c_elt = domish.Element((NS_ENTITY_CAPABILITY, "c"))
-        c_elt["hash"] = "sha-1"
-        c_elt["node"] = C.APP_URL
-        c_elt["ver"] = cap_hash
-        client._caps_elt = c_elt
-        if client._caps_optimize:
-            client._caps_sent = False
-        if cap_hash not in self.host.memory.disco.hashes:
-            self.host.memory.disco.hashes[cap_hash] = disco_infos
-            self.host.memory.update_entity_data(
-                client, client.jid, C.ENTITY_CAP_HASH, cap_hash
-            )
-
-    def _presence_add_elt(self, client, obj):
-        if client._caps_optimize:
-            if client._caps_sent:
-                return
-            client.caps_sent = True
-        obj.addChild(client._caps_elt)
-
-    def _presence_trigger(self, client, obj, presence_d):
-        if not hasattr(client, "_caps_optimize"):
-            presence_d.addCallback(lambda __: self._prepare_caps(client))
-
-        presence_d.addCallback(lambda __: self._presence_add_elt(client, obj))
-        return True
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0115_handler(XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-
-    @property
-    def client(self):
-        return self.parent
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(CAPABILITY_UPDATE, self.update)
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [
-            disco.DiscoFeature(NS_ENTITY_CAPABILITY),
-            disco.DiscoFeature(NS_CAPS_OPTIMIZE),
-        ]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
-
-    def update(self, presence):
-        """
-        Manage the capabilities of the entity
-
-        Check if we know the version of this capabilities and get the capabilities if necessary
-        """
-        from_jid = jid.JID(presence["from"])
-        c_elem = next(presence.elements(NS_ENTITY_CAPABILITY, "c"))
-        try:
-            c_ver = c_elem["ver"]
-            c_hash = c_elem["hash"]
-            c_node = c_elem["node"]
-        except KeyError:
-            log.warning(_("Received invalid capabilities tag: %s") % c_elem.toXml())
-            return
-
-        if c_ver in self.host.memory.disco.hashes:
-            # we already know the hash, we update the jid entity
-            log.debug(
-                "hash [%(hash)s] already in cache, updating entity [%(jid)s]"
-                % {"hash": c_ver, "jid": from_jid.full()}
-            )
-            self.host.memory.update_entity_data(
-                self.client, from_jid, C.ENTITY_CAP_HASH, c_ver
-            )
-            return
-
-        if c_hash != "sha-1":  # unknown hash method
-            log.warning(
-                _(
-                    "Unknown hash method for entity capabilities: [{hash_method}] "
-                    "(entity: {entity_jid}, node: {node})"
-                )
-                .format(hash_method = c_hash, entity_jid = from_jid, node = c_node)
-            )
-
-        def cb(__):
-            computed_hash = self.host.memory.get_entity_datum(
-                self.client, from_jid, C.ENTITY_CAP_HASH
-            )
-            if computed_hash != c_ver:
-                log.warning(
-                    _(
-                        "Computed hash differ from given hash:\n"
-                        "given: [{given}]\n"
-                        "computed: [{computed}]\n"
-                        "(entity: {entity_jid}, node: {node})"
-                    ).format(
-                        given = c_ver,
-                        computed = computed_hash,
-                        entity_jid = from_jid,
-                        node = c_node,
-                    )
-                )
-
-        def eb(failure):
-            if isinstance(failure.value, error.ConnectionDone):
-                return
-            msg = (
-                failure.value.condition
-                if hasattr(failure.value, "condition")
-                else failure.getErrorMessage()
-            )
-            log.error(
-                _("Couldn't retrieve disco info for {jid}: {error}").format(
-                    jid=from_jid.full(), error=msg
-                )
-            )
-
-        d = self.host.get_disco_infos(self.parent, from_jid)
-        d.addCallbacks(cb, eb)
-        # TODO: me must manage the full algorithm described at XEP-0115 #5.4 part 3
--- a/sat/plugins/plugin_xep_0163.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,203 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Personal Eventing Protocol (xep-0163)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional, Callable
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-from twisted.words.xish import domish
-
-from wokkel import disco, pubsub
-from wokkel.formats import Mood
-from sat.tools.common import data_format
-
-
-log = getLogger(__name__)
-
-NS_USER_MOOD = "http://jabber.org/protocol/mood"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Personal Eventing Protocol Plugin",
-    C.PI_IMPORT_NAME: "XEP-0163",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0163", "XEP-0107"],
-    C.PI_DEPENDENCIES: ["XEP-0060"],
-    C.PI_MAIN: "XEP_0163",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of Personal Eventing Protocol"""),
-}
-
-
-class XEP_0163(object):
-    def __init__(self, host):
-        log.info(_("PEP plugin initialization"))
-        self.host = host
-        self.pep_events = set()
-        self.pep_out_cb = {}
-        host.trigger.add("PubSub Disco Info", self.diso_info_trigger)
-        host.bridge.add_method(
-            "pep_send",
-            ".plugin",
-            in_sign="sa{ss}s",
-            out_sign="",
-            method=self.pep_send,
-            async_=True,
-        )  # args: type(MOOD, TUNE, etc), data, profile_key;
-        self.add_pep_event("MOOD", NS_USER_MOOD, self.user_mood_cb, self.send_mood)
-
-    def diso_info_trigger(self, disco_info, profile):
-        """Add info from managed PEP
-
-        @param disco_info: list of disco feature as returned by PubSub,
-            will be filled with PEP features
-        @param profile: profile we are handling
-        """
-        disco_info.extend(list(map(disco.DiscoFeature, self.pep_events)))
-        return True
-
-    def add_pep_event(
-        self,
-        event_type: Optional[str],
-        node: str,
-        in_callback: Callable,
-        out_callback: Optional[Callable] = None,
-        notify: bool = True
-    ) -> None:
-        """Add a Personal Eventing Protocol event manager
-
-        @param event_type: type of the event (stored uppercase),
-            only used when out_callback is set.
-            Can be MOOD, TUNE, etc.
-        @param node: namespace of the node (e.g. http://jabber.org/protocol/mood
-            for User Mood)
-        @param in_callback: method to call when this event occur
-            the callable will be called with (itemsEvent, profile) as arguments
-        @param out_callback: method to call when we want to publish this
-            event (must return a deferred)
-            the callable will be called when send_pep_event is called
-        @param notify: add autosubscribe (+notify) if True
-        """
-        if event_type and out_callback:
-            event_type = event_type.upper()
-            if event_type in self.pep_out_cb:
-                raise exceptions.ConflictError(
-                    f"event_type {event_type!r} already exists"
-                )
-            self.pep_out_cb[event_type] = out_callback
-        self.pep_events.add(node)
-        if notify:
-            self.pep_events.add(node + "+notify")
-
-        def filter_pep_event(client, itemsEvent):
-            """Ignore messages which are not coming from PEP (i.e. a bare jid)
-
-            @param itemsEvent(pubsub.ItemsEvent): pubsub event
-            """
-            if not itemsEvent.sender.user or itemsEvent.sender.resource:
-                log.debug(
-                    "ignoring non PEP event from {} (profile={})".format(
-                        itemsEvent.sender.full(), client.profile
-                    )
-                )
-                return
-            in_callback(itemsEvent, client.profile)
-
-        self.host.plugins["XEP-0060"].add_managed_node(node, items_cb=filter_pep_event)
-
-    def send_pep_event(self, node, data, profile):
-        """Publish the event data
-
-        @param node(unicode): node namespace
-        @param data: domish.Element to use as payload
-        @param profile: profile which send the data
-        """
-        client = self.host.get_client(profile)
-        item = pubsub.Item(payload=data)
-        return self.host.plugins["XEP-0060"].publish(client, None, node, [item])
-
-    def pep_send(self, event_type, data, profile_key=C.PROF_KEY_NONE):
-        """Send personal event after checking the data is alright
-
-        @param event_type: type of event (eg: MOOD, TUNE),
-            must be in self.pep_out_cb.keys()
-        @param data: dict of {string:string} of event_type dependant data
-        @param profile_key: profile who send the event
-        """
-        profile = self.host.memory.get_profile_name(profile_key)
-        if not profile:
-            log.error(
-                _("Trying to send personal event with an unknown profile key [%s]")
-                % profile_key
-            )
-            raise exceptions.ProfileUnknownError
-        if not event_type in list(self.pep_out_cb.keys()):
-            log.error(_("Trying to send personal event for an unknown type"))
-            raise exceptions.DataError("Type unknown")
-        return self.pep_out_cb[event_type](data, profile)
-
-    def user_mood_cb(self, itemsEvent, profile):
-        if not itemsEvent.items:
-            log.debug(_("No item found"))
-            return
-        try:
-            mood_elt = [
-                child for child in itemsEvent.items[0].elements() if child.name == "mood"
-            ][0]
-        except IndexError:
-            log.error(_("Can't find mood element in mood event"))
-            return
-        mood = Mood.fromXml(mood_elt)
-        if not mood:
-            log.debug(_("No mood found"))
-            return
-        self.host.bridge.ps_event(
-            C.PS_PEP,
-            itemsEvent.sender.full(),
-            itemsEvent.nodeIdentifier,
-            "MOOD",
-            data_format.serialise({"mood": mood.value or "", "text": mood.text or ""}),
-            profile,
-        )
-
-    def send_mood(self, data, profile):
-        """Send XEP-0107's User Mood
-
-        @param data: must include mood and text
-        @param profile: profile which send the mood"""
-        try:
-            value = data["mood"].lower()
-            text = data["text"] if "text" in data else ""
-        except KeyError:
-            raise exceptions.DataError("Mood data must contain at least 'mood' key")
-        mood = UserMood(value, text)
-        return self.send_pep_event(NS_USER_MOOD, mood, profile)
-
-
-class UserMood(Mood, domish.Element):
-    """Improved wokkel Mood which is also a domish.Element"""
-
-    def __init__(self, value, text=None):
-        Mood.__init__(self, value, text)
-        domish.Element.__init__(self, (NS_USER_MOOD, "mood"))
-        self.addElement(value)
-        if text:
-            self.addElement("text", content=text)
--- a/sat/plugins/plugin_xep_0166/__init__.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1409 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for Jingle (XEP-0166)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import time
-from typing import Any, Callable, Dict, Final, List, Optional, Tuple
-import uuid
-
-from twisted.internet import defer
-from twisted.internet import reactor
-from twisted.python import failure
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber import error
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import D_, _
-from sat.core.log import getLogger
-from sat.tools import xml_tools
-from sat.tools import utils
-
-from .models import (
-    ApplicationData,
-    BaseApplicationHandler,
-    BaseTransportHandler,
-    ContentData,
-    TransportData,
-)
-
-
-log = getLogger(__name__)
-
-
-IQ_SET : Final = '/iq[@type="set"]'
-NS_JINGLE : Final = "urn:xmpp:jingle:1"
-NS_JINGLE_ERROR : Final = "urn:xmpp:jingle:errors:1"
-JINGLE_REQUEST : Final = f'{IQ_SET}/jingle[@xmlns="{NS_JINGLE}"]'
-STATE_PENDING : Final = "PENDING"
-STATE_ACTIVE : Final = "ACTIVE"
-STATE_ENDED : Final = "ENDED"
-CONFIRM_TXT : Final = D_(
-    "{entity} want to start a jingle session with you, do you accept ?"
-)
-
-PLUGIN_INFO : Final = {
-    C.PI_NAME: "Jingle",
-    C.PI_IMPORT_NAME: "XEP-0166",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0166"],
-    C.PI_MAIN: "XEP_0166",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Jingle"""),
-}
-
-
-class XEP_0166:
-    namespace : Final = NS_JINGLE
-
-    ROLE_INITIATOR : Final = "initiator"
-    ROLE_RESPONDER : Final = "responder"
-
-    TRANSPORT_DATAGRAM : Final = "UDP"
-    TRANSPORT_STREAMING : Final = "TCP"
-
-    REASON_SUCCESS : Final = "success"
-    REASON_DECLINE : Final = "decline"
-    REASON_FAILED_APPLICATION : Final = "failed-application"
-    REASON_FAILED_TRANSPORT : Final = "failed-transport"
-    REASON_CONNECTIVITY_ERROR : Final = "connectivity-error"
-
-    # standard actions
-
-    A_SESSION_INITIATE : Final = "session-initiate"
-    A_SESSION_ACCEPT : Final = "session-accept"
-    A_SESSION_TERMINATE : Final = "session-terminate"
-    A_SESSION_INFO : Final = "session-info"
-    A_TRANSPORT_REPLACE : Final = "transport-replace"
-    A_TRANSPORT_ACCEPT : Final = "transport-accept"
-    A_TRANSPORT_REJECT : Final = "transport-reject"
-    A_TRANSPORT_INFO : Final = "transport-info"
-
-    # non standard actions
-
-    #: called before the confirmation request, first event for responder, useful for
-    #: parsing
-    A_PREPARE_CONFIRMATION : Final = "prepare-confirmation"
-    #: initiator must prepare tranfer
-    A_PREPARE_INITIATOR : Final = "prepare-initiator"
-    #: responder must prepare tranfer
-    A_PREPARE_RESPONDER : Final = "prepare-responder"
-    #; session accepted ack has been received from initiator
-    A_ACCEPTED_ACK : Final = (
-        "accepted-ack"
-    )
-    A_START : Final = "start"  # application can start
-    #: called when a transport is destroyed (e.g. because it is remplaced). Used to do
-    #: cleaning operations
-    A_DESTROY : Final = (
-        "destroy"
-    )
-
-    def __init__(self, host):
-        log.info(_("plugin Jingle initialization"))
-        self.host = host
-        self._applications = {}  # key: namespace, value: application data
-        self._transports = {}  # key: namespace, value: transport data
-        # we also keep transports by type, they are then sorted by priority
-        self._type_transports = {
-            XEP_0166.TRANSPORT_DATAGRAM: [],
-            XEP_0166.TRANSPORT_STREAMING: [],
-        }
-
-    def profile_connected(self, client):
-        client.jingle_sessions = {}  # key = sid, value = session_data
-
-    def get_handler(self, client):
-        return XEP_0166_handler(self)
-
-    def get_session(self, client: SatXMPPEntity, session_id: str) -> dict:
-        """Retrieve session from its SID
-
-        @param session_id: session ID
-        @return: found session
-
-        @raise exceptions.NotFound: no session with this SID has been found
-        """
-        try:
-            return client.jingle_sessions[session_id]
-        except KeyError:
-            raise exceptions.NotFound(
-                f"No session with SID {session_id} found"
-            )
-
-
-    def _del_session(self, client, sid):
-        try:
-            del client.jingle_sessions[sid]
-        except KeyError:
-            log.debug(
-                f"Jingle session id {sid!r} is unknown, nothing to delete "
-                f"[{client.profile}]")
-        else:
-            log.debug(f"Jingle session id {sid!r} deleted [{client.profile}]")
-
-    ## helpers methods to build stanzas ##
-
-    def _build_jingle_elt(
-        self,
-        client: SatXMPPEntity,
-        session: dict,
-        action: str
-    ) -> Tuple[xmlstream.IQ, domish.Element]:
-        iq_elt = client.IQ("set")
-        iq_elt["from"] = session['local_jid'].full()
-        iq_elt["to"] = session["peer_jid"].full()
-        jingle_elt = iq_elt.addElement("jingle", NS_JINGLE)
-        jingle_elt["sid"] = session["id"]
-        jingle_elt["action"] = action
-        return iq_elt, jingle_elt
-
-    def sendError(self, client, error_condition, sid, request, jingle_condition=None):
-        """Send error stanza
-
-        @param error_condition: one of twisted.words.protocols.jabber.error.STANZA_CONDITIONS keys
-        @param sid(unicode,None): jingle session id, or None, if session must not be destroyed
-        @param request(domish.Element): original request
-        @param jingle_condition(None, unicode): if not None, additional jingle-specific error information
-        """
-        iq_elt = error.StanzaError(error_condition).toResponse(request)
-        if jingle_condition is not None:
-            iq_elt.error.addElement((NS_JINGLE_ERROR, jingle_condition))
-        if error.STANZA_CONDITIONS[error_condition]["type"] == "cancel" and sid:
-            self._del_session(client, sid)
-            log.warning(
-                "Error while managing jingle session, cancelling: {condition}".format(
-                    condition=error_condition
-                )
-            )
-        return client.send(iq_elt)
-
-    def _terminate_eb(self, failure_):
-        log.warning(_("Error while terminating session: {msg}").format(msg=failure_))
-
-    def terminate(self, client, reason, session, text=None):
-        """Terminate the session
-
-        send the session-terminate action, and delete the session data
-        @param reason(unicode, list[domish.Element]): if unicode, will be transformed to an element
-            if a list of element, add them as children of the <reason/> element
-        @param session(dict): data of the session
-        """
-        iq_elt, jingle_elt = self._build_jingle_elt(
-            client, session, XEP_0166.A_SESSION_TERMINATE
-        )
-        reason_elt = jingle_elt.addElement("reason")
-        if isinstance(reason, str):
-            reason_elt.addElement(reason)
-        else:
-            for elt in reason:
-                reason_elt.addChild(elt)
-        if text is not None:
-            reason_elt.addElement("text", content=text)
-        self._del_session(client, session["id"])
-        d = iq_elt.send()
-        d.addErrback(self._terminate_eb)
-        return d
-
-    ## errors which doesn't imply a stanza sending ##
-
-    def _iq_error(self, failure_, sid, client):
-        """Called when we got an <iq/> error
-
-        @param failure_(failure.Failure): the exceptions raised
-        @param sid(unicode): jingle session id
-        """
-        log.warning(
-            "Error while sending jingle <iq/> stanza: {failure_}".format(
-                failure_=failure_.value
-            )
-        )
-        self._del_session(client, sid)
-
-    def _jingle_error_cb(self, failure_, session, request, client):
-        """Called when something is going wrong while parsing jingle request
-
-        The error condition depend of the exceptions raised:
-            exceptions.DataError raise a bad-request condition
-        @param fail(failure.Failure): the exceptions raised
-        @param session(dict): data of the session
-        @param request(domsih.Element): jingle request
-        @param client: %(doc_client)s
-        """
-        del session["jingle_elt"]
-        log.warning(f"Error while processing jingle request [{client.profile}]")
-        if isinstance(failure_.value, defer.FirstError):
-            failure_ = failure_.value.subFailure.value
-        if isinstance(failure_, exceptions.DataError):
-            return self.sendError(client, "bad-request", session["id"], request)
-        elif isinstance(failure_, error.StanzaError):
-            return self.terminate(client, self.REASON_FAILED_APPLICATION, session,
-                                  text=str(failure_))
-        else:
-            log.error(f"Unmanaged jingle exception: {failure_}")
-            return self.terminate(client, self.REASON_FAILED_APPLICATION, session,
-                                  text=str(failure_))
-
-    ## methods used by other plugins ##
-
-    def register_application(
-        self,
-        namespace: str,
-        handler: BaseApplicationHandler
-    ) -> None:
-        """Register an application plugin
-
-        @param namespace(unicode): application namespace managed by the plugin
-        @param handler(object): instance of a class which manage the application.
-            May have the following methods:
-                - request_confirmation(session, desc_elt, client):
-                    - if present, it is called on when session must be accepted.
-                    - if it return True the session is accepted, else rejected.
-                        A Deferred can be returned
-                    - if not present, a generic accept dialog will be used
-                - jingle_session_init(
-                        client, self, session, content_name[, *args, **kwargs]
-                    ): must return the domish.Element used for initial content
-                - jingle_handler(
-                        client, self, action, session, content_name, transport_elt
-                    ):
-                    called on several action to negociate the application or transport
-                - jingle_terminate: called on session terminate, with reason_elt
-                    May be used to clean session
-        """
-        if namespace in self._applications:
-            raise exceptions.ConflictError(
-                f"Trying to register already registered namespace {namespace}"
-            )
-        self._applications[namespace] = ApplicationData(
-            namespace=namespace, handler=handler
-        )
-        log.debug("new jingle application registered")
-
-    def register_transport(
-            self,
-            namespace: str,
-            transport_type: str,
-            handler: BaseTransportHandler,
-            priority: int = 0
-    ) -> None:
-        """Register a transport plugin
-
-        @param namespace: the XML namespace used for this transport
-        @param transport_type: type of transport to use (see XEP-0166 §8)
-        @param handler: instance of a class which manage the application.
-        @param priority: priority of this transport
-        """
-        assert transport_type in (
-            XEP_0166.TRANSPORT_DATAGRAM,
-            XEP_0166.TRANSPORT_STREAMING,
-        )
-        if namespace in self._transports:
-            raise exceptions.ConflictError(
-                "Trying to register already registered namespace {}".format(namespace)
-            )
-        transport_data = TransportData(
-            namespace=namespace, handler=handler, priority=priority
-        )
-        self._type_transports[transport_type].append(transport_data)
-        self._type_transports[transport_type].sort(
-            key=lambda transport_data: transport_data.priority, reverse=True
-        )
-        self._transports[namespace] = transport_data
-        log.debug("new jingle transport registered")
-
-    @defer.inlineCallbacks
-    def transport_replace(self, client, transport_ns, session, content_name):
-        """Replace a transport
-
-        @param transport_ns(unicode): namespace of the new transport to use
-        @param session(dict): jingle session data
-        @param content_name(unicode): name of the content
-        """
-        # XXX: for now we replace the transport before receiving confirmation from other peer
-        #      this is acceptable because we terminate the session if transport is rejected.
-        #      this behavious may change in the future.
-        content_data = session["contents"][content_name]
-        transport_data = content_data["transport_data"]
-        try:
-            transport = self._transports[transport_ns]
-        except KeyError:
-            raise exceptions.InternalError("Unkown transport")
-        yield content_data["transport"].handler.jingle_handler(
-            client, XEP_0166.A_DESTROY, session, content_name, None
-        )
-        content_data["transport"] = transport
-        transport_data.clear()
-
-        iq_elt, jingle_elt = self._build_jingle_elt(
-            client, session, XEP_0166.A_TRANSPORT_REPLACE
-        )
-        content_elt = jingle_elt.addElement("content")
-        content_elt["name"] = content_name
-        content_elt["creator"] = content_data["creator"]
-
-        transport_elt = transport.handler.jingle_session_init(client, session, content_name)
-        content_elt.addChild(transport_elt)
-        iq_elt.send()
-
-    def build_action(
-        self,
-        client: SatXMPPEntity,
-        action: str,
-        session: dict,
-        content_name: str,
-        iq_elt: Optional[xmlstream.IQ] = None,
-        context_elt: Optional[domish.Element] = None
-    ) -> Tuple[xmlstream.IQ, domish.Element]:
-        """Build an element according to requested action
-
-        @param action: a jingle action (see XEP-0166 §7.2),
-            session-* actions are not managed here
-            transport-replace is managed in the dedicated [transport_replace] method
-        @param session: jingle session data
-        @param content_name: name of the content
-        @param iq_elt: use this IQ instead of creating a new one if provided
-        @param context_elt: use this element instead of creating a new one if provided
-        @return: parent <iq> element, <transport> or <description> element, according to action
-        """
-        # we first build iq, jingle and content element which are the same in every cases
-        if iq_elt is not None:
-            try:
-                jingle_elt = next(iq_elt.elements(NS_JINGLE, "jingle"))
-            except StopIteration:
-                raise exceptions.InternalError(
-                    "The <iq> element provided doesn't have a <jingle> element"
-                )
-        else:
-            iq_elt, jingle_elt = self._build_jingle_elt(client, session, action)
-        # FIXME: XEP-0260 § 2.3 Ex 5 has an initiator attribute, but it should not according to XEP-0166 §7.1 table 1, must be checked
-        content_data = session["contents"][content_name]
-        content_elt = jingle_elt.addElement("content")
-        content_elt["name"] = content_name
-        content_elt["creator"] = content_data["creator"]
-
-        if context_elt is not None:
-            pass
-        elif action == XEP_0166.A_TRANSPORT_INFO:
-            context_elt = transport_elt = content_elt.addElement(
-                "transport", content_data["transport"].namespace
-            )
-        else:
-            raise exceptions.InternalError(f"unmanaged action {action}")
-
-        return iq_elt, context_elt
-
-    def build_session_info(self, client, session):
-        """Build a session-info action
-
-        @param session(dict): jingle session data
-        @return (tuple[domish.Element, domish.Element]): parent <iq> element, <jingle> element
-        """
-        return self._build_jingle_elt(client, session, XEP_0166.A_SESSION_INFO)
-
-    def get_application(self, namespace: str) -> ApplicationData:
-        """Retreive application corresponding to a namespace
-
-        @raise exceptions.NotFound if application can't be found
-        """
-        try:
-            return self._applications[namespace]
-        except KeyError:
-            raise exceptions.NotFound(
-                f"No application registered for {namespace}"
-            )
-
-    def get_content_data(self, content: dict) -> ContentData:
-        """"Retrieve application and its argument from content"""
-        app_ns = content["app_ns"]
-        try:
-            application = self.get_application(app_ns)
-        except exceptions.NotFound as e:
-            raise exceptions.InternalError(str(e))
-        app_args = content.get("app_args", [])
-        app_kwargs = content.get("app_kwargs", {})
-        transport_data = content.get("transport_data", {})
-        try:
-            content_name = content["name"]
-        except KeyError:
-            content_name = content["name"] = str(uuid.uuid4())
-        return ContentData(
-            application,
-            app_args,
-            app_kwargs,
-            transport_data,
-            content_name
-        )
-
-    async def initiate(
-        self,
-        client: SatXMPPEntity,
-        peer_jid: jid.JID,
-        contents: List[dict],
-        encrypted: bool = False,
-        **extra_data: Any
-    ) -> str:
-        """Send a session initiation request
-
-        @param peer_jid: jid to establith session with
-        @param contents: list of contents to use:
-            The dict must have the following keys:
-                - app_ns(str): namespace of the application
-            the following keys are optional:
-                - transport_type(str): type of transport to use (see XEP-0166 §8)
-                    default to TRANSPORT_STREAMING
-                - name(str): name of the content
-                - senders(str): One of XEP_0166.ROLE_INITIATOR, XEP_0166.ROLE_RESPONDER, both or none
-                    default to BOTH (see XEP-0166 §7.3)
-                - app_args(list): args to pass to the application plugin
-                - app_kwargs(dict): keyword args to pass to the application plugin
-        @param encrypted: if True, session must be encrypted and "encryption" must be set
-            to all content data of session
-        @return: jingle session id
-        """
-        assert contents  # there must be at least one content
-        if (peer_jid == client.jid
-            or client.is_component and peer_jid.host == client.jid.host):
-            raise ValueError(_("You can't do a jingle session with yourself"))
-        initiator = client.jid
-        sid = str(uuid.uuid4())
-        # TODO: session cleaning after timeout ?
-        session = client.jingle_sessions[sid] = {
-            "id": sid,
-            "state": STATE_PENDING,
-            "initiator": initiator,
-            "role": XEP_0166.ROLE_INITIATOR,
-            "local_jid": client.jid,
-            "peer_jid": peer_jid,
-            "started": time.time(),
-            "contents": {},
-            **extra_data,
-        }
-
-        if not await self.host.trigger.async_point(
-            "XEP-0166_initiate",
-            client, session, contents
-        ):
-            return sid
-
-        iq_elt, jingle_elt = self._build_jingle_elt(
-            client, session, XEP_0166.A_SESSION_INITIATE
-        )
-        jingle_elt["initiator"] = initiator.full()
-        session["jingle_elt"] = jingle_elt
-
-        session_contents = session["contents"]
-
-        for content in contents:
-            # we get the application plugin
-            content_data = self.get_content_data(content)
-
-            # and the transport plugin
-            transport_type = content.get("transport_type", XEP_0166.TRANSPORT_STREAMING)
-            try:
-                transport = self._type_transports[transport_type][0]
-            except IndexError:
-                raise exceptions.InternalError(
-                    "No transport registered for {}".format(transport_type)
-                )
-
-            # we build the session data for this content
-            application_data = {}
-            transport_data = content_data.transport_data
-            session_content = {
-                "application": content_data.application,
-                "application_data": application_data,
-                "transport": transport,
-                "transport_data": transport_data,
-                "creator": XEP_0166.ROLE_INITIATOR,
-                "senders": content.get("senders", "both"),
-            }
-            if content_data.content_name in session_contents:
-                raise exceptions.InternalError(
-                    "There is already a content with this name"
-                )
-            session_contents[content_data.content_name] = session_content
-
-            # we construct the content element
-            content_elt = jingle_elt.addElement("content")
-            content_elt["creator"] = session_content["creator"]
-            content_elt["name"] = content_data.content_name
-            try:
-                content_elt["senders"] = content["senders"]
-            except KeyError:
-                pass
-
-            # then the description element
-            application_data["desc_elt"] = desc_elt = await utils.as_deferred(
-                content_data.application.handler.jingle_session_init,
-                client, session, content_data.content_name,
-                *content_data.app_args, **content_data.app_kwargs
-            )
-            content_elt.addChild(desc_elt)
-
-            # and the transport one
-            transport_data["transport_elt"] = transport_elt = await utils.as_deferred(
-                transport.handler.jingle_session_init,
-                client, session, content_data.content_name,
-            )
-            content_elt.addChild(transport_elt)
-
-        if not await self.host.trigger.async_point(
-            "XEP-0166_initiate_elt_built",
-            client, session, iq_elt, jingle_elt
-        ):
-            return sid
-
-        # processing is done, we can remove elements
-        for content_data in session_contents.values():
-            del content_data["application_data"]["desc_elt"]
-            del content_data["transport_data"]["transport_elt"]
-        del session["jingle_elt"]
-
-        if encrypted:
-            for content in session["contents"].values():
-                if "encryption" not in content:
-                    raise exceptions.EncryptionError(
-                        "Encryption is requested, but no encryption has been set"
-                    )
-
-        try:
-            await iq_elt.send()
-        except Exception as e:
-            failure_ = failure.Failure(e)
-            self._iq_error(failure_, sid, client)
-            raise failure_
-        return sid
-
-    def delayed_content_terminate(self, *args, **kwargs):
-        """Put content_terminate in queue but don't execute immediately
-
-        This is used to terminate a content inside a handler, to avoid modifying contents
-        """
-        reactor.callLater(0, self.content_terminate, *args, **kwargs)
-
-    def content_terminate(self, client, session, content_name, reason=REASON_SUCCESS):
-        """Terminate and remove a content
-
-        if there is no more content, then session is terminated
-        @param session(dict): jingle session
-        @param content_name(unicode): name of the content terminated
-        @param reason(unicode): reason of the termination
-        """
-        contents = session["contents"]
-        del contents[content_name]
-        if not contents:
-            self.terminate(client, reason, session)
-
-    ## defaults methods called when plugin doesn't have them ##
-
-    def jingle_request_confirmation_default(
-        self, client, action, session, content_name, desc_elt
-    ):
-        """This method request confirmation for a jingle session"""
-        log.debug("Using generic jingle confirmation method")
-        return xml_tools.defer_confirm(
-            self.host,
-            _(CONFIRM_TXT).format(entity=session["peer_jid"].full()),
-            _("Confirm Jingle session"),
-            profile=client.profile,
-        )
-
-    ## jingle events ##
-
-    def _on_jingle_request(self, request: domish.Element, client: SatXMPPEntity) -> None:
-        defer.ensureDeferred(self.on_jingle_request(client, request))
-
-    async def on_jingle_request(
-        self,
-        client: SatXMPPEntity,
-        request: domish.Element
-    ) -> None:
-        """Called when any jingle request is received
-
-        The request will then be dispatched to appropriate method
-        according to current state
-        @param request(domish.Element): received IQ request
-        """
-        request.handled = True
-        jingle_elt = next(request.elements(NS_JINGLE, "jingle"))
-
-        # first we need the session id
-        try:
-            sid = jingle_elt["sid"]
-            if not sid:
-                raise KeyError
-        except KeyError:
-            log.warning("Received jingle request has no sid attribute")
-            self.sendError(client, "bad-request", None, request)
-            return
-
-        # then the action
-        try:
-            action = jingle_elt["action"]
-            if not action:
-                raise KeyError
-        except KeyError:
-            log.warning("Received jingle request has no action")
-            self.sendError(client, "bad-request", None, request)
-            return
-
-        peer_jid = jid.JID(request["from"])
-
-        # we get or create the session
-        try:
-            session = client.jingle_sessions[sid]
-        except KeyError:
-            if action == XEP_0166.A_SESSION_INITIATE:
-                pass
-            elif action == XEP_0166.A_SESSION_TERMINATE:
-                log.debug(
-                    "ignoring session terminate action (inexisting session id): {request_id} [{profile}]".format(
-                        request_id=sid, profile=client.profile
-                    )
-                )
-                return
-            else:
-                log.warning(
-                    "Received request for an unknown session id: {request_id} [{profile}]".format(
-                        request_id=sid, profile=client.profile
-                    )
-                )
-                self.sendError(client, "item-not-found", None, request, "unknown-session")
-                return
-
-            session = client.jingle_sessions[sid] = {
-                "id": sid,
-                "state": STATE_PENDING,
-                "initiator": peer_jid,
-                "role": XEP_0166.ROLE_RESPONDER,
-                # we store local_jid using request['to'] because for a component the jid
-                # used may not be client.jid (if a local part is used).
-                "local_jid": jid.JID(request['to']),
-                "peer_jid": peer_jid,
-                "started": time.time(),
-            }
-        else:
-            if session["peer_jid"] != peer_jid:
-                log.warning(
-                    "sid conflict ({}), the jid doesn't match. Can be a collision, a hack attempt, or a bad sid generation".format(
-                        sid
-                    )
-                )
-                self.sendError(client, "service-unavailable", sid, request)
-                return
-            if session["id"] != sid:
-                log.error("session id doesn't match")
-                self.sendError(client, "service-unavailable", sid, request)
-                raise exceptions.InternalError
-
-        if action == XEP_0166.A_SESSION_INITIATE:
-            await self.on_session_initiate(client, request, jingle_elt, session)
-        elif action == XEP_0166.A_SESSION_TERMINATE:
-            self.on_session_terminate(client, request, jingle_elt, session)
-        elif action == XEP_0166.A_SESSION_ACCEPT:
-            await self.on_session_accept(client, request, jingle_elt, session)
-        elif action == XEP_0166.A_SESSION_INFO:
-            self.on_session_info(client, request, jingle_elt, session)
-        elif action == XEP_0166.A_TRANSPORT_INFO:
-            self.on_transport_info(client, request, jingle_elt, session)
-        elif action == XEP_0166.A_TRANSPORT_REPLACE:
-            await self.on_transport_replace(client, request, jingle_elt, session)
-        elif action == XEP_0166.A_TRANSPORT_ACCEPT:
-            self.on_transport_accept(client, request, jingle_elt, session)
-        elif action == XEP_0166.A_TRANSPORT_REJECT:
-            self.on_transport_reject(client, request, jingle_elt, session)
-        else:
-            raise exceptions.InternalError(f"Unknown action {action}")
-
-    ## Actions callbacks ##
-
-    def _parse_elements(
-        self,
-        jingle_elt: domish.Element,
-        session: dict,
-        request: domish.Element,
-        client: SatXMPPEntity,
-        new: bool = False,
-        creator: str = ROLE_INITIATOR,
-        with_application: bool =True,
-        with_transport: bool = True,
-        store_in_session: bool = True,
-    ) -> Dict[str, dict]:
-        """Parse contents elements and fill contents_dict accordingly
-
-        after the parsing, contents_dict will containt handlers, "desc_elt" and
-        "transport_elt"
-        @param jingle_elt: parent <jingle> element, containing one or more <content>
-        @param session: session data
-        @param request: the whole request
-        @param client: %(doc_client)s
-        @param new: True if the content is new and must be created,
-            else the content must exists, and session data will be filled
-        @param creator: only used if new is True: creating pear (see § 7.3)
-        @param with_application: if True, raise an error if there is no <description>
-            element else ignore it
-        @param with_transport: if True, raise an error if there is no <transport> element
-            else ignore it
-        @param store_in_session: if True, the ``session`` contents will be updated with
-        the parsed elements.
-            Use False when you parse an action which can happen at any time (e.g.
-            transport-info) and meaning that a parsed element may already be present in
-            the session (e.g. if an authorisation request is waiting for user answer),
-            This can't be used when ``new`` is set.
-        @return: contents_dict (from session, or a new one if "store_in_session" is False)
-        @raise exceptions.CancelError: the error is treated and the calling method can
-            cancel the treatment (i.e. return)
-        """
-        if store_in_session:
-            contents_dict = session["contents"]
-        else:
-            if new:
-                raise exceptions.InternalError(
-                    '"store_in_session" must not be used when "new" is set'
-                )
-            contents_dict = {n: {} for n in session["contents"]}
-        content_elts = jingle_elt.elements(NS_JINGLE, "content")
-
-        for content_elt in content_elts:
-            name = content_elt["name"]
-
-            if new:
-                # the content must not exist, we check it
-                if not name or name in contents_dict:
-                    self.sendError(client, "bad-request", session["id"], request)
-                    raise exceptions.CancelError
-                content_data = contents_dict[name] = {
-                    "creator": creator,
-                    "senders": content_elt.attributes.get("senders", "both"),
-                }
-            else:
-                # the content must exist, we check it
-                try:
-                    content_data = contents_dict[name]
-                except KeyError:
-                    log.warning("Other peer try to access an unknown content")
-                    self.sendError(client, "bad-request", session["id"], request)
-                    raise exceptions.CancelError
-
-            # application
-            if with_application:
-                desc_elt = content_elt.description
-                if not desc_elt:
-                    self.sendError(client, "bad-request", session["id"], request)
-                    raise exceptions.CancelError
-
-                if new:
-                    # the content is new, we need to check and link the application
-                    app_ns = desc_elt.uri
-                    if not app_ns or app_ns == NS_JINGLE:
-                        self.sendError(client, "bad-request", session["id"], request)
-                        raise exceptions.CancelError
-
-                    try:
-                        application = self._applications[app_ns]
-                    except KeyError:
-                        log.warning(
-                            "Unmanaged application namespace [{}]".format(app_ns)
-                        )
-                        self.sendError(
-                            client, "service-unavailable", session["id"], request
-                        )
-                        raise exceptions.CancelError
-
-                    content_data["application"] = application
-                    content_data["application_data"] = {}
-                else:
-                    # the content exists, we check that we have not a former desc_elt
-                    if "desc_elt" in content_data:
-                        raise exceptions.InternalError(
-                            "desc_elt should not exist at this point"
-                        )
-
-                content_data["desc_elt"] = desc_elt
-
-            # transport
-            if with_transport:
-                transport_elt = content_elt.transport
-                if not transport_elt:
-                    self.sendError(client, "bad-request", session["id"], request)
-                    raise exceptions.CancelError
-
-                if new:
-                    # the content is new, we need to check and link the transport
-                    transport_ns = transport_elt.uri
-                    if not app_ns or app_ns == NS_JINGLE:
-                        self.sendError(client, "bad-request", session["id"], request)
-                        raise exceptions.CancelError
-
-                    try:
-                        transport = self._transports[transport_ns]
-                    except KeyError:
-                        raise exceptions.InternalError(
-                            "No transport registered for namespace {}".format(
-                                transport_ns
-                            )
-                        )
-                    content_data["transport"] = transport
-                    content_data["transport_data"] = {}
-                else:
-                    # the content exists, we check that we have not a former transport_elt
-                    if "transport_elt" in content_data:
-                        raise exceptions.InternalError(
-                            "transport_elt should not exist at this point"
-                        )
-
-                content_data["transport_elt"] = transport_elt
-
-        return contents_dict
-
-    def _ignore(self, client, action, session, content_name, elt):
-        """Dummy method used when not exception must be raised if a method is not implemented in _call_plugins
-
-        must be used as app_default_cb and/or transp_default_cb
-        """
-        return elt
-
-    def _call_plugins(
-        self,
-        client: SatXMPPEntity,
-        action: str,
-        session: dict,
-        app_method_name: Optional[str] = "jingle_handler",
-        transp_method_name: Optional[str] = "jingle_handler",
-        app_default_cb: Optional[Callable] = None,
-        transp_default_cb: Optional[Callable] = None,
-        delete: bool = True,
-        elements: bool = True,
-        force_element: Optional[domish.Element] = None
-    ) -> List[defer.Deferred]:
-        """Call application and transport plugin methods for all contents
-
-        @param action: jingle action name
-        @param session: jingle session data
-        @param app_method_name: name of the method to call for applications
-            None to ignore
-        @param transp_method_name: name of the method to call for transports
-            None to ignore
-        @param app_default_cb: default callback to use if plugin has not app_method_name
-            None to raise an exception instead
-        @param transp_default_cb: default callback to use if plugin has not transp_method_name
-            None to raise an exception instead
-        @param delete: if True, remove desc_elt and transport_elt from session
-            ignored if elements is False
-        @param elements: True if elements(desc_elt and tranport_elt) must be managed
-            must be True if _call_plugins is used in a request, and False if it is used
-            after a request (i.e. on <iq> result or error)
-        @param force_element: if elements is False, it is used as element parameter
-            else it is ignored
-        @return : list of launched Deferred
-        @raise exceptions.NotFound: method is not implemented
-        """
-        contents_dict = session["contents"]
-        defers_list = []
-        for content_name, content_data in contents_dict.items():
-            for method_name, handler_key, default_cb, elt_name in (
-                (app_method_name, "application", app_default_cb, "desc_elt"),
-                (transp_method_name, "transport", transp_default_cb, "transport_elt"),
-            ):
-                if method_name is None:
-                    continue
-
-                handler = content_data[handler_key].handler
-                try:
-                    method = getattr(handler, method_name)
-                except AttributeError:
-                    if default_cb is None:
-                        raise exceptions.NotFound(
-                            "{} not implemented !".format(method_name)
-                        )
-                    else:
-                        method = default_cb
-                if elements:
-                    elt = content_data.pop(elt_name) if delete else content_data[elt_name]
-                else:
-                    elt = force_element
-                d = utils.as_deferred(
-                    method, client, action, session, content_name, elt
-                )
-                defers_list.append(d)
-
-        return defers_list
-
-    async def on_session_initiate(
-        self,
-        client: SatXMPPEntity,
-        request: domish.Element,
-        jingle_elt: domish.Element,
-        session: Dict[str, Any]
-    ) -> None:
-        """Called on session-initiate action
-
-        The "jingle_request_confirmation" method of each application will be called
-        (or self.jingle_request_confirmation_default if the former doesn't exist).
-        The session is only accepted if all application are confirmed.
-        The application must manage itself multiple contents scenari (e.g. audio/video).
-        @param client: %(doc_client)s
-        @param request(domish.Element): full request
-        @param jingle_elt(domish.Element): <jingle> element
-        @param session(dict): session data
-        """
-        if "contents" in session:
-            raise exceptions.InternalError(
-                "Contents dict should not already exist at this point"
-            )
-        session["contents"] = contents_dict = {}
-
-        try:
-            self._parse_elements(
-                jingle_elt, session, request, client, True, XEP_0166.ROLE_INITIATOR
-            )
-        except exceptions.CancelError:
-            return
-
-        if not contents_dict:
-            # there MUST be at least one content
-            self.sendError(client, "bad-request", session["id"], request)
-            return
-
-        # at this point we can send the <iq/> result to confirm reception of the request
-        client.send(xmlstream.toResponse(request, "result"))
-
-
-        assert "jingle_elt" not in session
-        session["jingle_elt"] = jingle_elt
-        if not await self.host.trigger.async_point(
-            "XEP-0166_on_session_initiate",
-            client, session, request, jingle_elt
-        ):
-            return
-
-        await defer.DeferredList(self._call_plugins(
-            client,
-            XEP_0166.A_PREPARE_CONFIRMATION,
-            session,
-            delete=False
-        ))
-
-        # we now request each application plugin confirmation
-        # and if all are accepted, we can accept the session
-        confirm_defers = self._call_plugins(
-            client,
-            XEP_0166.A_SESSION_INITIATE,
-            session,
-            "jingle_request_confirmation",
-            None,
-            self.jingle_request_confirmation_default,
-            delete=False,
-        )
-
-        confirm_dlist = defer.gatherResults(confirm_defers)
-        confirm_dlist.addCallback(self._confirmation_cb, session, jingle_elt, client)
-        confirm_dlist.addErrback(self._jingle_error_cb, session, request, client)
-
-    def _confirmation_cb(self, confirm_results, session, jingle_elt, client):
-        """Method called when confirmation from user has been received
-
-        This method is only called for the responder
-        @param confirm_results(list[bool]): all True if session is accepted
-        @param session(dict): session data
-        @param jingle_elt(domish.Element): jingle data of this session
-        @param client: %(doc_client)s
-        """
-        del session["jingle_elt"]
-        confirmed = all(confirm_results)
-        if not confirmed:
-            return self.terminate(client, XEP_0166.REASON_DECLINE, session)
-
-        iq_elt, jingle_elt = self._build_jingle_elt(
-            client, session, XEP_0166.A_SESSION_ACCEPT
-        )
-        jingle_elt["responder"] = session['local_jid'].full()
-        session["jingle_elt"] = jingle_elt
-
-        # contents
-
-        def addElement(domish_elt, content_elt):
-            content_elt.addChild(domish_elt)
-
-        defers_list = []
-
-        for content_name, content_data in session["contents"].items():
-            content_elt = jingle_elt.addElement("content")
-            content_elt["creator"] = XEP_0166.ROLE_INITIATOR
-            content_elt["name"] = content_name
-
-            application = content_data["application"]
-            app_session_accept_cb = application.handler.jingle_handler
-
-            app_d = utils.as_deferred(
-                app_session_accept_cb,
-                client,
-                XEP_0166.A_SESSION_INITIATE,
-                session,
-                content_name,
-                content_data.pop("desc_elt"),
-            )
-            app_d.addCallback(addElement, content_elt)
-            defers_list.append(app_d)
-
-            transport = content_data["transport"]
-            transport_session_accept_cb = transport.handler.jingle_handler
-
-            transport_d = utils.as_deferred(
-                transport_session_accept_cb,
-                client,
-                XEP_0166.A_SESSION_INITIATE,
-                session,
-                content_name,
-                content_data.pop("transport_elt"),
-            )
-            transport_d.addCallback(addElement, content_elt)
-            defers_list.append(transport_d)
-
-        d_list = defer.DeferredList(defers_list)
-        d_list.addCallback(
-            lambda __: self._call_plugins(
-                client,
-                XEP_0166.A_PREPARE_RESPONDER,
-                session,
-                app_method_name=None,
-                elements=False,
-            )
-        )
-        d_list.addCallback(lambda __: session.pop("jingle_elt"))
-        d_list.addCallback(lambda __: iq_elt.send())
-
-        def change_state(__, session):
-            session["state"] = STATE_ACTIVE
-
-        d_list.addCallback(change_state, session)
-        d_list.addCallback(
-            lambda __: self._call_plugins(
-                client, XEP_0166.A_ACCEPTED_ACK, session, elements=False
-            )
-        )
-        d_list.addErrback(self._iq_error, session["id"], client)
-        return d_list
-
-    def on_session_terminate(self, client, request, jingle_elt, session):
-        # TODO: check reason, display a message to user if needed
-        log.debug(f"Jingle Session {session['id']} terminated")
-        try:
-            reason_elt = next(jingle_elt.elements(NS_JINGLE, "reason"))
-        except StopIteration:
-            log.warning("No reason given for session termination")
-            reason_elt = jingle_elt.addElement("reason")
-
-        terminate_defers = self._call_plugins(
-            client,
-            XEP_0166.A_SESSION_TERMINATE,
-            session,
-            "jingle_terminate",
-            "jingle_terminate",
-            self._ignore,
-            self._ignore,
-            elements=False,
-            force_element=reason_elt,
-        )
-        terminate_dlist = defer.DeferredList(terminate_defers)
-
-        terminate_dlist.addCallback(lambda __: self._del_session(client, session["id"]))
-        client.send(xmlstream.toResponse(request, "result"))
-
-    async def on_session_accept(self, client, request, jingle_elt, session):
-        """Method called once session is accepted
-
-        This method is only called for initiator
-        @param client: %(doc_client)s
-        @param request(domish.Element): full <iq> request
-        @param jingle_elt(domish.Element): the <jingle> element
-        @param session(dict): session data
-        """
-        log.debug(f"Jingle session {session['id']} has been accepted")
-
-        try:
-            self._parse_elements(jingle_elt, session, request, client)
-        except exceptions.CancelError:
-            return
-
-        # at this point we can send the <iq/> result to confirm reception of the request
-        client.send(xmlstream.toResponse(request, "result"))
-        # and change the state
-        session["state"] = STATE_ACTIVE
-        session["jingle_elt"] = jingle_elt
-
-        await defer.DeferredList(self._call_plugins(
-            client,
-            XEP_0166.A_PREPARE_INITIATOR,
-            session,
-            delete=False
-        ))
-
-        negociate_defers = []
-        negociate_defers = self._call_plugins(client, XEP_0166.A_SESSION_ACCEPT, session)
-
-        negociate_dlist = defer.gatherResults(negociate_defers)
-
-        # after negociations we start the transfer
-        negociate_dlist.addCallback(
-            lambda __: self._call_plugins(
-                client, XEP_0166.A_START, session, app_method_name=None, elements=False
-            )
-        )
-        negociate_dlist.addCallback(lambda __: session.pop("jingle_elt"))
-
-    def _on_session_cb(self, result, client, request, jingle_elt, session):
-        client.send(xmlstream.toResponse(request, "result"))
-
-    def _on_session_eb(self, failure_, client, request, jingle_elt, session):
-        log.error("Error while handling on_session_info: {}".format(failure_.value))
-        # XXX: only error managed so far, maybe some applications/transports need more
-        self.sendError(
-            client, "feature-not-implemented", None, request, "unsupported-info"
-        )
-
-    def on_session_info(self, client, request, jingle_elt, session):
-        """Method called when a session-info action is received from other peer
-
-        This method is only called for initiator
-        @param client: %(doc_client)s
-        @param request(domish.Element): full <iq> request
-        @param jingle_elt(domish.Element): the <jingle> element
-        @param session(dict): session data
-        """
-        if not jingle_elt.children:
-            # this is a session ping, see XEP-0166 §6.8
-            client.send(xmlstream.toResponse(request, "result"))
-            return
-
-        try:
-            # XXX: session-info is most likely only used for application, so we don't call transport plugins
-            #      if a future transport use it, this behaviour must be adapted
-            defers = self._call_plugins(
-                client,
-                XEP_0166.A_SESSION_INFO,
-                session,
-                "jingle_session_info",
-                None,
-                elements=False,
-                force_element=jingle_elt,
-            )
-        except exceptions.NotFound as e:
-            self._on_session_eb(failure.Failure(e), client, request, jingle_elt, session)
-            return
-
-        dlist = defer.DeferredList(defers, fireOnOneErrback=True)
-        dlist.addCallback(self._on_session_cb, client, request, jingle_elt, session)
-        dlist.addErrback(self._on_session_cb, client, request, jingle_elt, session)
-
-    async def on_transport_replace(self, client, request, jingle_elt, session):
-        """A transport change is requested
-
-        The request is parsed, and jingle_handler is called on concerned transport plugin(s)
-        @param client: %(doc_client)s
-        @param request(domish.Element): full <iq> request
-        @param jingle_elt(domish.Element): the <jingle> element
-        @param session(dict): session data
-        """
-        log.debug("Other peer wants to replace the transport")
-        try:
-            self._parse_elements(
-                jingle_elt, session, request, client, with_application=False
-            )
-        except exceptions.CancelError:
-            defer.returnValue(None)
-
-        client.send(xmlstream.toResponse(request, "result"))
-
-        content_name = None
-        to_replace = []
-
-        for content_name, content_data in session["contents"].items():
-            try:
-                transport_elt = content_data.pop("transport_elt")
-            except KeyError:
-                continue
-            transport_ns = transport_elt.uri
-            try:
-                transport = self._transports[transport_ns]
-            except KeyError:
-                log.warning(
-                    "Other peer want to replace current transport with an unknown one: {}".format(
-                        transport_ns
-                    )
-                )
-                content_name = None
-                break
-            to_replace.append((content_name, content_data, transport, transport_elt))
-
-        if content_name is None:
-            # wa can't accept the replacement
-            iq_elt, reject_jingle_elt = self._build_jingle_elt(
-                client, session, XEP_0166.A_TRANSPORT_REJECT
-            )
-            for child in jingle_elt.children:
-                reject_jingle_elt.addChild(child)
-
-            iq_elt.send()
-            defer.returnValue(None)
-
-        # at this point, everything is alright and we can replace the transport(s)
-        # this is similar to an session-accept action, but for transports only
-        iq_elt, accept_jingle_elt = self._build_jingle_elt(
-            client, session, XEP_0166.A_TRANSPORT_ACCEPT
-        )
-        for content_name, content_data, transport, transport_elt in to_replace:
-            # we can now actually replace the transport
-            await utils.as_deferred(
-                content_data["transport"].handler.jingle_handler,
-                client, XEP_0166.A_DESTROY, session, content_name, None
-            )
-            content_data["transport"] = transport
-            content_data["transport_data"].clear()
-            # and build the element
-            content_elt = accept_jingle_elt.addElement("content")
-            content_elt["name"] = content_name
-            content_elt["creator"] = content_data["creator"]
-            # we notify the transport and insert its <transport/> in the answer
-            accept_transport_elt = await utils.as_deferred(
-                transport.handler.jingle_handler,
-                client, XEP_0166.A_TRANSPORT_REPLACE, session, content_name, transport_elt
-            )
-            content_elt.addChild(accept_transport_elt)
-            # there is no confirmation needed here, so we can directly prepare it
-            await utils.as_deferred(
-                transport.handler.jingle_handler,
-                client, XEP_0166.A_PREPARE_RESPONDER, session, content_name, None
-            )
-
-        iq_elt.send()
-
-    def on_transport_accept(self, client, request, jingle_elt, session):
-        """Method called once transport replacement is accepted
-
-        @param client: %(doc_client)s
-        @param request(domish.Element): full <iq> request
-        @param jingle_elt(domish.Element): the <jingle> element
-        @param session(dict): session data
-        """
-        log.debug("new transport has been accepted")
-
-        try:
-            self._parse_elements(
-                jingle_elt, session, request, client, with_application=False
-            )
-        except exceptions.CancelError:
-            return
-
-        # at this point we can send the <iq/> result to confirm reception of the request
-        client.send(xmlstream.toResponse(request, "result"))
-
-        negociate_defers = []
-        negociate_defers = self._call_plugins(
-            client, XEP_0166.A_TRANSPORT_ACCEPT, session, app_method_name=None
-        )
-
-        negociate_dlist = defer.DeferredList(negociate_defers)
-
-        # after negociations we start the transfer
-        negociate_dlist.addCallback(
-            lambda __: self._call_plugins(
-                client, XEP_0166.A_START, session, app_method_name=None, elements=False
-            )
-        )
-
-    def on_transport_reject(self, client, request, jingle_elt, session):
-        """Method called when a transport replacement is refused
-
-        @param client: %(doc_client)s
-        @param request(domish.Element): full <iq> request
-        @param jingle_elt(domish.Element): the <jingle> element
-        @param session(dict): session data
-        """
-        # XXX: for now, we terminate the session in case of transport-reject
-        #      this behaviour may change in the future
-        self.terminate(client, "failed-transport", session)
-
-    def on_transport_info(
-        self,
-        client: SatXMPPEntity,
-        request: domish.Element,
-        jingle_elt: domish.Element,
-        session: dict
-    ) -> None:
-        """Method called when a transport-info action is received from other peer
-
-        The request is parsed, and jingle_handler is called on concerned transport
-        plugin(s)
-        @param client: %(doc_client)s
-        @param request: full <iq> request
-        @param jingle_elt: the <jingle> element
-        @param session: session data
-        """
-        log.debug(f"Jingle session {session['id']} has been accepted")
-
-        try:
-            parsed_contents = self._parse_elements(
-                jingle_elt, session, request, client, with_application=False,
-                store_in_session=False
-            )
-        except exceptions.CancelError:
-            return
-
-        # The parsing was OK, we send the <iq> result
-        client.send(xmlstream.toResponse(request, "result"))
-
-        for content_name, content_data in session["contents"].items():
-            try:
-                transport_elt = parsed_contents[content_name]["transport_elt"]
-            except KeyError:
-                continue
-            else:
-                utils.as_deferred(
-                    content_data["transport"].handler.jingle_handler,
-                    client,
-                    XEP_0166.A_TRANSPORT_INFO,
-                    session,
-                    content_name,
-                    transport_elt,
-                )
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0166_handler(xmlstream.XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(
-            JINGLE_REQUEST, self.plugin_parent._on_jingle_request, client=self.parent
-        )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_JINGLE)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0166/models.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,187 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for Jingle (XEP-0166)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import abc
-from dataclasses import dataclass
-from typing import Awaitable, Callable, Union
-
-from twisted.internet import defer
-from twisted.words.xish import domish
-
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-
-
-class BaseApplicationHandler(abc.ABC):
-
-    @abc.abstractmethod
-    def jingle_request_confirmation(
-        self,
-        client: SatXMPPEntity,
-        action: str,
-        session: dict,
-        content_name: str,
-        desc_elt: domish.Element,
-    ) -> Union[
-        Callable[..., Union[bool, defer.Deferred]],
-        Callable[..., Awaitable[bool]]
-    ]:
-        """
-        If present, it is called on when session must be accepted.
-        If not present, a generic accept dialog will be used.
-
-        @param session: Jingle Session
-        @param desc_elt: <description> element
-        @return: True if the session is accepted.
-            A Deferred can be returned.
-        """
-        pass
-
-    @abc.abstractmethod
-    def jingle_session_init(
-        self,
-        client: SatXMPPEntity,
-        session: dict,
-        content_name: str,
-        *args, **kwargs
-    ) -> Union[
-        Callable[..., domish.Element],
-        Callable[..., Awaitable[domish.Element]]
-    ]:
-        """
-        Must return the domish.Element used for initial content.
-
-        @param client: SatXMPPEntity instance
-        @param session: Jingle Session
-        @param content_name: Name of the content
-        @return: The domish.Element used for initial content
-        """
-        pass
-
-    @abc.abstractmethod
-    def jingle_handler(
-        self,
-        client: SatXMPPEntity,
-        action: str,
-        session: dict,
-        content_name: str,
-        transport_elt: domish.Element
-    ) -> Union[
-        Callable[..., None],
-        Callable[..., Awaitable[None]]
-    ]:
-        """
-        Called on several actions to negotiate the application or transport.
-
-        @param client: SatXMPPEntity instance
-        @param action: Jingle action
-        @param session: Jingle Session
-        @param content_name: Name of the content
-        @param transport_elt: Transport element
-        """
-        pass
-
-    @abc.abstractmethod
-    def jingle_terminate(
-        self,
-        client: SatXMPPEntity,
-        action: str,
-        session: dict,
-        content_name: str,
-        reason_elt: domish.Element
-    ) -> Union[
-        Callable[..., None],
-        Callable[..., Awaitable[None]]
-    ]:
-        """
-        Called on session terminate, with reason_elt.
-        May be used to clean session.
-
-        @param reason_elt: Reason element
-        """
-        pass
-
-
-class BaseTransportHandler(abc.ABC):
-
-    @abc.abstractmethod
-    def jingle_session_init(
-        self,
-        client: SatXMPPEntity,
-        session: dict,
-        content_name: str,
-        *args, **kwargs
-    ) -> Union[
-        Callable[..., domish.Element],
-        Callable[..., Awaitable[domish.Element]]
-    ]:
-        """
-        Must return the domish.Element used for initial content.
-
-        @param client: SatXMPPEntity instance
-        @param session: Jingle Session
-        @param content_name: Name of the content
-        @return: The domish.Element used for initial content
-        """
-        pass
-
-    @abc.abstractmethod
-    def jingle_handler(
-        self,
-        client: SatXMPPEntity,
-        action: str,
-        session: dict,
-        content_name: str,
-        reason_elt: domish.Element
-    ) -> Union[
-        Callable[..., None],
-        Callable[..., Awaitable[None]]
-    ]:
-        """
-        Called on several actions to negotiate the application or transport.
-
-        @param client: SatXMPPEntity instance
-        @param action: Jingle action
-        @param session: Jingle Session
-        @param content_name: Name of the content
-        @param reason_elt: <reason> element
-        """
-        pass
-
-
-@dataclass(frozen=True)
-class ApplicationData:
-    namespace: str
-    handler: BaseApplicationHandler
-
-
-@dataclass(frozen=True)
-class TransportData:
-    namespace: str
-    handler: BaseTransportHandler
-    priority: int
-
-
-@dataclass(frozen=True)
-class ContentData:
-    application: ApplicationData
-    app_args: list
-    app_kwargs: dict
-    transport_data: dict
-    content_name: str
--- a/sat/plugins/plugin_xep_0167/__init__.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,439 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional
-
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import D_, _
-from sat.core.log import getLogger
-from sat.tools import xml_tools
-from sat.tools.common import data_format
-
-from . import mapping
-from ..plugin_xep_0166 import BaseApplicationHandler
-from .constants import (
-    NS_JINGLE_RTP,
-    NS_JINGLE_RTP_INFO,
-    NS_JINGLE_RTP_AUDIO,
-    NS_JINGLE_RTP_VIDEO,
-)
-
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Jingle RTP Sessions",
-    C.PI_IMPORT_NAME: "XEP-0167",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0167"],
-    C.PI_DEPENDENCIES: ["XEP-0166"],
-    C.PI_MAIN: "XEP_0167",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Real-time Transport Protocol (RTP) is used for A/V calls"""),
-}
-
-CONFIRM = D_("{peer} wants to start a call ({call_type}) with you, do you accept?")
-CONFIRM_TITLE = D_("Incoming Call")
-SECURITY_LIMIT = 0
-
-ALLOWED_ACTIONS = (
-    "active",
-    "hold",
-    "unhold",
-    "mute",
-    "unmute",
-    "ringing",
-)
-
-
-class XEP_0167(BaseApplicationHandler):
-    def __init__(self, host):
-        log.info(f'Plugin "{PLUGIN_INFO[C.PI_NAME]}" initialization')
-        self.host = host
-        # FIXME: to be removed once host is accessible from global var
-        mapping.host = host
-        self._j = host.plugins["XEP-0166"]
-        self._j.register_application(NS_JINGLE_RTP, self)
-        host.bridge.add_method(
-            "call_start",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=self._call_start,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "call_end",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._call_end,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "call_info",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="",
-            method=self._call_start,
-        )
-        host.bridge.add_signal(
-            "call_accepted", ".plugin", signature="sss"
-        )  # args: session_id, answer_sdp, profile
-        host.bridge.add_signal(
-            "call_ended", ".plugin", signature="sss"
-        )  # args: session_id, data, profile
-        host.bridge.add_signal(
-            "call_info", ".plugin", signature="ssss"
-        )  # args: session_id, info_type, extra, profile
-
-    def get_handler(self, client):
-        return XEP_0167_handler()
-
-    # bridge methods
-
-    def _call_start(
-        self,
-        entity_s: str,
-        call_data_s: str,
-        profile_key: str,
-    ):
-        client = self.host.get_client(profile_key)
-        return defer.ensureDeferred(
-            self.call_start(
-                client, jid.JID(entity_s), data_format.deserialise(call_data_s)
-            )
-        )
-
-    async def call_start(
-        self,
-        client: SatXMPPEntity,
-        peer_jid: jid.JID,
-        call_data: dict,
-    ) -> None:
-        """Temporary method to test RTP session"""
-        contents = []
-        metadata = call_data.get("metadata") or {}
-
-        if "sdp" in call_data:
-            sdp_data = mapping.parse_sdp(call_data["sdp"])
-            for media_type in ("audio", "video"):
-                try:
-                    media_data = sdp_data.pop(media_type)
-                except KeyError:
-                    continue
-                call_data[media_type] = media_data["application_data"]
-                transport_data = media_data["transport_data"]
-                try:
-                    call_data[media_type]["fingerprint"] = transport_data["fingerprint"]
-                except KeyError:
-                    log.warning("fingerprint is missing")
-                    pass
-                try:
-                    call_data[media_type]["id"] = media_data["id"]
-                except KeyError:
-                    log.warning(f"no media ID found for {media_type}: {media_data}")
-                try:
-                    call_data[media_type]["ice-candidates"] = transport_data["candidates"]
-                    metadata["ice-ufrag"] = transport_data["ufrag"]
-                    metadata["ice-pwd"] = transport_data["pwd"]
-                except KeyError:
-                    log.warning("ICE data are missing from SDP")
-                    continue
-            metadata.update(sdp_data.get("metadata", {}))
-
-        call_type = (
-            C.META_SUBTYPE_CALL_VIDEO
-            if "video" in call_data
-            else C.META_SUBTYPE_CALL_AUDIO
-        )
-        seen_names = set()
-
-        for media in ("audio", "video"):
-            media_data = call_data.get(media)
-            if media_data is not None:
-                content = {
-                    "app_ns": NS_JINGLE_RTP,
-                    "senders": "both",
-                    "transport_type": self._j.TRANSPORT_DATAGRAM,
-                    "app_kwargs": {"media": media, "media_data": media_data},
-                    "transport_data": {
-                        "local_ice_data": {
-                            "ufrag": metadata["ice-ufrag"],
-                            "pwd": metadata["ice-pwd"],
-                            "candidates": media_data.pop("ice-candidates"),
-                            "fingerprint": media_data.pop("fingerprint", {}),
-                        }
-                    },
-                }
-                if "id" in media_data:
-                    name = media_data.pop("id")
-                    if name in seen_names:
-                        raise exceptions.DataError(
-                            f"Content name (mid) seen multiple times: {name}"
-                        )
-                    content["name"] = name
-                contents.append(content)
-        if not contents:
-            raise exceptions.DataError("no valid media data found: {call_data}")
-        return await self._j.initiate(
-            client,
-            peer_jid,
-            contents,
-            call_type=call_type,
-            metadata=metadata,
-            peer_metadata={},
-        )
-
-    def _call_end(
-        self,
-        session_id: str,
-        data_s: str,
-        profile_key: str,
-    ):
-        client = self.host.get_client(profile_key)
-        return defer.ensureDeferred(
-            self.call_end(
-                client, session_id, data_format.deserialise(data_s)
-            )
-        )
-
-    async def call_end(
-        self,
-        client: SatXMPPEntity,
-        session_id: str,
-        data: dict,
-    ) -> None:
-        """End a call
-
-        @param session_id: Jingle session ID of the call
-        @param data: optional extra data, may be used to indicate the reason to end the
-            call
-        """
-        session = self._j.get_session(client, session_id)
-        await self._j.terminate(client, self._j.REASON_SUCCESS, session)
-
-    # jingle callbacks
-
-    def jingle_session_init(
-        self,
-        client: SatXMPPEntity,
-        session: dict,
-        content_name: str,
-        media: str,
-        media_data: dict,
-    ) -> domish.Element:
-        if media not in ("audio", "video"):
-            raise ValueError('only "audio" and "video" media types are supported')
-        content_data = session["contents"][content_name]
-        application_data = content_data["application_data"]
-        application_data["media"] = media
-        application_data["local_data"] = media_data
-        desc_elt = mapping.build_description(media, media_data, session)
-        self.host.trigger.point(
-            "XEP-0167_jingle_session_init",
-            client,
-            session,
-            content_name,
-            media,
-            media_data,
-            desc_elt,
-            triggers_no_cancel=True,
-        )
-        return desc_elt
-
-    async def jingle_request_confirmation(
-        self,
-        client: SatXMPPEntity,
-        action: str,
-        session: dict,
-        content_name: str,
-        desc_elt: domish.Element,
-    ) -> bool:
-        if content_name != next(iter(session["contents"])):
-            # we request confirmation only for the first content, all others are
-            # automatically accepted. In practice, that means that the call confirmation
-            # is requested only once for audio and video contents.
-            return True
-        peer_jid = session["peer_jid"]
-
-        if any(
-            c["desc_elt"].getAttribute("media") == "video"
-            for c in session["contents"].values()
-        ):
-            call_type = session["call_type"] = C.META_SUBTYPE_CALL_VIDEO
-        else:
-            call_type = session["call_type"] = C.META_SUBTYPE_CALL_AUDIO
-
-        sdp = mapping.generate_sdp_from_session(session)
-
-        resp_data = await xml_tools.defer_dialog(
-            self.host,
-            _(CONFIRM).format(peer=peer_jid.userhost(), call_type=call_type),
-            _(CONFIRM_TITLE),
-            action_extra={
-                "session_id": session["id"],
-                "from_jid": peer_jid.full(),
-                "type": C.META_TYPE_CALL,
-                "sub_type": call_type,
-                "sdp": sdp,
-            },
-            security_limit=SECURITY_LIMIT,
-            profile=client.profile,
-        )
-
-        if resp_data.get("cancelled", False):
-            return False
-
-        answer_sdp = resp_data["sdp"]
-        parsed_answer = mapping.parse_sdp(answer_sdp)
-        session["peer_metadata"].update(parsed_answer["metadata"])
-        for media in ("audio", "video"):
-            for content in session["contents"].values():
-                if content["desc_elt"].getAttribute("media") == media:
-                    media_data = parsed_answer[media]
-                    application_data = content["application_data"]
-                    application_data["local_data"] = media_data["application_data"]
-                    transport_data = content["transport_data"]
-                    local_ice_data = media_data["transport_data"]
-                    transport_data["local_ice_data"] = local_ice_data
-
-        return True
-
-    async def jingle_handler(self, client, action, session, content_name, desc_elt):
-        content_data = session["contents"][content_name]
-        application_data = content_data["application_data"]
-        if action == self._j.A_PREPARE_CONFIRMATION:
-            session["metadata"] = {}
-            session["peer_metadata"] = {}
-            try:
-                media = application_data["media"] = desc_elt["media"]
-            except KeyError:
-                raise exceptions.DataError('"media" key is missing in {desc_elt.toXml()}')
-            if media not in ("audio", "video"):
-                raise exceptions.DataError(f"invalid media: {media!r}")
-            application_data["peer_data"] = mapping.parse_description(desc_elt)
-        elif action == self._j.A_SESSION_INITIATE:
-            application_data["peer_data"] = mapping.parse_description(desc_elt)
-            desc_elt = mapping.build_description(
-                application_data["media"], application_data["local_data"], session
-            )
-        elif action == self._j.A_ACCEPTED_ACK:
-            pass
-        elif action == self._j.A_PREPARE_INITIATOR:
-            application_data["peer_data"] = mapping.parse_description(desc_elt)
-        elif action == self._j.A_SESSION_ACCEPT:
-            if content_name == next(iter(session["contents"])):
-                # we only send the signal for first content, as it means that the whole
-                # session is accepted
-                answer_sdp = mapping.generate_sdp_from_session(session)
-                self.host.bridge.call_accepted(session["id"], answer_sdp, client.profile)
-        else:
-            log.warning(f"FIXME: unmanaged action {action}")
-
-        self.host.trigger.point(
-            "XEP-0167_jingle_handler",
-            client,
-            action,
-            session,
-            content_name,
-            desc_elt,
-            triggers_no_cancel=True,
-        )
-        return desc_elt
-
-    def jingle_session_info(
-        self,
-        client: SatXMPPEntity,
-        action: str,
-        session: dict,
-        content_name: str,
-        jingle_elt: domish.Element,
-    ) -> None:
-        """Informational messages"""
-        for elt in jingle_elt.elements():
-            if elt.uri == NS_JINGLE_RTP_INFO:
-                info_type = elt.name
-                if info_type not in ALLOWED_ACTIONS:
-                    log.warning("ignoring unknow info type: {info_type!r}")
-                    continue
-                extra = {}
-                if info_type in ("mute", "unmute"):
-                    name = elt.getAttribute("name")
-                    if name:
-                        extra["name"] = name
-                log.debug(f"{info_type} call info received (extra: {extra})")
-                self.host.bridge.call_info(
-                    session["id"], info_type, data_format.serialise(extra), client.profile
-                )
-
-    def _call_info(self, session_id, info_type, extra_s, profile_key):
-        client = self.host.get_client(profile_key)
-        extra = data_format.deserialise(extra_s)
-        return self.send_info(client, session_id, info_type, extra)
-
-
-    def send_info(
-        self,
-        client: SatXMPPEntity,
-        session_id: str,
-        info_type: str,
-        extra: Optional[dict],
-    ) -> None:
-        """Send information on the call"""
-        if info_type not in ALLOWED_ACTIONS:
-            raise ValueError(f"Unkown info type {info_type!r}")
-        session = self._j.get_session(client, session_id)
-        iq_elt, jingle_elt = self._j.build_session_info(client, session)
-        info_elt = jingle_elt.addElement((NS_JINGLE_RTP_INFO, info_type))
-        if extra and info_type in ("mute", "unmute") and "name" in extra:
-            info_elt["name"] = extra["name"]
-        iq_elt.send()
-
-    def jingle_terminate(
-        self,
-        client: SatXMPPEntity,
-        action: str,
-        session: dict,
-        content_name: str,
-        reason_elt: domish.Element,
-    ) -> None:
-        self.host.bridge.call_ended(session["id"], "", client.profile)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0167_handler(XMPPHandler):
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [
-            disco.DiscoFeature(NS_JINGLE_RTP),
-            disco.DiscoFeature(NS_JINGLE_RTP_AUDIO),
-            disco.DiscoFeature(NS_JINGLE_RTP_VIDEO),
-        ]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0167/constants.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,27 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for managing pipes (experimental)
-# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Final
-
-
-NS_JINGLE_RTP_BASE: Final = "urn:xmpp:jingle:apps:rtp"
-NS_JINGLE_RTP: Final = f"{NS_JINGLE_RTP_BASE}:1"
-NS_JINGLE_RTP_AUDIO: Final = f"{NS_JINGLE_RTP_BASE}:audio"
-NS_JINGLE_RTP_VIDEO: Final = f"{NS_JINGLE_RTP_BASE}:video"
-NS_JINGLE_RTP_ERRORS: Final = f"{NS_JINGLE_RTP_BASE}:errors:1"
-NS_JINGLE_RTP_INFO: Final = f"{NS_JINGLE_RTP_BASE}:info:1"
--- a/sat/plugins/plugin_xep_0167/mapping.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,645 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import base64
-from typing import Any, Dict, Optional
-
-from twisted.words.xish import domish
-
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-from .constants import NS_JINGLE_RTP
-
-log = getLogger(__name__)
-
-host = None
-
-
-def senders_to_sdp(senders: str, session: dict) -> str:
-    """Returns appropriate SDP attribute corresponding to Jingle senders attribute"""
-    if senders == "both":
-        return "a=sendrecv"
-    elif senders == "none":
-        return "a=inactive"
-    elif session["role"] == senders:
-        return "a=sendonly"
-    else:
-        return "a=recvonly"
-
-
-def generate_sdp_from_session(
-    session: dict, local: bool = False, port: int = 9999
-) -> str:
-    """Generate an SDP string from session data.
-
-    @param session: A dictionary containing the session data. It should have the
-        following structure:
-
-        {
-            "contents": {
-                "<content_id>": {
-                    "application_data": {
-                        "media": <str: "audio" or "video">,
-                        "local_data": <media_data dict>,
-                        "peer_data": <media_data dict>,
-                        ...
-                    },
-                    "transport_data": {
-                        "local_ice_data": <ice_data dict>,
-                        "peer_ice_data": <ice_data dict>,
-                        ...
-                    },
-                    ...
-                },
-                ...
-            }
-        }
-    @param local: A boolean value indicating whether to generate SDP for the local or
-        peer entity. If True, the method will generate SDP for the local entity,
-        otherwise for the peer entity. Generally the local SDP is received from frontends
-        and not needed in backend, except for debugging purpose.
-    @param port: The preferred port for communications.
-
-    @return: The generated SDP string.
-    """
-    sdp_lines = ["v=0"]
-
-    # Add originator (o=) line after the version (v=) line
-    username = base64.b64encode(session["local_jid"].full().encode()).decode()
-    session_id = "1"  # Increment this for each session
-    session_version = "1"  # Increment this when the session is updated
-    network_type = "IN"
-    address_type = "IP4"
-    connection_address = "0.0.0.0"
-    o_line = (
-        f"o={username} {session_id} {session_version} {network_type} {address_type} "
-        f"{connection_address}"
-    )
-    sdp_lines.append(o_line)
-
-    # Add the mandatory "s=" and t=" lines
-    sdp_lines.append("s=-")
-    sdp_lines.append("t=0 0")
-
-    # stream direction
-    all_senders = {c["senders"] for c in session["contents"].values()}
-    # if we don't have a common senders for all contents, we set them at media level
-    senders = all_senders.pop() if len(all_senders) == 1 else None
-    if senders is not None:
-        sdp_lines.append(senders_to_sdp(senders, session))
-
-    sdp_lines.append("a=msid-semantic:WMS *")
-
-    host.trigger.point(
-        "XEP-0167_generate_sdp_session",
-        session,
-        local,
-        sdp_lines,
-        triggers_no_cancel=True
-    )
-
-    contents = session["contents"]
-    for content_name, content_data in contents.items():
-        app_data_key = "local_data" if local else "peer_data"
-        application_data = content_data["application_data"]
-        media_data = application_data[app_data_key]
-        media = application_data["media"]
-        payload_types = media_data.get("payload_types", {})
-
-        # Generate m= line
-        transport = "UDP/TLS/RTP/SAVPF"
-        payload_type_ids = [str(pt_id) for pt_id in payload_types]
-        m_line = f"m={media} {port} {transport} {' '.join(payload_type_ids)}"
-        sdp_lines.append(m_line)
-
-        sdp_lines.append(f"c={network_type} {address_type} {connection_address}")
-
-        sdp_lines.append(f"a=mid:{content_name}")
-
-        # stream direction
-        if senders is None:
-            sdp_lines.append(senders_to_sdp(content_data["senders"], session))
-
-        # Generate a= lines for rtpmap and fmtp
-        for pt_id, pt in payload_types.items():
-            name = pt["name"]
-            clockrate = pt.get("clockrate", "")
-            sdp_lines.append(f"a=rtpmap:{pt_id} {name}/{clockrate}")
-
-            if "ptime" in pt:
-                sdp_lines.append(f"a=ptime:{pt['ptime']}")
-
-            if "parameters" in pt:
-                fmtp_params = ";".join([f"{k}={v}" for k, v in pt["parameters"].items()])
-                sdp_lines.append(f"a=fmtp:{pt_id} {fmtp_params}")
-
-        if "bandwidth" in media_data:
-            sdp_lines.append(f"a=b:{media_data['bandwidth']}")
-
-        if media_data.get("rtcp-mux"):
-            sdp_lines.append("a=rtcp-mux")
-
-        # Generate a= lines for fingerprint, ICE ufrag, pwd and candidates
-        ice_data_key = "local_ice_data" if local else "peer_ice_data"
-        ice_data = content_data["transport_data"][ice_data_key]
-
-        if "fingerprint" in ice_data:
-            fingerprint_data = ice_data["fingerprint"]
-            sdp_lines.append(
-                f"a=fingerprint:{fingerprint_data['hash']} "
-                f"{fingerprint_data['fingerprint']}"
-            )
-            sdp_lines.append(f"a=setup:{fingerprint_data['setup']}")
-
-        sdp_lines.append(f"a=ice-ufrag:{ice_data['ufrag']}")
-        sdp_lines.append(f"a=ice-pwd:{ice_data['pwd']}")
-
-        for candidate in ice_data["candidates"]:
-            foundation = candidate["foundation"]
-            component_id = candidate["component_id"]
-            transport = candidate["transport"]
-            priority = candidate["priority"]
-            address = candidate["address"]
-            candidate_port = candidate["port"]
-            candidate_type = candidate["type"]
-
-            candidate_line = (
-                f"a=candidate:{foundation} {component_id} {transport} {priority} "
-                f"{address} {candidate_port} typ {candidate_type}"
-            )
-
-            if "rel_addr" in candidate and "rel_port" in candidate:
-                candidate_line += (
-                    f" raddr {candidate['rel_addr']} rport {candidate['rel_port']}"
-                )
-
-            if "generation" in candidate:
-                candidate_line += f" generation {candidate['generation']}"
-
-            if "network" in candidate:
-                candidate_line += f" network {candidate['network']}"
-
-            sdp_lines.append(candidate_line)
-
-        # Generate a= lines for encryption
-        if "encryption" in media_data:
-            for enc_data in media_data["encryption"]:
-                crypto_suite = enc_data["crypto-suite"]
-                key_params = enc_data["key-params"]
-                session_params = enc_data.get("session-params", "")
-                tag = enc_data["tag"]
-
-                crypto_line = f"a=crypto:{tag} {crypto_suite} {key_params}"
-                if session_params:
-                    crypto_line += f" {session_params}"
-                sdp_lines.append(crypto_line)
-
-
-        host.trigger.point(
-            "XEP-0167_generate_sdp_content",
-            session,
-            local,
-            content_name,
-            content_data,
-            sdp_lines,
-            application_data,
-            app_data_key,
-            media_data,
-            media,
-            triggers_no_cancel=True
-        )
-
-    # Combine SDP lines and return the result
-    return "\r\n".join(sdp_lines) + "\r\n"
-
-
-def parse_sdp(sdp: str) -> dict:
-    """Parse SDP string.
-
-    @param sdp: The SDP string to parse.
-
-    @return: A dictionary containing parsed session data.
-    """
-    # FIXME: to be removed once host is accessible from global var
-    assert host is not None
-    lines = sdp.strip().split("\r\n")
-    # session metadata
-    metadata: Dict[str, Any] = {}
-    call_data = {"metadata": metadata}
-
-    media_type = None
-    media_data: Optional[Dict[str, Any]] = None
-    application_data: Optional[Dict[str, Any]] = None
-    transport_data: Optional[Dict[str, Any]] = None
-    fingerprint_data: Optional[Dict[str, str]] = None
-    ice_pwd: Optional[str] = None
-    ice_ufrag: Optional[str] = None
-    payload_types: Optional[Dict[int, dict]] = None
-
-    for line in lines:
-        try:
-            parts = line.split()
-            prefix = parts[0][:2]  # Extract the 'a=', 'm=', etc., prefix
-            parts[0] = parts[0][2:]  # Remove the prefix from the first element
-
-            if prefix == "m=":
-                media_type = parts[0]
-                port = int(parts[1])
-                payload_types = {}
-                for payload_type_id in [int(pt_id) for pt_id in parts[3:]]:
-                    payload_type = {"id": payload_type_id}
-                    payload_types[payload_type_id] = payload_type
-
-                application_data = {"media": media_type, "payload_types": payload_types}
-                transport_data = {"port": port}
-                if fingerprint_data is not None:
-                    transport_data["fingerprint"] = fingerprint_data
-                if ice_pwd is not None:
-                    transport_data["pwd"] = ice_pwd
-                if ice_ufrag is not None:
-                    transport_data["ufrag"] = ice_ufrag
-                media_data = call_data[media_type] = {
-                    "application_data": application_data,
-                    "transport_data": transport_data,
-                }
-
-            elif prefix == "a=":
-                if ":" in parts[0]:
-                    attribute, parts[0] = parts[0].split(":", 1)
-                else:
-                    attribute = parts[0]
-
-                if (
-                    media_type is None
-                    or application_data is None
-                    or transport_data is None
-                ) and not (
-                    attribute
-                    in (
-                        "sendrecv",
-                        "sendonly",
-                        "recvonly",
-                        "inactive",
-                        "fingerprint",
-                        "group",
-                        "ice-options",
-                        "msid-semantic",
-                        "ice-pwd",
-                        "ice-ufrag",
-                    )
-                ):
-                    log.warning(
-                        "Received attribute before media description, this is "
-                        f"invalid: {line}"
-                    )
-                    continue
-
-                if attribute == "mid":
-                    assert media_data is not None
-                    try:
-                        media_data["id"] = parts[0]
-                    except IndexError:
-                        log.warning(f"invalid media ID: {line}")
-
-                elif attribute == "rtpmap":
-                    assert application_data is not None
-                    assert payload_types is not None
-                    pt_id = int(parts[0])
-                    codec_info = parts[1].split("/")
-                    codec = codec_info[0]
-                    clockrate = int(codec_info[1])
-                    payload_type = {
-                        "id": pt_id,
-                        "name": codec,
-                        "clockrate": clockrate,
-                    }
-                    # Handle optional channel count
-                    if len(codec_info) > 2:
-                        channels = int(codec_info[2])
-                        payload_type["channels"] = channels
-
-                    payload_types.setdefault(pt_id, {}).update(payload_type)
-
-                elif attribute == "fmtp":
-                    assert payload_types is not None
-                    pt_id = int(parts[0])
-                    params = parts[1].split(";")
-                    try:
-                        payload_type = payload_types[pt_id]
-                    except KeyError:
-                        raise ValueError(
-                            f"Can find content type {pt_id}, ignoring: {line}"
-                        )
-
-                    try:
-                        payload_type["parameters"] = {
-                            name: value
-                            for name, value in (param.split("=") for param in params)
-                        }
-                    except ValueError:
-                        payload_type.setdefault("exra-parameters", []).extend(params)
-
-                elif attribute == "candidate":
-                    assert transport_data is not None
-                    candidate = {
-                        "foundation": parts[0],
-                        "component_id": int(parts[1]),
-                        "transport": parts[2],
-                        "priority": int(parts[3]),
-                        "address": parts[4],
-                        "port": int(parts[5]),
-                        "type": parts[7],
-                    }
-
-                    for part in parts[8:]:
-                        if part == "raddr":
-                            candidate["rel_addr"] = parts[parts.index(part) + 1]
-                        elif part == "rport":
-                            candidate["rel_port"] = int(parts[parts.index(part) + 1])
-                        elif part == "generation":
-                            candidate["generation"] = parts[parts.index(part) + 1]
-                        elif part == "network":
-                            candidate["network"] = parts[parts.index(part) + 1]
-
-                    transport_data.setdefault("candidates", []).append(candidate)
-
-                elif attribute == "fingerprint":
-                    algorithm, fingerprint = parts[0], parts[1]
-                    fingerprint_data = {"hash": algorithm, "fingerprint": fingerprint}
-                    if transport_data is not None:
-                        transport_data["fingerprint"] = fingerprint_data
-                elif attribute == "setup":
-                    assert transport_data is not None
-                    setup = parts[0]
-                    transport_data.setdefault("fingerprint", {})["setup"] = setup
-
-                elif attribute == "b":
-                    assert application_data is not None
-                    bandwidth = int(parts[0])
-                    application_data["bandwidth"] = bandwidth
-
-                elif attribute == "rtcp-mux":
-                    assert application_data is not None
-                    application_data["rtcp-mux"] = True
-
-                elif attribute == "ice-ufrag":
-                    if transport_data is not None:
-                        transport_data["ufrag"] = parts[0]
-
-                elif attribute == "ice-pwd":
-                    if transport_data is not None:
-                        transport_data["pwd"] = parts[0]
-
-                host.trigger.point(
-                    "XEP-0167_parse_sdp_a",
-                    attribute,
-                    parts,
-                    call_data,
-                    metadata,
-                    media_type,
-                    application_data,
-                    transport_data,
-                    triggers_no_cancel=True
-                )
-
-        except ValueError as e:
-            raise ValueError(f"Could not parse line. Invalid format ({e}): {line}") from e
-        except IndexError as e:
-            raise IndexError(f"Incomplete line. Missing data: {line}") from e
-
-    # we remove private data (data starting with _, used by some plugins (e.g. XEP-0294)
-    # to handle session data at media level))
-    for key in [k for k in call_data if k.startswith("_")]:
-        log.debug(f"cleaning remaining private data {key!r}")
-        del call_data[key]
-
-    # ICE candidates may only be specified for the first media, this
-    # duplicate the candidate for the other in this case
-    all_media = {k:v for k,v in call_data.items() if k in ("audio", "video")}
-    if len(all_media) > 1 and not all(
-        "candidates" in c["transport_data"] for c in all_media.values()
-    ):
-        first_content = next(iter(all_media.values()))
-        try:
-            ice_candidates = first_content["transport_data"]["candidates"]
-        except KeyError:
-            log.warning("missing candidates in SDP")
-        else:
-            for idx, content in enumerate(all_media.values()):
-                if idx == 0:
-                    continue
-                content["transport_data"].setdefault("candidates", ice_candidates)
-
-    return call_data
-
-
-def build_description(media: str, media_data: dict, session: dict) -> domish.Element:
-    """Generate <description> element from media data
-
-    @param media: media type ("audio" or "video")
-
-    @param media_data: A dictionary containing the media description data.
-        The keys and values are described below:
-
-        - ssrc (str, optional): The synchronization source identifier.
-        - payload_types (list): A list of dictionaries, each representing a payload
-          type.
-          Each dictionary may contain the following keys:
-            - channels (str, optional): Number of audio channels.
-            - clockrate (str, optional): Clock rate of the media.
-            - id (str): The unique identifier of the payload type.
-            - maxptime (str, optional): Maximum packet time.
-            - name (str, optional): Name of the codec.
-            - ptime (str, optional): Preferred packet time.
-            - parameters (dict, optional): A dictionary of codec-specific parameters.
-              Key-value pairs represent the parameter name and value, respectively.
-        - bandwidth (str, optional): The bandwidth type.
-        - rtcp-mux (bool, optional): Indicates whether RTCP multiplexing is enabled or
-          not.
-        - encryption (list, optional): A list of dictionaries, each representing an
-          encryption method.
-          Each dictionary may contain the following keys:
-            - tag (str): The unique identifier of the encryption method.
-            - crypto-suite (str): The encryption suite in use.
-            - key-params (str): Key parameters for the encryption suite.
-            - session-params (str, optional): Session parameters for the encryption
-              suite.
-
-    @return: A <description> element.
-    """
-    # FIXME: to be removed once host is accessible from global var
-    assert host is not None
-
-    desc_elt = domish.Element((NS_JINGLE_RTP, "description"), attribs={"media": media})
-
-    for pt_id, pt_data in media_data.get("payload_types", {}).items():
-        payload_type_elt = desc_elt.addElement("payload-type")
-        payload_type_elt["id"] = str(pt_id)
-        for attr in ["channels", "clockrate", "maxptime", "name", "ptime"]:
-            if attr in pt_data:
-                payload_type_elt[attr] = str(pt_data[attr])
-
-        if "parameters" in pt_data:
-            for param_name, param_value in pt_data["parameters"].items():
-                param_elt = payload_type_elt.addElement("parameter")
-                param_elt["name"] = param_name
-                param_elt["value"] = param_value
-        host.trigger.point(
-            "XEP-0167_build_description_payload_type",
-            desc_elt,
-            media_data,
-            pt_data,
-            payload_type_elt,
-            triggers_no_cancel=True
-        )
-
-    if "bandwidth" in media_data:
-        bandwidth_elt = desc_elt.addElement("bandwidth")
-        bandwidth_elt["type"] = media_data["bandwidth"]
-
-    if media_data.get("rtcp-mux"):
-        desc_elt.addElement("rtcp-mux")
-
-    # Add encryption element
-    if "encryption" in media_data:
-        encryption_elt = desc_elt.addElement("encryption")
-        # we always want require encryption if the `encryption` data is present
-        encryption_elt["required"] = "1"
-        for enc_data in media_data["encryption"]:
-            crypto_elt = encryption_elt.addElement("crypto")
-            for attr in ["tag", "crypto-suite", "key-params", "session-params"]:
-                if attr in enc_data:
-                    crypto_elt[attr] = enc_data[attr]
-
-    host.trigger.point(
-        "XEP-0167_build_description",
-        desc_elt,
-        media_data,
-        session,
-        triggers_no_cancel=True
-    )
-
-    return desc_elt
-
-
-def parse_description(desc_elt: domish.Element) -> dict:
-    """Parse <desciption> to a dict
-
-    @param desc_elt: <description> element
-    @return: media data as in [build_description]
-    """
-    # FIXME: to be removed once host is accessible from global var
-    assert host is not None
-
-    media_data = {}
-    if desc_elt.hasAttribute("ssrc"):
-        media_data.setdefault("ssrc", {})[desc_elt["ssrc"]] = {}
-
-    payload_types = {}
-    for payload_type_elt in desc_elt.elements(NS_JINGLE_RTP, "payload-type"):
-        payload_type_data = {
-            attr: payload_type_elt[attr]
-            for attr in [
-                "channels",
-                "clockrate",
-                "maxptime",
-                "name",
-                "ptime",
-            ]
-            if payload_type_elt.hasAttribute(attr)
-        }
-        try:
-            pt_id = int(payload_type_elt["id"])
-        except KeyError:
-            log.warning(
-                f"missing ID in payload type, ignoring: {payload_type_elt.toXml()}"
-            )
-            continue
-
-        parameters = {}
-        for param_elt in payload_type_elt.elements(NS_JINGLE_RTP, "parameter"):
-            param_name = param_elt.getAttribute("name")
-            param_value = param_elt.getAttribute("value")
-            if not param_name or param_value is None:
-                log.warning(f"invalid parameter: {param_elt.toXml()}")
-                continue
-            parameters[param_name] = param_value
-
-        if parameters:
-            payload_type_data["parameters"] = parameters
-
-        host.trigger.point(
-            "XEP-0167_parse_description_payload_type",
-            desc_elt,
-            media_data,
-            payload_type_elt,
-            payload_type_data,
-            triggers_no_cancel=True
-        )
-        payload_types[pt_id] = payload_type_data
-
-    # bandwidth
-    media_data["payload_types"] = payload_types
-    try:
-        bandwidth_elt = next(desc_elt.elements(NS_JINGLE_RTP, "bandwidth"))
-    except StopIteration:
-        pass
-    else:
-        bandwidth = bandwidth_elt.getAttribute("type")
-        if not bandwidth:
-            log.warning(f"invalid bandwidth: {bandwidth_elt.toXml}")
-        else:
-            media_data["bandwidth"] = bandwidth
-
-    # rtcp-mux
-    rtcp_mux_elt = next(desc_elt.elements(NS_JINGLE_RTP, "rtcp-mux"), None)
-    media_data["rtcp-mux"] = rtcp_mux_elt is not None
-
-    # Encryption
-    encryption_data = []
-    encryption_elt = next(desc_elt.elements(NS_JINGLE_RTP, "encryption"), None)
-    if encryption_elt:
-        media_data["encryption_required"] = C.bool(
-            encryption_elt.getAttribute("required", C.BOOL_FALSE)
-        )
-
-        for crypto_elt in encryption_elt.elements(NS_JINGLE_RTP, "crypto"):
-            crypto_data = {
-                attr: crypto_elt[attr]
-                for attr in [
-                    "crypto-suite",
-                    "key-params",
-                    "session-params",
-                    "tag",
-                ]
-                if crypto_elt.hasAttribute(attr)
-            }
-            encryption_data.append(crypto_data)
-
-    if encryption_data:
-        media_data["encryption"] = encryption_data
-
-    host.trigger.point(
-        "XEP-0167_parse_description",
-        desc_elt,
-        media_data,
-        triggers_no_cancel=True
-    )
-
-    return media_data
--- a/sat/plugins/plugin_xep_0176.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,394 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for Jingle (XEP-0176)
-# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Dict, List, Optional
-import uuid
-
-from twisted.internet import defer
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools.common import data_format
-
-from .plugin_xep_0166 import BaseTransportHandler
-
-log = getLogger(__name__)
-
-NS_JINGLE_ICE_UDP= "urn:xmpp:jingle:transports:ice-udp:1"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Jingle ICE-UDP Transport Method",
-    C.PI_IMPORT_NAME: "XEP-0176",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0176"],
-    C.PI_DEPENDENCIES: ["XEP-0166"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "XEP_0176",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Jingle ICE-UDP transport"""),
-}
-
-
-class XEP_0176(BaseTransportHandler):
-
-    def __init__(self, host):
-        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
-        self.host = host
-        self._j = host.plugins["XEP-0166"]  # shortcut to access jingle
-        self._j.register_transport(
-            NS_JINGLE_ICE_UDP, self._j.TRANSPORT_DATAGRAM, self, 100
-        )
-        host.bridge.add_method(
-            "ice_candidates_add",
-            ".plugin",
-            in_sign="sss",
-            out_sign="",
-            method=self._ice_candidates_add,
-            async_=True,
-        )
-        host.bridge.add_signal(
-            "ice_candidates_new", ".plugin", signature="sss"
-        )  # args: jingle_sid, candidates_serialised, profile
-        host.bridge.add_signal(
-            "ice_restart", ".plugin", signature="sss"
-        )  # args: jingle_sid, side ("local" or "peer"), profile
-
-    def get_handler(self, client):
-        return XEP_0176_handler()
-
-    def _ice_candidates_add(
-        self,
-        session_id: str,
-        media_ice_data_s: str,
-        profile_key: str,
-    ):
-        client = self.host.get_client(profile_key)
-        return defer.ensureDeferred(self.ice_candidates_add(
-            client,
-            session_id,
-            data_format.deserialise(media_ice_data_s),
-        ))
-
-    def build_transport(self, ice_data: dict) -> domish.Element:
-        """Generate <transport> element from ICE data
-
-        @param ice_data: a dict containing the following keys:
-            - "ufrag" (str): The ICE username fragment.
-            - "pwd" (str): The ICE password.
-            - "candidates" (List[dict]): A list of ICE candidate dictionaries, each
-              containing:
-                - "component_id" (int): The component ID.
-                - "foundation" (str): The candidate foundation.
-                - "address" (str): The candidate IP address.
-                - "port" (int): The candidate port.
-                - "priority" (int): The candidate priority.
-                - "transport" (str): The candidate transport protocol, e.g., "udp".
-                - "type" (str): The candidate type, e.g., "host", "srflx", "prflx", or
-                  "relay".
-                - "generation" (str, optional): The candidate generation. Defaults to "0".
-                - "network" (str, optional): The candidate network. Defaults to "0".
-                - "rel_addr" (str, optional): The related address for the candidate, if
-                  any.
-                - "rel_port" (int, optional): The related port for the candidate, if any.
-
-        @return: A <transport> element.
-        """
-        try:
-            ufrag: str = ice_data["ufrag"]
-            pwd: str = ice_data["pwd"]
-            candidates: List[dict] = ice_data["candidates"]
-        except KeyError as e:
-            raise exceptions.DataError(f"ICE {e} must be provided")
-
-        candidates.sort(key=lambda c: int(c.get("priority", 0)), reverse=True)
-        transport_elt = domish.Element(
-            (NS_JINGLE_ICE_UDP, "transport"),
-            attribs={"ufrag": ufrag, "pwd": pwd}
-        )
-
-        for candidate in candidates:
-            try:
-                candidate_elt = transport_elt.addElement("candidate")
-                candidate_elt["component"] = str(candidate["component_id"])
-                candidate_elt["foundation"] = candidate["foundation"]
-                candidate_elt["generation"] = str(candidate.get("generation", "0"))
-                candidate_elt["id"] = candidate.get("id") or str(uuid.uuid4())
-                candidate_elt["ip"] = candidate["address"]
-                candidate_elt["network"] = str(candidate.get("network", "0"))
-                candidate_elt["port"] = str(candidate["port"])
-                candidate_elt["priority"] = str(candidate["priority"])
-                candidate_elt["protocol"] = candidate["transport"]
-                candidate_elt["type"] = candidate["type"]
-            except KeyError as e:
-                raise exceptions.DataError(
-                    f"Mandatory ICE candidate attribute {e} is missing"
-                )
-
-            if "rel_addr" in candidate and "rel_port" in candidate:
-                candidate_elt["rel-addr"] = candidate["rel_addr"]
-                candidate_elt["rel-port"] = str(candidate["rel_port"])
-
-        self.host.trigger.point("XEP-0176_build_transport", transport_elt, ice_data)
-
-        return transport_elt
-
-    def parse_transport(self, transport_elt: domish.Element) -> dict:
-        """Parse <transport> to a dict
-
-        @param transport_elt: <transport> element
-        @return: ICE data (as in [build_transport])
-        """
-        try:
-            ice_data = {
-                "ufrag": transport_elt["ufrag"],
-                "pwd": transport_elt["pwd"]
-            }
-        except KeyError as e:
-            raise exceptions.DataError(
-                f"<transport> is missing mandatory attribute {e}: {transport_elt.toXml()}"
-            )
-        ice_data["candidates"] = ice_candidates = []
-
-        for candidate_elt in transport_elt.elements(NS_JINGLE_ICE_UDP, "candidate"):
-            try:
-                candidate = {
-                    "component_id": int(candidate_elt["component"]),
-                    "foundation": candidate_elt["foundation"],
-                    "address": candidate_elt["ip"],
-                    "port": int(candidate_elt["port"]),
-                    "priority": int(candidate_elt["priority"]),
-                    "transport": candidate_elt["protocol"],
-                    "type": candidate_elt["type"],
-                }
-            except KeyError as e:
-                raise exceptions.DataError(
-                    f"Mandatory attribute {e} is missing in candidate element"
-                )
-
-            if candidate_elt.hasAttribute("generation"):
-                candidate["generation"] = candidate_elt["generation"]
-
-            if candidate_elt.hasAttribute("network"):
-                candidate["network"] = candidate_elt["network"]
-
-            if candidate_elt.hasAttribute("rel-addr"):
-                candidate["rel_addr"] = candidate_elt["rel-addr"]
-
-            if candidate_elt.hasAttribute("rel-port"):
-                candidate["rel_port"] = int(candidate_elt["rel-port"])
-
-            ice_candidates.append(candidate)
-
-        self.host.trigger.point("XEP-0176_parse_transport", transport_elt, ice_data)
-
-        return ice_data
-
-    async def jingle_session_init(
-        self,
-        client: SatXMPPEntity,
-        session: dict,
-        content_name: str,
-    ) -> domish.Element:
-        """Create a Jingle session initiation transport element with ICE candidates.
-
-        @param client: SatXMPPEntity object representing the client.
-        @param session: Dictionary containing session data.
-        @param content_name: Name of the content.
-        @param ufrag: ICE username fragment.
-        @param pwd: ICE password.
-        @param candidates: List of ICE candidate dictionaries parsed from the
-            parse_ice_candidate method.
-
-        @return: domish.Element representing the Jingle transport element.
-
-        @raise exceptions.DataError: If mandatory data is missing from the candidates.
-        """
-        content_data = session["contents"][content_name]
-        transport_data = content_data["transport_data"]
-        ice_data = transport_data["local_ice_data"]
-        return self.build_transport(ice_data)
-
-    async def jingle_handler(
-        self,
-        client: SatXMPPEntity,
-        action: str,
-        session: dict,
-        content_name: str,
-        transport_elt: domish.Element,
-    ) -> domish.Element:
-        """Handle Jingle requests
-
-        @param client: The SatXMPPEntity instance.
-        @param action: The action to be performed with the session.
-        @param session: A dictionary containing the session information.
-        @param content_name: The name of the content.
-        @param transport_elt: The domish.Element instance representing the transport
-            element.
-
-        @return: <transport> element
-        """
-        content_data = session["contents"][content_name]
-        transport_data = content_data["transport_data"]
-        if action in (self._j.A_PREPARE_CONFIRMATION, self._j.A_PREPARE_INITIATOR):
-            peer_ice_data = self.parse_transport(transport_elt)
-            transport_data["peer_ice_data"] = peer_ice_data
-
-        elif action in (self._j.A_ACCEPTED_ACK, self._j.A_PREPARE_RESPONDER):
-            pass
-
-        elif action == self._j.A_SESSION_ACCEPT:
-            pass
-
-        elif action == self._j.A_START:
-            pass
-
-        elif action == self._j.A_SESSION_INITIATE:
-            # responder side, we give our candidates
-            transport_elt = self.build_transport(transport_data["local_ice_data"])
-        elif action == self._j.A_TRANSPORT_INFO:
-
-            media_type = content_data["application_data"].get("media")
-            new_ice_data = self.parse_transport(transport_elt)
-            restart = self.update_candidates(transport_data, new_ice_data, local=False)
-            if restart:
-                log.debug(
-                    f"Peer ICE restart detected on session {session['id']} "
-                    f"[{client.profile}]"
-                )
-                self.host.bridge.ice_restart(session["id"], "peer", client.profile)
-
-            self.host.bridge.ice_candidates_new(
-                session["id"],
-                data_format.serialise({media_type: new_ice_data}),
-                client.profile
-            )
-        elif action == self._j.A_DESTROY:
-           pass
-        else:
-            log.warning("FIXME: unmanaged action {}".format(action))
-
-        return transport_elt
-
-    def jingle_terminate(
-        self,
-        client: SatXMPPEntity,
-        action: str,
-        session: dict,
-        content_name: str,
-        reason_elt: domish.Element,
-    ) -> None:
-        log.debug("ICE-UDP session terminated")
-
-    def update_candidates(
-        self,
-        transport_data: dict,
-        new_ice_data: dict,
-        local: bool
-    ) -> bool:
-        """Update ICE candidates when new one are received
-
-        @param transport_data: transport_data of the content linked to the candidates
-        @param new_ice_data: new ICE data, in the same format as returned
-            by [self.parse_transport]
-        @param local: True if it's our candidates, False if it's peer ones
-        @return: True if there is a ICE restart
-        """
-        key = "local_ice_data" if local else "peer_ice_data"
-        try:
-            ice_data = transport_data[key]
-        except KeyError:
-            log.warning(
-                f"no {key} available"
-            )
-            transport_data[key] = new_ice_data
-        else:
-            if (
-                new_ice_data["ufrag"] != ice_data["ufrag"]
-                or new_ice_data["pwd"] != ice_data["pwd"]
-            ):
-                ice_data["ufrag"] = new_ice_data["ufrag"]
-                ice_data["pwd"] = new_ice_data["pwd"]
-                ice_data["candidates"] = new_ice_data["candidates"]
-                return True
-        return False
-
-    async def ice_candidates_add(
-        self,
-        client: SatXMPPEntity,
-        session_id: str,
-        media_ice_data: Dict[str, dict]
-    ) -> None:
-        """Called when a new ICE candidates are available for a session
-
-        @param session_id: Session ID
-        @param candidates: a map from media type (audio, video) to ICE data
-            ICE data must be in the same format as in [self.parse_transport]
-        """
-        session = self._j.get_session(client, session_id)
-        iq_elt: Optional[domish.Element] = None
-
-        for media_type, new_ice_data in media_ice_data.items():
-            for content_name, content_data in session["contents"].items():
-                if content_data["application_data"].get("media") == media_type:
-                    break
-            else:
-                log.warning(
-                    "no media of type {media_type} has been found"
-                )
-                continue
-            restart = self.update_candidates(
-                content_data["transport_data"], new_ice_data, True
-            )
-            if restart:
-                log.debug(
-                    f"Local ICE restart detected on session {session['id']} "
-                    f"[{client.profile}]"
-                )
-                self.host.bridge.ice_restart(session["id"], "local", client.profile)
-            transport_elt = self.build_transport(new_ice_data)
-            iq_elt, __ = self._j.build_action(
-                client, self._j.A_TRANSPORT_INFO, session, content_name, iq_elt=iq_elt,
-                transport_elt=transport_elt
-            )
-
-        if iq_elt is not None:
-            try:
-                await iq_elt.send()
-            except Exception as e:
-                log.warning(f"Could not send new ICE candidates: {e}")
-
-        else:
-            log.error("Could not find any content to apply new ICE candidates")
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0176_handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_JINGLE_ICE_UDP)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0184.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,237 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing xep-0184
-# Copyright (C) 2009-2016 Geoffrey POUZET (chteufleur@kingpenguin.tk)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from twisted.internet import reactor
-from twisted.words.protocols.jabber import xmlstream, jid
-from twisted.words.xish import domish
-
-log = getLogger(__name__)
-
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-
-
-NS_MESSAGE_DELIVERY_RECEIPTS = "urn:xmpp:receipts"
-
-MSG = "message"
-
-MSG_CHAT = "/" + MSG + '[@type="chat"]'
-MSG_CHAT_MESSAGE_DELIVERY_RECEIPTS_REQUEST = (
-    MSG_CHAT + '/request[@xmlns="' + NS_MESSAGE_DELIVERY_RECEIPTS + '"]'
-)
-MSG_CHAT_MESSAGE_DELIVERY_RECEIPTS_RECEIVED = (
-    MSG_CHAT + '/received[@xmlns="' + NS_MESSAGE_DELIVERY_RECEIPTS + '"]'
-)
-
-MSG_NORMAL = "/" + MSG + '[@type="normal"]'
-MSG_NORMAL_MESSAGE_DELIVERY_RECEIPTS_REQUEST = (
-    MSG_NORMAL + '/request[@xmlns="' + NS_MESSAGE_DELIVERY_RECEIPTS + '"]'
-)
-MSG_NORMAL_MESSAGE_DELIVERY_RECEIPTS_RECEIVED = (
-    MSG_NORMAL + '/received[@xmlns="' + NS_MESSAGE_DELIVERY_RECEIPTS + '"]'
-)
-
-
-PARAM_KEY = "Privacy"
-PARAM_NAME = "Enable message delivery receipts"
-ENTITY_KEY = PARAM_KEY + "_" + PARAM_NAME
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP-0184 Plugin",
-    C.PI_IMPORT_NAME: "XEP-0184",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0184"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "XEP_0184",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Message Delivery Receipts"""),
-}
-
-
-STATUS_MESSAGE_DELIVERY_RECEIVED = "delivered"
-TEMPO_DELETE_WAITING_ACK_S = 300  # 5 min
-
-
-class XEP_0184(object):
-    """
-    Implementation for XEP 0184.
-    """
-
-    params = """
-    <params>
-    <individual>
-    <category name="%(category_name)s" label="%(category_label)s">
-        <param name="%(param_name)s" label="%(param_label)s" value="true" type="bool" security="0"/>
-     </category>
-    </individual>
-    </params>
-    """ % {
-        "category_name": PARAM_KEY,
-        "category_label": _(PARAM_KEY),
-        "param_name": PARAM_NAME,
-        "param_label": _("Enable message delivery receipts"),
-    }
-
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0184 (message delivery receipts) initialization"))
-        self.host = host
-        self._dictRequest = dict()
-
-        # parameter value is retrieved before each use
-        host.memory.update_params(self.params)
-
-        host.trigger.add("sendMessage", self.send_message_trigger)
-        host.bridge.add_signal(
-            "message_state", ".plugin", signature="sss"
-        )  # message_uid, status, profile
-
-    def get_handler(self, client):
-        return XEP_0184_handler(self, client.profile)
-
-    def send_message_trigger(
-        self, client, mess_data, pre_xml_treatments, post_xml_treatments
-    ):
-        """Install SendMessage command hook """
-
-        def treatment(mess_data):
-            message = mess_data["xml"]
-            message_type = message.getAttribute("type")
-
-            if self._is_actif(client.profile) and (
-                message_type == "chat" or message_type == "normal"
-            ):
-                message.addElement("request", NS_MESSAGE_DELIVERY_RECEIPTS)
-                uid = mess_data["uid"]
-                msg_id = message.getAttribute("id")
-                self._dictRequest[msg_id] = uid
-                reactor.callLater(
-                    TEMPO_DELETE_WAITING_ACK_S, self._clear_dict_request, msg_id
-                )
-                log.debug(
-                    _(
-                        "[XEP-0184] Request acknowledgment for message id {}".format(
-                            msg_id
-                        )
-                    )
-                )
-
-            return mess_data
-
-        post_xml_treatments.addCallback(treatment)
-        return True
-
-    def on_message_delivery_receipts_request(self, msg_elt, client):
-        """This method is called on message delivery receipts **request** (XEP-0184 #7)
-        @param msg_elt: message element
-        @param client: %(doc_client)s"""
-        from_jid = jid.JID(msg_elt["from"])
-
-        if self._is_actif(client.profile) and client.roster.is_subscribed_from(from_jid):
-            received_elt_ret = domish.Element((NS_MESSAGE_DELIVERY_RECEIPTS, "received"))
-            try:
-                received_elt_ret["id"] = msg_elt["id"]
-            except KeyError:
-                log.warning(f"missing id for message element: {msg_elt.toXml}")
-                return
-
-            msg_result_elt = xmlstream.toResponse(msg_elt, "result")
-            msg_result_elt.addChild(received_elt_ret)
-            client.send(msg_result_elt)
-
-    def on_message_delivery_receipts_received(self, msg_elt, client):
-        """This method is called on message delivery receipts **received** (XEP-0184 #7)
-        @param msg_elt: message element
-        @param client: %(doc_client)s"""
-        msg_elt.handled = True
-        rcv_elt = next(msg_elt.elements(NS_MESSAGE_DELIVERY_RECEIPTS, "received"))
-        msg_id = rcv_elt["id"]
-
-        try:
-            uid = self._dictRequest[msg_id]
-            del self._dictRequest[msg_id]
-            self.host.bridge.message_state(
-                uid, STATUS_MESSAGE_DELIVERY_RECEIVED, client.profile
-            )
-            log.debug(
-                _("[XEP-0184] Receive acknowledgment for message id {}".format(msg_id))
-            )
-        except KeyError:
-            pass
-
-    def _clear_dict_request(self, msg_id):
-        try:
-            del self._dictRequest[msg_id]
-            log.debug(
-                _(
-                    "[XEP-0184] Delete waiting acknowledgment for message id {}".format(
-                        msg_id
-                    )
-                )
-            )
-        except KeyError:
-            pass
-
-    def _is_actif(self, profile):
-        return self.host.memory.param_get_a(PARAM_NAME, PARAM_KEY, profile_key=profile)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0184_handler(XMPPHandler):
-
-    def __init__(self, plugin_parent, profile):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-        self.profile = profile
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(
-            MSG_CHAT_MESSAGE_DELIVERY_RECEIPTS_REQUEST,
-            self.plugin_parent.on_message_delivery_receipts_request,
-            client=self.parent,
-        )
-        self.xmlstream.addObserver(
-            MSG_CHAT_MESSAGE_DELIVERY_RECEIPTS_RECEIVED,
-            self.plugin_parent.on_message_delivery_receipts_received,
-            client=self.parent,
-        )
-
-        self.xmlstream.addObserver(
-            MSG_NORMAL_MESSAGE_DELIVERY_RECEIPTS_REQUEST,
-            self.plugin_parent.on_message_delivery_receipts_request,
-            client=self.parent,
-        )
-        self.xmlstream.addObserver(
-            MSG_NORMAL_MESSAGE_DELIVERY_RECEIPTS_RECEIVED,
-            self.plugin_parent.on_message_delivery_receipts_received,
-            client=self.parent,
-        )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_MESSAGE_DELIVERY_RECEIPTS)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0191.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,210 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for XEP-0191
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import List, Set
-
-from twisted.words.protocols.jabber import xmlstream, jid
-from twisted.words.xish import domish
-from twisted.internet import defer
-from zope.interface import implementer
-from wokkel import disco, iwokkel
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.core_types import SatXMPPEntity
-from sat.tools.utils import ensure_deferred
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Blokcing Commands",
-    C.PI_IMPORT_NAME: "XEP-0191",
-    C.PI_TYPE: C.PLUG_TYPE_XEP,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0191"],
-    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0376"],
-    C.PI_MAIN: "XEP_0191",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implement the protocol to block users or whole domains"""),
-}
-
-NS_BLOCKING = "urn:xmpp:blocking"
-IQ_BLOCK_PUSH = f'{C.IQ_SET}/block[@xmlns="{NS_BLOCKING}"]'
-IQ_UNBLOCK_PUSH = f'{C.IQ_SET}/unblock[@xmlns="{NS_BLOCKING}"]'
-
-
-class XEP_0191:
-
-    def __init__(self, host):
-        log.info(_("Blocking Command initialization"))
-        host.register_namespace("blocking", NS_BLOCKING)
-        self.host = host
-        host.bridge.add_method(
-            "blocking_list",
-            ".plugin",
-            in_sign="s",
-            out_sign="as",
-            method=self._block_list,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "blocking_block",
-            ".plugin",
-            in_sign="ass",
-            out_sign="",
-            method=self._block,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "blocking_unblock",
-            ".plugin",
-            in_sign="ass",
-            out_sign="",
-            method=self._unblock,
-            async_=True,
-        )
-
-    def get_handler(self, client):
-        return XEP_0191_Handler(self)
-
-    @ensure_deferred
-    async def _block_list(
-        self,
-        profile_key=C.PROF_KEY_NONE
-    ) -> List[str]:
-        client = self.host.get_client(profile_key)
-        blocked_jids = await self.block_list(client)
-        return [j.full() for j in blocked_jids]
-
-    async def block_list(self, client: SatXMPPEntity) -> Set[jid.JID]:
-        await self.host.check_feature(client, NS_BLOCKING)
-        iq_elt = client.IQ("get")
-        iq_elt.addElement((NS_BLOCKING, "blocklist"))
-        iq_result_elt = await iq_elt.send()
-        try:
-            blocklist_elt = next(iq_result_elt.elements(NS_BLOCKING, "blocklist"))
-        except StopIteration:
-            log.warning(f"missing <blocklist> element: {iq_result_elt.toXml()}")
-            return []
-        blocked_jids = set()
-        for item_elt in blocklist_elt.elements(NS_BLOCKING, "item"):
-            try:
-                blocked_jid = jid.JID(item_elt["jid"])
-            except (RuntimeError, AttributeError):
-                log.warning(f"Invalid <item> element in block list: {item_elt.toXml()}")
-            else:
-                blocked_jids.add(blocked_jid)
-
-        return blocked_jids
-
-    def _block(
-        self,
-        entities: List[str],
-        profile_key: str = C.PROF_KEY_NONE
-    ) -> str:
-        client = self.host.get_client(profile_key)
-        return defer.ensureDeferred(
-            self.block(client, [jid.JID(entity) for entity in entities])
-        )
-
-    async def block(self, client: SatXMPPEntity, entities: List[jid.JID]) -> None:
-        await self.host.check_feature(client, NS_BLOCKING)
-        iq_elt = client.IQ("set")
-        block_elt = iq_elt.addElement((NS_BLOCKING, "block"))
-        for entity in entities:
-            item_elt = block_elt.addElement("item")
-            item_elt["jid"] = entity.full()
-        await iq_elt.send()
-
-    def _unblock(
-        self,
-        entities: List[str],
-        profile_key: str = C.PROF_KEY_NONE
-    ) -> None:
-        client = self.host.get_client(profile_key)
-        return defer.ensureDeferred(
-            self.unblock(client, [jid.JID(e) for e in entities])
-        )
-
-    async def unblock(self, client: SatXMPPEntity, entities: List[jid.JID]) -> None:
-        await self.host.check_feature(client, NS_BLOCKING)
-        iq_elt = client.IQ("set")
-        unblock_elt = iq_elt.addElement((NS_BLOCKING, "unblock"))
-        for entity in entities:
-            item_elt = unblock_elt.addElement("item")
-            item_elt["jid"] = entity.full()
-        await iq_elt.send()
-
-    def on_block_push(self, iq_elt: domish.Element, client: SatXMPPEntity) -> None:
-        # TODO: send notification to user
-        iq_elt.handled = True
-        for item_elt in iq_elt.block.elements(NS_BLOCKING, "item"):
-            try:
-                entity = jid.JID(item_elt["jid"])
-            except (KeyError, RuntimeError):
-                log.warning(f"invalid item received in block push: {item_elt.toXml()}")
-            else:
-                log.info(f"{entity.full()} has been blocked for {client.profile}")
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        client.send(iq_result_elt)
-
-    def on_unblock_push(self, iq_elt: domish.Element, client: SatXMPPEntity) -> None:
-        # TODO: send notification to user
-        iq_elt.handled = True
-        items = list(iq_elt.unblock.elements(NS_BLOCKING, "item"))
-        if not items:
-            log.info(f"All entities have been unblocked for {client.profile}")
-        else:
-            for item_elt in items:
-                try:
-                    entity = jid.JID(item_elt["jid"])
-                except (KeyError, RuntimeError):
-                    log.warning(
-                        f"invalid item received in unblock push: {item_elt.toXml()}"
-                    )
-                else:
-                    log.info(f"{entity.full()} has been unblocked for {client.profile}")
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        client.send(iq_result_elt)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0191_Handler(xmlstream.XMPPHandler):
-
-    def __init__(self, plugin_parent: XEP_0191):
-        self.plugin_parent = plugin_parent
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(
-            IQ_BLOCK_PUSH,
-            self.plugin_parent.on_block_push,
-            client=self.parent
-
-        )
-        self.xmlstream.addObserver(
-            IQ_UNBLOCK_PUSH,
-            self.plugin_parent.on_unblock_push,
-            client=self.parent
-        )
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_BLOCKING)]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0198.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,555 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin for managing Stream-Management
-# Copyright (C) 2009-2021  Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-from twisted.words.protocols.jabber import client as jabber_client
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.xish import domish
-from twisted.internet import defer
-from twisted.internet import task, reactor
-from functools import partial
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-import collections
-import time
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Stream Management",
-    C.PI_IMPORT_NAME: "XEP-0198",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0198"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_RECOMMENDATIONS: ["XEP-0045", "XEP-0313"],
-    C.PI_MAIN: "XEP_0198",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Stream Management"""),
-}
-
-NS_SM = "urn:xmpp:sm:3"
-SM_ENABLED = '/enabled[@xmlns="' + NS_SM + '"]'
-SM_RESUMED = '/resumed[@xmlns="' + NS_SM + '"]'
-SM_FAILED = '/failed[@xmlns="' + NS_SM + '"]'
-SM_R_REQUEST = '/r[@xmlns="' + NS_SM + '"]'
-SM_A_REQUEST = '/a[@xmlns="' + NS_SM + '"]'
-SM_H_REQUEST = '/h[@xmlns="' + NS_SM + '"]'
-# Max number of stanza to send before requesting ack
-MAX_STANZA_ACK_R = 5
-# Max number of seconds before requesting ack
-MAX_DELAY_ACK_R = 30
-MAX_COUNTER = 2**32
-RESUME_MAX = 5*60
-# if we don't have an answer to ACK REQUEST after this delay, connection is aborted
-ACK_TIMEOUT = 35
-
-
-class ProfileSessionData(object):
-    out_counter = 0
-    in_counter = 0
-    session_id = None
-    location = None
-    session_max = None
-    # True when an ack answer is expected
-    ack_requested = False
-    last_ack_r = 0
-    disconnected_time = None
-
-    def __init__(self, callback, **kw):
-        self.buffer = collections.deque()
-        self.buffer_idx = 0
-        self._enabled = False
-        self.timer = None
-        # time used when doing a ack request
-        # when it times out, connection is aborted
-        self.req_timer = None
-        self.callback_data = (callback, kw)
-
-    @property
-    def enabled(self):
-        return self._enabled
-
-    @enabled.setter
-    def enabled(self, enabled):
-        if enabled:
-            if self._enabled:
-                raise exceptions.InternalError(
-                    "Stream Management can't be enabled twice")
-            self._enabled = True
-            callback, kw = self.callback_data
-            self.timer = task.LoopingCall(callback, **kw)
-            self.timer.start(MAX_DELAY_ACK_R, now=False)
-        else:
-            self._enabled = False
-            if self.timer is not None:
-                self.timer.stop()
-                self.timer = None
-
-    @property
-    def resume_enabled(self):
-        return self.session_id is not None
-
-    def reset(self):
-        self.enabled = False
-        self.buffer.clear()
-        self.buffer_idx = 0
-        self.in_counter = self.out_counter = 0
-        self.session_id = self.location = None
-        self.ack_requested = False
-        self.last_ack_r = 0
-        if self.req_timer is not None:
-            if self.req_timer.active():
-                log.error("req_timer has been called/cancelled but not reset")
-            else:
-                self.req_timer.cancel()
-            self.req_timer = None
-
-    def get_buffer_copy(self):
-        return list(self.buffer)
-
-
-class XEP_0198(object):
-    # FIXME: location is not handled yet
-
-    def __init__(self, host):
-        log.info(_("Plugin Stream Management initialization"))
-        self.host = host
-        host.register_namespace('sm', NS_SM)
-        host.trigger.add("stream_hooks", self.add_hooks)
-        host.trigger.add("xml_init", self._xml_init_trigger)
-        host.trigger.add("disconnecting", self._disconnecting_trigger)
-        host.trigger.add("disconnected", self._disconnected_trigger)
-        try:
-            self._ack_timeout = int(host.memory.config_get("", "ack_timeout", ACK_TIMEOUT))
-        except ValueError:
-            log.error(_("Invalid ack_timeout value, please check your configuration"))
-            self._ack_timeout = ACK_TIMEOUT
-        if not self._ack_timeout:
-            log.info(_("Ack timeout disabled"))
-        else:
-            log.info(_("Ack timeout set to {timeout}s").format(
-                timeout=self._ack_timeout))
-
-    def profile_connecting(self, client):
-        client._xep_0198_session = ProfileSessionData(callback=self.check_acks,
-                                                      client=client)
-
-    def get_handler(self, client):
-        return XEP_0198_handler(self)
-
-    def add_hooks(self, client, receive_hooks, send_hooks):
-        """Add hooks to handle in/out stanzas counters"""
-        receive_hooks.append(partial(self.on_receive, client=client))
-        send_hooks.append(partial(self.on_send, client=client))
-        return True
-
-    def _xml_init_trigger(self, client):
-        """Enable or resume a stream mangement"""
-        if not (NS_SM, 'sm') in client.xmlstream.features:
-            log.warning(_(
-                "Your server doesn't support stream management ({namespace}), this is "
-                "used to improve connection problems detection (like network outages). "
-                "Please ask your server administrator to enable this feature.".format(
-                namespace=NS_SM)))
-            return True
-        session = client._xep_0198_session
-
-        # a disconnect timer from a previous disconnection may still be active
-        try:
-            disconnect_timer = session.disconnect_timer
-        except AttributeError:
-            pass
-        else:
-            if disconnect_timer.active():
-                disconnect_timer.cancel()
-            del session.disconnect_timer
-
-        if session.resume_enabled:
-            # we are resuming a session
-            resume_elt = domish.Element((NS_SM, 'resume'))
-            resume_elt['h'] = str(session.in_counter)
-            resume_elt['previd'] = session.session_id
-            client.send(resume_elt)
-            session.resuming = True
-            # session.enabled will be set on <resumed/> reception
-            return False
-        else:
-            # we start a new session
-            assert session.out_counter == 0
-            enable_elt = domish.Element((NS_SM, 'enable'))
-            enable_elt['resume'] = 'true'
-            client.send(enable_elt)
-            session.enabled = True
-            return True
-
-    def _disconnecting_trigger(self, client):
-        session = client._xep_0198_session
-        if session.enabled:
-            self.send_ack(client)
-        # This is a requested disconnection, so we can reset the session
-        # to disable resuming and close normally the stream
-        session.reset()
-        return True
-
-    def _disconnected_trigger(self, client, reason):
-        if client.is_component:
-            return True
-        session = client._xep_0198_session
-        session.enabled = False
-        if session.resume_enabled:
-            session.disconnected_time = time.time()
-            session.disconnect_timer = reactor.callLater(session.session_max,
-                                                         client.disconnect_profile,
-                                                         reason)
-            # disconnect_profile must not be called at this point
-            # because session can be resumed
-            return False
-        else:
-            return True
-
-    def check_acks(self, client):
-        """Request ack if needed"""
-        session = client._xep_0198_session
-        # log.debug("check_acks (in_counter={}, out_counter={}, buf len={}, buf idx={})"
-        #     .format(session.in_counter, session.out_counter, len(session.buffer),
-        #             session.buffer_idx))
-        if session.ack_requested or not session.buffer:
-            return
-        if (session.out_counter - session.buffer_idx >= MAX_STANZA_ACK_R
-            or time.time() - session.last_ack_r >= MAX_DELAY_ACK_R):
-            self.request_ack(client)
-            session.ack_requested = True
-            session.last_ack_r = time.time()
-
-    def update_buffer(self, session, server_acked):
-        """Update buffer and buffer_index"""
-        if server_acked > session.buffer_idx:
-            diff = server_acked - session.buffer_idx
-            try:
-                for i in range(diff):
-                    session.buffer.pop()
-            except IndexError:
-                log.error(
-                    "error while cleaning buffer, invalid index (buffer is empty):\n"
-                    "diff = {diff}\n"
-                    "server_acked = {server_acked}\n"
-                    "buffer_idx = {buffer_id}".format(
-                        diff=diff, server_acked=server_acked,
-                        buffer_id=session.buffer_idx))
-            session.buffer_idx += diff
-
-    def replay_buffer(self, client, buffer_, discard_results=False):
-        """Resend all stanza in buffer
-
-        @param buffer_(collection.deque, list): buffer to replay
-            the buffer will be cleared by this method
-        @param discard_results(bool): if True, don't replay IQ result stanzas
-        """
-        while True:
-            try:
-                stanza = buffer_.pop()
-            except IndexError:
-                break
-            else:
-                if ((discard_results
-                     and stanza.name == 'iq'
-                     and stanza.getAttribute('type') == 'result')):
-                    continue
-                client.send(stanza)
-
-    def send_ack(self, client):
-        """Send an answer element with current IN counter"""
-        a_elt = domish.Element((NS_SM, 'a'))
-        a_elt['h'] = str(client._xep_0198_session.in_counter)
-        client.send(a_elt)
-
-    def request_ack(self, client):
-        """Send a request element"""
-        session = client._xep_0198_session
-        r_elt = domish.Element((NS_SM, 'r'))
-        client.send(r_elt)
-        if session.req_timer is not None:
-            raise exceptions.InternalError("req_timer should not be set")
-        if self._ack_timeout:
-            session.req_timer = reactor.callLater(self._ack_timeout, self.on_ack_time_out,
-                                                  client)
-
-    def _connectionFailed(self, failure_, connector):
-        normal_host, normal_port = connector.normal_location
-        del connector.normal_location
-        log.warning(_(
-            "Connection failed using location given by server (host: {host}, port: "
-            "{port}), switching to normal host and port (host: {normal_host}, port: "
-            "{normal_port})".format(host=connector.host, port=connector.port,
-                                     normal_host=normal_host, normal_port=normal_port)))
-        connector.host, connector.port = normal_host, normal_port
-        connector.connectionFailed = connector.connectionFailed_ori
-        del connector.connectionFailed_ori
-        return connector.connectionFailed(failure_)
-
-    def on_enabled(self, enabled_elt, client):
-        session = client._xep_0198_session
-        session.in_counter = 0
-
-        # we check that resuming is possible and that we have a session id
-        resume = C.bool(enabled_elt.getAttribute('resume'))
-        session_id = enabled_elt.getAttribute('id')
-        if not session_id:
-            log.warning(_('Incorrect <enabled/> element received, no "id" attribute'))
-        if not resume or not session_id:
-            log.warning(_(
-                "You're server doesn't support session resuming with stream management, "
-                "please contact your server administrator to enable it"))
-            return
-
-        session.session_id = session_id
-
-        # XXX: we disable resource binding, which must not be done
-        #      when we resume the session.
-        client.factory.authenticator.res_binding = False
-
-        # location, in case server want resuming session to be elsewhere
-        try:
-            location = enabled_elt['location']
-        except KeyError:
-            pass
-        else:
-            # TODO: handle IPv6 here (in brackets, cf. XEP)
-            try:
-                domain, port = location.split(':', 1)
-                port = int(port)
-            except ValueError:
-                log.warning(_("Invalid location received: {location}")
-                    .format(location=location))
-            else:
-                session.location = (domain, port)
-                # we monkey patch connector to use the new location
-                connector = client.xmlstream.transport.connector
-                connector.normal_location = connector.host, connector.port
-                connector.host = domain
-                connector.port = port
-                connector.connectionFailed_ori = connector.connectionFailed
-                connector.connectionFailed = partial(self._connectionFailed,
-                                                     connector=connector)
-
-        # resuming time
-        try:
-            max_s = int(enabled_elt['max'])
-        except (ValueError, KeyError) as e:
-            if isinstance(e, ValueError):
-                log.warning(_('Invalid "max" attribute'))
-            max_s = RESUME_MAX
-            log.info(_("Using default session max value ({max_s} s).".format(
-                max_s=max_s)))
-            log.info(_("Stream Management enabled"))
-        else:
-            log.info(_(
-                "Stream Management enabled, with a resumption time of {res_m:.2f} min"
-                .format(res_m = max_s/60)))
-        session.session_max = max_s
-
-    def on_resumed(self, enabled_elt, client):
-        session = client._xep_0198_session
-        assert not session.enabled
-        del session.resuming
-        server_acked = int(enabled_elt['h'])
-        self.update_buffer(session, server_acked)
-        resend_count = len(session.buffer)
-        # we resend all stanza which have not been received properly
-        self.replay_buffer(client, session.buffer)
-        # now we can continue the session
-        session.enabled = True
-        d_time = time.time() - session.disconnected_time
-        log.info(_("Stream session resumed (disconnected for {d_time} s, {count} "
-                   "stanza(s) resent)").format(d_time=int(d_time), count=resend_count))
-
-    def on_failed(self, failed_elt, client):
-        session = client._xep_0198_session
-        condition_elt = failed_elt.firstChildElement()
-        buffer_ = session.get_buffer_copy()
-        session.reset()
-
-        try:
-            del session.resuming
-        except AttributeError:
-            # stream management can't be started at all
-            msg = _("Can't use stream management")
-            if condition_elt is None:
-                log.error(msg + '.')
-            else:
-                log.error(_("{msg}: {reason}").format(
-                msg=msg, reason=condition_elt.name))
-        else:
-            # only stream resumption failed, we can try full session init
-            # XXX: we try to start full session init from this point, with many
-            #      variables/attributes already initialised with a potentially different
-            #      jid. This is experimental and may not be safe. It may be more
-            #      secured to abord the connection and restart everything with a fresh
-            #      client.
-            msg = _("stream resumption not possible, restarting full session")
-
-            if condition_elt is None:
-                log.warning('{msg}.'.format(msg=msg))
-            else:
-                log.warning("{msg}: {reason}".format(
-                    msg=msg, reason=condition_elt.name))
-            # stream resumption failed, but we still can do normal stream management
-            # we restore attributes as if the session was new, and init stream
-            # we keep everything initialized, and only do binding, roster request
-            # and initial presence sending.
-            if client.conn_deferred.called:
-                client.conn_deferred = defer.Deferred()
-            else:
-                log.error("conn_deferred should be called at this point")
-            plg_0045 = self.host.plugins.get('XEP-0045')
-            plg_0313 = self.host.plugins.get('XEP-0313')
-
-            # FIXME: we should call all loaded plugins with generic callbacks
-            #        (e.g. prepareResume and resume), so a hot resuming can be done
-            #        properly for all plugins.
-
-            if plg_0045 is not None:
-                # we have to remove joined rooms
-                muc_join_args = plg_0045.pop_rooms(client)
-            # we need to recreate roster
-            client.handlers.remove(client.roster)
-            client.roster = client.roster.__class__(self.host)
-            client.roster.setHandlerParent(client)
-            # bind init is not done when resuming is possible, so we have to do it now
-            bind_init = jabber_client.BindInitializer(client.xmlstream)
-            bind_init.required = True
-            d = bind_init.start()
-            # we set the jid, which may have changed
-            d.addCallback(lambda __: setattr(client.factory.authenticator, "jid", client.jid))
-            # we call the trigger who will send the <enable/> element
-            d.addCallback(lambda __: self._xml_init_trigger(client))
-            # then we have to re-request the roster, as changes may have occured
-            d.addCallback(lambda __: client.roster.request_roster())
-            # we add got_roster to be sure to have roster before sending initial presence
-            d.addCallback(lambda __: client.roster.got_roster)
-            if plg_0313 is not None:
-                # we retrieve one2one MAM archives
-                d.addCallback(lambda __: defer.ensureDeferred(plg_0313.resume(client)))
-            # initial presence must be sent manually
-            d.addCallback(lambda __: client.presence.available())
-            if plg_0045 is not None:
-                # we re-join MUC rooms
-                muc_d_list = defer.DeferredList(
-                    [defer.ensureDeferred(plg_0045.join(*args))
-                     for args in muc_join_args]
-                )
-                d.addCallback(lambda __: muc_d_list)
-            # at the end we replay the buffer, as those stanzas have probably not
-            # been received
-            d.addCallback(lambda __: self.replay_buffer(client, buffer_,
-                                                       discard_results=True))
-
-    def on_receive(self, element, client):
-        if not client.is_component:
-            session = client._xep_0198_session
-            if session.enabled and element.name.lower() in C.STANZA_NAMES:
-                session.in_counter += 1 % MAX_COUNTER
-
-    def on_send(self, obj, client):
-        if not client.is_component:
-            session = client._xep_0198_session
-            if (session.enabled
-                and domish.IElement.providedBy(obj)
-                and obj.name.lower() in C.STANZA_NAMES):
-                session.out_counter += 1 % MAX_COUNTER
-                session.buffer.appendleft(obj)
-                self.check_acks(client)
-
-    def on_ack_request(self, r_elt, client):
-        self.send_ack(client)
-
-    def on_ack_answer(self, a_elt, client):
-        session = client._xep_0198_session
-        session.ack_requested = False
-        if self._ack_timeout:
-            if session.req_timer is None:
-                log.error("req_timer should be set")
-            else:
-                session.req_timer.cancel()
-                session.req_timer = None
-        try:
-            server_acked = int(a_elt['h'])
-        except ValueError:
-            log.warning(_("Server returned invalid ack element, disabling stream "
-                          "management: {xml}").format(xml=a_elt))
-            session.enabled = False
-            return
-
-        if server_acked > session.out_counter:
-            log.error(_("Server acked more stanzas than we have sent, disabling stream "
-                        "management."))
-            session.reset()
-            return
-
-        self.update_buffer(session, server_acked)
-        self.check_acks(client)
-
-    def on_ack_time_out(self, client):
-        """Called when a requested ACK has not been received in time"""
-        log.info(_("Ack was not received in time, aborting connection"))
-        try:
-            xmlstream = client.xmlstream
-        except AttributeError:
-            log.warning("xmlstream has already been terminated")
-        else:
-            transport = xmlstream.transport
-            if transport is None:
-                log.warning("transport was already removed")
-            else:
-                transport.abortConnection()
-        client._xep_0198_session.req_timer = None
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0198_handler(xmlstream.XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(
-            SM_ENABLED, self.plugin_parent.on_enabled, client=self.parent
-        )
-        self.xmlstream.addObserver(
-            SM_RESUMED, self.plugin_parent.on_resumed, client=self.parent
-        )
-        self.xmlstream.addObserver(
-            SM_FAILED, self.plugin_parent.on_failed, client=self.parent
-        )
-        self.xmlstream.addObserver(
-            SM_R_REQUEST, self.plugin_parent.on_ack_request, client=self.parent
-        )
-        self.xmlstream.addObserver(
-            SM_A_REQUEST, self.plugin_parent.on_ack_answer, client=self.parent
-        )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_SM)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0199.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,156 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Delayed Delivery (XEP-0199)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core.constants import Const as C
-from wokkel import disco, iwokkel
-from twisted.words.protocols.jabber import xmlstream, jid
-from zope.interface import implementer
-import time
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XMPP PING",
-    C.PI_IMPORT_NAME: "XEP-0199",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-199"],
-    C.PI_MAIN: "XEP_0199",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: D_("""Implementation of XMPP Ping"""),
-}
-
-NS_PING = "urn:xmpp:ping"
-PING_REQUEST = C.IQ_GET + '/ping[@xmlns="' + NS_PING + '"]'
-
-
-class XEP_0199(object):
-
-    def __init__(self, host):
-        log.info(_("XMPP Ping plugin initialization"))
-        self.host = host
-        host.bridge.add_method(
-            "ping", ".plugin", in_sign='ss', out_sign='d', method=self._ping, async_=True)
-        try:
-            self.text_cmds = self.host.plugins[C.TEXT_CMDS]
-        except KeyError:
-            log.info(_("Text commands not available"))
-        else:
-            self.text_cmds.register_text_commands(self)
-
-    def get_handler(self, client):
-        return XEP_0199_handler(self)
-
-    def _ping_raise_if_failure(self, pong):
-        """If ping didn't succeed, raise the failure, else return pong delay"""
-        if pong[0] != "PONG":
-            raise pong[0]
-        return pong[1]
-
-    def _ping(self, jid_s, profile):
-        client = self.host.get_client(profile)
-        entity_jid = jid.JID(jid_s)
-        d = self.ping(client, entity_jid)
-        d.addCallback(self._ping_raise_if_failure)
-        return d
-
-    def _ping_cb(self, iq_result, send_time):
-        receive_time = time.time()
-        return ("PONG", receive_time - send_time)
-
-    def _ping_eb(self, failure_, send_time):
-        receive_time = time.time()
-        return (failure_.value, receive_time - send_time)
-
-    def ping(self, client, entity_jid):
-        """Ping an XMPP entity
-
-        @param entity_jid(jid.JID): entity to ping
-        @return (tuple[(unicode,failure), float]): pong data:
-            - either u"PONG" if it was successful, or failure
-            - delay between sending time and reception time
-        """
-        iq_elt = client.IQ("get")
-        iq_elt["to"] = entity_jid.full()
-        iq_elt.addElement((NS_PING, "ping"))
-        d = iq_elt.send()
-        send_time = time.time()
-        d.addCallback(self._ping_cb, send_time)
-        d.addErrback(self._ping_eb, send_time)
-        return d
-
-    def _cmd_ping_fb(self, pong, client, mess_data):
-        """Send feedback to client when pong data is received"""
-        txt_cmd = self.host.plugins[C.TEXT_CMDS]
-
-        if pong[0] == "PONG":
-            txt_cmd.feed_back(client, "PONG ({time} s)".format(time=pong[1]), mess_data)
-        else:
-            txt_cmd.feed_back(
-                client, _("ping error ({err_msg}). Response time: {time} s")
-                .format(err_msg=pong[0], time=pong[1]), mess_data)
-
-    def cmd_ping(self, client, mess_data):
-        """ping an entity
-
-        @command (all): [JID]
-            - JID: jid of the entity to ping
-        """
-        if mess_data["unparsed"].strip():
-            try:
-                entity_jid = jid.JID(mess_data["unparsed"].strip())
-            except RuntimeError:
-                txt_cmd = self.host.plugins[C.TEXT_CMDS]
-                txt_cmd.feed_back(client, _('Invalid jid: "{entity_jid}"').format(
-                    entity_jid=mess_data["unparsed"].strip()), mess_data)
-                return False
-        else:
-            entity_jid = mess_data["to"]
-        d = self.ping(client, entity_jid)
-        d.addCallback(self._cmd_ping_fb, client, mess_data)
-
-        return False
-
-    def on_ping_request(self, iq_elt, client):
-        log.info(_("XMPP PING received from {from_jid} [{profile}]").format(
-            from_jid=iq_elt["from"], profile=client.profile))
-        iq_elt.handled = True
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        client.send(iq_result_elt)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0199_handler(xmlstream.XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(
-            PING_REQUEST, self.plugin_parent.on_ping_request, client=self.parent
-        )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_PING)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0203.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,86 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Delayed Delivery (XEP-0203)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-from wokkel import disco, iwokkel, delay
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-from zope.interface import implementer
-
-
-NS_DD = "urn:xmpp:delay"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Delayed Delivery",
-    C.PI_IMPORT_NAME: "XEP-0203",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0203"],
-    C.PI_MAIN: "XEP_0203",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Delayed Delivery"""),
-}
-
-
-class XEP_0203(object):
-    def __init__(self, host):
-        log.info(_("Delayed Delivery plugin initialization"))
-        self.host = host
-
-    def get_handler(self, client):
-        return XEP_0203_handler(self, client.profile)
-
-    def delay(self, stamp, sender=None, desc="", parent=None):
-        """Build a delay element, eventually append it to the given parent element.
-
-        @param stamp (datetime): offset-aware timestamp of the original sending.
-        @param sender (JID): entity that originally sent or delayed the message.
-        @param desc (unicode): optional natural language description.
-        @param parent (domish.Element): add the delay element to this element.
-        @return: the delay element (domish.Element)
-        """
-        elt = delay.Delay(stamp, sender).toElement()
-        if desc:
-            elt.addContent(desc)
-        if parent:
-            parent.addChild(elt)
-        return elt
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0203_handler(XMPPHandler):
-
-    def __init__(self, plugin_parent, profile):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-        self.profile = profile
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_DD)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0215.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,328 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin
-# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Dict, Final, List, Optional, Optional
-
-from twisted.internet import defer
-from twisted.words.protocols.jabber import error, jid
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from wokkel import data_form, disco, iwokkel
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools import xml_tools
-from sat.tools import utils
-from sat.tools.common import data_format
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "External Service Discovery",
-    C.PI_IMPORT_NAME: "XEP-0215",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "XEP_0215",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Discover services external to the XMPP network"""),
-}
-
-NS_EXTDISCO: Final = "urn:xmpp:extdisco:2"
-IQ_PUSH: Final = f'{C.IQ_SET}/services[@xmlns="{NS_EXTDISCO}"]'
-
-
-class XEP_0215:
-    def __init__(self, host):
-        log.info(_("External Service Discovery plugin initialization"))
-        self.host = host
-        host.bridge.add_method(
-            "external_disco_get",
-            ".plugin",
-            in_sign="ss",
-            out_sign="s",
-            method=self._external_disco_get,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "external_disco_credentials_get",
-            ".plugin",
-            in_sign="ssis",
-            out_sign="s",
-            method=self._external_disco_credentials_get,
-            async_=True,
-        )
-
-    def get_handler(self, client):
-        return XEP_0215_handler(self)
-
-    async def profile_connecting(self, client: SatXMPPEntity) -> None:
-        client._xep_0215_services = {}
-
-    def parse_services(
-        self, element: domish.Element, parent_elt_name: str = "services"
-    ) -> List[dict]:
-        """Retrieve services from element
-
-        @param element: <[parent_elt_name]/> element or its parent
-        @param parent_elt_name: name of the parent element
-            can be "services" or "credentials"
-        @return: list of parsed services
-        """
-        if parent_elt_name not in ("services", "credentials"):
-            raise exceptions.InternalError(
-                f"invalid parent_elt_name: {parent_elt_name!r}"
-            )
-        if element.name == parent_elt_name and element.uri == NS_EXTDISCO:
-            services_elt = element
-        else:
-            try:
-                services_elt = next(element.elements(NS_EXTDISCO, parent_elt_name))
-            except StopIteration:
-                raise exceptions.DataError(
-                    f"XEP-0215 response is missing <{parent_elt_name}> element"
-                )
-
-        services = []
-        for service_elt in services_elt.elements(NS_EXTDISCO, "service"):
-            service = {}
-            for key in [
-                "action",
-                "expires",
-                "host",
-                "name",
-                "password",
-                "port",
-                "restricted",
-                "transport",
-                "type",
-                "username",
-            ]:
-                value = service_elt.getAttribute(key)
-                if value is not None:
-                    if key == "expires":
-                        try:
-                            service[key] = utils.parse_xmpp_date(value)
-                        except ValueError:
-                            log.warning(f"invalid expiration date: {value!r}")
-                            continue
-                    elif key == "port":
-                        try:
-                            service[key] = int(value)
-                        except ValueError:
-                            log.warning(f"invalid port: {value!r}")
-                            continue
-                    elif key == "restricted":
-                        service[key] = C.bool(value)
-                    else:
-                        service[key] = value
-            if not {"host", "type"}.issubset(service):
-                log.warning(
-                    'mandatory "host" or "type" are missing in service, ignoring it: '
-                    "{service_elt.toXml()}"
-                )
-                continue
-            for x_elt in service_elt.elements(data_form.NS_X_DATA, "x"):
-                form = data_form.Form.fromElement(x_elt)
-                extended = service.setdefault("extended", [])
-                extended.append(xml_tools.data_form_2_data_dict(form))
-            services.append(service)
-
-        return services
-
-    def _external_disco_get(self, entity: str, profile_key: str) -> defer.Deferred:
-        client = self.host.get_client(profile_key)
-        d = defer.ensureDeferred(
-            self.get_external_services(client, jid.JID(entity) if entity else None)
-        )
-        d.addCallback(data_format.serialise)
-        return d
-
-    async def get_external_services(
-        self, client: SatXMPPEntity, entity: Optional[jid.JID] = None
-    ) -> List[Dict]:
-        """Get non XMPP service proposed by the entity
-
-        Response is cached after first query
-
-        @param entity: XMPP entity to query. Default to our own server
-        @return: found services
-        """
-        if entity is None:
-            entity = client.server_jid
-
-        if entity.resource:
-            raise exceptions.DataError("A bare jid was expected for target entity")
-
-        try:
-            cached_services = client._xep_0215_services[entity]
-        except KeyError:
-            if not self.host.hasFeature(client, NS_EXTDISCO, entity):
-                cached_services = client._xep_0215_services[entity] = None
-            else:
-                iq_elt = client.IQ("get")
-                iq_elt["to"] = entity.full()
-                iq_elt.addElement((NS_EXTDISCO, "services"))
-                try:
-                    iq_result_elt = await iq_elt.send()
-                except error.StanzaError as e:
-                    log.warning(f"Can't get external services: {e}")
-                    cached_services = client._xep_0215_services[entity] = None
-                else:
-                    cached_services = self.parse_services(iq_result_elt)
-                    client._xep_0215_services[entity] = cached_services
-
-        return cached_services or []
-
-    def _external_disco_credentials_get(
-        self,
-        entity: str,
-        host: str,
-        type_: str,
-        port: int = 0,
-        profile_key=C.PROF_KEY_NONE,
-    ) -> defer.Deferred:
-        client = self.host.get_client(profile_key)
-        d = defer.ensureDeferred(
-            self.request_credentials(
-                client, host, type_, port or None, jid.JID(entity) if entity else None
-            )
-        )
-        d.addCallback(data_format.serialise)
-        return d
-
-    async def request_credentials(
-        self,
-        client: SatXMPPEntity,
-        host: str,
-        type_: str,
-        port: Optional[int] = None,
-        entity: Optional[jid.JID] = None,
-    ) -> List[dict]:
-        """Request credentials for specified service(s)
-
-        While usually a single service is expected, several may be returned if the same
-        service is launched on several ports (cf. XEP-0215 §3.3)
-        @param entity: XMPP entity to query. Defaut to our own server
-        @param host: service host
-        @param type_: service type
-        @param port: service port (to be used when several services have same host and
-            type but on different ports)
-        @return: matching services with filled credentials
-        """
-        if entity is None:
-            entity = client.server_jid
-
-        iq_elt = client.IQ("get")
-        iq_elt["to"] = entity.full()
-        iq_elt.addElement((NS_EXTDISCO, "credentials"))
-        iq_result_elt = await iq_elt.send()
-        return self.parse_services(iq_result_elt, parent_elt_name="credentials")
-
-    def get_matching_service(
-        self, services: List[dict], host: str, type_: str, port: Optional[int]
-    ) -> Optional[dict]:
-        """Retrieve service data from its characteristics"""
-        try:
-            return next(
-                s
-                for s in services
-                if (
-                    s["host"] == host
-                    and s["type"] == type_
-                    and (port is None or s.get("port") == port)
-                )
-            )
-        except StopIteration:
-            return None
-
-    def on_services_push(self, iq_elt: domish.Element, client: SatXMPPEntity) -> None:
-        iq_elt.handled = True
-        entity = jid.JID(iq_elt["from"]).userhostJID()
-        cached_services = client._xep_0215_services.get(entity)
-        if cached_services is None:
-            log.info(f"ignoring services push for uncached entity {entity}")
-            return
-        try:
-            services = self.parse_services(iq_elt)
-        except Exception:
-            log.exception(f"Can't parse services push: {iq_elt.toXml()}")
-            return
-        for service in services:
-            host = service["host"]
-            type_ = service["type"]
-            port = service.get("port")
-
-            action = service.pop("action", None)
-            if action is None:
-                # action is not specified, we first check if the service exists
-                found_service = self.get_matching_service(
-                    cached_services, host, type_, port
-                )
-                if found_service is not None:
-                    # existing service, we replace by the new one
-                    found_service.clear()
-                    found_service.update(service)
-                else:
-                    # new service
-                    cached_services.append(service)
-            elif action == "add":
-                cached_services.append(service)
-            elif action in ("modify", "delete"):
-                found_service = self.get_matching_service(
-                    cached_services, host, type_, port
-                )
-                if found_service is None:
-                    log.warning(
-                        f"{entity} want to {action} an unknow service, we ask for the "
-                        "full list again"
-                    )
-                    # we delete cache and request a fresh list to make a new one
-                    del client._xep_0215_services[entity]
-                    defer.ensureDeferred(self.get_external_services(client, entity))
-                elif action == "modify":
-                    found_service.clear()
-                    found_service.update(service)
-                else:
-                    cached_services.remove(found_service)
-            else:
-                log.warning(f"unknown action for services push, ignoring: {action!r}")
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0215_handler(XMPPHandler):
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-
-    def connectionInitialized(self):
-        self.xmlstream.addObserver(
-            IQ_PUSH, self.plugin_parent.on_services_push, client=self.parent
-        )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_EXTDISCO)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0231.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,250 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Bit of Binary handling (XEP-0231)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import base64
-import time
-from pathlib import Path
-from functools import partial
-from zope.interface import implementer
-from twisted.python import failure
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber import error as jabber_error
-from twisted.internet import defer
-from wokkel import disco, iwokkel
-from sat.tools import xml_tools
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Bits of Binary",
-    C.PI_IMPORT_NAME: "XEP-0231",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0231"],
-    C.PI_MAIN: "XEP_0231",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _(
-        """Implementation of bits of binary (used for small images/files)"""
-    ),
-}
-
-NS_BOB = "urn:xmpp:bob"
-IQ_BOB_REQUEST = C.IQ_GET + '/data[@xmlns="' + NS_BOB + '"]'
-
-
-class XEP_0231(object):
-    def __init__(self, host):
-        log.info(_("plugin Bits of Binary initialization"))
-        self.host = host
-        host.register_namespace("bob", NS_BOB)
-        host.trigger.add("xhtml_post_treat", self.xhtml_trigger)
-        host.bridge.add_method(
-            "bob_get_file",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=self._get_file,
-            async_=True,
-        )
-
-    def dump_data(self, cache, data_elt, cid):
-        """save file encoded in data_elt to cache
-
-        @param cache(memory.cache.Cache): cache to use to store the data
-        @param data_elt(domish.Element): <data> as in XEP-0231
-        @param cid(unicode): content-id
-        @return(unicode): full path to dumped file
-        """
-        #  FIXME: is it needed to use a separate thread?
-        #        probably not with the little data expected with BoB
-        try:
-            max_age = int(data_elt["max-age"])
-            if max_age < 0:
-                raise ValueError
-        except (KeyError, ValueError):
-            log.warning("invalid max-age found")
-            max_age = None
-
-        with cache.cache_data(
-            PLUGIN_INFO[C.PI_IMPORT_NAME], cid, data_elt.getAttribute("type"), max_age
-        ) as f:
-
-            file_path = Path(f.name)
-            f.write(base64.b64decode(str(data_elt)))
-
-        return file_path
-
-    def get_handler(self, client):
-        return XEP_0231_handler(self)
-
-    def _request_cb(self, iq_elt, cache, cid):
-        for data_elt in iq_elt.elements(NS_BOB, "data"):
-            if data_elt.getAttribute("cid") == cid:
-                file_path = self.dump_data(cache, data_elt, cid)
-                return file_path
-
-        log.warning(
-            "invalid data stanza received, requested cid was not found:\n{iq_elt}\nrequested cid: {cid}".format(
-                iq_elt=iq_elt, cid=cid
-            )
-        )
-        raise failure.Failure(exceptions.DataError("missing data"))
-
-    def _request_eb(self, failure_):
-        """Log the error and continue errback chain"""
-        log.warning("Can't get requested data:\n{reason}".format(reason=failure_))
-        return failure_
-
-    def request_data(self, client, to_jid, cid, cache=None):
-        """Request data if we don't have it in cache
-
-        @param to_jid(jid.JID): jid to request the data to
-        @param cid(unicode): content id
-        @param cache(memory.cache.Cache, None): cache to use
-            client.cache will be used if None
-        @return D(unicode): path to file with data
-        """
-        if cache is None:
-            cache = client.cache
-        iq_elt = client.IQ("get")
-        iq_elt["to"] = to_jid.full()
-        data_elt = iq_elt.addElement((NS_BOB, "data"))
-        data_elt["cid"] = cid
-        d = iq_elt.send()
-        d.addCallback(self._request_cb, cache, cid)
-        d.addErrback(self._request_eb)
-        return d
-
-    def _set_img_elt_src(self, path, img_elt):
-        img_elt["src"] = "file://{}".format(path)
-
-    def xhtml_trigger(self, client, message_elt, body_elt, lang, treat_d):
-        for img_elt in xml_tools.find_all(body_elt, C.NS_XHTML, "img"):
-            source = img_elt.getAttribute("src", "")
-            if source.startswith("cid:"):
-                cid = source[4:]
-                file_path = client.cache.get_file_path(cid)
-                if file_path is not None:
-                    #  image is in cache, we change the url
-                    img_elt["src"] = "file://{}".format(file_path)
-                    continue
-                else:
-                    # image is not in cache, is it given locally?
-                    for data_elt in message_elt.elements(NS_BOB, "data"):
-                        if data_elt.getAttribute("cid") == cid:
-                            file_path = self.dump_data(client.cache, data_elt, cid)
-                            img_elt["src"] = "file://{}".format(file_path)
-                            break
-                    else:
-                        # cid not found locally, we need to request it
-                        # so we use the deferred
-                        d = self.request_data(client, jid.JID(message_elt["from"]), cid)
-                        d.addCallback(partial(self._set_img_elt_src, img_elt=img_elt))
-                        treat_d.addCallback(lambda __: d)
-
-    def on_component_request(self, iq_elt, client):
-        """cache data is retrieve from common cache for components"""
-        # FIXME: this is a security/privacy issue as no access check is done
-        #        but this is mitigated by the fact that the cid must be known.
-        #        An access check should be implemented though.
-
-        iq_elt.handled = True
-        data_elt = next(iq_elt.elements(NS_BOB, "data"))
-        try:
-            cid = data_elt["cid"]
-        except KeyError:
-            error_elt = jabber_error.StanzaError("not-acceptable").toResponse(iq_elt)
-            client.send(error_elt)
-            return
-
-        metadata = self.host.common_cache.get_metadata(cid)
-        if metadata is None:
-            error_elt = jabber_error.StanzaError("item-not-found").toResponse(iq_elt)
-            client.send(error_elt)
-            return
-
-        with open(metadata["path"], 'rb') as f:
-            data = f.read()
-
-        result_elt = xmlstream.toResponse(iq_elt, "result")
-        data_elt = result_elt.addElement(
-            (NS_BOB, "data"), content=base64.b64encode(data).decode())
-        data_elt["cid"] = cid
-        data_elt["type"] = metadata["mime_type"]
-        data_elt["max-age"] = str(int(max(0, metadata["eol"] - time.time())))
-        client.send(result_elt)
-
-    def _get_file(self, peer_jid_s, cid, profile):
-        peer_jid = jid.JID(peer_jid_s)
-        assert cid
-        client = self.host.get_client(profile)
-        d = self.get_file(client, peer_jid, cid)
-        d.addCallback(lambda path: str(path))
-        return d
-
-    def get_file(self, client, peer_jid, cid, parent_elt=None):
-        """Retrieve a file from it's content-id
-
-        @param peer_jid(jid.JID): jid of the entity offering the data
-        @param cid(unicode): content-id of file data
-        @param parent_elt(domish.Element, None): if file is not in cache,
-            data will be looked after in children of this elements.
-            None to ignore
-        @return D(Path): path to cached data
-        """
-        file_path = client.cache.get_file_path(cid)
-        if file_path is not None:
-            #  file is in cache
-            return defer.succeed(file_path)
-        else:
-            # file not in cache, is it given locally?
-            if parent_elt is not None:
-                for data_elt in parent_elt.elements(NS_BOB, "data"):
-                    if data_elt.getAttribute("cid") == cid:
-                        return defer.succeed(self.dump_data(client.cache, data_elt, cid))
-
-            # cid not found locally, we need to request it
-            # so we use the deferred
-            return self.request_data(client, peer_jid, cid)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0231_handler(xmlstream.XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-
-    def connectionInitialized(self):
-        if self.parent.is_component:
-            self.xmlstream.addObserver(
-                IQ_BOB_REQUEST, self.plugin_parent.on_component_request, client=self.parent
-            )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_BOB)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0234.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,826 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin for Jingle File Transfer (XEP-0234)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from collections import namedtuple
-import mimetypes
-import os.path
-
-from twisted.internet import defer
-from twisted.internet import reactor
-from twisted.internet import error as internet_error
-from twisted.python import failure
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.i18n import D_, _
-from sat.core.log import getLogger
-from sat.tools import utils
-from sat.tools import stream
-from sat.tools.common import date_utils
-from sat.tools.common import regex
-
-
-log = getLogger(__name__)
-
-NS_JINGLE_FT = "urn:xmpp:jingle:apps:file-transfer:5"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Jingle File Transfer",
-    C.PI_IMPORT_NAME: "XEP-0234",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0234"],
-    C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0300", "FILE"],
-    C.PI_MAIN: "XEP_0234",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Jingle File Transfer"""),
-}
-
-EXTRA_ALLOWED = {"path", "namespace", "file_desc", "file_hash", "hash_algo"}
-Range = namedtuple("Range", ("offset", "length"))
-
-
-class XEP_0234:
-    # TODO: assure everything is closed when file is sent or session terminate is received
-    # TODO: call self._f.unregister when unloading order will be managing (i.e. when
-    #   dependencies will be unloaded at the end)
-    Range = Range  # we copy the class here, so it can be used by other plugins
-    name = PLUGIN_INFO[C.PI_NAME]
-    human_name = D_("file transfer")
-
-    def __init__(self, host):
-        log.info(_("plugin Jingle File Transfer initialization"))
-        self.host = host
-        host.register_namespace("jingle-ft", NS_JINGLE_FT)
-        self._j = host.plugins["XEP-0166"]  # shortcut to access jingle
-        self._j.register_application(NS_JINGLE_FT, self)
-        self._f = host.plugins["FILE"]
-        self._f.register(self, priority=10000)
-        self._hash = self.host.plugins["XEP-0300"]
-        host.bridge.add_method(
-            "file_jingle_send",
-            ".plugin",
-            in_sign="ssssa{ss}s",
-            out_sign="",
-            method=self._file_send,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "file_jingle_request",
-            ".plugin",
-            in_sign="sssssa{ss}s",
-            out_sign="s",
-            method=self._file_jingle_request,
-            async_=True,
-        )
-
-    def get_handler(self, client):
-        return XEP_0234_handler()
-
-    def get_progress_id(self, session, content_name):
-        """Return a unique progress ID
-
-        @param session(dict): jingle session
-        @param content_name(unicode): name of the content
-        @return (unicode): unique progress id
-        """
-        return "{}_{}".format(session["id"], content_name)
-
-    async def can_handle_file_send(self, client, peer_jid, filepath):
-        if peer_jid.resource:
-            return await self.host.hasFeature(client, NS_JINGLE_FT, peer_jid)
-        else:
-            # if we have a bare jid, Jingle Message Initiation will be tried
-            return True
-
-    # generic methods
-
-    def build_file_element(
-        self, client, name=None, file_hash=None, hash_algo=None, size=None,
-        mime_type=None, desc=None, modified=None, transfer_range=None, path=None,
-        namespace=None, file_elt=None, **kwargs):
-        """Generate a <file> element with available metadata
-
-        @param file_hash(unicode, None): hash of the file
-            empty string to set <hash-used/> element
-        @param hash_algo(unicode, None): hash algorithm used
-            if file_hash is None and hash_algo is set, a <hash-used/> element will be
-            generated
-        @param transfer_range(Range, None): where transfer must start/stop
-        @param modified(int, unicode, None): date of last modification
-            0 to use current date
-            int to use an unix timestamp
-            else must be an unicode string which will be used as it (it must be an XMPP
-            time)
-        @param file_elt(domish.Element, None): element to use
-            None to create a new one
-        @param **kwargs: data for plugin extension (ignored by default)
-        @return (domish.Element): generated element
-        @trigger XEP-0234_buildFileElement(file_elt, extra_args): can be used to extend
-            elements to add
-        """
-        if file_elt is None:
-            file_elt = domish.Element((NS_JINGLE_FT, "file"))
-        for name, value in (
-            ("name", name),
-            ("size", size),
-            ("media-type", mime_type),
-            ("desc", desc),
-            ("path", path),
-            ("namespace", namespace),
-        ):
-            if value is not None:
-                file_elt.addElement(name, content=str(value))
-
-        if modified is not None:
-            if isinstance(modified, int):
-                file_elt.addElement("date", utils.xmpp_date(modified or None))
-            else:
-                file_elt.addElement("date", modified)
-        elif "created" in kwargs:
-            file_elt.addElement("date", utils.xmpp_date(kwargs.pop("created")))
-
-        range_elt = file_elt.addElement("range")
-        if transfer_range is not None:
-            if transfer_range.offset is not None:
-                range_elt["offset"] = transfer_range.offset
-            if transfer_range.length is not None:
-                range_elt["length"] = transfer_range.length
-        if file_hash is not None:
-            if not file_hash:
-                file_elt.addChild(self._hash.build_hash_used_elt())
-            else:
-                file_elt.addChild(self._hash.build_hash_elt(file_hash, hash_algo))
-        elif hash_algo is not None:
-            file_elt.addChild(self._hash.build_hash_used_elt(hash_algo))
-        self.host.trigger.point(
-            "XEP-0234_buildFileElement", client, file_elt, extra_args=kwargs)
-        if kwargs:
-            for kw in kwargs:
-                log.debug("ignored keyword: {}".format(kw))
-        return file_elt
-
-    def build_file_element_from_dict(self, client, file_data, **kwargs):
-        """like build_file_element but get values from a file_data dict
-
-        @param file_data(dict): metadata to use
-        @param **kwargs: data to override
-        """
-        if kwargs:
-            file_data = file_data.copy()
-            file_data.update(kwargs)
-        try:
-            file_data["mime_type"] = (
-                f'{file_data.pop("media_type")}/{file_data.pop("media_subtype")}'
-            )
-        except KeyError:
-            pass
-        return self.build_file_element(client, **file_data)
-
-    async def parse_file_element(
-            self, client, file_elt, file_data=None, given=False, parent_elt=None,
-            keep_empty_range=False):
-        """Parse a <file> element and file dictionary accordingly
-
-        @param file_data(dict, None): dict where the data will be set
-            following keys will be set (and overwritten if they already exist):
-                name, file_hash, hash_algo, size, mime_type, desc, path, namespace, range
-            if None, a new dict is created
-        @param given(bool): if True, prefix hash key with "given_"
-        @param parent_elt(domish.Element, None): parent of the file element
-            if set, file_elt must not be set
-        @param keep_empty_range(bool): if True, keep empty range (i.e. range when offset
-            and length are None).
-            Empty range is useful to know if a peer_jid can handle range
-        @return (dict): file_data
-        @trigger XEP-0234_parseFileElement(file_elt, file_data): can be used to parse new
-            elements
-        @raise exceptions.NotFound: there is not <file> element in parent_elt
-        @raise exceptions.DataError: if file_elt uri is not NS_JINGLE_FT
-        """
-        if parent_elt is not None:
-            if file_elt is not None:
-                raise exceptions.InternalError(
-                    "file_elt must be None if parent_elt is set"
-                )
-            try:
-                file_elt = next(parent_elt.elements(NS_JINGLE_FT, "file"))
-            except StopIteration:
-                raise exceptions.NotFound()
-        else:
-            if not file_elt or file_elt.uri != NS_JINGLE_FT:
-                raise exceptions.DataError(
-                    "invalid <file> element: {stanza}".format(stanza=file_elt.toXml())
-                )
-
-        if file_data is None:
-            file_data = {}
-
-        for name in ("name", "desc", "path", "namespace"):
-            try:
-                file_data[name] = str(next(file_elt.elements(NS_JINGLE_FT, name)))
-            except StopIteration:
-                pass
-
-        name = file_data.get("name")
-        if name == "..":
-            # we don't want to go to parent dir when joining to a path
-            name = "--"
-            file_data["name"] = name
-        elif name is not None and ("/" in name or "\\" in name):
-            file_data["name"] = regex.path_escape(name)
-
-        try:
-            file_data["mime_type"] = str(
-                next(file_elt.elements(NS_JINGLE_FT, "media-type"))
-            )
-        except StopIteration:
-            pass
-
-        try:
-            file_data["size"] = int(
-                str(next(file_elt.elements(NS_JINGLE_FT, "size")))
-            )
-        except StopIteration:
-            pass
-
-        try:
-            file_data["modified"] = date_utils.date_parse(
-                next(file_elt.elements(NS_JINGLE_FT, "date"))
-            )
-        except StopIteration:
-            pass
-
-        try:
-            range_elt = next(file_elt.elements(NS_JINGLE_FT, "range"))
-        except StopIteration:
-            pass
-        else:
-            offset = range_elt.getAttribute("offset")
-            length = range_elt.getAttribute("length")
-            if offset or length or keep_empty_range:
-                file_data["transfer_range"] = Range(offset=offset, length=length)
-
-        prefix = "given_" if given else ""
-        hash_algo_key, hash_key = "hash_algo", prefix + "file_hash"
-        try:
-            file_data[hash_algo_key], file_data[hash_key] = self._hash.parse_hash_elt(
-                file_elt
-            )
-        except exceptions.NotFound:
-            pass
-
-        self.host.trigger.point("XEP-0234_parseFileElement", client, file_elt, file_data)
-
-        return file_data
-
-    # bridge methods
-
-    def _file_send(
-        self,
-        peer_jid,
-        filepath,
-        name="",
-        file_desc="",
-        extra=None,
-        profile=C.PROF_KEY_NONE,
-    ):
-        client = self.host.get_client(profile)
-        return defer.ensureDeferred(self.file_send(
-            client,
-            jid.JID(peer_jid),
-            filepath,
-            name or None,
-            file_desc or None,
-            extra or None,
-        ))
-
-    async def file_send(
-        self, client, peer_jid, filepath, name, file_desc=None, extra=None
-    ):
-        """Send a file using jingle file transfer
-
-        @param peer_jid(jid.JID): destinee jid
-        @param filepath(str): absolute path of the file
-        @param name(unicode, None): name of the file
-        @param file_desc(unicode, None): description of the file
-        @return (D(unicode)): progress id
-        """
-        progress_id_d = defer.Deferred()
-        if extra is None:
-            extra = {}
-        if file_desc is not None:
-            extra["file_desc"] = file_desc
-        encrypted = extra.pop("encrypted", False)
-        await self._j.initiate(
-            client,
-            peer_jid,
-            [
-                {
-                    "app_ns": NS_JINGLE_FT,
-                    "senders": self._j.ROLE_INITIATOR,
-                    "app_kwargs": {
-                        "filepath": filepath,
-                        "name": name,
-                        "extra": extra,
-                        "progress_id_d": progress_id_d,
-                    },
-                }
-            ],
-            encrypted = encrypted
-        )
-        return await progress_id_d
-
-    def _file_jingle_request(
-            self, peer_jid, filepath, name="", file_hash="", hash_algo="", extra=None,
-            profile=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile)
-        return defer.ensureDeferred(self.file_jingle_request(
-            client,
-            jid.JID(peer_jid),
-            filepath,
-            name or None,
-            file_hash or None,
-            hash_algo or None,
-            extra or None,
-        ))
-
-    async def file_jingle_request(
-            self, client, peer_jid, filepath, name=None, file_hash=None, hash_algo=None,
-            extra=None):
-        """Request a file using jingle file transfer
-
-        @param peer_jid(jid.JID): destinee jid
-        @param filepath(str): absolute path where the file will be downloaded
-        @param name(unicode, None): name of the file
-        @param file_hash(unicode, None): hash of the file
-        @return (D(unicode)): progress id
-        """
-        progress_id_d = defer.Deferred()
-        if extra is None:
-            extra = {}
-        if file_hash is not None:
-            if hash_algo is None:
-                raise ValueError(_("hash_algo must be set if file_hash is set"))
-            extra["file_hash"] = file_hash
-            extra["hash_algo"] = hash_algo
-        else:
-            if hash_algo is not None:
-                raise ValueError(_("file_hash must be set if hash_algo is set"))
-        await self._j.initiate(
-            client,
-            peer_jid,
-            [
-                {
-                    "app_ns": NS_JINGLE_FT,
-                    "senders": self._j.ROLE_RESPONDER,
-                    "app_kwargs": {
-                        "filepath": filepath,
-                        "name": name,
-                        "extra": extra,
-                        "progress_id_d": progress_id_d,
-                    },
-                }
-            ],
-        )
-        return await progress_id_d
-
-    # jingle callbacks
-
-    def jingle_description_elt(
-        self, client, session, content_name, filepath, name, extra, progress_id_d
-    ):
-        return domish.Element((NS_JINGLE_FT, "description"))
-
-    def jingle_session_init(
-        self, client, session, content_name, filepath, name, extra, progress_id_d
-    ):
-        if extra is None:
-            extra = {}
-        else:
-            if not EXTRA_ALLOWED.issuperset(extra):
-                raise ValueError(
-                    _("only the following keys are allowed in extra: {keys}").format(
-                        keys=", ".join(EXTRA_ALLOWED)
-                    )
-                )
-        progress_id_d.callback(self.get_progress_id(session, content_name))
-        content_data = session["contents"][content_name]
-        application_data = content_data["application_data"]
-        assert "file_path" not in application_data
-        application_data["file_path"] = filepath
-        file_data = application_data["file_data"] = {}
-        desc_elt = self.jingle_description_elt(
-            client, session, content_name, filepath, name, extra, progress_id_d)
-        file_elt = desc_elt.addElement("file")
-
-        if content_data["senders"] == self._j.ROLE_INITIATOR:
-            # we send a file
-            if name is None:
-                name = os.path.basename(filepath)
-            file_data["date"] = utils.xmpp_date()
-            file_data["desc"] = extra.pop("file_desc", "")
-            file_data["name"] = name
-            mime_type = mimetypes.guess_type(name, strict=False)[0]
-            if mime_type is not None:
-                file_data["mime_type"] = mime_type
-            file_data["size"] = os.path.getsize(filepath)
-            if "namespace" in extra:
-                file_data["namespace"] = extra["namespace"]
-            if "path" in extra:
-                file_data["path"] = extra["path"]
-            self.build_file_element_from_dict(
-                client, file_data, file_elt=file_elt, file_hash="")
-        else:
-            # we request a file
-            file_hash = extra.pop("file_hash", "")
-            if not name and not file_hash:
-                raise ValueError(_("you need to provide at least name or file hash"))
-            if name:
-                file_data["name"] = name
-            if file_hash:
-                file_data["file_hash"] = file_hash
-                file_data["hash_algo"] = extra["hash_algo"]
-            else:
-                file_data["hash_algo"] = self._hash.get_default_algo()
-            if "namespace" in extra:
-                file_data["namespace"] = extra["namespace"]
-            if "path" in extra:
-                file_data["path"] = extra["path"]
-            self.build_file_element_from_dict(client, file_data, file_elt=file_elt)
-
-        return desc_elt
-
-    async def jingle_request_confirmation(
-        self, client, action, session, content_name, desc_elt
-    ):
-        """This method request confirmation for a jingle session"""
-        content_data = session["contents"][content_name]
-        senders = content_data["senders"]
-        if senders not in (self._j.ROLE_INITIATOR, self._j.ROLE_RESPONDER):
-            log.warning("Bad sender, assuming initiator")
-            senders = content_data["senders"] = self._j.ROLE_INITIATOR
-        # first we grab file informations
-        try:
-            file_elt = next(desc_elt.elements(NS_JINGLE_FT, "file"))
-        except StopIteration:
-            raise failure.Failure(exceptions.DataError)
-        file_data = {"progress_id": self.get_progress_id(session, content_name)}
-
-        if senders == self._j.ROLE_RESPONDER:
-            # we send the file
-            return await self._file_sending_request_conf(
-                client, session, content_data, content_name, file_data, file_elt
-            )
-        else:
-            # we receive the file
-            return await self._file_receiving_request_conf(
-                client, session, content_data, content_name, file_data, file_elt
-            )
-
-    async def _file_sending_request_conf(
-        self, client, session, content_data, content_name, file_data, file_elt
-    ):
-        """parse file_elt, and handle file retrieving/permission checking"""
-        await self.parse_file_element(client, file_elt, file_data)
-        content_data["application_data"]["file_data"] = file_data
-        finished_d = content_data["finished_d"] = defer.Deferred()
-
-        # confirmed_d is a deferred returning confimed value (only used if cont is False)
-        cont, confirmed_d = self.host.trigger.return_point(
-            "XEP-0234_fileSendingRequest",
-            client,
-            session,
-            content_data,
-            content_name,
-            file_data,
-            file_elt,
-        )
-        if not cont:
-            confirmed = await confirmed_d
-            if confirmed:
-                args = [client, session, content_name, content_data]
-                finished_d.addCallbacks(
-                    self._finished_cb, self._finished_eb, args, None, args
-                )
-            return confirmed
-
-        log.warning(_("File continue is not implemented yet"))
-        return False
-
-    async def _file_receiving_request_conf(
-        self, client, session, content_data, content_name, file_data, file_elt
-    ):
-        """parse file_elt, and handle user permission/file opening"""
-        await self.parse_file_element(client, file_elt, file_data, given=True)
-        try:
-            hash_algo, file_data["given_file_hash"] = self._hash.parse_hash_elt(file_elt)
-        except exceptions.NotFound:
-            try:
-                hash_algo = self._hash.parse_hash_used_elt(file_elt)
-            except exceptions.NotFound:
-                raise failure.Failure(exceptions.DataError)
-
-        if hash_algo is not None:
-            file_data["hash_algo"] = hash_algo
-            file_data["hash_hasher"] = hasher = self._hash.get_hasher(hash_algo)
-            file_data["data_cb"] = lambda data: hasher.update(data)
-
-        try:
-            file_data["size"] = int(file_data["size"])
-        except ValueError:
-            raise failure.Failure(exceptions.DataError)
-
-        name = file_data["name"]
-        if "/" in name or "\\" in name:
-            log.warning(
-                "File name contain path characters, we replace them: {}".format(name)
-            )
-            file_data["name"] = name.replace("/", "_").replace("\\", "_")
-
-        content_data["application_data"]["file_data"] = file_data
-
-        # now we actualy request permission to user
-
-        # deferred to track end of transfer
-        finished_d = content_data["finished_d"] = defer.Deferred()
-        confirmed = await self._f.get_dest_dir(
-            client, session["peer_jid"], content_data, file_data, stream_object=True
-        )
-        if confirmed:
-            await self.host.trigger.async_point(
-                "XEP-0234_file_receiving_request_conf",
-                client, session, content_data, file_elt
-            )
-            args = [client, session, content_name, content_data]
-            finished_d.addCallbacks(
-                self._finished_cb, self._finished_eb, args, None, args
-            )
-        return confirmed
-
-    async def jingle_handler(self, client, action, session, content_name, desc_elt):
-        content_data = session["contents"][content_name]
-        application_data = content_data["application_data"]
-        if action in (self._j.A_ACCEPTED_ACK,):
-            pass
-        elif action == self._j.A_SESSION_INITIATE:
-            file_elt = next(desc_elt.elements(NS_JINGLE_FT, "file"))
-            try:
-                next(file_elt.elements(NS_JINGLE_FT, "range"))
-            except StopIteration:
-                # initiator doesn't manage <range>, but we do so we advertise it
-                # FIXME: to be checked
-                log.debug("adding <range> element")
-                file_elt.addElement("range")
-        elif action == self._j.A_SESSION_ACCEPT:
-            assert not "stream_object" in content_data
-            file_data = application_data["file_data"]
-            file_path = application_data["file_path"]
-            senders = content_data["senders"]
-            if senders != session["role"]:
-                # we are receiving the file
-                try:
-                    # did the responder specified the size of the file?
-                    file_elt = next(desc_elt.elements(NS_JINGLE_FT, "file"))
-                    size_elt = next(file_elt.elements(NS_JINGLE_FT, "size"))
-                    size = int(str(size_elt))
-                except (StopIteration, ValueError):
-                    size = None
-                # XXX: hash security is not critical here, so we just take the higher
-                #      mandatory one
-                hasher = file_data["hash_hasher"] = self._hash.get_hasher()
-                progress_id = self.get_progress_id(session, content_name)
-                try:
-                    content_data["stream_object"] = stream.FileStreamObject(
-                        self.host,
-                        client,
-                        file_path,
-                        mode="wb",
-                        uid=progress_id,
-                        size=size,
-                        data_cb=lambda data: hasher.update(data),
-                    )
-                except Exception as e:
-                    self.host.bridge.progress_error(
-                        progress_id, C.PROGRESS_ERROR_FAILED, client.profile
-                    )
-                    await self._j.terminate(
-                        client, self._j.REASON_FAILED_APPLICATION, session)
-                    raise e
-            else:
-                # we are sending the file
-                size = file_data["size"]
-                # XXX: hash security is not critical here, so we just take the higher
-                #      mandatory one
-                hasher = file_data["hash_hasher"] = self._hash.get_hasher()
-                content_data["stream_object"] = stream.FileStreamObject(
-                    self.host,
-                    client,
-                    file_path,
-                    uid=self.get_progress_id(session, content_name),
-                    size=size,
-                    data_cb=lambda data: hasher.update(data),
-                )
-            finished_d = content_data["finished_d"] = defer.Deferred()
-            args = [client, session, content_name, content_data]
-            finished_d.addCallbacks(self._finished_cb, self._finished_eb, args, None, args)
-            await self.host.trigger.async_point(
-                "XEP-0234_jingle_handler",
-                client, session, content_data, desc_elt
-            )
-        else:
-            log.warning("FIXME: unmanaged action {}".format(action))
-        return desc_elt
-
-    def jingle_session_info(self, client, action, session, content_name, jingle_elt):
-        """Called on session-info action
-
-        manage checksum, and ignore <received/> element
-        """
-        # TODO: manage <received/> element
-        content_data = session["contents"][content_name]
-        elts = [elt for elt in jingle_elt.elements() if elt.uri == NS_JINGLE_FT]
-        if not elts:
-            return
-        for elt in elts:
-            if elt.name == "received":
-                pass
-            elif elt.name == "checksum":
-                # we have received the file hash, we need to parse it
-                if content_data["senders"] == session["role"]:
-                    log.warning(
-                        "unexpected checksum received while we are the file sender"
-                    )
-                    raise exceptions.DataError
-                info_content_name = elt["name"]
-                if info_content_name != content_name:
-                    # it was for an other content...
-                    return
-                file_data = content_data["application_data"]["file_data"]
-                try:
-                    file_elt = next(elt.elements(NS_JINGLE_FT, "file"))
-                except StopIteration:
-                    raise exceptions.DataError
-                algo, file_data["given_file_hash"] = self._hash.parse_hash_elt(file_elt)
-                if algo != file_data.get("hash_algo"):
-                    log.warning(
-                        "Hash algorithm used in given hash ({peer_algo}) doesn't correspond to the one we have used ({our_algo}) [{profile}]".format(
-                            peer_algo=algo,
-                            our_algo=file_data.get("hash_algo"),
-                            profile=client.profile,
-                        )
-                    )
-                else:
-                    self._receiver_try_terminate(
-                        client, session, content_name, content_data
-                    )
-            else:
-                raise NotImplementedError
-
-    def jingle_terminate(self, client, action, session, content_name, reason_elt):
-        if reason_elt.decline:
-            # progress is the only way to tell to frontends that session has been declined
-            progress_id = self.get_progress_id(session, content_name)
-            self.host.bridge.progress_error(
-                progress_id, C.PROGRESS_ERROR_DECLINED, client.profile
-            )
-        elif not reason_elt.success:
-            progress_id = self.get_progress_id(session, content_name)
-            first_child = reason_elt.firstChildElement()
-            if first_child is not None:
-                reason = first_child.name
-                if reason_elt.text is not None:
-                    reason = f"{reason} - {reason_elt.text}"
-            else:
-                reason = C.PROGRESS_ERROR_FAILED
-            self.host.bridge.progress_error(
-                progress_id, reason, client.profile
-            )
-
-    def _send_check_sum(self, client, session, content_name, content_data):
-        """Send the session-info with the hash checksum"""
-        file_data = content_data["application_data"]["file_data"]
-        hasher = file_data["hash_hasher"]
-        hash_ = hasher.hexdigest()
-        log.debug("Calculated hash: {}".format(hash_))
-        iq_elt, jingle_elt = self._j.build_session_info(client, session)
-        checksum_elt = jingle_elt.addElement((NS_JINGLE_FT, "checksum"))
-        checksum_elt["creator"] = content_data["creator"]
-        checksum_elt["name"] = content_name
-        file_elt = checksum_elt.addElement("file")
-        file_elt.addChild(self._hash.build_hash_elt(hash_))
-        iq_elt.send()
-
-    def _receiver_try_terminate(
-        self, client, session, content_name, content_data, last_try=False
-    ):
-        """Try to terminate the session
-
-        This method must only be used by the receiver.
-        It check if transfer is finished, and hash available,
-        if everything is OK, it check hash and terminate the session
-        @param last_try(bool): if True this mean than session must be terminated even given hash is not available
-        @return (bool): True if session was terminated
-        """
-        if not content_data.get("transfer_finished", False):
-            return False
-        file_data = content_data["application_data"]["file_data"]
-        given_hash = file_data.get("given_file_hash")
-        if given_hash is None:
-            if last_try:
-                log.warning(
-                    "sender didn't sent hash checksum, we can't check the file [{profile}]".format(
-                        profile=client.profile
-                    )
-                )
-                self._j.delayed_content_terminate(client, session, content_name)
-                content_data["stream_object"].close()
-                return True
-            return False
-        hasher = file_data["hash_hasher"]
-        hash_ = hasher.hexdigest()
-
-        if hash_ == given_hash:
-            log.info(f"Hash checked, file was successfully transfered: {hash_}")
-            progress_metadata = {
-                "hash": hash_,
-                "hash_algo": file_data["hash_algo"],
-                "hash_verified": C.BOOL_TRUE,
-            }
-            error = None
-        else:
-            log.warning("Hash mismatch, the file was not transfered correctly")
-            progress_metadata = None
-            error = "Hash mismatch: given={algo}:{given}, calculated={algo}:{our}".format(
-                algo=file_data["hash_algo"], given=given_hash, our=hash_
-            )
-
-        self._j.delayed_content_terminate(client, session, content_name)
-        content_data["stream_object"].close(progress_metadata, error)
-        # we may have the last_try timer still active, so we try to cancel it
-        try:
-            content_data["last_try_timer"].cancel()
-        except (KeyError, internet_error.AlreadyCalled):
-            pass
-        return True
-
-    def _finished_cb(self, __, client, session, content_name, content_data):
-        log.info("File transfer terminated")
-        if content_data["senders"] != session["role"]:
-            # we terminate the session only if we are the receiver,
-            # as recommanded in XEP-0234 §2 (after example 6)
-            content_data["transfer_finished"] = True
-            if not self._receiver_try_terminate(
-                client, session, content_name, content_data
-            ):
-                # we have not received the hash yet, we wait 5 more seconds
-                content_data["last_try_timer"] = reactor.callLater(
-                    5,
-                    self._receiver_try_terminate,
-                    client,
-                    session,
-                    content_name,
-                    content_data,
-                    last_try=True,
-                )
-        else:
-            # we are the sender, we send the checksum
-            self._send_check_sum(client, session, content_name, content_data)
-            content_data["stream_object"].close()
-
-    def _finished_eb(self, failure, client, session, content_name, content_data):
-        log.warning("Error while streaming file: {}".format(failure))
-        content_data["stream_object"].close()
-        self._j.content_terminate(
-            client, session, content_name, reason=self._j.REASON_FAILED_TRANSPORT
-        )
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0234_handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_JINGLE_FT)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0249.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,237 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing xep-0249
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.i18n import D_, _
-from sat.core.log import getLogger
-from sat.tools import xml_tools
-
-log = getLogger(__name__)
-
-
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-
-MESSAGE = "/message"
-NS_X_CONFERENCE = "jabber:x:conference"
-AUTOJOIN_KEY = "Misc"
-AUTOJOIN_NAME = "Auto-join MUC on invitation"
-AUTOJOIN_VALUES = ["ask", "always", "never"]
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP 0249 Plugin",
-    C.PI_IMPORT_NAME: "XEP-0249",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0249"],
-    C.PI_DEPENDENCIES: ["XEP-0045"],
-    C.PI_RECOMMENDATIONS: [C.TEXT_CMDS],
-    C.PI_MAIN: "XEP_0249",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Direct MUC Invitations"""),
-}
-
-
-class XEP_0249(object):
-
-    params = """
-    <params>
-    <individual>
-    <category name="%(category_name)s" label="%(category_label)s">
-        <param name="%(param_name)s" label="%(param_label)s" type="list" security="0">
-            %(param_options)s
-        </param>
-     </category>
-    </individual>
-    </params>
-    """ % {
-        "category_name": AUTOJOIN_KEY,
-        "category_label": _("Misc"),
-        "param_name": AUTOJOIN_NAME,
-        "param_label": _("Auto-join MUC on invitation"),
-        "param_options": "\n".join(
-            [
-                '<option value="%s" %s/>'
-                % (value, 'selected="true"' if value == AUTOJOIN_VALUES[0] else "")
-                for value in AUTOJOIN_VALUES
-            ]
-        ),
-    }
-
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0249 initialization"))
-        self.host = host
-        host.memory.update_params(self.params)
-        host.bridge.add_method(
-            "muc_invite", ".plugin", in_sign="ssa{ss}s", out_sign="", method=self._invite
-        )
-        try:
-            self.host.plugins[C.TEXT_CMDS].register_text_commands(self)
-        except KeyError:
-            log.info(_("Text commands not available"))
-        host.register_namespace('x-conference', NS_X_CONFERENCE)
-        host.trigger.add("message_received", self._message_received_trigger)
-
-    def get_handler(self, client):
-        return XEP_0249_handler()
-
-    def _invite(self, guest_jid_s, room_jid_s, options, profile_key):
-        """Invite an user to a room
-
-        @param guest_jid_s: jid of the user to invite
-        @param service: jid of the MUC service
-        @param roomId: name of the room
-        @param profile_key: %(doc_profile_key)s
-        """
-        # TODO: check parameters validity
-        client = self.host.get_client(profile_key)
-        self.invite(client, jid.JID(guest_jid_s), jid.JID(room_jid_s, options))
-
-    def invite(self, client, guest, room, options={}):
-        """Invite a user to a room
-
-        @param guest(jid.JID): jid of the user to invite
-        @param room(jid.JID): jid of the room where the user is invited
-        @param options(dict): attribute with extra info (reason, password) as in #XEP-0249
-        """
-        message = domish.Element((None, "message"))
-        message["to"] = guest.full()
-        x_elt = message.addElement((NS_X_CONFERENCE, "x"))
-        x_elt["jid"] = room.userhost()
-        for key, value in options.items():
-            if key not in ("password", "reason", "thread"):
-                log.warning("Ignoring invalid invite option: {}".format(key))
-                continue
-            x_elt[key] = value
-        #  there is not body in this message, so we can use directly send()
-        client.send(message)
-
-    def _accept(self, room_jid, profile_key=C.PROF_KEY_NONE):
-        """Accept the invitation to join a MUC.
-
-        @param room (jid.JID): JID of the room
-        """
-        client = self.host.get_client(profile_key)
-        log.info(
-            _("Invitation accepted for room %(room)s [%(profile)s]")
-            % {"room": room_jid.userhost(), "profile": client.profile}
-        )
-        d = defer.ensureDeferred(
-            self.host.plugins["XEP-0045"].join(client, room_jid, client.jid.user, {})
-        )
-        return d
-
-    def _message_received_trigger(self, client, message_elt, post_treat):
-        """Check if a direct invitation is in the message, and handle it"""
-        x_elt = next(message_elt.elements(NS_X_CONFERENCE, 'x'), None)
-        if x_elt is None:
-            return True
-
-        try:
-            room_jid_s = x_elt["jid"]
-        except KeyError:
-            log.warning(_("invalid invitation received: {xml}").format(
-                xml=message_elt.toXml()))
-            return False
-        log.info(
-            _("Invitation received for room %(room)s [%(profile)s]")
-            % {"room": room_jid_s, "profile": client.profile}
-        )
-        from_jid_s = message_elt["from"]
-        room_jid = jid.JID(room_jid_s)
-        try:
-            self.host.plugins["XEP-0045"].check_room_joined(client, room_jid)
-        except exceptions.NotFound:
-            pass
-        else:
-            log.info(
-                _("Invitation silently discarded because user is already in the room.")
-            )
-            return
-
-        autojoin = self.host.memory.param_get_a(
-            AUTOJOIN_NAME, AUTOJOIN_KEY, profile_key=client.profile
-        )
-
-        if autojoin == "always":
-            self._accept(room_jid, client.profile)
-        elif autojoin == "never":
-            msg = D_(
-                "An invitation from %(user)s to join the room %(room)s has been "
-                "declined according to your personal settings."
-            ) % {"user": from_jid_s, "room": room_jid_s}
-            title = D_("MUC invitation")
-            xml_tools.quick_note(self.host, client, msg, title, C.XMLUI_DATA_LVL_INFO)
-        else:  # leave the default value here
-            confirm_msg = D_(
-                "You have been invited by %(user)s to join the room %(room)s. "
-                "Do you accept?"
-            ) % {"user": from_jid_s, "room": room_jid_s}
-            confirm_title = D_("MUC invitation")
-            d = xml_tools.defer_confirm(
-                self.host, confirm_msg, confirm_title, profile=client.profile
-            )
-
-            def accept_cb(accepted):
-                if accepted:
-                    self._accept(room_jid, client.profile)
-
-            d.addCallback(accept_cb)
-        return False
-
-    def cmd_invite(self, client, mess_data):
-        """invite someone in the room
-
-        @command (group): JID
-            - JID: the JID of the person to invite
-        """
-        contact_jid_s = mess_data["unparsed"].strip()
-        my_host = client.jid.host
-        try:
-            contact_jid = jid.JID(contact_jid_s)
-        except (RuntimeError, jid.InvalidFormat, AttributeError):
-            feedback = _(
-                "You must provide a valid JID to invite, like in '/invite "
-                "contact@{host}'"
-            ).format(host=my_host)
-            self.host.plugins[C.TEXT_CMDS].feed_back(client, feedback, mess_data)
-            return False
-        if not contact_jid.user:
-            contact_jid.user, contact_jid.host = contact_jid.host, my_host
-        self.invite(client, contact_jid, mess_data["to"])
-        return False
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0249_handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_X_CONFERENCE)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0260.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,551 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Jingle (XEP-0260)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core import exceptions
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-from twisted.words.xish import domish
-from twisted.words.protocols.jabber import jid
-from twisted.internet import defer
-import uuid
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-
-
-NS_JINGLE_S5B = "urn:xmpp:jingle:transports:s5b:1"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Jingle SOCKS5 Bytestreams",
-    C.PI_IMPORT_NAME: "XEP-0260",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0260"],
-    C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0065"],
-    C.PI_RECOMMENDATIONS: ["XEP-0261"],  # needed for fallback
-    C.PI_MAIN: "XEP_0260",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Jingle SOCKS5 Bytestreams"""),
-}
-
-
-class ProxyError(Exception):
-    def __str__(self):
-        return "an error happened while trying to use the proxy"
-
-
-class XEP_0260(object):
-    # TODO: udp handling
-
-    def __init__(self, host):
-        log.info(_("plugin Jingle SOCKS5 Bytestreams"))
-        self.host = host
-        self._j = host.plugins["XEP-0166"]  # shortcut to access jingle
-        self._s5b = host.plugins["XEP-0065"]  # and socks5 bytestream
-        try:
-            self._jingle_ibb = host.plugins["XEP-0261"]
-        except KeyError:
-            self._jingle_ibb = None
-        self._j.register_transport(NS_JINGLE_S5B, self._j.TRANSPORT_STREAMING, self, 100)
-
-    def get_handler(self, client):
-        return XEP_0260_handler()
-
-    def _parse_candidates(self, transport_elt):
-        """Parse <candidate> elements
-
-        @param transport_elt(domish.Element): parent <transport> element
-        @return (list[plugin_xep_0065.Candidate): list of parsed candidates
-        """
-        candidates = []
-        for candidate_elt in transport_elt.elements(NS_JINGLE_S5B, "candidate"):
-            try:
-                cid = candidate_elt["cid"]
-                host = candidate_elt["host"]
-                jid_ = jid.JID(candidate_elt["jid"])
-                port = int(candidate_elt.getAttribute("port", 1080))
-                priority = int(candidate_elt["priority"])
-                type_ = candidate_elt.getAttribute("type", self._s5b.TYPE_DIRECT)
-            except (KeyError, ValueError):
-                raise exceptions.DataError()
-            candidate = self._s5b.Candidate(host, port, type_, priority, jid_, cid)
-            candidates.append(candidate)
-            # self._s5b.registerCandidate(candidate)
-        return candidates
-
-    def _build_candidates(self, session, candidates, sid, session_hash, client, mode=None):
-        """Build <transport> element with candidates
-
-        @param session(dict): jingle session data
-        @param candidates(iterator[plugin_xep_0065.Candidate]): iterator of candidates to add
-        @param sid(unicode): transport stream id
-        @param client: %(doc_client)s
-        @param mode(str, None): 'tcp' or 'udp', or None to have no attribute
-        @return (domish.Element): parent <transport> element where <candidate> elements must be added
-        """
-        proxy = next(
-            (
-                candidate
-                for candidate in candidates
-                if candidate.type == self._s5b.TYPE_PROXY
-            ),
-            None,
-        )
-        transport_elt = domish.Element((NS_JINGLE_S5B, "transport"))
-        transport_elt["sid"] = sid
-        if proxy is not None:
-            transport_elt["dstaddr"] = session_hash
-        if mode is not None:
-            transport_elt["mode"] = "tcp"  # XXX: we only manage tcp for now
-
-        for candidate in candidates:
-            log.debug("Adding candidate: {}".format(candidate))
-            candidate_elt = transport_elt.addElement("candidate", NS_JINGLE_S5B)
-            if candidate.id is None:
-                candidate.id = str(uuid.uuid4())
-            candidate_elt["cid"] = candidate.id
-            candidate_elt["host"] = candidate.host
-            candidate_elt["jid"] = candidate.jid.full()
-            candidate_elt["port"] = str(candidate.port)
-            candidate_elt["priority"] = str(candidate.priority)
-            candidate_elt["type"] = candidate.type
-        return transport_elt
-
-    @defer.inlineCallbacks
-    def jingle_session_init(self, client, session, content_name):
-        content_data = session["contents"][content_name]
-        transport_data = content_data["transport_data"]
-        sid = transport_data["sid"] = str(uuid.uuid4())
-        session_hash = transport_data["session_hash"] = self._s5b.get_session_hash(
-            session["local_jid"], session["peer_jid"], sid
-        )
-        transport_data["peer_session_hash"] = self._s5b.get_session_hash(
-            session["peer_jid"], session["local_jid"], sid
-        )  # requester and target are inversed for peer candidates
-        transport_data["stream_d"] = self._s5b.register_hash(client, session_hash, None)
-        candidates = transport_data["candidates"] = yield self._s5b.get_candidates(
-            client, session["local_jid"])
-        mode = "tcp"  # XXX: we only manage tcp for now
-        transport_elt = self._build_candidates(
-            session, candidates, sid, session_hash, client, mode
-        )
-
-        defer.returnValue(transport_elt)
-
-    def _proxy_activated_cb(self, iq_result_elt, client, candidate, session, content_name):
-        """Called when activation confirmation has been received from proxy
-
-        cf XEP-0260 § 2.4
-        """
-        # now that the proxy is activated, we have to inform other peer
-        content_data = session["contents"][content_name]
-        iq_elt, transport_elt = self._j.build_action(
-            client, self._j.A_TRANSPORT_INFO, session, content_name
-        )
-        transport_elt["sid"] = content_data["transport_data"]["sid"]
-        activated_elt = transport_elt.addElement("activated")
-        activated_elt["cid"] = candidate.id
-        iq_elt.send()
-
-    def _proxy_activated_eb(self, stanza_error, client, candidate, session, content_name):
-        """Called when activation error has been received from proxy
-
-        cf XEP-0260 § 2.4
-        """
-        # TODO: fallback to IBB
-        # now that the proxy is activated, we have to inform other peer
-        content_data = session["contents"][content_name]
-        iq_elt, transport_elt = self._j.build_action(
-            client, self._j.A_TRANSPORT_INFO, session, content_name
-        )
-        transport_elt["sid"] = content_data["transport_data"]["sid"]
-        transport_elt.addElement("proxy-error")
-        iq_elt.send()
-        log.warning(
-            "Can't activate proxy, we need to fallback to IBB: {reason}".format(
-                reason=stanza_error.value.condition
-            )
-        )
-        self.do_fallback(session, content_name, client)
-
-    def _found_peer_candidate(
-        self, candidate, session, transport_data, content_name, client
-    ):
-        """Called when the best candidate from other peer is found
-
-        @param candidate(XEP_0065.Candidate, None): selected candidate,
-            or None if no candidate is accessible
-        @param session(dict):  session data
-        @param transport_data(dict): transport data
-        @param content_name(unicode): name of the current content
-        @param client(unicode): %(doc_client)s
-        """
-
-        content_data = session["contents"][content_name]
-        transport_data["best_candidate"] = candidate
-        # we need to disconnect all non selected candidates before removing them
-        for c in transport_data["peer_candidates"]:
-            if c is None or c is candidate:
-                continue
-            c.discard()
-        del transport_data["peer_candidates"]
-        iq_elt, transport_elt = self._j.build_action(
-            client, self._j.A_TRANSPORT_INFO, session, content_name
-        )
-        transport_elt["sid"] = content_data["transport_data"]["sid"]
-        if candidate is None:
-            log.warning("Can't connect to any peer candidate")
-            candidate_elt = transport_elt.addElement("candidate-error")
-        else:
-            log.info("Found best peer candidate: {}".format(str(candidate)))
-            candidate_elt = transport_elt.addElement("candidate-used")
-            candidate_elt["cid"] = candidate.id
-        iq_elt.send()  # TODO: check result stanza
-        self._check_candidates(session, content_name, transport_data, client)
-
-    def _check_candidates(self, session, content_name, transport_data, client):
-        """Called when a candidate has been choosed
-
-        if we have both candidates, we select one, or fallback to an other transport
-        @param session(dict):  session data
-        @param content_name(unicode): name of the current content
-        @param transport_data(dict): transport data
-        @param client(unicode): %(doc_client)s
-        """
-        content_data = session["contents"][content_name]
-        try:
-            best_candidate = transport_data["best_candidate"]
-        except KeyError:
-            # we have not our best candidate yet
-            return
-        try:
-            peer_best_candidate = transport_data["peer_best_candidate"]
-        except KeyError:
-            # we have not peer best candidate yet
-            return
-
-        # at this point we have both candidates, it's time to choose one
-        if best_candidate is None or peer_best_candidate is None:
-            choosed_candidate = best_candidate or peer_best_candidate
-        else:
-            if best_candidate.priority == peer_best_candidate.priority:
-                # same priority, we choose initiator one according to XEP-0260 §2.4 #4
-                log.debug(
-                    "Candidates have same priority, we select the one choosed by initiator"
-                )
-                if session["initiator"] == session["local_jid"]:
-                    choosed_candidate = best_candidate
-                else:
-                    choosed_candidate = peer_best_candidate
-            else:
-                choosed_candidate = max(
-                    best_candidate, peer_best_candidate, key=lambda c: c.priority
-                )
-
-        if choosed_candidate is None:
-            log.warning("Socks5 negociation failed, we need to fallback to IBB")
-            self.do_fallback(session, content_name, client)
-        else:
-            if choosed_candidate == peer_best_candidate:
-                # peer_best_candidate was choosed from the candidates we have sent
-                # so our_candidate is true if choosed_candidate is peer_best_candidate
-                our_candidate = True
-                # than also mean that best_candidate must be discarded !
-                try:
-                    best_candidate.discard()
-                except AttributeError:  # but it can be None
-                    pass
-            else:
-                our_candidate = False
-
-            log.info(
-                "Socks5 negociation successful, {who} candidate will be used: {candidate}".format(
-                    who="our" if our_candidate else "other peer",
-                    candidate=choosed_candidate,
-                )
-            )
-            del transport_data["best_candidate"]
-            del transport_data["peer_best_candidate"]
-
-            if choosed_candidate.type == self._s5b.TYPE_PROXY:
-                # the stream transfer need to wait for proxy activation
-                # (see XEP-0260 § 2.4)
-                if our_candidate:
-                    d = self._s5b.connect_candidate(
-                        client, choosed_candidate, transport_data["session_hash"]
-                    )
-                    d.addCallback(
-                        lambda __: choosed_candidate.activate(
-                            transport_data["sid"], session["peer_jid"], client
-                        )
-                    )
-                    args = [client, choosed_candidate, session, content_name]
-                    d.addCallbacks(
-                        self._proxy_activated_cb, self._proxy_activated_eb, args, None, args
-                    )
-                else:
-                    # this Deferred will be called when we'll receive activation confirmation from other peer
-                    d = transport_data["activation_d"] = defer.Deferred()
-            else:
-                d = defer.succeed(None)
-
-            if content_data["senders"] == session["role"]:
-                # we can now start the stream transfer (or start it after proxy activation)
-                d.addCallback(
-                    lambda __: choosed_candidate.start_transfer(
-                        transport_data["session_hash"]
-                    )
-                )
-                d.addErrback(self._start_eb, session, content_name, client)
-
-    def _start_eb(self, fail, session, content_name, client):
-        """Called when it's not possible to start the transfer
-
-        Will try to fallback to IBB
-        """
-        try:
-            reason = str(fail.value)
-        except AttributeError:
-            reason = str(fail)
-        log.warning("Cant start transfert, we'll try fallback method: {}".format(reason))
-        self.do_fallback(session, content_name, client)
-
-    def _candidate_info(
-        self, candidate_elt, session, content_name, transport_data, client
-    ):
-        """Called when best candidate has been received from peer (or if none is working)
-
-        @param candidate_elt(domish.Element): candidate-used or candidate-error element
-            (see XEP-0260 §2.3)
-        @param session(dict):  session data
-        @param content_name(unicode): name of the current content
-        @param transport_data(dict): transport data
-        @param client(unicode): %(doc_client)s
-        """
-        if candidate_elt.name == "candidate-error":
-            # candidate-error, no candidate worked
-            transport_data["peer_best_candidate"] = None
-        else:
-            # candidate-used, one candidate was choosed
-            try:
-                cid = candidate_elt.attributes["cid"]
-            except KeyError:
-                log.warning("No cid found in <candidate-used>")
-                raise exceptions.DataError
-            try:
-                candidate = next((
-                    c for c in transport_data["candidates"] if c.id == cid
-                ))
-            except StopIteration:
-                log.warning("Given cid doesn't correspond to any known candidate !")
-                raise exceptions.DataError  # TODO: send an error to other peer, and use better exception
-            except KeyError:
-                # a transport-info can also be intentionaly sent too early by other peer
-                # but there is little probability
-                log.error(
-                    '"candidates" key doesn\'t exists in transport_data, it should at this point'
-                )
-                raise exceptions.InternalError
-            # at this point we have the candidate choosed by other peer
-            transport_data["peer_best_candidate"] = candidate
-            log.info("Other peer best candidate: {}".format(candidate))
-
-        del transport_data["candidates"]
-        self._check_candidates(session, content_name, transport_data, client)
-
-    def _proxy_activation_info(
-        self, proxy_elt, session, content_name, transport_data, client
-    ):
-        """Called when proxy has been activated (or has sent an error)
-
-        @param proxy_elt(domish.Element): <activated/> or <proxy-error/> element
-            (see XEP-0260 §2.4)
-        @param session(dict):  session data
-        @param content_name(unicode): name of the current content
-        @param transport_data(dict): transport data
-        @param client(unicode): %(doc_client)s
-        """
-        try:
-            activation_d = transport_data.pop("activation_d")
-        except KeyError:
-            log.warning("Received unexpected transport-info for proxy activation")
-
-        if proxy_elt.name == "activated":
-            activation_d.callback(None)
-        else:
-            activation_d.errback(ProxyError())
-
-    @defer.inlineCallbacks
-    def jingle_handler(self, client, action, session, content_name, transport_elt):
-        content_data = session["contents"][content_name]
-        transport_data = content_data["transport_data"]
-
-        if action in (self._j.A_ACCEPTED_ACK, self._j.A_PREPARE_RESPONDER):
-            pass
-
-        elif action == self._j.A_SESSION_ACCEPT:
-            # initiator side, we select a candidate in the ones sent by responder
-            assert "peer_candidates" not in transport_data
-            transport_data["peer_candidates"] = self._parse_candidates(transport_elt)
-
-        elif action == self._j.A_START:
-            session_hash = transport_data["session_hash"]
-            peer_candidates = transport_data["peer_candidates"]
-            stream_object = content_data["stream_object"]
-            self._s5b.associate_stream_object(client, session_hash, stream_object)
-            stream_d = transport_data.pop("stream_d")
-            stream_d.chainDeferred(content_data["finished_d"])
-            peer_session_hash = transport_data["peer_session_hash"]
-            d = self._s5b.get_best_candidate(
-                client, peer_candidates, session_hash, peer_session_hash
-            )
-            d.addCallback(
-                self._found_peer_candidate, session, transport_data, content_name, client
-            )
-
-        elif action == self._j.A_SESSION_INITIATE:
-            # responder side, we select a candidate in the ones sent by initiator
-            # and we give our candidates
-            assert "peer_candidates" not in transport_data
-            sid = transport_data["sid"] = transport_elt["sid"]
-            session_hash = transport_data["session_hash"] = self._s5b.get_session_hash(
-                session["local_jid"], session["peer_jid"], sid
-            )
-            peer_session_hash = transport_data[
-                "peer_session_hash"
-            ] = self._s5b.get_session_hash(
-                session["peer_jid"], session["local_jid"], sid
-            )  # requester and target are inversed for peer candidates
-            peer_candidates = transport_data["peer_candidates"] = self._parse_candidates(
-                transport_elt
-            )
-            stream_object = content_data["stream_object"]
-            stream_d = self._s5b.register_hash(client, session_hash, stream_object)
-            stream_d.chainDeferred(content_data["finished_d"])
-            d = self._s5b.get_best_candidate(
-                client, peer_candidates, session_hash, peer_session_hash
-            )
-            d.addCallback(
-                self._found_peer_candidate, session, transport_data, content_name, client
-            )
-            candidates = yield self._s5b.get_candidates(client, session["local_jid"])
-            # we remove duplicate candidates
-            candidates = [
-                candidate for candidate in candidates if candidate not in peer_candidates
-            ]
-
-            transport_data["candidates"] = candidates
-            # we can now build a new <transport> element with our candidates
-            transport_elt = self._build_candidates(
-                session, candidates, sid, session_hash, client
-            )
-
-        elif action == self._j.A_TRANSPORT_INFO:
-            # transport-info can be about candidate or proxy activation
-            candidate_elt = None
-
-            for method, names in (
-                (self._candidate_info, ("candidate-used", "candidate-error")),
-                (self._proxy_activation_info, ("activated", "proxy-error")),
-            ):
-                for name in names:
-                    try:
-                        candidate_elt = next(transport_elt.elements(NS_JINGLE_S5B, name))
-                    except StopIteration:
-                        continue
-                    else:
-                        method(
-                            candidate_elt, session, content_name, transport_data, client
-                        )
-                        break
-
-            if candidate_elt is None:
-                log.warning(
-                    "Unexpected transport element: {}".format(transport_elt.toXml())
-                )
-        elif action == self._j.A_DESTROY:
-            # the transport is replaced (fallback ?), We need mainly to kill XEP-0065 session.
-            # note that sid argument is not necessary for sessions created by this plugin
-            self._s5b.kill_session(None, transport_data["session_hash"], None, client)
-        else:
-            log.warning("FIXME: unmanaged action {}".format(action))
-
-        defer.returnValue(transport_elt)
-
-    def jingle_terminate(self, client, action, session, content_name, reason_elt):
-        if reason_elt.decline:
-            log.debug("Session declined, deleting S5B session")
-            # we just need to clean the S5B session if it is declined
-            content_data = session["contents"][content_name]
-            transport_data = content_data["transport_data"]
-            self._s5b.kill_session(None, transport_data["session_hash"], None, client)
-
-    def _do_fallback(self, feature_checked, session, content_name, client):
-        """Do the fallback, method called once feature is checked
-
-         @param feature_checked(bool): True if other peer can do IBB
-         """
-        if not feature_checked:
-            log.warning(
-                "Other peer can't manage jingle IBB, be have to terminate the session"
-            )
-            self._j.terminate(client, self._j.REASON_CONNECTIVITY_ERROR, session)
-        else:
-            self._j.transport_replace(
-                client, self._jingle_ibb.NAMESPACE, session, content_name
-            )
-
-    def do_fallback(self, session, content_name, client):
-        """Fallback to IBB transport, used in last resort
-
-        @param session(dict):  session data
-        @param content_name(unicode): name of the current content
-        @param client(unicode): %(doc_client)s
-        """
-        if session["role"] != self._j.ROLE_INITIATOR:
-            # only initiator must do the fallback, see XEP-0260 §3
-            return
-        if self._jingle_ibb is None:
-            log.warning(
-                "Jingle IBB (XEP-0261) plugin is not available, we have to close the session"
-            )
-            self._j.terminate(client, self._j.REASON_CONNECTIVITY_ERROR, session)
-        else:
-            d = self.host.hasFeature(
-                client, self._jingle_ibb.NAMESPACE, session["peer_jid"]
-            )
-            d.addCallback(self._do_fallback, session, content_name, client)
-        return d
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0260_handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_JINGLE_S5B)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0261.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,113 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Jingle (XEP-0261)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-from twisted.words.xish import domish
-import uuid
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-
-
-NS_JINGLE_IBB = "urn:xmpp:jingle:transports:ibb:1"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Jingle In-Band Bytestreams",
-    C.PI_IMPORT_NAME: "XEP-0261",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0261"],
-    C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0047"],
-    C.PI_MAIN: "XEP_0261",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Jingle In-Band Bytestreams"""),
-}
-
-
-class XEP_0261(object):
-    NAMESPACE = NS_JINGLE_IBB  # used by XEP-0260 plugin for transport-replace
-
-    def __init__(self, host):
-        log.info(_("plugin Jingle In-Band Bytestreams"))
-        self.host = host
-        self._j = host.plugins["XEP-0166"]  # shortcut to access jingle
-        self._ibb = host.plugins["XEP-0047"]  # and in-band bytestream
-        self._j.register_transport(
-            NS_JINGLE_IBB, self._j.TRANSPORT_STREAMING, self, -10000
-        )  # must be the lowest priority
-
-    def get_handler(self, client):
-        return XEP_0261_handler()
-
-    def jingle_session_init(self, client, session, content_name):
-        transport_elt = domish.Element((NS_JINGLE_IBB, "transport"))
-        content_data = session["contents"][content_name]
-        transport_data = content_data["transport_data"]
-        transport_data["block_size"] = self._ibb.BLOCK_SIZE
-        transport_elt["block-size"] = str(transport_data["block_size"])
-        transport_elt["sid"] = transport_data["sid"] = str(uuid.uuid4())
-        return transport_elt
-
-    def jingle_handler(self, client, action, session, content_name, transport_elt):
-        content_data = session["contents"][content_name]
-        transport_data = content_data["transport_data"]
-        if action in (
-            self._j.A_SESSION_ACCEPT,
-            self._j.A_ACCEPTED_ACK,
-            self._j.A_TRANSPORT_ACCEPT,
-        ):
-            pass
-        elif action in (self._j.A_SESSION_INITIATE, self._j.A_TRANSPORT_REPLACE):
-            transport_data["sid"] = transport_elt["sid"]
-        elif action in (self._j.A_START, self._j.A_PREPARE_RESPONDER):
-            local_jid = session["local_jid"]
-            peer_jid = session["peer_jid"]
-            sid = transport_data["sid"]
-            stream_object = content_data["stream_object"]
-            if action == self._j.A_START:
-                block_size = transport_data["block_size"]
-                d = self._ibb.start_stream(
-                    client, stream_object, local_jid, peer_jid, sid, block_size
-                )
-                d.chainDeferred(content_data["finished_d"])
-            else:
-                d = self._ibb.create_session(
-                    client, stream_object, local_jid, peer_jid, sid)
-                d.chainDeferred(content_data["finished_d"])
-        else:
-            log.warning("FIXME: unmanaged action {}".format(action))
-        return transport_elt
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0261_handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_JINGLE_IBB)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0264.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,207 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin for managing xep-0264
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from twisted.internet import threads
-from twisted.python.failure import Failure
-
-from zope.interface import implementer
-
-from wokkel import disco, iwokkel
-
-from sat.core import exceptions
-import hashlib
-
-try:
-    from PIL import Image, ImageOps
-except:
-    raise exceptions.MissingModule(
-        "Missing module pillow, please download/install it from https://python-pillow.github.io"
-    )
-
-#  cf. https://stackoverflow.com/a/23575424
-from PIL import ImageFile
-
-ImageFile.LOAD_TRUNCATED_IMAGES = True
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-
-
-MIME_TYPE = "image/jpeg"
-SAVE_FORMAT = "JPEG"  # (cf. Pillow documentation)
-
-NS_THUMBS = "urn:xmpp:thumbs:1"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP-0264",
-    C.PI_IMPORT_NAME: "XEP-0264",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0264"],
-    C.PI_DEPENDENCIES: ["XEP-0234"],
-    C.PI_MAIN: "XEP_0264",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Thumbnails handling"""),
-}
-
-
-class XEP_0264(object):
-    SIZE_SMALL = (320, 320)
-    SIZE_MEDIUM = (640, 640)
-    SIZE_BIG = (1280, 1280)
-    SIZE_FULL_SCREEN = (2560, 2560)
-    # FIXME: SIZE_FULL_SCREEN is currently discarded as the resulting files are too big
-    # for BoB
-    # TODO: use an other mechanism than BoB for bigger files
-    SIZES = (SIZE_SMALL, SIZE_MEDIUM, SIZE_BIG)
-
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0264 initialization"))
-        self.host = host
-        host.trigger.add("XEP-0234_buildFileElement", self._add_file_thumbnails)
-        host.trigger.add("XEP-0234_parseFileElement", self._get_file_thumbnails)
-
-    def get_handler(self, client):
-        return XEP_0264_handler()
-
-    ## triggers ##
-
-    def _add_file_thumbnails(self, client, file_elt, extra_args):
-        try:
-            thumbnails = extra_args["extra"][C.KEY_THUMBNAILS]
-        except KeyError:
-            return
-        for thumbnail in thumbnails:
-            thumbnail_elt = file_elt.addElement((NS_THUMBS, "thumbnail"))
-            thumbnail_elt["uri"] = "cid:" + thumbnail["id"]
-            thumbnail_elt["media-type"] = MIME_TYPE
-            width, height = thumbnail["size"]
-            thumbnail_elt["width"] = str(width)
-            thumbnail_elt["height"] = str(height)
-        return True
-
-    def _get_file_thumbnails(self, client, file_elt, file_data):
-        thumbnails = []
-        for thumbnail_elt in file_elt.elements(NS_THUMBS, "thumbnail"):
-            uri = thumbnail_elt["uri"]
-            if uri.startswith("cid:"):
-                thumbnail = {"id": uri[4:]}
-            width = thumbnail_elt.getAttribute("width")
-            height = thumbnail_elt.getAttribute("height")
-            if width and height:
-                try:
-                    thumbnail["size"] = (int(width), int(height))
-                except ValueError:
-                    pass
-            try:
-                thumbnail["mime_type"] = thumbnail_elt["media-type"]
-            except KeyError:
-                pass
-            thumbnails.append(thumbnail)
-
-        if thumbnails:
-            # we want thumbnails ordered from smallest to biggest
-            thumbnails.sort(key=lambda t: t.get('size', (0, 0)))
-            file_data.setdefault("extra", {})[C.KEY_THUMBNAILS] = thumbnails
-        return True
-
-    ## thumbnails generation ##
-
-    def get_thumb_id(self, image_uid, size):
-        """return an ID unique for image/size combination
-
-        @param image_uid(unicode): unique id of the image
-            can be a hash
-        @param size(tuple(int)): requested size of thumbnail
-        @return (unicode): unique id for this image/size
-        """
-        return hashlib.sha256(repr((image_uid, size)).encode()).hexdigest()
-
-    def _blocking_gen_thumb(
-            self, source_path, size=None, max_age=None, image_uid=None,
-            fix_orientation=True):
-        """Generate a thumbnail for image
-
-        This is a blocking method and must be executed in a thread
-        params are the same as for [generate_thumbnail]
-        """
-        if size is None:
-            size = self.SIZE_SMALL
-        try:
-            img = Image.open(source_path)
-        except IOError:
-            return Failure(exceptions.DataError("Can't open image"))
-
-        img.thumbnail(size)
-        if fix_orientation:
-            img = ImageOps.exif_transpose(img)
-
-        uid = self.get_thumb_id(image_uid or source_path, size)
-
-        with self.host.common_cache.cache_data(
-            PLUGIN_INFO[C.PI_IMPORT_NAME], uid, MIME_TYPE, max_age
-        ) as f:
-            img.save(f, SAVE_FORMAT)
-            if fix_orientation:
-                log.debug(f"fixed orientation for {f.name}")
-
-        return img.size, uid
-
-    def generate_thumbnail(
-        self, source_path, size=None, max_age=None, image_uid=None, fix_orientation=True):
-        """Generate a thumbnail of image
-
-        @param source_path(unicode): absolute path to source image
-        @param size(int, None): max size of the thumbnail
-            can be one of self.SIZE_*
-            None to use default value (i.e. self.SIZE_SMALL)
-        @param max_age(int, None): same as for [memory.cache.Cache.cache_data])
-        @param image_uid(unicode, None): unique ID to identify the image
-            use hash whenever possible
-            if None, source_path will be used
-        @param fix_orientation(bool): if True, fix orientation using EXIF data
-        @return D(tuple[tuple[int,int], unicode]): tuple with:
-            - size of the thumbnail
-            - unique Id of the thumbnail
-        """
-        d = threads.deferToThread(
-            self._blocking_gen_thumb, source_path, size, max_age, image_uid=image_uid,
-            fix_orientation=fix_orientation
-        )
-        d.addErrback(
-            lambda failure_: log.error("thumbnail generation error: {}".format(failure_))
-        )
-        return d
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0264_handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_THUMBS)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0277.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1724 +0,0 @@
-#!/usr/bin/env python3
-
-# SAT plugin for microblogging over XMPP (xep-0277)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import time
-import dateutil
-import calendar
-from mimetypes import guess_type
-from secrets import token_urlsafe
-from typing import List, Optional, Dict, Tuple, Any, Dict
-from functools import partial
-
-import shortuuid
-
-from twisted.words.protocols.jabber import jid, error
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from twisted.internet import defer
-from twisted.python import failure
-
-# XXX: sat_tmp.wokkel.pubsub is actually used instead of wokkel version
-from wokkel import pubsub
-from wokkel import disco, iwokkel, rsm
-from zope.interface import implementer
-
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.core.core_types import SatXMPPEntity
-from sat.tools import xml_tools
-from sat.tools import sat_defer
-from sat.tools import utils
-from sat.tools.common import data_format
-from sat.tools.common import uri as xmpp_uri
-from sat.tools.common import regex
-
-
-log = getLogger(__name__)
-
-
-NS_MICROBLOG = "urn:xmpp:microblog:0"
-NS_ATOM = "http://www.w3.org/2005/Atom"
-NS_PUBSUB_EVENT = f"{pubsub.NS_PUBSUB}#event"
-NS_COMMENT_PREFIX = f"{NS_MICROBLOG}:comments/"
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Microblogging over XMPP Plugin",
-    C.PI_IMPORT_NAME: "XEP-0277",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0277"],
-    C.PI_DEPENDENCIES: ["XEP-0163", "XEP-0060", "TEXT_SYNTAXES"],
-    C.PI_RECOMMENDATIONS: ["XEP-0059", "EXTRA-PEP", "PUBSUB_CACHE"],
-    C.PI_MAIN: "XEP_0277",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of microblogging Protocol"""),
-}
-
-
-class NodeAccessChangeException(Exception):
-    pass
-
-
-class XEP_0277(object):
-    namespace = NS_MICROBLOG
-    NS_ATOM = NS_ATOM
-
-    def __init__(self, host):
-        log.info(_("Microblogging plugin initialization"))
-        self.host = host
-        host.register_namespace("microblog", NS_MICROBLOG)
-        self._p = self.host.plugins[
-            "XEP-0060"
-        ]  # this facilitate the access to pubsub plugin
-        ps_cache = self.host.plugins.get("PUBSUB_CACHE")
-        if ps_cache is not None:
-            ps_cache.register_analyser(
-                {
-                    "name": "XEP-0277",
-                    "node": NS_MICROBLOG,
-                    "namespace": NS_ATOM,
-                    "type": "blog",
-                    "to_sync": True,
-                    "parser": self.item_2_mb_data,
-                    "match_cb": self._cache_node_match_cb,
-                }
-            )
-        self.rt_sessions = sat_defer.RTDeferredSessions()
-        self.host.plugins["XEP-0060"].add_managed_node(
-            NS_MICROBLOG, items_cb=self._items_received
-        )
-
-        host.bridge.add_method(
-            "mb_send",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="s",
-            method=self._mb_send,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "mb_repeat",
-            ".plugin",
-            in_sign="sssss",
-            out_sign="s",
-            method=self._mb_repeat,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "mb_preview",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="s",
-            method=self._mb_preview,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "mb_retract",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="",
-            method=self._mb_retract,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "mb_get",
-            ".plugin",
-            in_sign="ssiasss",
-            out_sign="s",
-            method=self._mb_get,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "mb_rename",
-            ".plugin",
-            in_sign="sssss",
-            out_sign="",
-            method=self._mb_rename,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "mb_access_set",
-            ".plugin",
-            in_sign="ss",
-            out_sign="",
-            method=self.mb_access_set,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "mb_subscribe_to_many",
-            ".plugin",
-            in_sign="sass",
-            out_sign="s",
-            method=self._mb_subscribe_to_many,
-        )
-        host.bridge.add_method(
-            "mb_get_from_many_rt_result",
-            ".plugin",
-            in_sign="ss",
-            out_sign="(ua(sssasa{ss}))",
-            method=self._mb_get_from_many_rt_result,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "mb_get_from_many",
-            ".plugin",
-            in_sign="sasia{ss}s",
-            out_sign="s",
-            method=self._mb_get_from_many,
-        )
-        host.bridge.add_method(
-            "mb_get_from_many_with_comments_rt_result",
-            ".plugin",
-            in_sign="ss",
-            out_sign="(ua(sssa(sa(sssasa{ss}))a{ss}))",
-            method=self._mb_get_from_many_with_comments_rt_result,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "mb_get_from_many_with_comments",
-            ".plugin",
-            in_sign="sasiia{ss}a{ss}s",
-            out_sign="s",
-            method=self._mb_get_from_many_with_comments,
-        )
-        host.bridge.add_method(
-            "mb_is_comment_node",
-            ".plugin",
-            in_sign="s",
-            out_sign="b",
-            method=self.is_comment_node,
-        )
-
-    def get_handler(self, client):
-        return XEP_0277_handler()
-
-    def _cache_node_match_cb(
-        self,
-        client: SatXMPPEntity,
-        analyse: dict,
-    ) -> None:
-        """Check is analysed node is a comment and fill analyse accordingly"""
-        if analyse["node"].startswith(NS_COMMENT_PREFIX):
-            analyse["subtype"] = "comment"
-
-    def _check_features_cb(self, available):
-        return {"available": C.BOOL_TRUE}
-
-    def _check_features_eb(self, fail):
-        return {"available": C.BOOL_FALSE}
-
-    def features_get(self, profile):
-        client = self.host.get_client(profile)
-        d = self.host.check_features(client, [], identity=("pubsub", "pep"))
-        d.addCallbacks(self._check_features_cb, self._check_features_eb)
-        return d
-
-    ## plugin management methods ##
-
-    def _items_received(self, client, itemsEvent):
-        """Callback which manage items notifications (publish + retract)"""
-
-        def manage_item(data, event):
-            self.host.bridge.ps_event(
-                C.PS_MICROBLOG,
-                itemsEvent.sender.full(),
-                itemsEvent.nodeIdentifier,
-                event,
-                data_format.serialise(data),
-                client.profile,
-            )
-
-        for item in itemsEvent.items:
-            if item.name == C.PS_ITEM:
-                # FIXME: service and node should be used here
-                self.item_2_mb_data(client, item, None, None).addCallbacks(
-                    manage_item, lambda failure: None, (C.PS_PUBLISH,)
-                )
-            elif item.name == C.PS_RETRACT:
-                manage_item({"id": item["id"]}, C.PS_RETRACT)
-            else:
-                raise exceptions.InternalError("Invalid event value")
-
-    ## data/item transformation ##
-
-    @defer.inlineCallbacks
-    def item_2_mb_data(
-        self,
-        client: SatXMPPEntity,
-        item_elt: domish.Element,
-        service: Optional[jid.JID],
-        # FIXME: node is Optional until all calls to item_2_mb_data set properly service
-        #   and node. Once done, the Optional must be removed here
-        node: Optional[str]
-    ) -> dict:
-        """Convert an XML Item to microblog data
-
-        @param item_elt: microblog item element
-        @param service: PubSub service where the item has been retrieved
-            profile's PEP is used when service is None
-        @param node: PubSub node where the item has been retrieved
-            if None, "uri" won't be set
-        @return: microblog data
-        """
-        if service is None:
-            service = client.jid.userhostJID()
-
-        extra: Dict[str, Any] = {}
-        microblog_data: Dict[str, Any] = {
-            "service": service.full(),
-            "extra": extra
-        }
-
-        def check_conflict(key, increment=False):
-            """Check if key is already in microblog data
-
-            @param key(unicode): key to check
-            @param increment(bool): if suffix the key with an increment
-                instead of raising an exception
-            @raise exceptions.DataError: the key already exists
-                (not raised if increment is True)
-            """
-            if key in microblog_data:
-                if not increment:
-                    raise failure.Failure(
-                        exceptions.DataError(
-                            "key {} is already present for item {}"
-                        ).format(key, item_elt["id"])
-                    )
-                else:
-                    idx = 1  # the idx 0 is the key without suffix
-                    fmt = "{}#{}"
-                    new_key = fmt.format(key, idx)
-                    while new_key in microblog_data:
-                        idx += 1
-                        new_key = fmt.format(key, idx)
-                    key = new_key
-            return key
-
-        @defer.inlineCallbacks
-        def parseElement(elem):
-            """Parse title/content elements and fill microblog_data accordingly"""
-            type_ = elem.getAttribute("type")
-            if type_ == "xhtml":
-                data_elt = elem.firstChildElement()
-                if data_elt is None:
-                    raise failure.Failure(
-                        exceptions.DataError(
-                            "XHML content not wrapped in a <div/> element, this is not "
-                            "standard !"
-                        )
-                    )
-                if data_elt.uri != C.NS_XHTML:
-                    raise failure.Failure(
-                        exceptions.DataError(
-                            _("Content of type XHTML must declare its namespace!")
-                        )
-                    )
-                key = check_conflict("{}_xhtml".format(elem.name))
-                data = data_elt.toXml()
-                microblog_data[key] = yield self.host.plugins["TEXT_SYNTAXES"].clean_xhtml(
-                    data
-                )
-            else:
-                key = check_conflict(elem.name)
-                microblog_data[key] = str(elem)
-
-        id_ = item_elt.getAttribute("id", "")  # there can be no id for transient nodes
-        microblog_data["id"] = id_
-        if item_elt.uri not in (pubsub.NS_PUBSUB, NS_PUBSUB_EVENT):
-            msg = "Unsupported namespace {ns} in pubsub item {id_}".format(
-                ns=item_elt.uri, id_=id_
-            )
-            log.warning(msg)
-            raise failure.Failure(exceptions.DataError(msg))
-
-        try:
-            entry_elt = next(item_elt.elements(NS_ATOM, "entry"))
-        except StopIteration:
-            msg = "No atom entry found in the pubsub item {}".format(id_)
-            raise failure.Failure(exceptions.DataError(msg))
-
-        # uri
-        # FIXME: node should alway be set in the future, check FIXME in method signature
-        if node is not None:
-            microblog_data["node"] = node
-            microblog_data['uri'] = xmpp_uri.build_xmpp_uri(
-                "pubsub",
-                path=service.full(),
-                node=node,
-                item=id_,
-            )
-
-        # language
-        try:
-            microblog_data["language"] = entry_elt[(C.NS_XML, "lang")].strip()
-        except KeyError:
-            pass
-
-        # atom:id
-        try:
-            id_elt = next(entry_elt.elements(NS_ATOM, "id"))
-        except StopIteration:
-            msg = ("No atom id found in the pubsub item {}, this is not standard !"
-                   .format(id_))
-            log.warning(msg)
-            microblog_data["atom_id"] = ""
-        else:
-            microblog_data["atom_id"] = str(id_elt)
-
-        # title/content(s)
-
-        # FIXME: ATOM and XEP-0277 only allow 1 <title/> element
-        #        but in the wild we have some blogs with several ones
-        #        so we don't respect the standard for now (it doesn't break
-        #        anything anyway), and we'll find a better option later
-        # try:
-        #     title_elt = entry_elt.elements(NS_ATOM, 'title').next()
-        # except StopIteration:
-        #     msg = u'No atom title found in the pubsub item {}'.format(id_)
-        #     raise failure.Failure(exceptions.DataError(msg))
-        title_elts = list(entry_elt.elements(NS_ATOM, "title"))
-        if not title_elts:
-            msg = "No atom title found in the pubsub item {}".format(id_)
-            raise failure.Failure(exceptions.DataError(msg))
-        for title_elt in title_elts:
-            yield parseElement(title_elt)
-
-        # FIXME: as for <title/>, Atom only authorise at most 1 content
-        #        but XEP-0277 allows several ones. So for no we handle as
-        #        if more than one can be present
-        for content_elt in entry_elt.elements(NS_ATOM, "content"):
-            yield parseElement(content_elt)
-
-        # we check that text content is present
-        for key in ("title", "content"):
-            if key not in microblog_data and ("{}_xhtml".format(key)) in microblog_data:
-                log.warning(
-                    "item {id_} provide a {key}_xhtml data but not a text one".format(
-                        id_=id_, key=key
-                    )
-                )
-                # ... and do the conversion if it's not
-                microblog_data[key] = yield self.host.plugins["TEXT_SYNTAXES"].convert(
-                    microblog_data["{}_xhtml".format(key)],
-                    self.host.plugins["TEXT_SYNTAXES"].SYNTAX_XHTML,
-                    self.host.plugins["TEXT_SYNTAXES"].SYNTAX_TEXT,
-                    False,
-                )
-
-        if "content" not in microblog_data:
-            # use the atom title data as the microblog body content
-            microblog_data["content"] = microblog_data["title"]
-            del microblog_data["title"]
-            if "title_xhtml" in microblog_data:
-                microblog_data["content_xhtml"] = microblog_data["title_xhtml"]
-                del microblog_data["title_xhtml"]
-
-        # published/updated dates
-        try:
-            updated_elt = next(entry_elt.elements(NS_ATOM, "updated"))
-        except StopIteration:
-            msg = "No atom updated element found in the pubsub item {}".format(id_)
-            raise failure.Failure(exceptions.DataError(msg))
-        microblog_data["updated"] = calendar.timegm(
-            dateutil.parser.parse(str(updated_elt)).utctimetuple()
-        )
-        try:
-            published_elt = next(entry_elt.elements(NS_ATOM, "published"))
-        except StopIteration:
-            microblog_data["published"] = microblog_data["updated"]
-        else:
-            microblog_data["published"] = calendar.timegm(
-                dateutil.parser.parse(str(published_elt)).utctimetuple()
-            )
-
-        # links
-        comments = microblog_data['comments'] = []
-        for link_elt in entry_elt.elements(NS_ATOM, "link"):
-            href = link_elt.getAttribute("href")
-            if not href:
-                log.warning(
-                    f'missing href in <link> element: {link_elt.toXml()}'
-                )
-                continue
-            rel = link_elt.getAttribute("rel")
-            if (rel == "replies" and link_elt.getAttribute("title") == "comments"):
-                uri = href
-                comments_data = {
-                    "uri": uri,
-                }
-                try:
-                    comment_service, comment_node = self.parse_comment_url(uri)
-                except Exception as e:
-                    log.warning(f"Can't parse comments url: {e}")
-                    continue
-                else:
-                    comments_data["service"] = comment_service.full()
-                    comments_data["node"] = comment_node
-                comments.append(comments_data)
-            elif rel == "via":
-                try:
-                    repeater_jid = jid.JID(item_elt["publisher"])
-                except (KeyError, RuntimeError):
-                    try:
-                        # we look for stanza element which is at the root, meaning that it
-                        # has not parent
-                        top_elt = item_elt.parent
-                        while top_elt.parent is not None:
-                            top_elt = top_elt.parent
-                        repeater_jid = jid.JID(top_elt["from"])
-                    except (AttributeError, RuntimeError):
-                        # we should always have either the "publisher" attribute or the
-                        # stanza available
-                        log.error(
-                            f"Can't find repeater of the post: {item_elt.toXml()}"
-                        )
-                        continue
-
-                extra["repeated"] = {
-                    "by": repeater_jid.full(),
-                    "uri": href
-                }
-            elif rel in ("related", "enclosure"):
-                attachment: Dict[str, Any] = {
-                    "sources": [{"url": href}]
-                }
-                if rel == "related":
-                    attachment["external"] = True
-                for attr, key in (
-                    ("type", "media_type"),
-                    ("title", "desc"),
-                ):
-                    value = link_elt.getAttribute(attr)
-                    if value:
-                        attachment[key] = value
-                try:
-                    attachment["size"] = int(link_elt.attributes["lenght"])
-                except (KeyError, ValueError):
-                    pass
-                if "media_type" not in attachment:
-                    media_type = guess_type(href, False)[0]
-                    if media_type is not None:
-                        attachment["media_type"] = media_type
-
-                attachments = extra.setdefault("attachments", [])
-                attachments.append(attachment)
-            else:
-                log.warning(
-                    f"Unmanaged link element: {link_elt.toXml()}"
-                )
-
-        # author
-        publisher = item_elt.getAttribute("publisher")
-        try:
-            author_elt = next(entry_elt.elements(NS_ATOM, "author"))
-        except StopIteration:
-            log.debug("Can't find author element in item {}".format(id_))
-        else:
-            # name
-            try:
-                name_elt = next(author_elt.elements(NS_ATOM, "name"))
-            except StopIteration:
-                log.warning(
-                    "No name element found in author element of item {}".format(id_)
-                )
-                author = None
-            else:
-                author = microblog_data["author"] = str(name_elt).strip()
-            # uri
-            try:
-                uri_elt = next(author_elt.elements(NS_ATOM, "uri"))
-            except StopIteration:
-                log.debug(
-                    "No uri element found in author element of item {}".format(id_)
-                )
-                if publisher:
-                    microblog_data["author_jid"] = publisher
-            else:
-                uri = str(uri_elt)
-                if uri.startswith("xmpp:"):
-                    uri = uri[5:]
-                    microblog_data["author_jid"] = uri
-                else:
-                    microblog_data["author_jid"] = (
-                        item_elt.getAttribute("publisher") or ""
-                    )
-                if not author and microblog_data["author_jid"]:
-                    # FIXME: temporary workaround for missing author name, would be
-                    #   better to use directly JID's identity (to be done from frontends?)
-                    try:
-                        microblog_data["author"] = jid.JID(microblog_data["author_jid"]).user
-                    except Exception as e:
-                        log.warning(f"No author name found, and can't parse author jid: {e}")
-
-                if not publisher:
-                    log.debug("No publisher attribute, we can't verify author jid")
-                    microblog_data["author_jid_verified"] = False
-                elif jid.JID(publisher).userhostJID() == jid.JID(uri).userhostJID():
-                    microblog_data["author_jid_verified"] = True
-                else:
-                    if "repeated" not in extra:
-                        log.warning(
-                            "item atom:uri differ from publisher attribute, spoofing "
-                            "attempt ? atom:uri = {} publisher = {}".format(
-                                uri, item_elt.getAttribute("publisher")
-                            )
-                        )
-                    microblog_data["author_jid_verified"] = False
-            # email
-            try:
-                email_elt = next(author_elt.elements(NS_ATOM, "email"))
-            except StopIteration:
-                pass
-            else:
-                microblog_data["author_email"] = str(email_elt)
-
-        if not microblog_data.get("author_jid"):
-            if publisher:
-                microblog_data["author_jid"] = publisher
-                microblog_data["author_jid_verified"] = True
-            else:
-                iq_elt = xml_tools.find_ancestor(item_elt, "iq", C.NS_STREAM)
-                microblog_data["author_jid"] = iq_elt["from"]
-                microblog_data["author_jid_verified"] = False
-
-        # categories
-        categories = [
-            category_elt.getAttribute("term", "")
-            for category_elt in entry_elt.elements(NS_ATOM, "category")
-        ]
-        microblog_data["tags"] = categories
-
-        ## the trigger ##
-        # if other plugins have things to add or change
-        yield self.host.trigger.point(
-            "XEP-0277_item2data", item_elt, entry_elt, microblog_data
-        )
-
-        defer.returnValue(microblog_data)
-
-    async def mb_data_2_entry_elt(self, client, mb_data, item_id, service, node):
-        """Convert a data dict to en entry usable to create an item
-
-        @param mb_data: data dict as given by bridge method.
-        @param item_id(unicode): id of the item to use
-        @param service(jid.JID, None): pubsub service where the item is sent
-            Needed to construct Atom id
-        @param node(unicode): pubsub node where the item is sent
-            Needed to construct Atom id
-        @return: deferred which fire domish.Element
-        """
-        entry_elt = domish.Element((NS_ATOM, "entry"))
-        extra = mb_data.get("extra", {})
-
-        ## language ##
-        if "language" in mb_data:
-            entry_elt[(C.NS_XML, "lang")] = mb_data["language"].strip()
-
-        ## content and title ##
-        synt = self.host.plugins["TEXT_SYNTAXES"]
-
-        for elem_name in ("title", "content"):
-            for type_ in ["", "_rich", "_xhtml"]:
-                attr = f"{elem_name}{type_}"
-                if attr in mb_data:
-                    elem = entry_elt.addElement(elem_name)
-                    if type_:
-                        if type_ == "_rich":  # convert input from current syntax to XHTML
-                            xml_content = await synt.convert(
-                                mb_data[attr], synt.get_current_syntax(client.profile), "XHTML"
-                            )
-                            if f"{elem_name}_xhtml" in mb_data:
-                                raise failure.Failure(
-                                    exceptions.DataError(
-                                        _(
-                                            "Can't have xhtml and rich content at the same time"
-                                        )
-                                    )
-                                )
-                        else:
-                            xml_content = mb_data[attr]
-
-                        div_elt = xml_tools.ElementParser()(
-                            xml_content, namespace=C.NS_XHTML
-                        )
-                        if (
-                            div_elt.name != "div"
-                            or div_elt.uri != C.NS_XHTML
-                            or div_elt.attributes
-                        ):
-                            # we need a wrapping <div/> at the top with XHTML namespace
-                            wrap_div_elt = domish.Element((C.NS_XHTML, "div"))
-                            wrap_div_elt.addChild(div_elt)
-                            div_elt = wrap_div_elt
-                        elem.addChild(div_elt)
-                        elem["type"] = "xhtml"
-                        if elem_name not in mb_data:
-                            # there is raw text content, which is mandatory
-                            # so we create one from xhtml content
-                            elem_txt = entry_elt.addElement(elem_name)
-                            text_content = await self.host.plugins[
-                                "TEXT_SYNTAXES"
-                            ].convert(
-                                xml_content,
-                                self.host.plugins["TEXT_SYNTAXES"].SYNTAX_XHTML,
-                                self.host.plugins["TEXT_SYNTAXES"].SYNTAX_TEXT,
-                                False,
-                            )
-                            elem_txt.addContent(text_content)
-                            elem_txt["type"] = "text"
-
-                    else:  # raw text only needs to be escaped to get HTML-safe sequence
-                        elem.addContent(mb_data[attr])
-                        elem["type"] = "text"
-
-        try:
-            next(entry_elt.elements(NS_ATOM, "title"))
-        except StopIteration:
-            # we have no title element which is mandatory
-            # so we transform content element to title
-            elems = list(entry_elt.elements(NS_ATOM, "content"))
-            if not elems:
-                raise exceptions.DataError(
-                    "There must be at least one content or title element"
-                )
-            for elem in elems:
-                elem.name = "title"
-
-        ## attachments ##
-        attachments = extra.get(C.KEY_ATTACHMENTS)
-        if attachments:
-            for attachment in attachments:
-                try:
-                    url = attachment["url"]
-                except KeyError:
-                    try:
-                        url = next(
-                            s['url'] for s in attachment["sources"] if 'url' in s
-                        )
-                    except (StopIteration, KeyError):
-                        log.warning(
-                            f'"url" missing in attachment, ignoring: {attachment}'
-                        )
-                        continue
-
-                if not url.startswith("http"):
-                    log.warning(f"non HTTP URL in attachment, ignoring: {attachment}")
-                    continue
-                link_elt = entry_elt.addElement("link")
-                # XXX: "uri" is set in self._manage_comments if not already existing
-                link_elt["href"] = url
-                if attachment.get("external", False):
-                    # this is a link to an external data such as a website
-                    link_elt["rel"] = "related"
-                else:
-                    # this is an attached file
-                    link_elt["rel"] = "enclosure"
-                for key, attr in (
-                    ("media_type", "type"),
-                    ("desc", "title"),
-                    ("size", "lenght")
-                ):
-                    value = attachment.get(key)
-                    if value:
-                        link_elt[attr]  = str(value)
-
-        ## author ##
-        author_elt = entry_elt.addElement("author")
-        try:
-            author_name = mb_data["author"]
-        except KeyError:
-            # FIXME: must use better name
-            author_name = client.jid.user
-        author_elt.addElement("name", content=author_name)
-
-        try:
-            author_jid_s = mb_data["author_jid"]
-        except KeyError:
-            author_jid_s = client.jid.userhost()
-        author_elt.addElement("uri", content="xmpp:{}".format(author_jid_s))
-
-        try:
-            author_jid_s = mb_data["author_email"]
-        except KeyError:
-            pass
-
-        ## published/updated time ##
-        current_time = time.time()
-        entry_elt.addElement(
-            "updated", content=utils.xmpp_date(float(mb_data.get("updated", current_time)))
-        )
-        entry_elt.addElement(
-            "published",
-            content=utils.xmpp_date(float(mb_data.get("published", current_time))),
-        )
-
-        ## categories ##
-        for tag in mb_data.get('tags', []):
-            category_elt = entry_elt.addElement("category")
-            category_elt["term"] = tag
-
-        ## id ##
-        entry_id = mb_data.get(
-            "id",
-            xmpp_uri.build_xmpp_uri(
-                "pubsub",
-                path=service.full() if service is not None else client.jid.userhost(),
-                node=node,
-                item=item_id,
-            ),
-        )
-        entry_elt.addElement("id", content=entry_id)  #
-
-        ## comments ##
-        for comments_data in mb_data.get('comments', []):
-            link_elt = entry_elt.addElement("link")
-            # XXX: "uri" is set in self._manage_comments if not already existing
-            link_elt["href"] = comments_data["uri"]
-            link_elt["rel"] = "replies"
-            link_elt["title"] = "comments"
-
-        if "repeated" in extra:
-            try:
-                repeated = extra["repeated"]
-                link_elt = entry_elt.addElement("link")
-                link_elt["rel"] = "via"
-                link_elt["href"] = repeated["uri"]
-            except KeyError as e:
-                log.warning(
-                    f"invalid repeated element({e}): {extra['repeated']}"
-                )
-
-        ## final item building ##
-        item_elt = pubsub.Item(id=item_id, payload=entry_elt)
-
-        ## the trigger ##
-        # if other plugins have things to add or change
-        self.host.trigger.point(
-            "XEP-0277_data2entry", client, mb_data, entry_elt, item_elt
-        )
-
-        return item_elt
-
-    ## publish/preview ##
-
-    def is_comment_node(self, node: str) -> bool:
-        """Indicate if the node is prefixed with comments namespace"""
-        return node.startswith(NS_COMMENT_PREFIX)
-
-    def get_parent_item(self, item_id: str) -> str:
-        """Return parent of a comment node
-
-        @param item_id: a comment node
-        """
-        if not self.is_comment_node(item_id):
-            raise ValueError("This node is not a comment node")
-        return item_id[len(NS_COMMENT_PREFIX):]
-
-    def get_comments_node(self, item_id):
-        """Generate comment node
-
-        @param item_id(unicode): id of the parent item
-        @return (unicode): comment node to use
-        """
-        return f"{NS_COMMENT_PREFIX}{item_id}"
-
-    def get_comments_service(self, client, parent_service=None):
-        """Get prefered PubSub service to create comment node
-
-        @param pubsub_service(jid.JID, None): PubSub service of the parent item
-        @param return((D)jid.JID, None): PubSub service to use
-        """
-        if parent_service is not None:
-            if parent_service.user:
-                # we are on a PEP
-                if parent_service.host == client.jid.host:
-                    #  it's our server, we use already found client.pubsub_service below
-                    pass
-                else:
-                    # other server, let's try to find a non PEP service there
-                    d = self.host.find_service_entity(
-                        client, "pubsub", "service", parent_service
-                    )
-                    d.addCallback(lambda entity: entity or parent_service)
-            else:
-                # parent is already on a normal Pubsub service, we re-use it
-                return defer.succeed(parent_service)
-
-        return defer.succeed(
-            client.pubsub_service if client.pubsub_service is not None else parent_service
-        )
-
-    async def _manage_comments(self, client, mb_data, service, node, item_id, access=None):
-        """Check comments keys in mb_data and create comments node if necessary
-
-        if a comments node metadata is set in the mb_data['comments'] list, it is used
-        otherwise it is generated (if allow_comments is True).
-        @param mb_data(dict): microblog mb_data
-        @param service(jid.JID, None): PubSub service of the parent item
-        @param node(unicode): node of the parent item
-        @param item_id(unicode): id of the parent item
-        @param access(unicode, None): access model
-            None to use same access model as parent item
-        """
-        allow_comments = mb_data.pop("allow_comments", None)
-        if allow_comments is None:
-            if "comments" in mb_data:
-                mb_data["allow_comments"] = True
-            else:
-                # no comments set or requested, nothing to do
-                return
-        elif allow_comments == False:
-            if "comments" in mb_data:
-                log.warning(
-                    "comments are not allowed but there is already a comments node, "
-                    "it may be lost: {uri}".format(
-                        uri=mb_data["comments"]
-                    )
-                )
-                del mb_data["comments"]
-            return
-
-        # we have usually a single comment node, but the spec allow several, so we need to
-        # handle this in a list
-        if len(mb_data.setdefault('comments', [])) == 0:
-            # we need at least one comment node
-            comments_data = {}
-            mb_data['comments'].append({})
-
-        if access is None:
-            # TODO: cache access models per service/node
-            parent_node_config = await self._p.getConfiguration(client, service, node)
-            access = parent_node_config.get(self._p.OPT_ACCESS_MODEL, self._p.ACCESS_OPEN)
-
-        options = {
-            self._p.OPT_ACCESS_MODEL: access,
-            self._p.OPT_MAX_ITEMS: "max",
-            self._p.OPT_PERSIST_ITEMS: 1,
-            self._p.OPT_DELIVER_PAYLOADS: 1,
-            self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
-            # FIXME: would it make sense to restrict publish model to subscribers?
-            self._p.OPT_PUBLISH_MODEL: self._p.ACCESS_OPEN,
-        }
-
-        # if other plugins need to change the options
-        self.host.trigger.point("XEP-0277_comments", client, mb_data, options)
-
-        for comments_data in mb_data['comments']:
-            uri = comments_data.get('uri')
-            comments_node = comments_data.get('node')
-            try:
-                comments_service = jid.JID(comments_data["service"])
-            except KeyError:
-                comments_service = None
-
-            if uri:
-                uri_service, uri_node = self.parse_comment_url(uri)
-                if ((comments_node is not None and comments_node!=uri_node)
-                     or (comments_service is not None and comments_service!=uri_service)):
-                    raise ValueError(
-                        f"Incoherence between comments URI ({uri}) and comments_service "
-                        f"({comments_service}) or comments_node ({comments_node})")
-                comments_data['service'] = comments_service = uri_service
-                comments_data['node'] = comments_node = uri_node
-            else:
-                if not comments_node:
-                    comments_node = self.get_comments_node(item_id)
-                comments_data['node'] = comments_node
-                if comments_service is None:
-                    comments_service = await self.get_comments_service(client, service)
-                    if comments_service is None:
-                        comments_service = client.jid.userhostJID()
-                comments_data['service'] = comments_service
-
-                comments_data['uri'] = xmpp_uri.build_xmpp_uri(
-                    "pubsub",
-                    path=comments_service.full(),
-                    node=comments_node,
-                )
-
-            try:
-                await self._p.createNode(client, comments_service, comments_node, options)
-            except error.StanzaError as e:
-                if e.condition == "conflict":
-                    log.info(
-                        "node {} already exists on service {}".format(
-                            comments_node, comments_service
-                        )
-                    )
-                else:
-                    raise e
-            else:
-                if access == self._p.ACCESS_WHITELIST:
-                    # for whitelist access we need to copy affiliations from parent item
-                    comments_affiliations = await self._p.get_node_affiliations(
-                        client, service, node
-                    )
-                    # …except for "member", that we transform to publisher
-                    # because we wants members to be able to write to comments
-                    for jid_, affiliation in list(comments_affiliations.items()):
-                        if affiliation == "member":
-                            comments_affiliations[jid_] == "publisher"
-
-                    await self._p.set_node_affiliations(
-                        client, comments_service, comments_node, comments_affiliations
-                    )
-
-    def friendly_id(self, data):
-        """Generate a user friendly id from title or content"""
-        # TODO: rich content should be converted to plain text
-        id_base = regex.url_friendly_text(
-            data.get('title')
-            or data.get('title_rich')
-            or data.get('content')
-            or data.get('content_rich')
-            or ''
-        )
-        return f"{id_base}-{token_urlsafe(3)}"
-
-    def _mb_send(self, service, node, data, profile_key):
-        service = jid.JID(service) if service else None
-        node = node if node else NS_MICROBLOG
-        client = self.host.get_client(profile_key)
-        data = data_format.deserialise(data)
-        return defer.ensureDeferred(self.send(client, data, service, node))
-
-    async def send(
-        self,
-        client: SatXMPPEntity,
-        data: dict,
-        service: Optional[jid.JID] = None,
-        node: Optional[str] = NS_MICROBLOG
-    ) -> Optional[str]:
-        """Send XEP-0277's microblog data
-
-        @param data: microblog data (must include at least a "content" or a "title" key).
-            see http://wiki.goffi.org/wiki/Bridge_API_-_Microblogging/en for details
-        @param service: PubSub service where the microblog must be published
-            None to publish on profile's PEP
-        @param node: PubSub node to use (defaut to microblog NS)
-            None is equivalend as using default value
-        @return: ID of the published item
-        """
-        # TODO: check that all data keys are used, this would avoid sending publicly a private message
-        #       by accident (e.g. if group plugin is not loaded, and "group*" key are not used)
-        if service is None:
-            service = client.jid.userhostJID()
-        if node is None:
-            node = NS_MICROBLOG
-
-        item_id = data.get("id")
-        if item_id is None:
-            if data.get("user_friendly_id", True):
-                item_id = self.friendly_id(data)
-            else:
-                item_id = str(shortuuid.uuid())
-
-        try:
-            await self._manage_comments(client, data, service, node, item_id, access=None)
-        except error.StanzaError:
-            log.warning("Can't create comments node for item {}".format(item_id))
-        item = await self.mb_data_2_entry_elt(client, data, item_id, service, node)
-
-        if not await self.host.trigger.async_point(
-            "XEP-0277_send", client, service, node, item, data
-        ):
-            return None
-
-        extra = {}
-        for key in ("encrypted", "encrypted_for", "signed"):
-            value = data.get(key)
-            if value is not None:
-                extra[key] = value
-
-        await self._p.publish(client, service, node, [item], extra=extra)
-        return item_id
-
-    def _mb_repeat(
-            self,
-            service_s: str,
-            node: str,
-            item: str,
-            extra_s: str,
-            profile_key: str
-    ) -> defer.Deferred:
-        service = jid.JID(service_s) if service_s else None
-        node = node if node else NS_MICROBLOG
-        client = self.host.get_client(profile_key)
-        extra = data_format.deserialise(extra_s)
-        d = defer.ensureDeferred(
-            self.repeat(client, item, service, node, extra)
-        )
-        # [repeat] can return None, and we always need a str
-        d.addCallback(lambda ret: ret or "")
-        return d
-
-    async def repeat(
-        self,
-        client: SatXMPPEntity,
-        item: str,
-        service: Optional[jid.JID] = None,
-        node: str = NS_MICROBLOG,
-        extra: Optional[dict] = None,
-    ) -> Optional[str]:
-        """Re-publish a post from somewhere else
-
-        This is a feature often name "share" or "boost", it is generally used to make a
-        publication more visible by sharing it with our own audience
-        """
-        if service is None:
-            service = client.jid.userhostJID()
-
-        # we first get the post to repeat
-        items, __ = await self._p.get_items(
-            client,
-            service,
-            node,
-            item_ids = [item]
-        )
-        if not items:
-            raise exceptions.NotFound(
-                f"no item found at node {node!r} on {service} with ID {item!r}"
-            )
-        item_elt = items[0]
-        try:
-            entry_elt = next(item_elt.elements(NS_ATOM, "entry"))
-        except StopIteration:
-            raise exceptions.DataError(
-                "post to repeat is not a XEP-0277 blog item"
-            )
-
-        # we want to be sure that we have an author element
-        try:
-            author_elt = next(entry_elt.elements(NS_ATOM, "author"))
-        except StopIteration:
-            author_elt = entry_elt.addElement("author")
-
-        try:
-            next(author_elt.elements(NS_ATOM, "name"))
-        except StopIteration:
-            author_elt.addElement("name", content=service.user)
-
-        try:
-            next(author_elt.elements(NS_ATOM, "uri"))
-        except StopIteration:
-            entry_elt.addElement(
-                "uri", content=xmpp_uri.build_xmpp_uri(None, path=service.full())
-            )
-
-        # we add the link indicating that it's a repeated post
-        link_elt = entry_elt.addElement("link")
-        link_elt["rel"] = "via"
-        link_elt["href"] = xmpp_uri.build_xmpp_uri(
-            "pubsub", path=service.full(), node=node, item=item
-        )
-
-        return await self._p.send_item(
-            client,
-            client.jid.userhostJID(),
-            NS_MICROBLOG,
-            entry_elt
-        )
-
-    def _mb_preview(self, service, node, data, profile_key):
-        service = jid.JID(service) if service else None
-        node = node if node else NS_MICROBLOG
-        client = self.host.get_client(profile_key)
-        data = data_format.deserialise(data)
-        d = defer.ensureDeferred(self.preview(client, data, service, node))
-        d.addCallback(data_format.serialise)
-        return d
-
-    async def preview(
-        self,
-        client: SatXMPPEntity,
-        data: dict,
-        service: Optional[jid.JID] = None,
-        node: Optional[str] = NS_MICROBLOG
-    ) -> dict:
-        """Preview microblog data without publishing them
-
-        params are the same as for [send]
-        @return: microblog data as would be retrieved from published item
-        """
-        if node is None:
-            node = NS_MICROBLOG
-
-        item_id = data.get("id", "")
-
-        # we have to serialise then deserialise to be sure that all triggers are called
-        item_elt = await self.mb_data_2_entry_elt(client, data, item_id, service, node)
-        item_elt.uri = pubsub.NS_PUBSUB
-        return await self.item_2_mb_data(client, item_elt, service, node)
-
-
-    ## retract ##
-
-    def _mb_retract(self, service_jid_s, nodeIdentifier, itemIdentifier, profile_key):
-        """Call self._p._retract_item, but use default node if node is empty"""
-        return self._p._retract_item(
-            service_jid_s,
-            nodeIdentifier or NS_MICROBLOG,
-            itemIdentifier,
-            True,
-            profile_key,
-        )
-
-    ## get ##
-
-    def _mb_get_serialise(self, data):
-        items, metadata = data
-        metadata['items'] = items
-        return data_format.serialise(metadata)
-
-    def _mb_get(self, service="", node="", max_items=10, item_ids=None, extra="",
-               profile_key=C.PROF_KEY_NONE):
-        """
-        @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
-        @param item_ids (list[unicode]): list of item IDs
-        """
-        client = self.host.get_client(profile_key)
-        service = jid.JID(service) if service else None
-        max_items = None if max_items == C.NO_LIMIT else max_items
-        extra = self._p.parse_extra(data_format.deserialise(extra))
-        d = defer.ensureDeferred(
-            self.mb_get(client, service, node or None, max_items, item_ids,
-                       extra.rsm_request, extra.extra)
-        )
-        d.addCallback(self._mb_get_serialise)
-        return d
-
-    async def mb_get(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID] = None,
-        node: Optional[str] = None,
-        max_items: Optional[int] = 10,
-        item_ids: Optional[List[str]] = None,
-        rsm_request: Optional[rsm.RSMRequest] = None,
-        extra: Optional[Dict[str, Any]] = None
-    ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
-        """Get some microblogs
-
-        @param service(jid.JID, None): jid of the publisher
-            None to get profile's PEP
-        @param node(unicode, None): node to get (or microblog node if None)
-        @param max_items(int): maximum number of item to get, None for no limit
-            ignored if rsm_request is set
-        @param item_ids (list[unicode]): list of item IDs
-        @param rsm_request (rsm.RSMRequest): RSM request data
-        @param extra (dict): extra data
-
-        @return: a deferred couple with the list of items and metadatas.
-        """
-        if node is None:
-            node = NS_MICROBLOG
-        if rsm_request:
-            max_items = None
-        items_data = await self._p.get_items(
-            client,
-            service,
-            node,
-            max_items=max_items,
-            item_ids=item_ids,
-            rsm_request=rsm_request,
-            extra=extra,
-        )
-        mb_data_list, metadata = await self._p.trans_items_data_d(
-            items_data, partial(self.item_2_mb_data, client, service=service, node=node))
-        encrypted = metadata.pop("encrypted", None)
-        if encrypted is not None:
-            for mb_data in mb_data_list:
-                try:
-                    mb_data["encrypted"] = encrypted[mb_data["id"]]
-                except KeyError:
-                    pass
-        return (mb_data_list, metadata)
-
-    def _mb_rename(self, service, node, item_id, new_id, profile_key):
-        return defer.ensureDeferred(self.mb_rename(
-            self.host.get_client(profile_key),
-            jid.JID(service) if service else None,
-            node or None,
-            item_id,
-            new_id
-        ))
-
-    async def mb_rename(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: Optional[str],
-        item_id: str,
-        new_id: str
-    ) -> None:
-        if not node:
-            node = NS_MICROBLOG
-        await self._p.rename_item(client, service, node, item_id, new_id)
-
-    def parse_comment_url(self, node_url):
-        """Parse a XMPP URI
-
-        Determine the fields comments_service and comments_node of a microblog data
-        from the href attribute of an entry's link element. For example this input:
-        xmpp:sat-pubsub.example.net?;node=urn%3Axmpp%3Acomments%3A_af43b363-3259-4b2a-ba4c-1bc33aa87634__urn%3Axmpp%3Agroupblog%3Asomebody%40example.net
-        will return(JID(u'sat-pubsub.example.net'), 'urn:xmpp:comments:_af43b363-3259-4b2a-ba4c-1bc33aa87634__urn:xmpp:groupblog:somebody@example.net')
-        @return (tuple[jid.JID, unicode]): service and node
-        """
-        try:
-            parsed_url = xmpp_uri.parse_xmpp_uri(node_url)
-            service = jid.JID(parsed_url["path"])
-            node = parsed_url["node"]
-        except Exception as e:
-            raise exceptions.DataError(f"Invalid comments link: {e}")
-
-        return (service, node)
-
-    ## configure ##
-
-    def mb_access_set(self, access="presence", profile_key=C.PROF_KEY_NONE):
-        """Create a microblog node on PEP with given access
-
-        If the node already exists, it change options
-        @param access: Node access model, according to xep-0060 #4.5
-        @param profile_key: profile key
-        """
-        #  FIXME: check if this mehtod is need, deprecate it if not
-        client = self.host.get_client(profile_key)
-
-        _options = {
-            self._p.OPT_ACCESS_MODEL: access,
-            self._p.OPT_MAX_ITEMS: "max",
-            self._p.OPT_PERSIST_ITEMS: 1,
-            self._p.OPT_DELIVER_PAYLOADS: 1,
-            self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
-        }
-
-        def cb(result):
-            # Node is created with right permission
-            log.debug(_("Microblog node has now access %s") % access)
-
-        def fatal_err(s_error):
-            # Something went wrong
-            log.error(_("Can't set microblog access"))
-            raise NodeAccessChangeException()
-
-        def err_cb(s_error):
-            # If the node already exists, the condition is "conflict",
-            # else we have an unmanaged error
-            if s_error.value.condition == "conflict":
-                # d = self.host.plugins["XEP-0060"].deleteNode(client, client.jid.userhostJID(), NS_MICROBLOG)
-                # d.addCallback(lambda x: create_node().addCallback(cb).addErrback(fatal_err))
-                change_node_options().addCallback(cb).addErrback(fatal_err)
-            else:
-                fatal_err(s_error)
-
-        def create_node():
-            return self._p.createNode(
-                client, client.jid.userhostJID(), NS_MICROBLOG, _options
-            )
-
-        def change_node_options():
-            return self._p.setOptions(
-                client.jid.userhostJID(),
-                NS_MICROBLOG,
-                client.jid.userhostJID(),
-                _options,
-                profile_key=profile_key,
-            )
-
-        create_node().addCallback(cb).addErrback(err_cb)
-
-    ## methods to manage several stanzas/jids at once ##
-
-    # common
-
-    def _get_client_and_node_data(self, publishers_type, publishers, profile_key):
-        """Helper method to construct node_data from publishers_type/publishers
-
-        @param publishers_type: type of the list of publishers, one of:
-            C.ALL: get all jids from roster, publishers is not used
-            C.GROUP: get jids from groups
-            C.JID: use publishers directly as list of jids
-        @param publishers: list of publishers, according to "publishers_type" (None,
-            list of groups or list of jids)
-        @param profile_key: %(doc_profile_key)s
-        """
-        client = self.host.get_client(profile_key)
-        if publishers_type == C.JID:
-            jids_set = set(publishers)
-        else:
-            jids_set = client.roster.get_jids_set(publishers_type, publishers)
-            if publishers_type == C.ALL:
-                try:
-                    # display messages from salut-a-toi@libervia.org or other PEP services
-                    services = self.host.plugins["EXTRA-PEP"].get_followed_entities(
-                        profile_key
-                    )
-                except KeyError:
-                    pass  # plugin is not loaded
-                else:
-                    if services:
-                        log.debug(
-                            "Extra PEP followed entities: %s"
-                            % ", ".join([str(service) for service in services])
-                        )
-                        jids_set.update(services)
-
-        node_data = []
-        for jid_ in jids_set:
-            node_data.append((jid_, NS_MICROBLOG))
-        return client, node_data
-
-    def _check_publishers(self, publishers_type, publishers):
-        """Helper method to deserialise publishers coming from bridge
-
-        publishers_type(unicode): type of the list of publishers, one of:
-        publishers: list of publishers according to type
-        @return: deserialised (publishers_type, publishers) tuple
-        """
-        if publishers_type == C.ALL:
-            if publishers:
-                raise failure.Failure(
-                    ValueError(
-                        "Can't use publishers with {} type".format(publishers_type)
-                    )
-                )
-            else:
-                publishers = None
-        elif publishers_type == C.JID:
-            publishers[:] = [jid.JID(publisher) for publisher in publishers]
-        return publishers_type, publishers
-
-    # subscribe #
-
-    def _mb_subscribe_to_many(self, publishers_type, publishers, profile_key):
-        """
-
-        @return (str): session id: Use pubsub.getSubscribeRTResult to get the results
-        """
-        publishers_type, publishers = self._check_publishers(publishers_type, publishers)
-        return self.mb_subscribe_to_many(publishers_type, publishers, profile_key)
-
-    def mb_subscribe_to_many(self, publishers_type, publishers, profile_key):
-        """Subscribe microblogs for a list of groups or jids
-
-        @param publishers_type: type of the list of publishers, one of:
-            C.ALL: get all jids from roster, publishers is not used
-            C.GROUP: get jids from groups
-            C.JID: use publishers directly as list of jids
-        @param publishers: list of publishers, according to "publishers_type" (None, list
-            of groups or list of jids)
-        @param profile: %(doc_profile)s
-        @return (str): session id
-        """
-        client, node_data = self._get_client_and_node_data(
-            publishers_type, publishers, profile_key
-        )
-        return self._p.subscribe_to_many(
-            node_data, client.jid.userhostJID(), profile_key=profile_key
-        )
-
-    # get #
-
-    def _mb_get_from_many_rt_result(self, session_id, profile_key=C.PROF_KEY_DEFAULT):
-        """Get real-time results for mb_get_from_many session
-
-        @param session_id: id of the real-time deferred session
-        @param return (tuple): (remaining, results) where:
-            - remaining is the number of still expected results
-            - results is a list of tuple with
-                - service (unicode): pubsub service
-                - node (unicode): pubsub node
-                - failure (unicode): empty string in case of success, error message else
-                - items_data(list): data as returned by [mb_get]
-                - items_metadata(dict): metadata as returned by [mb_get]
-        @param profile_key: %(doc_profile_key)s
-        """
-
-        client = self.host.get_client(profile_key)
-
-        def onSuccess(items_data):
-            """convert items elements to list of microblog data in items_data"""
-            d = self._p.trans_items_data_d(
-                items_data,
-                # FIXME: service and node should be used here
-                partial(self.item_2_mb_data, client),
-                serialise=True
-            )
-            d.addCallback(lambda serialised: ("", serialised))
-            return d
-
-        d = self._p.get_rt_results(
-            session_id,
-            on_success=onSuccess,
-            on_error=lambda failure: (str(failure.value), ([], {})),
-            profile=client.profile,
-        )
-        d.addCallback(
-            lambda ret: (
-                ret[0],
-                [
-                    (service.full(), node, failure, items, metadata)
-                    for (service, node), (success, (failure, (items, metadata))) in ret[
-                        1
-                    ].items()
-                ],
-            )
-        )
-        return d
-
-    def _mb_get_from_many(self, publishers_type, publishers, max_items=10, extra_dict=None,
-                       profile_key=C.PROF_KEY_NONE):
-        """
-        @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
-        """
-        max_items = None if max_items == C.NO_LIMIT else max_items
-        publishers_type, publishers = self._check_publishers(publishers_type, publishers)
-        extra = self._p.parse_extra(extra_dict)
-        return self.mb_get_from_many(
-            publishers_type,
-            publishers,
-            max_items,
-            extra.rsm_request,
-            extra.extra,
-            profile_key,
-        )
-
-    def mb_get_from_many(self, publishers_type, publishers, max_items=None, rsm_request=None,
-                      extra=None, profile_key=C.PROF_KEY_NONE):
-        """Get the published microblogs for a list of groups or jids
-
-        @param publishers_type (str): type of the list of publishers (one of "GROUP" or
-            "JID" or "ALL")
-        @param publishers (list): list of publishers, according to publishers_type (list
-            of groups or list of jids)
-        @param max_items (int): optional limit on the number of retrieved items.
-        @param rsm_request (rsm.RSMRequest): RSM request data, common to all publishers
-        @param extra (dict): Extra data
-        @param profile_key: profile key
-        @return (str): RT Deferred session id
-        """
-        # XXX: extra is unused here so far
-        client, node_data = self._get_client_and_node_data(
-            publishers_type, publishers, profile_key
-        )
-        return self._p.get_from_many(
-            node_data, max_items, rsm_request, profile_key=profile_key
-        )
-
-    # comments #
-
-    def _mb_get_from_many_with_comments_rt_result_serialise(self, data):
-        """Serialisation of result
-
-        This is probably the longest method name of whole SàT ecosystem ^^
-        @param data(dict): data as received by rt_sessions
-        @return (tuple): see [_mb_get_from_many_with_comments_rt_result]
-        """
-        ret = []
-        data_iter = iter(data[1].items())
-        for (service, node), (success, (failure_, (items_data, metadata))) in data_iter:
-            items = []
-            for item, item_metadata in items_data:
-                item = data_format.serialise(item)
-                items.append((item, item_metadata))
-            ret.append((
-                service.full(),
-                node,
-                failure_,
-                items,
-                metadata))
-
-        return data[0], ret
-
-    def _mb_get_from_many_with_comments_rt_result(self, session_id,
-                                           profile_key=C.PROF_KEY_DEFAULT):
-        """Get real-time results for [mb_get_from_many_with_comments] session
-
-        @param session_id: id of the real-time deferred session
-        @param return (tuple): (remaining, results) where:
-            - remaining is the number of still expected results
-            - results is a list of 5-tuple with
-                - service (unicode): pubsub service
-                - node (unicode): pubsub node
-                - failure (unicode): empty string in case of success, error message else
-                - items(list[tuple(dict, list)]): list of 2-tuple with
-                    - item(dict): item microblog data
-                    - comments_list(list[tuple]): list of 5-tuple with
-                        - service (unicode): pubsub service where the comments node is
-                        - node (unicode): comments node
-                        - failure (unicode): empty in case of success, else error message
-                        - comments(list[dict]): list of microblog data
-                        - comments_metadata(dict): metadata of the comment node
-                - metadata(dict): original node metadata
-        @param profile_key: %(doc_profile_key)s
-        """
-        profile = self.host.get_client(profile_key).profile
-        d = self.rt_sessions.get_results(session_id, profile=profile)
-        d.addCallback(self._mb_get_from_many_with_comments_rt_result_serialise)
-        return d
-
-    def _mb_get_from_many_with_comments(self, publishers_type, publishers, max_items=10,
-                                   max_comments=C.NO_LIMIT, extra_dict=None,
-                                   extra_comments_dict=None, profile_key=C.PROF_KEY_NONE):
-        """
-        @param max_items(int): maximum number of item to get, C.NO_LIMIT for no limit
-        @param max_comments(int): maximum number of comments to get, C.NO_LIMIT for no
-            limit
-        """
-        max_items = None if max_items == C.NO_LIMIT else max_items
-        max_comments = None if max_comments == C.NO_LIMIT else max_comments
-        publishers_type, publishers = self._check_publishers(publishers_type, publishers)
-        extra = self._p.parse_extra(extra_dict)
-        extra_comments = self._p.parse_extra(extra_comments_dict)
-        return self.mb_get_from_many_with_comments(
-            publishers_type,
-            publishers,
-            max_items,
-            max_comments or None,
-            extra.rsm_request,
-            extra.extra,
-            extra_comments.rsm_request,
-            extra_comments.extra,
-            profile_key,
-        )
-
-    def mb_get_from_many_with_comments(self, publishers_type, publishers, max_items=None,
-                                  max_comments=None, rsm_request=None, extra=None,
-                                  rsm_comments=None, extra_comments=None,
-                                  profile_key=C.PROF_KEY_NONE):
-        """Helper method to get the microblogs and their comments in one shot
-
-        @param publishers_type (str): type of the list of publishers (one of "GROUP" or
-            "JID" or "ALL")
-        @param publishers (list): list of publishers, according to publishers_type (list
-            of groups or list of jids)
-        @param max_items (int): optional limit on the number of retrieved items.
-        @param max_comments (int): maximum number of comments to retrieve
-        @param rsm_request (rsm.RSMRequest): RSM request for initial items only
-        @param extra (dict): extra configuration for initial items only
-        @param rsm_comments (rsm.RSMRequest): RSM request for comments only
-        @param extra_comments (dict): extra configuration for comments only
-        @param profile_key: profile key
-        @return (str): RT Deferred session id
-        """
-        # XXX: this method seems complicated because it do a couple of treatments
-        #      to serialise and associate the data, but it make life in frontends side
-        #      a lot easier
-
-        client, node_data = self._get_client_and_node_data(
-            publishers_type, publishers, profile_key
-        )
-
-        def get_comments(items_data):
-            """Retrieve comments and add them to the items_data
-
-            @param items_data: serialised items data
-            @return (defer.Deferred): list of items where each item is associated
-                with a list of comments data (service, node, list of items, metadata)
-            """
-            items, metadata = items_data
-            items_dlist = []  # deferred list for items
-            for item in items:
-                dlist = []  # deferred list for comments
-                for key, value in item.items():
-                    # we look for comments
-                    if key.startswith("comments") and key.endswith("_service"):
-                        prefix = key[: key.find("_")]
-                        service_s = value
-                        service = jid.JID(service_s)
-                        node = item["{}{}".format(prefix, "_node")]
-                        # time to get the comments
-                        d = defer.ensureDeferred(
-                            self._p.get_items(
-                                client,
-                                service,
-                                node,
-                                max_comments,
-                                rsm_request=rsm_comments,
-                                extra=extra_comments,
-                            )
-                        )
-                        # then serialise
-                        d.addCallback(
-                            lambda items_data: self._p.trans_items_data_d(
-                                items_data,
-                                partial(
-                                    self.item_2_mb_data, client, service=service, node=node
-                                ),
-                                serialise=True
-                            )
-                        )
-                        # with failure handling
-                        d.addCallback(
-                            lambda serialised_items_data: ("",) + serialised_items_data
-                        )
-                        d.addErrback(lambda failure: (str(failure.value), [], {}))
-                        # and associate with service/node (needed if there are several
-                        # comments nodes)
-                        d.addCallback(
-                            lambda serialised, service_s=service_s, node=node: (
-                                service_s,
-                                node,
-                            )
-                            + serialised
-                        )
-                        dlist.append(d)
-                # we get the comments
-                comments_d = defer.gatherResults(dlist)
-                # and add them to the item data
-                comments_d.addCallback(
-                    lambda comments_data, item=item: (item, comments_data)
-                )
-                items_dlist.append(comments_d)
-            # we gather the items + comments in a list
-            items_d = defer.gatherResults(items_dlist)
-            # and add the metadata
-            items_d.addCallback(lambda items_completed: (items_completed, metadata))
-            return items_d
-
-        deferreds = {}
-        for service, node in node_data:
-            d = deferreds[(service, node)] = defer.ensureDeferred(self._p.get_items(
-                client, service, node, max_items, rsm_request=rsm_request, extra=extra
-            ))
-            d.addCallback(
-                lambda items_data: self._p.trans_items_data_d(
-                    items_data,
-                    partial(self.item_2_mb_data, client, service=service, node=node),
-                )
-            )
-            d.addCallback(get_comments)
-            d.addCallback(lambda items_comments_data: ("", items_comments_data))
-            d.addErrback(lambda failure: (str(failure.value), ([], {})))
-
-        return self.rt_sessions.new_session(deferreds, client.profile)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0277_handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_MICROBLOG)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0280.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,174 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for managing xep-0280
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from twisted.words.protocols.jabber.error import StanzaError
-from twisted.internet import defer
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-
-
-PARAM_CATEGORY = "Misc"
-PARAM_NAME = "carbon"
-PARAM_LABEL = D_("Message carbons")
-NS_CARBONS = "urn:xmpp:carbons:2"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP-0280 Plugin",
-    C.PI_IMPORT_NAME: "XEP-0280",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0280"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "XEP_0280",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: D_("""Implementation of Message Carbons"""),
-}
-
-
-class XEP_0280(object):
-    #  TODO: param is only checked at profile connection
-    #       activate carbons on param change even after profile connection
-    # TODO: chat state notifications are not handled yet (and potentially other XEPs?)
-
-    params = """
-    <params>
-    <individual>
-    <category name="{category_name}" label="{category_label}">
-        <param name="{param_name}" label="{param_label}" value="true" type="bool" security="0" />
-     </category>
-    </individual>
-    </params>
-    """.format(
-        category_name=PARAM_CATEGORY,
-        category_label=D_(PARAM_CATEGORY),
-        param_name=PARAM_NAME,
-        param_label=PARAM_LABEL,
-    )
-
-    def __init__(self, host):
-        log.info(_("Plugin XEP_0280 initialization"))
-        self.host = host
-        host.memory.update_params(self.params)
-        host.trigger.add("message_received", self.message_received_trigger, priority=200000)
-
-    def get_handler(self, client):
-        return XEP_0280_handler()
-
-    def set_private(self, message_elt):
-        """Add a <private/> element to a message
-
-        this method is intented to be called on final domish.Element by other plugins
-        (in particular end 2 end encryption plugins)
-        @param message_elt(domish.Element): <message> stanza
-        """
-        if message_elt.name != "message":
-            log.error("addPrivateElt must be used with <message> stanzas")
-            return
-        message_elt.addElement((NS_CARBONS, "private"))
-
-    @defer.inlineCallbacks
-    def profile_connected(self, client):
-        """activate message carbons on connection if possible and activated in config"""
-        activate = self.host.memory.param_get_a(
-            PARAM_NAME, PARAM_CATEGORY, profile_key=client.profile
-        )
-        if not activate:
-            log.info(_("Not activating message carbons as requested in params"))
-            return
-        try:
-            yield self.host.check_features(client, (NS_CARBONS,))
-        except exceptions.FeatureNotFound:
-            log.warning(_("server doesn't handle message carbons"))
-        else:
-            log.info(_("message carbons available, enabling it"))
-            iq_elt = client.IQ()
-            iq_elt.addElement((NS_CARBONS, "enable"))
-            try:
-                yield iq_elt.send()
-            except StanzaError as e:
-                log.warning("Can't activate message carbons: {}".format(e))
-            else:
-                log.info(_("message carbons activated"))
-
-    def message_received_trigger(self, client, message_elt, post_treat):
-        """get message and handle it if carbons namespace is present"""
-        carbons_elt = None
-        for e in message_elt.elements():
-            if e.uri == NS_CARBONS:
-                carbons_elt = e
-                break
-
-        if carbons_elt is None:
-            # this is not a message carbons,
-            # we continue normal behaviour
-            return True
-
-        if message_elt["from"] != client.jid.userhost():
-            log.warning(
-                "The message carbon received is not from our server, hack attempt?\n{xml}".format(
-                    xml=message_elt.toXml()
-                )
-            )
-            return
-        forwarded_elt = next(carbons_elt.elements(C.NS_FORWARD, "forwarded"))
-        cc_message_elt = next(forwarded_elt.elements(C.NS_CLIENT, "message"))
-
-        # we replace the wrapping message with the CCed one
-        # and continue the normal behaviour
-        if carbons_elt.name == "received":
-            message_elt["from"] = cc_message_elt["from"]
-        elif carbons_elt.name == "sent":
-            try:
-                message_elt["to"] = cc_message_elt["to"]
-            except KeyError:
-                # we may not have "to" in case of message from ourself (from an other
-                # device)
-                pass
-        else:
-            log.warning(
-                "invalid message carbons received:\n{xml}".format(
-                    xml=message_elt.toXml()
-                )
-            )
-            return False
-
-        del message_elt.children[:]
-        for c in cc_message_elt.children:
-            message_elt.addChild(c)
-
-        return True
-
-@implementer(iwokkel.IDisco)
-class XEP_0280_handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_CARBONS)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0292.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,246 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for XEP-0292
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import List, Dict, Union, Any, Optional
-from functools import partial
-
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.protocols.jabber import jid
-from twisted.words.xish import domish
-from zope.interface import implementer
-from wokkel import disco, iwokkel
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.core_types import SatXMPPEntity
-from sat.core import exceptions
-from sat.tools.common.async_utils import async_lru
-
-
-log = getLogger(__name__)
-
-IMPORT_NAME = "XEP-0292"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "vCard4 Over XMPP",
-    C.PI_IMPORT_NAME: IMPORT_NAME,
-    C.PI_TYPE: C.PLUG_TYPE_XEP,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0292"],
-    C.PI_DEPENDENCIES: ["IDENTITY", "XEP-0060", "XEP-0163"],
-    C.PI_MAIN: "XEP_0292",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""XEP-0292 (vCard4 Over XMPP) implementation"""),
-}
-
-NS_VCARD4 = "urn:ietf:params:xml:ns:vcard-4.0"
-VCARD4_NODE = "urn:xmpp:vcard4"
-text_fields = {
-    "fn": "name",
-    "nickname": "nicknames",
-    "note": "description"
-}
-text_fields_inv = {v: k for k,v in text_fields.items()}
-
-
-class XEP_0292:
-    namespace = NS_VCARD4
-    node = VCARD4_NODE
-
-    def __init__(self, host):
-        # XXX: as of XEP-0292 v0.11, there is a dedicated <IQ/> protocol in this XEP which
-        # should be used according to the XEP. Hovewer it feels like an outdated behaviour
-        # and other clients don't seem to use it. After discussing it on xsf@ MUC, it
-        # seems that implemeting the dedicated <IQ/> protocol is a waste of time, and thus
-        # it is not done here. It is expected that this dedicated protocol will be removed
-        # from a future version of the XEP.
-        log.info(_("vCard4 Over XMPP initialization"))
-        host.register_namespace("vcard4", NS_VCARD4)
-        self.host = host
-        self._p = host.plugins["XEP-0060"]
-        self._i = host.plugins['IDENTITY']
-        self._i.register(
-            IMPORT_NAME,
-            'nicknames',
-            partial(self.getValue, field="nicknames"),
-            partial(self.set_value, field="nicknames"),
-            priority=1000
-        )
-        self._i.register(
-            IMPORT_NAME,
-            'description',
-            partial(self.getValue, field="description"),
-            partial(self.set_value, field="description"),
-            priority=1000
-        )
-
-    def get_handler(self, client):
-        return XEP_0292_Handler()
-
-    def vcard_2_dict(self, vcard_elt: domish.Element) -> Dict[str, Any]:
-        """Convert vcard element to equivalent identity metadata"""
-        vcard: Dict[str, Any] = {}
-
-        for metadata_elt in vcard_elt.elements():
-            # Text values
-            for source_field, dest_field in text_fields.items():
-                if metadata_elt.name == source_field:
-                    if metadata_elt.text is not None:
-                        dest_type = self._i.get_field_type(dest_field)
-                        value = str(metadata_elt.text)
-                        if dest_type is str:
-                            if dest_field in vcard:
-                                vcard[dest_field] +=  value
-                            else:
-                                vcard[dest_field] = value
-                        elif dest_type is list:
-                            vcard.setdefault(dest_field, []).append(value)
-                        else:
-                            raise exceptions.InternalError(
-                                f"unexpected dest_type: {dest_type!r}"
-                            )
-                    break
-            else:
-                log.debug(
-                    f"Following element is currently unmanaged: {metadata_elt.toXml()}"
-                )
-        return vcard
-
-    def dict_2_v_card(self, vcard: dict[str, Any]) -> domish.Element:
-        """Convert vcard metadata to vCard4 element"""
-        vcard_elt = domish.Element((NS_VCARD4, "vcard"))
-        for field, elt_name in text_fields_inv.items():
-            value = vcard.get(field)
-            if value:
-                if isinstance(value, str):
-                    value = [value]
-                if isinstance(value, list):
-                    for v in value:
-                        field_elt = vcard_elt.addElement(elt_name)
-                        field_elt.addElement("text", content=v)
-                else:
-                    log.warning(
-                        f"ignoring unexpected value: {value!r}"
-                    )
-
-        return vcard_elt
-
-    @async_lru(5)
-    async def get_card(self, client: SatXMPPEntity, entity: jid.JID) -> dict:
-        try:
-            items, metadata = await self._p.get_items(
-                client, entity, VCARD4_NODE, item_ids=["current"]
-            )
-        except exceptions.NotFound:
-            log.info(f"No vCard node found for {entity}")
-            return {}
-        item_elt = items[0]
-        try:
-            vcard_elt = next(item_elt.elements(NS_VCARD4, "vcard"))
-        except StopIteration:
-            log.info(f"vCard element is not present for {entity}")
-            return {}
-
-        return self.vcard_2_dict(vcard_elt)
-
-    async def update_vcard_elt(
-        self,
-        client: SatXMPPEntity,
-        vcard_elt: domish.Element,
-        entity: Optional[jid.JID] = None
-    ) -> None:
-        """Update VCard 4 of given entity, create node if doesn't already exist
-
-        @param vcard_elt: whole vCard element to update
-        @param entity: entity for which the vCard must be updated
-            None to update profile's own vCard
-        """
-        service = entity or client.jid.userhostJID()
-        node_options = {
-            self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
-            self._p.OPT_PUBLISH_MODEL: self._p.PUBLISH_MODEL_PUBLISHERS
-        }
-        await self._p.create_if_new_node(client, service, VCARD4_NODE, node_options)
-        await self._p.send_item(
-            client, service, VCARD4_NODE, vcard_elt, item_id=self._p.ID_SINGLETON
-        )
-
-    async def update_v_card(
-        self,
-        client: SatXMPPEntity,
-        vcard: Dict[str, Any],
-        entity: Optional[jid.JID] = None,
-        update: bool = True,
-    ) -> None:
-        """Update VCard 4 of given entity, create node if doesn't already exist
-
-        @param vcard: identity metadata
-        @param entity: entity for which the vCard must be updated
-            None to update profile's own vCard
-        @param update: if True, current vCard will be retrieved and updated with given
-        vcard (thus if False, `vcard` data will fully replace previous one).
-        """
-        service = entity or client.jid.userhostJID()
-        if update:
-            current_vcard = await self.get_card(client, service)
-            current_vcard.update(vcard)
-            vcard = current_vcard
-        vcard_elt = self.dict_2_v_card(vcard)
-        await self.update_vcard_elt(client, vcard_elt, service)
-
-    async def getValue(
-        self,
-        client: SatXMPPEntity,
-        entity: jid.JID,
-        field: str,
-    ) -> Optional[Union[str, List[str]]]:
-        """Return generic value
-
-        @param entity: entity from who the vCard comes
-        @param field: name of the field to get
-            This has to be a string field
-        @return request value
-        """
-        vcard_data = await self.get_card(client, entity)
-        return vcard_data.get(field)
-
-    async def set_value(
-        self,
-        client: SatXMPPEntity,
-        value: Union[str, List[str]],
-        entity: jid.JID,
-        field: str
-    ) -> None:
-        """Set generic value
-
-        @param entity: entity from who the vCard comes
-        @param field: name of the field to get
-            This has to be a string field
-        """
-        await self.update_v_card(client, {field: value}, entity)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0292_Handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_VCARD4)]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0293.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,312 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin
-# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import List
-
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-NS_JINGLE_RTP_RTCP_FB = "urn:xmpp:jingle:apps:rtp:rtcp-fb:0"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Jingle RTP Feedback Negotiation",
-    C.PI_IMPORT_NAME: "XEP-0293",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0293"],
-    C.PI_DEPENDENCIES: ["XEP-0092", "XEP-0166", "XEP-0167"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "XEP_0293",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Jingle RTP Feedback Negotiation"""),
-}
-
-RTCP_FB_KEY = "rtcp-fb"
-
-
-class XEP_0293:
-    def __init__(self, host):
-        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
-        host.trigger.add("XEP-0167_parse_sdp_a", self._parse_sdp_a_trigger)
-        host.trigger.add(
-            "XEP-0167_generate_sdp_content", self._generate_sdp_content_trigger
-        )
-        host.trigger.add("XEP-0167_parse_description", self._parse_description_trigger)
-        host.trigger.add(
-            "XEP-0167_parse_description_payload_type",
-            self._parse_description_payload_type_trigger,
-        )
-        host.trigger.add("XEP-0167_build_description", self._build_description_trigger)
-        host.trigger.add(
-            "XEP-0167_build_description_payload_type",
-            self._build_description_payload_type_trigger,
-        )
-
-    def get_handler(self, client):
-        return XEP_0293_handler()
-
-    ## SDP
-
-    def _parse_sdp_a_trigger(
-        self,
-        attribute: str,
-        parts: List[str],
-        call_data: dict,
-        metadata: dict,
-        media_type: str,
-        application_data: dict,
-        transport_data: dict,
-    ) -> None:
-        """Parse "rtcp-fb" and "rtcp-fb-trr-int" attributes
-
-        @param attribute: The attribute being parsed.
-        @param parts: The list of parts in the attribute.
-        @param call_data: The call data dict.
-        @param metadata: The metadata dict.
-        @param media_type: The media type (e.g., audio, video).
-        @param application_data: The application data dict.
-        @param transport_data: The transport data dict.
-        @param payload_map: The payload map dict.
-        """
-        if attribute == "rtcp-fb":
-            pt_id = parts[0]
-            feedback_type = parts[1]
-
-            feedback_subtype = None
-            parameters = {}
-
-            # Check if there are extra parameters
-            if len(parts) > 2:
-                feedback_subtype = parts[2]
-
-            if len(parts) > 3:
-                for parameter in parts[3:]:
-                    name, _, value = parameter.partition("=")
-                    parameters[name] = value or None
-
-            # Check if this feedback is linked to a payload type
-            if pt_id == "*":
-                # Not linked to a payload type, add to application data
-                application_data.setdefault(RTCP_FB_KEY, []).append(
-                    (feedback_type, feedback_subtype, parameters)
-                )
-            else:
-                payload_types = application_data.get("payload_types", {})
-                try:
-                    payload_type = payload_types[int(pt_id)]
-                except KeyError:
-                    log.warning(
-                        f"Got reference to unknown payload type {pt_id}: "
-                        f"{' '.join(parts)}"
-                    )
-                else:
-                    # Linked to a payload type, add to payload data
-                    payload_type.setdefault(RTCP_FB_KEY, []).append(
-                        (feedback_type, feedback_subtype, parameters)
-                    )
-
-        elif attribute == "rtcp-fb-trr-int":
-            pt_id = parts[0]  # Payload type ID
-            interval = int(parts[1])
-
-            # Check if this interval is linked to a payload type
-            if pt_id == "*":
-                # Not linked to a payload type, add to application data
-                application_data["rtcp-fb-trr-int"] = interval
-            else:
-                payload_types = application_data.get("payload_types", {})
-                try:
-                    payload_type = payload_types[int(pt_id)]
-                except KeyError:
-                    log.warning(
-                        f"Got reference to unknown payload type {pt_id}: "
-                        f"{' '.join(parts)}"
-                    )
-                else:
-                    # Linked to a payload type, add to payload data
-                    payload_type["rtcp-fb-trr-int"] = interval
-
-    def _generate_rtcp_fb_lines(
-        self, data: dict, pt_id: str, sdp_lines: List[str]
-    ) -> None:
-        for type_, subtype, parameters in data.get(RTCP_FB_KEY, []):
-            parameters_strs = [
-                f"{k}={v}" if v is not None else k for k, v in parameters.items()
-            ]
-            parameters_str = " ".join(parameters_strs)
-
-            sdp_line = f"a=rtcp-fb:{pt_id} {type_}"
-            if subtype:
-                sdp_line += f" {subtype}"
-            if parameters_str:
-                sdp_line += f" {parameters_str}"
-            sdp_lines.append(sdp_line)
-
-    def _generate_rtcp_fb_trr_int_lines(
-        self, data: dict, pt_id: str, sdp_lines: List[str]
-    ) -> None:
-        if "rtcp-fb-trr-int" not in data:
-            return
-        sdp_lines.append(f"a=rtcp-fb:{pt_id} trr-int {data['rtcp-fb-trr-int']}")
-
-    def _generate_sdp_content_trigger(
-        self,
-        session: dict,
-        local: bool,
-        content_name: str,
-        content_data: dict,
-        sdp_lines: List[str],
-        application_data: dict,
-        app_data_key: str,
-        media_data: dict,
-        media: str,
-    ) -> None:
-        """Generate SDP attributes "rtcp-fb" and "rtcp-fb-trr-int" from application data.
-
-        @param session: The session data.
-        @param local: Whether this is local or remote content.
-        @param content_name: The name of the content.
-        @param content_data: The data of the content.
-        @param sdp_lines: The list of SDP lines to append to.
-        @param application_data: The application data dict.
-        @param app_data_key: The key for the application data.
-        @param media_data: The media data dict.
-        @param media: The media type (e.g., audio, video).
-        """
-        # Generate lines for application data
-        self._generate_rtcp_fb_lines(application_data, "*", sdp_lines)
-        self._generate_rtcp_fb_trr_int_lines(application_data, "*", sdp_lines)
-
-        # Generate lines for each payload type
-        for pt_id, payload_data in media_data.get("payload_types", {}).items():
-            self._generate_rtcp_fb_lines(payload_data, pt_id, sdp_lines)
-            self._generate_rtcp_fb_trr_int_lines(payload_data, pt_id, sdp_lines)
-
-    ## XML
-
-    def _parse_rtcp_fb_elements(self, parent_elt: domish.Element, data: dict) -> None:
-        """Parse the <rtcp-fb> and <rtcp-fb-trr-int> elements.
-
-        @param parent_elt: The parent domish.Element.
-        @param data: The data dict to populate.
-        """
-        for rtcp_fb_elt in parent_elt.elements(NS_JINGLE_RTP_RTCP_FB, "rtcp-fb"):
-            try:
-                type_ = rtcp_fb_elt["type"]
-                subtype = rtcp_fb_elt.getAttribute("subtype")
-
-                parameters = {}
-                for parameter_elt in rtcp_fb_elt.elements(
-                    NS_JINGLE_RTP_RTCP_FB, "parameter"
-                ):
-                    parameters[parameter_elt["name"]] = parameter_elt.getAttribute(
-                        "value"
-                    )
-
-                data.setdefault(RTCP_FB_KEY, []).append((type_, subtype, parameters))
-            except (KeyError, ValueError) as e:
-                log.warning(f"Error while parsing <rtcp-fb>: {e}\n{rtcp_fb_elt.toXml()}")
-
-        for rtcp_fb_trr_int_elt in parent_elt.elements(
-            NS_JINGLE_RTP_RTCP_FB, "rtcp-fb-trr-int"
-        ):
-            try:
-                interval_value = int(rtcp_fb_trr_int_elt["value"])
-                data.setdefault("rtcp_fb_trr_int", []).append(interval_value)
-            except (KeyError, ValueError) as e:
-                log.warning(
-                    f"Error while parsing <rtcp-fb-trr-int>: {e}\n"
-                    f"{rtcp_fb_trr_int_elt.toXml()}"
-                )
-
-    def _parse_description_trigger(
-        self, desc_elt: domish.Element, media_data: dict
-    ) -> None:
-        """Parse the <rtcp-fb> and <rtcp-fb-trr-int> elements from a description.
-
-        @param desc_elt: The <description> domish.Element.
-        @param media_data: The media data dict to populate.
-        """
-        self._parse_rtcp_fb_elements(desc_elt, media_data)
-
-    def _parse_description_payload_type_trigger(
-        self,
-        desc_elt: domish.Element,
-        media_data: dict,
-        payload_type_elt: domish.Element,
-        payload_type_data: dict,
-    ) -> None:
-        """Parse the <rtcp-fb> and <rtcp-fb-trr-int> elements from a payload type.
-
-        @param desc_elt: The <description> domish.Element.
-        @param media_data: The media data dict.
-        @param payload_type_elt: The <payload-type> domish.Element.
-        @param payload_type_data: The payload type data dict to populate.
-        """
-        self._parse_rtcp_fb_elements(payload_type_elt, payload_type_data)
-
-    def build_rtcp_fb_elements(self, parent_elt: domish.Element, data: dict) -> None:
-        """Helper method to build the <rtcp-fb> and <rtcp-fb-trr-int> elements"""
-        for type_, subtype, parameters in data.get(RTCP_FB_KEY, []):
-            rtcp_fb_elt = parent_elt.addElement((NS_JINGLE_RTP_RTCP_FB, "rtcp-fb"))
-            rtcp_fb_elt["type"] = type_
-            if subtype:
-                rtcp_fb_elt["subtype"] = subtype
-            for name, value in parameters.items():
-                param_elt = rtcp_fb_elt.addElement(name)
-                if value is not None:
-                    param_elt.addContent(str(value))
-
-        if "rtcp-fb-trr-int" in data:
-            rtcp_fb_trr_int_elt = parent_elt.addElement(
-                (NS_JINGLE_RTP_RTCP_FB, "rtcp-fb-trr-int")
-            )
-            rtcp_fb_trr_int_elt["value"] = str(data["rtcp-fb-trr-int"])
-
-    def _build_description_payload_type_trigger(
-        self,
-        desc_elt: domish.Element,
-        media_data: dict,
-        payload_type: dict,
-        payload_type_elt: domish.Element,
-    ) -> None:
-        """Build the <rtcp-fb> and <rtcp-fb-trr-int> elements for a payload type"""
-        self.build_rtcp_fb_elements(payload_type_elt, payload_type)
-
-    def _build_description_trigger(
-        self, desc_elt: domish.Element, media_data: dict, session: dict
-    ) -> None:
-        """Build the <rtcp-fb> and <rtcp-fb-trr-int> elements for a media description"""
-        self.build_rtcp_fb_elements(desc_elt, media_data)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0293_handler(XMPPHandler):
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_JINGLE_RTP_RTCP_FB)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0294.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,253 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin
-# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Dict, List, Optional, Union
-
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-NS_JINGLE_RTP_HDREXT = "urn:xmpp:jingle:apps:rtp:rtp-hdrext:0"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Jingle RTP Header Extensions Negotiation",
-    C.PI_IMPORT_NAME: "XEP-0294",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0294"],
-    C.PI_DEPENDENCIES: ["XEP-0167"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "XEP_0294",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Jingle RTP Header Extensions Negotiation"""),
-}
-
-
-class XEP_0294:
-    def __init__(self, host):
-        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
-        host.trigger.add("XEP-0167_parse_sdp_a", self._parse_sdp_a_trigger)
-        host.trigger.add(
-            "XEP-0167_generate_sdp_content", self._generate_sdp_content_trigger
-        )
-        host.trigger.add("XEP-0167_parse_description", self._parse_description_trigger)
-        host.trigger.add("XEP-0167_build_description", self._build_description_trigger)
-
-    def get_handler(self, client):
-        return XEP_0294_handler()
-
-    def _parse_extmap(self, parts: List[str], application_data: dict) -> None:
-        """Parse an individual extmap line and fill application_data accordingly"""
-        if "/" in parts[0]:
-            id_, direction = parts[0].split("/", 1)
-        else:
-            id_ = parts[0]
-            direction = None
-        uri = parts[1]
-        attributes = parts[2:]
-
-        if direction in (None, "sendrecv"):
-            senders = "both"
-        elif direction == "sendonly":
-            senders = "initiator"
-        elif direction == "recvonly":
-            senders = "responder"
-        elif direction == "inactive":
-            senders = "none"
-        else:
-            log.warning(f"invalid direction for extmap: {direction}")
-            senders = "sendrecv"
-
-        rtp_hdr_ext_data: Dict[str, Union[str, dict]] = {
-            "id": id_,
-            "uri": uri,
-            "senders": senders,
-        }
-
-        if attributes:
-            parameters = {}
-            for attribute in attributes:
-                name, *value = attribute.split("=", 1)
-                parameters[name] = value[0] if value else None
-            rtp_hdr_ext_data["parameters"] = parameters
-
-        application_data.setdefault("rtp-hdrext", {})[id_] = rtp_hdr_ext_data
-
-    def _parse_sdp_a_trigger(
-        self,
-        attribute: str,
-        parts: List[str],
-        call_data: dict,
-        metadata: dict,
-        media_type: str,
-        application_data: Optional[dict],
-        transport_data: dict,
-    ) -> None:
-        """Parse "extmap" and "extmap-allow-mixed" attributes"""
-        if attribute == "extmap":
-            if application_data is None:
-                call_data.setdefault("_extmaps", []).append(parts)
-            else:
-                self._parse_extmap(parts, application_data)
-        elif attribute == "extmap-allow-mixed":
-            if application_data is None:
-                call_data["_extmap-allow-mixed"] = True
-            else:
-                application_data["extmap-allow-mixed"] = True
-        elif (
-            application_data is not None
-            and "_extmaps" in call_data
-            and "rtp-hdrext" not in application_data
-        ):
-            extmaps = call_data.pop("_extmaps")
-            for parts in extmaps:
-                self._parse_extmap(parts, application_data)
-        elif (
-            application_data is not None
-            and "_extmap-allow-mixed" in call_data
-            and "extmap-allow-mixed" not in application_data
-        ):
-            value = call_data.pop("_extmap-allow-mixed")
-            application_data["extmap-allow-mixed"] = value
-
-    def _generate_sdp_content_trigger(
-        self,
-        session: dict,
-        local: bool,
-        idx: int,
-        content_data: dict,
-        sdp_lines: List[str],
-        application_data: dict,
-        app_data_key: str,
-        media_data: dict,
-        media: str,
-    ) -> None:
-        """Generate "extmap" and "extmap-allow-mixed" attributes"""
-        rtp_hdrext_dict = media_data.get("rtp-hdrext", {})
-
-        for id_, ext_data in rtp_hdrext_dict.items():
-            senders = ext_data.get("senders")
-            if senders in (None, "both"):
-                direction = "sendrecv"
-            elif senders == "initiator":
-                direction = "sendonly"
-            elif senders == "responder":
-                direction = "recvonly"
-            elif senders == "none":
-                direction = "inactive"
-            else:
-                raise exceptions.InternalError(
-                    f"Invalid senders value for extmap: {ext_data.get('senders')}"
-                )
-
-            parameters_str = ""
-            if "parameters" in ext_data:
-                parameters_str = " " + " ".join(
-                    f"{k}={v}" if v is not None else f"{k}"
-                    for k, v in ext_data["parameters"].items()
-                )
-
-            sdp_lines.append(
-                f"a=extmap:{id_}/{direction} {ext_data['uri']}{parameters_str}"
-            )
-
-        if media_data.get("extmap-allow-mixed", False):
-            sdp_lines.append("a=extmap-allow-mixed")
-
-    def _parse_description_trigger(
-        self, desc_elt: domish.Element, media_data: dict
-    ) -> None:
-        """Parse the <rtp-hdrext> and <extmap-allow-mixed> elements"""
-        for rtp_hdrext_elt in desc_elt.elements(NS_JINGLE_RTP_HDREXT, "rtp-hdrext"):
-            id_ = rtp_hdrext_elt["id"]
-            uri = rtp_hdrext_elt["uri"]
-            senders = rtp_hdrext_elt.getAttribute("senders", "both")
-            # FIXME: workaround for Movim bug https://github.com/movim/movim/issues/1212
-            if senders in ("sendonly", "recvonly", "sendrecv", "inactive"):
-                log.warning("Movim bug workaround for wrong extmap value")
-                if senders == "sendonly":
-                    senders = "initiator"
-                elif senders == "recvonly":
-                    senders = "responder"
-                elif senders == "sendrecv":
-                    senders = "both"
-                else:
-                    senders = "none"
-
-            media_data.setdefault("rtp-hdrext", {})[id_] = {
-                "id": id_,
-                "uri": uri,
-                "senders": senders,
-            }
-
-            parameters = {}
-            for param_elt in rtp_hdrext_elt.elements(NS_JINGLE_RTP_HDREXT, "parameter"):
-                try:
-                    parameters[param_elt["name"]] = param_elt.getAttribute("value")
-                except KeyError:
-                    log.warning(f"invalid parameters (missing name): {param_elt.toXml()}")
-
-            if parameters:
-                media_data["rtp-hdrext"][id_]["parameters"] = parameters
-
-        try:
-            next(desc_elt.elements(NS_JINGLE_RTP_HDREXT, "extmap-allow-mixed"))
-        except StopIteration:
-            pass
-        else:
-            media_data["extmap-allow-mixed"] = True
-
-    def _build_description_trigger(
-        self, desc_elt: domish.Element, media_data: dict, session: dict
-    ) -> None:
-        """Build the <rtp-hdrext> and <extmap-allow-mixed> elements if possible"""
-        for id_, hdrext_data in media_data.get("rtp-hdrext", {}).items():
-            rtp_hdrext_elt = desc_elt.addElement((NS_JINGLE_RTP_HDREXT, "rtp-hdrext"))
-            rtp_hdrext_elt["id"] = id_
-            rtp_hdrext_elt["uri"] = hdrext_data["uri"]
-            senders = hdrext_data.get("senders", "both")
-            if senders != "both":
-                # we must not set "both" senders otherwise calls will fail with Movim due
-                # to https://github.com/movim/movim/issues/1213
-                rtp_hdrext_elt["senders"] = senders
-
-            for name, value in hdrext_data.get("parameters", {}).items():
-                param_elt = rtp_hdrext_elt.addElement((NS_JINGLE_RTP_HDREXT, "parameter"))
-                param_elt["name"] = name
-                if value is not None:
-                    param_elt["value"] = value
-
-        if media_data.get("extmap-allow-mixed", False):
-            desc_elt.addElement((NS_JINGLE_RTP_HDREXT, "extmap-allow-mixed"))
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0294_handler(XMPPHandler):
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_JINGLE_RTP_HDREXT)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0297.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,124 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Stanza Forwarding (XEP-0297)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger
-
-from twisted.internet import defer
-
-log = getLogger(__name__)
-
-from wokkel import disco, iwokkel
-
-try:
-    from twisted.words.protocols.xmlstream import XMPPHandler
-except ImportError:
-    from wokkel.subprotocols import XMPPHandler
-from zope.interface import implementer
-
-from twisted.words.xish import domish
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Stanza Forwarding",
-    C.PI_IMPORT_NAME: "XEP-0297",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0297"],
-    C.PI_MAIN: "XEP_0297",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: D_("""Implementation of Stanza Forwarding"""),
-}
-
-
-class XEP_0297(object):
-    # FIXME: check this implementation which doesn't seems to be used
-
-    def __init__(self, host):
-        log.info(_("Stanza Forwarding plugin initialization"))
-        self.host = host
-
-    def get_handler(self, client):
-        return XEP_0297_handler(self, client.profile)
-
-    @classmethod
-    def update_uri(cls, element, uri):
-        """Update recursively the element URI.
-
-        @param element (domish.Element): element to update
-        @param uri (unicode): new URI
-        """
-        # XXX: we need this because changing the URI of an existing element
-        # containing children doesn't update the children's blank URI.
-        element.uri = uri
-        element.defaultUri = uri
-        for child in element.children:
-            if isinstance(child, domish.Element) and not child.uri:
-                XEP_0297.update_uri(child, uri)
-
-    def forward(self, stanza, to_jid, stamp, body="", profile_key=C.PROF_KEY_NONE):
-        """Forward a message to the given JID.
-
-        @param stanza (domish.Element): original stanza to be forwarded.
-        @param to_jid (JID): recipient JID.
-        @param stamp (datetime): offset-aware timestamp of the original reception.
-        @param body (unicode): optional description.
-        @param profile_key (unicode): %(doc_profile_key)s
-        @return: a Deferred when the message has been sent
-        """
-        # FIXME: this method is not used and doesn't use mess_data which should be used for client.send_message_data
-        #        should it be deprecated? A method constructing the element without sending it seems more natural
-        log.warning(
-            "THIS METHOD IS DEPRECATED"
-        )  #  FIXME: we use this warning until we check the method
-        msg = domish.Element((None, "message"))
-        msg["to"] = to_jid.full()
-        msg["type"] = stanza["type"]
-
-        body_elt = domish.Element((None, "body"))
-        if body:
-            body_elt.addContent(body)
-
-        forwarded_elt = domish.Element((C.NS_FORWARD, "forwarded"))
-        delay_elt = self.host.plugins["XEP-0203"].delay(stamp)
-        forwarded_elt.addChild(delay_elt)
-        if not stanza.uri:  # None or ''
-            XEP_0297.update_uri(stanza, "jabber:client")
-        forwarded_elt.addChild(stanza)
-
-        msg.addChild(body_elt)
-        msg.addChild(forwarded_elt)
-
-        client = self.host.get_client(profile_key)
-        return defer.ensureDeferred(client.send_message_data({"xml": msg}))
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0297_handler(XMPPHandler):
-
-    def __init__(self, plugin_parent, profile):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-        self.profile = profile
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(C.NS_FORWARD)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0300.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,228 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Hash functions (XEP-0300)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Tuple
-import base64
-from collections import OrderedDict
-import hashlib
-
-from twisted.internet import threads
-from twisted.internet import defer
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Cryptographic Hash Functions",
-    C.PI_IMPORT_NAME: "XEP-0300",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0300"],
-    C.PI_MAIN: "XEP_0300",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Management of cryptographic hashes"""),
-}
-
-NS_HASHES = "urn:xmpp:hashes:2"
-NS_HASHES_FUNCTIONS = "urn:xmpp:hash-function-text-names:{}"
-BUFFER_SIZE = 2 ** 12
-ALGO_DEFAULT = "sha-256"
-
-
-class XEP_0300(object):
-    # TODO: add blake after moving to Python 3
-    ALGOS = OrderedDict(
-        (
-            ("md5", hashlib.md5),
-            ("sha-1", hashlib.sha1),
-            ("sha-256", hashlib.sha256),
-            ("sha-512", hashlib.sha512),
-        )
-    )
-    ALGO_DEFAULT = ALGO_DEFAULT
-
-    def __init__(self, host):
-        log.info(_("plugin Hashes initialization"))
-        host.register_namespace("hashes", NS_HASHES)
-
-    def get_handler(self, client):
-        return XEP_0300_handler()
-
-    def get_hasher(self, algo=ALGO_DEFAULT):
-        """Return hasher instance
-
-        @param algo(unicode): one of the XEP_300.ALGOS keys
-        @return (hash object): same object s in hashlib.
-           update method need to be called for each chunh
-           diget or hexdigest can be used at the end
-        """
-        return self.ALGOS[algo]()
-
-    def get_default_algo(self):
-        return ALGO_DEFAULT
-
-    @defer.inlineCallbacks
-    def get_best_peer_algo(self, to_jid, profile):
-        """Return the best available hashing algorith of other peer
-
-         @param to_jid(jid.JID): peer jid
-         @parm profile: %(doc_profile)s
-         @return (D(unicode, None)): best available algorithm,
-            or None if hashing is not possible
-        """
-        client = self.host.get_client(profile)
-        for algo in reversed(XEP_0300.ALGOS):
-            has_feature = yield self.host.hasFeature(
-                client, NS_HASHES_FUNCTIONS.format(algo), to_jid
-            )
-            if has_feature:
-                log.debug(
-                    "Best hashing algorithm found for {jid}: {algo}".format(
-                        jid=to_jid.full(), algo=algo
-                    )
-                )
-                defer.returnValue(algo)
-
-    def _calculate_hash_blocking(self, file_obj, hasher):
-        """Calculate hash in a blocking way
-
-        /!\\ blocking method, please use calculate_hash instead
-        @param file_obj(file): a file-like object
-        @param hasher(hash object): the method to call to initialise hash object
-        @return (str): the hex digest of the hash
-        """
-        while True:
-            buf = file_obj.read(BUFFER_SIZE)
-            if not buf:
-                break
-            hasher.update(buf)
-        return hasher.hexdigest()
-
-    def calculate_hash(self, file_obj, hasher):
-        return threads.deferToThread(self._calculate_hash_blocking, file_obj, hasher)
-
-    def calculate_hash_elt(self, file_obj=None, algo=ALGO_DEFAULT):
-        """Compute hash and build hash element
-
-        @param file_obj(file, None): file-like object to use to calculate the hash
-        @param algo(unicode): algorithme to use, must be a key of XEP_0300.ALGOS
-        @return (D(domish.Element)): hash element
-        """
-
-        def hash_calculated(hash_):
-            return self.build_hash_elt(hash_, algo)
-
-        hasher = self.get_hasher(algo)
-        hash_d = self.calculate_hash(file_obj, hasher)
-        hash_d.addCallback(hash_calculated)
-        return hash_d
-
-    def build_hash_used_elt(self, algo=ALGO_DEFAULT):
-        hash_used_elt = domish.Element((NS_HASHES, "hash-used"))
-        hash_used_elt["algo"] = algo
-        return hash_used_elt
-
-    def parse_hash_used_elt(self, parent):
-        """Find and parse a hash-used element
-
-        @param (domish.Element): parent of <hash/> element
-        @return (unicode): hash algorithm used
-        @raise exceptions.NotFound: the element is not present
-        @raise exceptions.DataError: the element is invalid
-        """
-        try:
-            hash_used_elt = next(parent.elements(NS_HASHES, "hash-used"))
-        except StopIteration:
-            raise exceptions.NotFound
-        algo = hash_used_elt["algo"]
-        if not algo:
-            raise exceptions.DataError
-        return algo
-
-    def build_hash_elt(self, hash_, algo=ALGO_DEFAULT):
-        """Compute hash and build hash element
-
-        @param hash_(str): hash to use
-        @param algo(unicode): algorithme to use, must be a key of XEP_0300.ALGOS
-        @return (domish.Element): computed hash
-        """
-        assert hash_
-        assert algo
-        hash_elt = domish.Element((NS_HASHES, "hash"))
-        if hash_ is not None:
-            b64_hash = base64.b64encode(hash_.encode('utf-8')).decode('utf-8')
-            hash_elt.addContent(b64_hash)
-        hash_elt["algo"] = algo
-        return hash_elt
-
-    def parse_hash_elt(self, parent: domish.Element) -> Tuple[str, bytes]:
-        """Find and parse a hash element
-
-        if multiple elements are found, the strongest managed one is returned
-        @param parent: parent of <hash/> element
-        @return: (algo, hash) tuple
-            both values can be None if <hash/> is empty
-        @raise exceptions.NotFound: the element is not present
-        @raise exceptions.DataError: the element is invalid
-        """
-        algos = list(XEP_0300.ALGOS.keys())
-        hash_elt = None
-        best_algo = None
-        best_value = None
-        for hash_elt in parent.elements(NS_HASHES, "hash"):
-            algo = hash_elt.getAttribute("algo")
-            try:
-                idx = algos.index(algo)
-            except ValueError:
-                log.warning(f"Proposed {algo} algorithm is not managed")
-                algo = None
-                continue
-
-            if best_algo is None or algos.index(best_algo) < idx:
-                best_algo = algo
-                best_value = base64.b64decode(str(hash_elt)).decode('utf-8')
-
-        if not hash_elt:
-            raise exceptions.NotFound
-        if not best_algo or not best_value:
-            raise exceptions.DataError
-        return best_algo, best_value
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0300_handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        hash_functions_names = [
-            disco.DiscoFeature(NS_HASHES_FUNCTIONS.format(algo))
-            for algo in XEP_0300.ALGOS
-        ]
-        return [disco.DiscoFeature(NS_HASHES)] + hash_functions_names
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0313.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,459 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Message Archive Management (XEP-0313)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.tools.common import data_format
-from twisted.words.protocols.jabber import jid
-from twisted.internet import defer
-from zope.interface import implementer
-from datetime import datetime
-from dateutil import tz
-from wokkel import disco
-from wokkel import data_form
-import uuid
-
-# XXX: mam and rsm come from sat_tmp.wokkel
-from wokkel import rsm
-from wokkel import mam
-
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Message Archive Management",
-    C.PI_IMPORT_NAME: "XEP-0313",
-    C.PI_TYPE: "XEP",
-    # XEP-0431 only defines a namespace, so we register it here
-    C.PI_PROTOCOLS: ["XEP-0313", "XEP-0431"],
-    C.PI_DEPENDENCIES: ["XEP-0059", "XEP-0359"],
-    C.PI_MAIN: "XEP_0313",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Message Archive Management"""),
-}
-
-MAM_PREFIX = "mam_"
-FILTER_PREFIX = MAM_PREFIX + "filter_"
-KEY_LAST_STANZA_ID = "last_stanza_id"
-MESSAGE_RESULT = "/message/result[@xmlns='{mam_ns}' and @queryid='{query_id}']"
-MESSAGE_STANZA_ID = '/message/stanza-id[@xmlns="{ns_stanza_id}"]'
-NS_FTS = "urn:xmpp:fulltext:0"
-
-
-class XEP_0313(object):
-    def __init__(self, host):
-        log.info(_("Message Archive Management plugin initialization"))
-        self.host = host
-        self.host.register_namespace("mam", mam.NS_MAM)
-        host.register_namespace("fulltextmam", NS_FTS)
-        self._rsm = host.plugins["XEP-0059"]
-        self._sid = host.plugins["XEP-0359"]
-        # Deferred used to store last stanza id in order of reception
-        self._last_stanza_id_d = defer.Deferred()
-        self._last_stanza_id_d.callback(None)
-        host.bridge.add_method(
-            "mam_get", ".plugin", in_sign='sss',
-            out_sign='(a(sdssa{ss}a{ss}ss)ss)', method=self._get_archives,
-            async_=True)
-
-    async def resume(self, client):
-        """Retrieve one2one messages received since the last we have in local storage"""
-        stanza_id_data = await self.host.memory.storage.get_privates(
-            mam.NS_MAM, [KEY_LAST_STANZA_ID], profile=client.profile)
-        stanza_id = stanza_id_data.get(KEY_LAST_STANZA_ID)
-        rsm_req = None
-        if stanza_id is None:
-            log.info("can't retrieve last stanza ID, checking history")
-            last_mess = await self.host.memory.history_get(
-                None, None, limit=1, filters={'not_types': C.MESS_TYPE_GROUPCHAT,
-                                              'last_stanza_id': True},
-                profile=client.profile)
-            if not last_mess:
-                log.info(_("It seems that we have no MAM history yet"))
-                stanza_id = None
-                rsm_req = rsm.RSMRequest(max_=50, before="")
-            else:
-                stanza_id = last_mess[0][-1]['stanza_id']
-        if rsm_req is None:
-            rsm_req = rsm.RSMRequest(max_=100, after=stanza_id)
-        mam_req = mam.MAMRequest(rsm_=rsm_req)
-        complete = False
-        count = 0
-        while not complete:
-            mam_data = await self.get_archives(client, mam_req,
-                                              service=client.jid.userhostJID())
-            elt_list, rsm_response, mam_response = mam_data
-            complete = mam_response["complete"]
-            # we update MAM request for next iteration
-            mam_req.rsm.after = rsm_response.last
-            # before may be set if we had no previous history
-            mam_req.rsm.before = None
-            if not elt_list:
-                break
-            else:
-                count += len(elt_list)
-
-            for mess_elt in elt_list:
-                try:
-                    fwd_message_elt = self.get_message_from_result(
-                        client, mess_elt, mam_req)
-                except exceptions.DataError:
-                    continue
-
-                try:
-                    destinee = jid.JID(fwd_message_elt['to'])
-                except KeyError:
-                    log.warning(_('missing "to" attribute in forwarded message'))
-                    destinee = client.jid
-                if destinee.userhostJID() == client.jid.userhostJID():
-                    # message to use, we insert the forwarded message in the normal
-                    # workflow
-                    client.xmlstream.dispatch(fwd_message_elt)
-                else:
-                    # this message should be from us, we just add it to history
-                    try:
-                        from_jid = jid.JID(fwd_message_elt['from'])
-                    except KeyError:
-                        log.warning(_('missing "from" attribute in forwarded message'))
-                        from_jid = client.jid
-                    if from_jid.userhostJID() != client.jid.userhostJID():
-                        log.warning(_(
-                            'was expecting a message sent by our jid, but this one if '
-                            'from {from_jid}, ignoring\n{xml}').format(
-                                from_jid=from_jid.full(), xml=mess_elt.toXml()))
-                        continue
-                    # adding message to history
-                    mess_data = client.messageProt.parse_message(fwd_message_elt)
-                    try:
-                        await client.messageProt.add_to_history(mess_data)
-                    except exceptions.CancelError as e:
-                        log.warning(
-                            "message has not been added to history: {e}".format(e=e))
-                    except Exception as e:
-                        log.error(
-                            "can't add message to history: {e}\n{xml}"
-                            .format(e=e, xml=mess_elt.toXml()))
-
-        if not count:
-            log.info(_("We have received no message while offline"))
-        else:
-            log.info(_("We have received {num_mess} message(s) while offline.")
-                .format(num_mess=count))
-
-    def profile_connected(self, client):
-        defer.ensureDeferred(self.resume(client))
-
-    def get_handler(self, client):
-        mam_client = client._mam = SatMAMClient(self)
-        return mam_client
-
-    def parse_extra(self, extra, with_rsm=True):
-        """Parse extra dictionnary to retrieve MAM arguments
-
-        @param extra(dict): data for parse
-        @param with_rsm(bool): if True, RSM data will be parsed too
-        @return (data_form, None): request with parsed arguments
-            or None if no MAM arguments have been found
-        """
-        mam_args = {}
-        form_args = {}
-        for arg in ("start", "end"):
-            try:
-                value = extra.pop(MAM_PREFIX + arg)
-                form_args[arg] = datetime.fromtimestamp(float(value), tz.tzutc())
-            except (TypeError, ValueError):
-                log.warning("Bad value for {arg} filter ({value}), ignoring".format(
-                    arg=arg, value=value))
-            except KeyError:
-                continue
-
-        try:
-            form_args["with_jid"] = jid.JID(extra.pop(
-                MAM_PREFIX + "with"))
-        except (jid.InvalidFormat):
-            log.warning("Bad value for jid filter")
-        except KeyError:
-            pass
-
-        for name, value in extra.items():
-            if name.startswith(FILTER_PREFIX):
-                var = name[len(FILTER_PREFIX):]
-                extra_fields = form_args.setdefault("extra_fields", [])
-                extra_fields.append(data_form.Field(var=var, value=value))
-
-        for arg in ("node", "query_id"):
-            try:
-                value = extra.pop(MAM_PREFIX + arg)
-                mam_args[arg] = value
-            except KeyError:
-                continue
-
-        if with_rsm:
-            rsm_request = self._rsm.parse_extra(extra)
-            if rsm_request is not None:
-                mam_args["rsm_"] = rsm_request
-
-        if form_args:
-            mam_args["form"] = mam.buildForm(**form_args)
-
-        # we only set orderBy if we have other MAM args
-        # else we would make a MAM query while it's not expected
-        if "order_by" in extra and mam_args:
-            order_by = extra.pop("order_by")
-            assert isinstance(order_by, list)
-            mam_args["orderBy"] = order_by
-
-        return mam.MAMRequest(**mam_args) if mam_args else None
-
-    def get_message_from_result(self, client, mess_elt, mam_req, service=None):
-        """Extract usable <message/> from MAM query result
-
-        The message will be validated, and stanza-id/delay will be added if necessary.
-        @param mess_elt(domish.Element): result <message/> element wrapping the message
-            to retrieve
-        @param mam_req(mam.MAMRequest): request used (needed to get query_id)
-        @param service(jid.JID, None): MAM service where the request has been sent
-            None if it's user server
-        @return domish.Element): <message/> that can be used directly with onMessage
-        """
-        if mess_elt.name != "message":
-            log.warning("unexpected stanza in archive: {xml}".format(
-                xml=mess_elt.toXml()))
-            raise exceptions.DataError("Invalid element")
-        service_jid = client.jid.userhostJID() if service is None else service
-        mess_from = mess_elt["from"]
-        # we check that the message has been sent by the right service
-        # if service is None (i.e. message expected from our own server)
-        # from can be server jid or user's bare jid
-        if (mess_from != service_jid.full()
-            and not (service is None and mess_from == client.jid.host)):
-            log.error("Message is not from our server, something went wrong: "
-                      "{xml}".format(xml=mess_elt.toXml()))
-            raise exceptions.DataError("Invalid element")
-        try:
-            result_elt = next(mess_elt.elements(mam.NS_MAM, "result"))
-            forwarded_elt = next(result_elt.elements(C.NS_FORWARD, "forwarded"))
-            try:
-                delay_elt = next(forwarded_elt.elements(C.NS_DELAY, "delay"))
-            except StopIteration:
-                # delay_elt is not mandatory
-                delay_elt = None
-            fwd_message_elt = next(forwarded_elt.elements(C.NS_CLIENT, "message"))
-        except StopIteration:
-            log.warning("Invalid message received from MAM: {xml}".format(
-                xml=mess_elt.toXml()))
-            raise exceptions.DataError("Invalid element")
-        else:
-            if not result_elt["queryid"] == mam_req.query_id:
-                log.error("Unexpected query id (was expecting {query_id}): {xml}"
-                    .format(query_id=mam.query_id, xml=mess_elt.toXml()))
-                raise exceptions.DataError("Invalid element")
-            stanza_id = self._sid.get_stanza_id(fwd_message_elt,
-                                              service_jid)
-            if stanza_id is None:
-                # not stanza-id element is present, we add one so message
-                # will be archived with it, and we won't request several times
-                # the same MAM achive
-                try:
-                    stanza_id = result_elt["id"]
-                except AttributeError:
-                    log.warning('Invalid MAM result: missing "id" attribute: {xml}'
-                                .format(xml=result_elt.toXml()))
-                    raise exceptions.DataError("Invalid element")
-                self._sid.add_stanza_id(client, fwd_message_elt, stanza_id, by=service_jid)
-
-            if delay_elt is not None:
-                fwd_message_elt.addChild(delay_elt)
-
-            return fwd_message_elt
-
-    def queryFields(self, client, service=None):
-        """Ask the server about supported fields.
-
-        @param service: entity offering the MAM service (None for user archives)
-        @return (D(data_form.Form)): form with the implemented fields (cf XEP-0313 §4.1.5)
-        """
-        return client._mam.queryFields(service)
-
-    def queryArchive(self, client, mam_req, service=None):
-        """Query a user, MUC or pubsub archive.
-
-        @param mam_req(mam.MAMRequest): MAM query instance
-        @param service(jid.JID, None): entity offering the MAM service
-            None for user server
-        @return (D(domish.Element)): <IQ/> result
-        """
-        return client._mam.queryArchive(mam_req, service)
-
-    def _append_message(self, elt_list, message_cb, message_elt):
-        if message_cb is not None:
-            elt_list.append(message_cb(message_elt))
-        else:
-            elt_list.append(message_elt)
-
-    def _query_finished(self, iq_result, client, elt_list, event):
-        client.xmlstream.removeObserver(event, self._append_message)
-        try:
-            fin_elt = next(iq_result.elements(mam.NS_MAM, "fin"))
-        except StopIteration:
-            raise exceptions.DataError("Invalid MAM result")
-
-        mam_response = {"complete": C.bool(fin_elt.getAttribute("complete", C.BOOL_FALSE)),
-                        "stable": C.bool(fin_elt.getAttribute("stable", C.BOOL_TRUE))}
-
-        try:
-            rsm_response = rsm.RSMResponse.fromElement(fin_elt)
-        except rsm.RSMNotFoundError:
-            rsm_response = None
-
-        return (elt_list, rsm_response, mam_response)
-
-    def serialize_archive_result(self, data, client, mam_req, service):
-        elt_list, rsm_response, mam_response = data
-        mess_list = []
-        for elt in elt_list:
-            fwd_message_elt = self.get_message_from_result(client, elt, mam_req,
-                                                        service=service)
-            mess_data = client.messageProt.parse_message(fwd_message_elt)
-            mess_list.append(client.message_get_bridge_args(mess_data))
-        metadata = {
-            'rsm': self._rsm.response2dict(rsm_response),
-            'mam': mam_response
-        }
-        return mess_list, data_format.serialise(metadata), client.profile
-
-    def _get_archives(self, service, extra_ser, profile_key):
-        """
-        @return: tuple with:
-            - list of message with same data as in bridge.message_new
-            - response metadata with:
-                - rsm data (first, last, count, index)
-                - mam data (complete, stable)
-            - profile
-        """
-        client = self.host.get_client(profile_key)
-        service = jid.JID(service) if service else None
-        extra = data_format.deserialise(extra_ser, {})
-        mam_req = self.parse_extra(extra)
-
-        d = self.get_archives(client, mam_req, service=service)
-        d.addCallback(self.serialize_archive_result, client, mam_req, service)
-        return d
-
-    def get_archives(self, client, query, service=None, message_cb=None):
-        """Query archive and gather page result
-
-        @param query(mam.MAMRequest): MAM request
-        @param service(jid.JID, None): MAM service to use
-            None to use our own server
-        @param message_cb(callable, None): callback to use on each message
-            this method can be used to unwrap messages
-        @return (tuple[list[domish.Element], rsm.RSMResponse, dict): result data with:
-            - list of found elements
-            - RSM response
-            - MAM response, which is a dict with following value:
-                - complete: a boolean which is True if all items have been received
-                - stable: a boolean which is False if items order may be changed
-        """
-        if query.query_id is None:
-            query.query_id = str(uuid.uuid4())
-        elt_list = []
-        event = MESSAGE_RESULT.format(mam_ns=mam.NS_MAM, query_id=query.query_id)
-        client.xmlstream.addObserver(event, self._append_message, 0, elt_list, message_cb)
-        d = self.queryArchive(client, query, service)
-        d.addCallback(self._query_finished, client, elt_list, event)
-        return d
-
-    def get_prefs(self, client, service=None):
-        """Retrieve the current user preferences.
-
-        @param service: entity offering the MAM service (None for user archives)
-        @return: the server response as a Deferred domish.Element
-        """
-        # http://xmpp.org/extensions/xep-0313.html#prefs
-        return client._mam.queryPrefs(service)
-
-    def _set_prefs(self, service_s=None, default="roster", always=None, never=None,
-                  profile_key=C.PROF_KEY_NONE):
-        service = jid.JID(service_s) if service_s else None
-        always_jid = [jid.JID(entity) for entity in always]
-        never_jid = [jid.JID(entity) for entity in never]
-        # TODO: why not build here a MAMPrefs object instead of passing the args separately?
-        return self.setPrefs(service, default, always_jid, never_jid, profile_key)
-
-    def setPrefs(self, client, service=None, default="roster", always=None, never=None):
-        """Set news user preferences.
-
-        @param service: entity offering the MAM service (None for user archives)
-        @param default (unicode): a value in ('always', 'never', 'roster')
-        @param always (list): a list of JID instances
-        @param never (list): a list of JID instances
-        @param profile_key (unicode): %(doc_profile_key)s
-        @return: the server response as a Deferred domish.Element
-        """
-        # http://xmpp.org/extensions/xep-0313.html#prefs
-        return client._mam.setPrefs(service, default, always, never)
-
-    def on_message_stanza_id(self, message_elt, client):
-        """Called when a message with a stanza-id is received
-
-        the messages' stanza ids are stored when received, so the last one can be used
-        to retrieve missing history on next connection
-        @param message_elt(domish.Element): <message> with a stanza-id
-        """
-        service_jid = client.jid.userhostJID()
-        stanza_id = self._sid.get_stanza_id(message_elt, service_jid)
-        if stanza_id is None:
-            log.debug("Ignoring <message>, stanza id is not from our server")
-        else:
-            # we use self._last_stanza_id_d do be sure that last_stanza_id is stored in
-            # the order of reception
-            self._last_stanza_id_d.addCallback(
-                lambda __: self.host.memory.storage.set_private_value(
-                    namespace=mam.NS_MAM,
-                    key=KEY_LAST_STANZA_ID,
-                    value=stanza_id,
-                    profile=client.profile))
-
-
-@implementer(disco.IDisco)
-class SatMAMClient(mam.MAMClient):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-
-    @property
-    def host(self):
-        return self.parent.host_app
-
-    def connectionInitialized(self):
-        observer_xpath = MESSAGE_STANZA_ID.format(
-            ns_stanza_id=self.host.ns_map['stanza_id'])
-        self.xmlstream.addObserver(
-            observer_xpath, self.plugin_parent.on_message_stanza_id, client=self.parent
-        )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(mam.NS_MAM)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0320.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,105 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin
-# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-
-log = getLogger(__name__)
-
-NS_JINGLE_DTLS = "urn:xmpp:jingle:apps:dtls:0"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Use of DTLS-SRTP in Jingle Sessions",
-    C.PI_IMPORT_NAME: "XEP-0320",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0320"],
-    C.PI_DEPENDENCIES: ["XEP-0176"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "XEP_0320",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Use of DTLS-SRTP with RTP (for e2ee of A/V calls)"""),
-}
-
-
-class XEP_0320:
-    def __init__(self, host):
-        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
-        host.trigger.add("XEP-0176_parse_transport", self._parse_transport_trigger)
-        host.trigger.add("XEP-0176_build_transport", self._build_transport_trigger)
-
-    def get_handler(self, client):
-        return XEP_0320_handler()
-
-    def _parse_transport_trigger(
-        self, transport_elt: domish.Element, ice_data: dict
-    ) -> bool:
-        """Parse the <fingerprint> element"""
-        fingerprint_elt = next(
-            transport_elt.elements(NS_JINGLE_DTLS, "fingerprint"), None
-        )
-        if fingerprint_elt is not None:
-            try:
-                ice_data["fingerprint"] = {
-                    "hash": fingerprint_elt["hash"],
-                    "setup": fingerprint_elt["setup"],
-                    "fingerprint": str(fingerprint_elt),
-                }
-            except KeyError as e:
-                log.warning(
-                    f"invalid <fingerprint> (attribue {e} is missing): "
-                    f"{fingerprint_elt.toXml()})"
-                )
-
-        return True
-
-    def _build_transport_trigger(
-        self, tranport_elt: domish.Element, ice_data: dict
-    ) -> bool:
-        """Build the <fingerprint> element if possible"""
-        try:
-            fingerprint_data = ice_data["fingerprint"]
-            hash_ = fingerprint_data["hash"]
-            setup = fingerprint_data["setup"]
-            fingerprint = fingerprint_data["fingerprint"]
-        except KeyError:
-            pass
-        else:
-            fingerprint_elt = tranport_elt.addElement(
-                (NS_JINGLE_DTLS, "fingerprint"), content=fingerprint
-            )
-            fingerprint_elt["hash"] = hash_
-            fingerprint_elt["setup"] = setup
-
-        return True
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0320_handler(XMPPHandler):
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_JINGLE_DTLS)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0329.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1275 +0,0 @@
-#!/usr/bin/env python3
-
-# SAT plugin for File Information Sharing (XEP-0329)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import mimetypes
-import json
-import os
-import traceback
-from pathlib import Path
-from typing import Optional, Dict
-from zope.interface import implementer
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber import error as jabber_error
-from twisted.internet import defer
-from wokkel import disco, iwokkel, data_form
-from sat.core.i18n import _
-from sat.core.xmpp import SatXMPPEntity
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.tools import stream
-from sat.tools import utils
-from sat.tools.common import regex
-
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "File Information Sharing",
-    C.PI_IMPORT_NAME: "XEP-0329",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0329"],
-    C.PI_DEPENDENCIES: ["XEP-0231", "XEP-0234", "XEP-0300", "XEP-0106"],
-    C.PI_MAIN: "XEP_0329",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of File Information Sharing"""),
-}
-
-NS_FIS = "urn:xmpp:fis:0"
-NS_FIS_AFFILIATION = "org.salut-a-toi.fis-affiliation"
-NS_FIS_CONFIGURATION = "org.salut-a-toi.fis-configuration"
-NS_FIS_CREATE = "org.salut-a-toi.fis-create"
-
-IQ_FIS_REQUEST = f'{C.IQ_GET}/query[@xmlns="{NS_FIS}"]'
-# not in the standard, but needed, and it's handy to keep it here
-IQ_FIS_AFFILIATION_GET = f'{C.IQ_GET}/affiliations[@xmlns="{NS_FIS_AFFILIATION}"]'
-IQ_FIS_AFFILIATION_SET = f'{C.IQ_SET}/affiliations[@xmlns="{NS_FIS_AFFILIATION}"]'
-IQ_FIS_CONFIGURATION_GET = f'{C.IQ_GET}/configuration[@xmlns="{NS_FIS_CONFIGURATION}"]'
-IQ_FIS_CONFIGURATION_SET = f'{C.IQ_SET}/configuration[@xmlns="{NS_FIS_CONFIGURATION}"]'
-IQ_FIS_CREATE_DIR = f'{C.IQ_SET}/dir[@xmlns="{NS_FIS_CREATE}"]'
-SINGLE_FILES_DIR = "files"
-TYPE_VIRTUAL = "virtual"
-TYPE_PATH = "path"
-SHARE_TYPES = (TYPE_PATH, TYPE_VIRTUAL)
-KEY_TYPE = "type"
-
-
-class RootPathException(Exception):
-    """Root path is requested"""
-
-
-class ShareNode(object):
-    """Node containing directory or files to share, virtual or real"""
-
-    host = None
-
-    def __init__(self, name, parent, type_, access, path=None):
-        assert type_ in SHARE_TYPES
-        if name is not None:
-            if name == ".." or "/" in name or "\\" in name:
-                log.warning(
-                    _("path change chars found in name [{name}], hack attempt?").format(
-                        name=name
-                    )
-                )
-                if name == "..":
-                    name = "--"
-                else:
-                    name = regex.path_escape(name)
-        self.name = name
-        self.children = {}
-        self.type = type_
-        self.access = {} if access is None else access
-        assert isinstance(self.access, dict)
-        self.parent = None
-        if parent is not None:
-            assert name
-            parent.addChild(self)
-        else:
-            assert name is None
-        if path is not None:
-            if type_ != TYPE_PATH:
-                raise exceptions.InternalError(_("path can only be set on path nodes"))
-            self._path = path
-
-    @property
-    def path(self):
-        return self._path
-
-    def __getitem__(self, key):
-        return self.children[key]
-
-    def __contains__(self, item):
-        return self.children.__contains__(item)
-
-    def __iter__(self):
-        return self.children.__iter__()
-
-    def items(self):
-        return self.children.items()
-
-    def values(self):
-        return self.children.values()
-
-    def get_or_create(self, name, type_=TYPE_VIRTUAL, access=None):
-        """Get a node or create a virtual node and return it"""
-        if access is None:
-            access = {C.ACCESS_PERM_READ: {KEY_TYPE: C.ACCESS_TYPE_PUBLIC}}
-        try:
-            return self.children[name]
-        except KeyError:
-            node = ShareNode(name, self, type_=type_, access=access)
-            return node
-
-    def addChild(self, node):
-        if node.parent is not None:
-            raise exceptions.ConflictError(_("a node can't have several parents"))
-        node.parent = self
-        self.children[node.name] = node
-
-    def remove_from_parent(self):
-        try:
-            del self.parent.children[self.name]
-        except TypeError:
-            raise exceptions.InternalError(
-                "trying to remove a node from inexisting parent"
-            )
-        except KeyError:
-            raise exceptions.InternalError("node not found in parent's children")
-        self.parent = None
-
-    def _check_node_permission(self, client, node, perms, peer_jid):
-        """Check access to this node for peer_jid
-
-        @param node(SharedNode): node to check access
-        @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_*
-        @param peer_jid(jid.JID): entity which try to access the node
-        @return (bool): True if entity can access
-        """
-        file_data = {"access": self.access, "owner": client.jid.userhostJID()}
-        try:
-            self.host.memory.check_file_permission(file_data, peer_jid, perms)
-        except exceptions.PermissionError:
-            return False
-        else:
-            return True
-
-    def check_permissions(
-        self, client, peer_jid, perms=(C.ACCESS_PERM_READ,), check_parents=True
-    ):
-        """Check that peer_jid can access this node and all its parents
-
-        @param peer_jid(jid.JID): entrity trying to access the node
-        @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_*
-        @param check_parents(bool): if True, access of all parents of this node will be
-            checked too
-        @return (bool): True if entity can access this node
-        """
-        peer_jid = peer_jid.userhostJID()
-        if peer_jid == client.jid.userhostJID():
-            return True
-
-        parent = self
-        while parent != None:
-            if not self._check_node_permission(client, parent, perms, peer_jid):
-                return False
-            parent = parent.parent
-
-        return True
-
-    @staticmethod
-    def find(client, path, peer_jid, perms=(C.ACCESS_PERM_READ,)):
-        """find node corresponding to a path
-
-        @param path(unicode): path to the requested file or directory
-        @param peer_jid(jid.JID): entity trying to find the node
-            used to check permission
-        @return (dict, unicode): shared data, remaining path
-        @raise exceptions.PermissionError: user can't access this file
-        @raise exceptions.DataError: path is invalid
-        @raise NotFound: path lead to a non existing file/directory
-        """
-        path_elts = [_f for _f in path.split("/") if _f]
-
-        if ".." in path_elts:
-            log.warning(_(
-                'parent dir ("..") found in path, hack attempt? path is {path} '
-                '[{profile}]').format(path=path, profile=client.profile))
-            raise exceptions.PermissionError("illegal path elements")
-
-        node = client._XEP_0329_root_node
-
-        while path_elts:
-            if node.type == TYPE_VIRTUAL:
-                try:
-                    node = node[path_elts.pop(0)]
-                except KeyError:
-                    raise exceptions.NotFound
-            elif node.type == TYPE_PATH:
-                break
-
-        if not node.check_permissions(client, peer_jid, perms=perms):
-            raise exceptions.PermissionError("permission denied")
-
-        return node, "/".join(path_elts)
-
-    def find_by_local_path(self, path):
-        """retrieve nodes linking to local path
-
-        @return (list[ShareNode]): found nodes associated to path
-        @raise exceptions.NotFound: no node has been found with this path
-        """
-        shared_paths = self.get_shared_paths()
-        try:
-            return shared_paths[path]
-        except KeyError:
-            raise exceptions.NotFound
-
-    def _get_shared_paths(self, node, paths):
-        if node.type == TYPE_VIRTUAL:
-            for node in node.values():
-                self._get_shared_paths(node, paths)
-        elif node.type == TYPE_PATH:
-            paths.setdefault(node.path, []).append(node)
-        else:
-            raise exceptions.InternalError(
-                "unknown node type: {type}".format(type=node.type)
-            )
-
-    def get_shared_paths(self):
-        """retrieve nodes by shared path
-
-        this method will retrieve recursively shared path in children of this node
-        @return (dict): map from shared path to list of nodes
-        """
-        if self.type == TYPE_PATH:
-            raise exceptions.InternalError(
-                "get_shared_paths must be used on a virtual node"
-            )
-        paths = {}
-        self._get_shared_paths(self, paths)
-        return paths
-
-
-class XEP_0329(object):
-    def __init__(self, host):
-        log.info(_("File Information Sharing initialization"))
-        self.host = host
-        ShareNode.host = host
-        self._b = host.plugins["XEP-0231"]
-        self._h = host.plugins["XEP-0300"]
-        self._jf = host.plugins["XEP-0234"]
-        host.bridge.add_method(
-            "fis_list",
-            ".plugin",
-            in_sign="ssa{ss}s",
-            out_sign="aa{ss}",
-            method=self._list_files,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "fis_local_shares_get",
-            ".plugin",
-            in_sign="s",
-            out_sign="as",
-            method=self._local_shares_get,
-        )
-        host.bridge.add_method(
-            "fis_share_path",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="s",
-            method=self._share_path,
-        )
-        host.bridge.add_method(
-            "fis_unshare_path",
-            ".plugin",
-            in_sign="ss",
-            out_sign="",
-            method=self._unshare_path,
-        )
-        host.bridge.add_method(
-            "fis_affiliations_get",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="a{ss}",
-            method=self._affiliations_get,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "fis_affiliations_set",
-            ".plugin",
-            in_sign="sssa{ss}s",
-            out_sign="",
-            method=self._affiliations_set,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "fis_configuration_get",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="a{ss}",
-            method=self._configuration_get,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "fis_configuration_set",
-            ".plugin",
-            in_sign="sssa{ss}s",
-            out_sign="",
-            method=self._configuration_set,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "fis_create_dir",
-            ".plugin",
-            in_sign="sssa{ss}s",
-            out_sign="",
-            method=self._create_dir,
-            async_=True,
-        )
-        host.bridge.add_signal("fis_shared_path_new", ".plugin", signature="sss")
-        host.bridge.add_signal("fis_shared_path_removed", ".plugin", signature="ss")
-        host.trigger.add("XEP-0234_fileSendingRequest", self._file_sending_request_trigger)
-        host.register_namespace("fis", NS_FIS)
-
-    def get_handler(self, client):
-        return XEP_0329_handler(self)
-
-    def profile_connected(self, client):
-        if client.is_component:
-            client._file_sharing_allowed_hosts = self.host.memory.config_get(
-                'component file_sharing',
-                'http_upload_allowed_hosts_list') or [client.host]
-        else:
-            client._XEP_0329_root_node = ShareNode(
-                None,
-                None,
-                TYPE_VIRTUAL,
-                {C.ACCESS_PERM_READ: {KEY_TYPE: C.ACCESS_TYPE_PUBLIC}},
-            )
-            client._XEP_0329_names_data = {}  #  name to share map
-
-    def _file_sending_request_trigger(
-        self, client, session, content_data, content_name, file_data, file_elt
-    ):
-        """This trigger check that a requested file is available, and fill suitable data
-
-        Path and name are used to retrieve the file. If path is missing, we try our luck
-        with known names
-        """
-        if client.is_component:
-            return True, None
-
-        try:
-            name = file_data["name"]
-        except KeyError:
-            return True, None
-        assert "/" not in name
-
-        path = file_data.get("path")
-        if path is not None:
-            # we have a path, we can follow it to find node
-            try:
-                node, rem_path = ShareNode.find(client, path, session["peer_jid"])
-            except (exceptions.PermissionError, exceptions.NotFound):
-                #  no file, or file not allowed, we continue normal workflow
-                return True, None
-            except exceptions.DataError:
-                log.warning(_("invalid path: {path}").format(path=path))
-                return True, None
-
-            if node.type == TYPE_VIRTUAL:
-                # we have a virtual node, so name must link to a path node
-                try:
-                    path = node[name].path
-                except KeyError:
-                    return True, None
-            elif node.type == TYPE_PATH:
-                # we have a path node, so we can retrieve the full path now
-                path = os.path.join(node.path, rem_path, name)
-            else:
-                raise exceptions.InternalError(
-                    "unknown type: {type}".format(type=node.type)
-                )
-            if not os.path.exists(path):
-                return True, None
-            size = os.path.getsize(path)
-        else:
-            # we don't have the path, we try to find the file by its name
-            try:
-                name_data = client._XEP_0329_names_data[name]
-            except KeyError:
-                return True, None
-
-            for path, shared_file in name_data.items():
-                if True:  #  FIXME: filters are here
-                    break
-            else:
-                return True, None
-            parent_node = shared_file["parent"]
-            if not parent_node.check_permissions(client, session["peer_jid"]):
-                log.warning(
-                    _(
-                        "{peer_jid} requested a file (s)he can't access [{profile}]"
-                    ).format(peer_jid=session["peer_jid"], profile=client.profile)
-                )
-                return True, None
-            size = shared_file["size"]
-
-        file_data["size"] = size
-        file_elt.addElement("size", content=str(size))
-        hash_algo = file_data["hash_algo"] = self._h.get_default_algo()
-        hasher = file_data["hash_hasher"] = self._h.get_hasher(hash_algo)
-        file_elt.addChild(self._h.build_hash_used_elt(hash_algo))
-        content_data["stream_object"] = stream.FileStreamObject(
-            self.host,
-            client,
-            path,
-            uid=self._jf.get_progress_id(session, content_name),
-            size=size,
-            data_cb=lambda data: hasher.update(data),
-        )
-        return False, defer.succeed(True)
-
-    # common methods
-
-    def _request_handler(self, client, iq_elt, root_nodes_cb, files_from_node_cb):
-        iq_elt.handled = True
-        node = iq_elt.query.getAttribute("node")
-        if not node:
-            d = utils.as_deferred(root_nodes_cb, client, iq_elt)
-        else:
-            d = utils.as_deferred(files_from_node_cb, client, iq_elt, node)
-        d.addErrback(
-            lambda failure_: log.error(
-                _("error while retrieving files: {msg}").format(msg=failure_)
-            )
-        )
-
-    def _iq_error(self, client, iq_elt, condition="item-not-found"):
-        error_elt = jabber_error.StanzaError(condition).toResponse(iq_elt)
-        client.send(error_elt)
-
-    #  client
-
-    def _add_path_data(self, client, query_elt, path, parent_node):
-        """Fill query_elt with files/directories found in path"""
-        name = os.path.basename(path)
-        if os.path.isfile(path):
-            size = os.path.getsize(path)
-            mime_type = mimetypes.guess_type(path, strict=False)[0]
-            file_elt = self._jf.build_file_element(
-                client=client, name=name, size=size, mime_type=mime_type,
-                modified=os.path.getmtime(path)
-            )
-
-            query_elt.addChild(file_elt)
-            # we don't specify hash as it would be too resource intensive to calculate
-            # it for all files.
-            # we add file to name_data, so users can request it later
-            name_data = client._XEP_0329_names_data.setdefault(name, {})
-            if path not in name_data:
-                name_data[path] = {
-                    "size": size,
-                    "mime_type": mime_type,
-                    "parent": parent_node,
-                }
-        else:
-            # we have a directory
-            directory_elt = query_elt.addElement("directory")
-            directory_elt["name"] = name
-
-    def _path_node_handler(self, client, iq_elt, query_elt, node, path):
-        """Fill query_elt for path nodes, i.e. physical directories"""
-        path = os.path.join(node.path, path)
-
-        if not os.path.exists(path):
-            # path may have been moved since it has been shared
-            return self._iq_error(client, iq_elt)
-        elif os.path.isfile(path):
-            self._add_path_data(client, query_elt, path, node)
-        else:
-            for name in sorted(os.listdir(path.encode("utf-8")), key=lambda n: n.lower()):
-                try:
-                    name = name.decode("utf-8", "strict")
-                except UnicodeDecodeError as e:
-                    log.warning(
-                        _("ignoring invalid unicode name ({name}): {msg}").format(
-                            name=name.decode("utf-8", "replace"), msg=e
-                        )
-                    )
-                    continue
-                full_path = os.path.join(path, name)
-                self._add_path_data(client, query_elt, full_path, node)
-
-    def _virtual_node_handler(self, client, peer_jid, iq_elt, query_elt, node):
-        """Fill query_elt for virtual nodes"""
-        for name, child_node in node.items():
-            if not child_node.check_permissions(client, peer_jid, check_parents=False):
-                continue
-            node_type = child_node.type
-            if node_type == TYPE_VIRTUAL:
-                directory_elt = query_elt.addElement("directory")
-                directory_elt["name"] = name
-            elif node_type == TYPE_PATH:
-                self._add_path_data(client, query_elt, child_node.path, child_node)
-            else:
-                raise exceptions.InternalError(
-                    _("unexpected type: {type}").format(type=node_type)
-                )
-
-    def _get_root_nodes_cb(self, client, iq_elt):
-        peer_jid = jid.JID(iq_elt["from"])
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        query_elt = iq_result_elt.addElement((NS_FIS, "query"))
-        for name, node in client._XEP_0329_root_node.items():
-            if not node.check_permissions(client, peer_jid, check_parents=False):
-                continue
-            directory_elt = query_elt.addElement("directory")
-            directory_elt["name"] = name
-        client.send(iq_result_elt)
-
-    def _get_files_from_node_cb(self, client, iq_elt, node_path):
-        """Main method to retrieve files/directories from a node_path"""
-        peer_jid = jid.JID(iq_elt["from"])
-        try:
-            node, path = ShareNode.find(client, node_path, peer_jid)
-        except (exceptions.PermissionError, exceptions.NotFound):
-            return self._iq_error(client, iq_elt)
-        except exceptions.DataError:
-            return self._iq_error(client, iq_elt, condition="not-acceptable")
-
-        node_type = node.type
-        peer_jid = jid.JID(iq_elt["from"])
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        query_elt = iq_result_elt.addElement((NS_FIS, "query"))
-        query_elt["node"] = node_path
-
-        # we now fill query_elt according to node_type
-        if node_type == TYPE_PATH:
-            #  it's a physical path
-            self._path_node_handler(client, iq_elt, query_elt, node, path)
-        elif node_type == TYPE_VIRTUAL:
-            assert not path
-            self._virtual_node_handler(client, peer_jid, iq_elt, query_elt, node)
-        else:
-            raise exceptions.InternalError(
-                _("unknown node type: {type}").format(type=node_type)
-            )
-
-        client.send(iq_result_elt)
-
-    def on_request(self, iq_elt, client):
-        return self._request_handler(
-            client, iq_elt, self._get_root_nodes_cb, self._get_files_from_node_cb
-        )
-
-    # Component
-
-    def _comp_parse_jids(self, client, iq_elt):
-        """Retrieve peer_jid and owner to use from IQ stanza
-
-        @param iq_elt(domish.Element): IQ stanza of the FIS request
-        @return (tuple[jid.JID, jid.JID]): peer_jid and owner
-        """
-
-    async def _comp_get_root_nodes_cb(self, client, iq_elt):
-        peer_jid, owner = client.get_owner_and_peer(iq_elt)
-        files_data = await self.host.memory.get_files(
-            client,
-            peer_jid=peer_jid,
-            parent="",
-            type_=C.FILE_TYPE_DIRECTORY,
-            owner=owner,
-        )
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        query_elt = iq_result_elt.addElement((NS_FIS, "query"))
-        for file_data in files_data:
-            name = file_data["name"]
-            directory_elt = query_elt.addElement("directory")
-            directory_elt["name"] = name
-        client.send(iq_result_elt)
-
-    async def _comp_get_files_from_node_cb(self, client, iq_elt, node_path):
-        """Retrieve files from local files repository according to permissions
-
-        result stanza is then built and sent to requestor
-        @trigger XEP-0329_compGetFilesFromNode(client, iq_elt, owner, node_path,
-                                               files_data):
-            can be used to add data/elements
-        """
-        peer_jid, owner = client.get_owner_and_peer(iq_elt)
-        try:
-            files_data = await self.host.memory.get_files(
-                client, peer_jid=peer_jid, path=node_path, owner=owner
-            )
-        except exceptions.NotFound:
-            self._iq_error(client, iq_elt)
-            return
-        except exceptions.PermissionError:
-            self._iq_error(client, iq_elt, condition='not-allowed')
-            return
-        except Exception as e:
-            tb = traceback.format_tb(e.__traceback__)
-            log.error(f"internal server error: {e}\n{''.join(tb)}")
-            self._iq_error(client, iq_elt, condition='internal-server-error')
-            return
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        query_elt = iq_result_elt.addElement((NS_FIS, "query"))
-        query_elt["node"] = node_path
-        if not self.host.trigger.point(
-            "XEP-0329_compGetFilesFromNode",
-            client,
-            iq_elt,
-            iq_result_elt,
-            owner,
-            node_path,
-            files_data
-        ):
-            return
-        for file_data in files_data:
-            if file_data['type'] == C.FILE_TYPE_DIRECTORY:
-                directory_elt = query_elt.addElement("directory")
-                directory_elt['name'] = file_data['name']
-                self.host.trigger.point(
-                    "XEP-0329_compGetFilesFromNode_build_directory",
-                    client,
-                    file_data,
-                    directory_elt,
-                    owner,
-                    node_path,
-                )
-            else:
-                file_elt = self._jf.build_file_element_from_dict(
-                    client,
-                    file_data,
-                    modified=file_data.get("modified", file_data["created"])
-                )
-                query_elt.addChild(file_elt)
-        client.send(iq_result_elt)
-
-    def on_component_request(self, iq_elt, client):
-        return self._request_handler(
-            client, iq_elt, self._comp_get_root_nodes_cb, self._comp_get_files_from_node_cb
-        )
-
-    async def _parse_result(self, client, peer_jid, iq_elt):
-        query_elt = next(iq_elt.elements(NS_FIS, "query"))
-        files = []
-
-        for elt in query_elt.elements():
-            if elt.name == "file":
-                # we have a file
-                try:
-                    file_data = await self._jf.parse_file_element(client, elt)
-                except exceptions.DataError:
-                    continue
-                file_data["type"] = C.FILE_TYPE_FILE
-                try:
-                    thumbs = file_data['extra'][C.KEY_THUMBNAILS]
-                except KeyError:
-                    log.debug(f"No thumbnail found for {file_data}")
-                else:
-                    for thumb in thumbs:
-                        if 'url' not in thumb and "id" in thumb:
-                            try:
-                                file_path = await self._b.get_file(client, peer_jid, thumb['id'])
-                            except Exception as e:
-                                log.warning(f"Can't get thumbnail {thumb['id']!r} for {file_data}: {e}")
-                            else:
-                                thumb['filename'] = file_path.name
-
-            elif elt.name == "directory" and elt.uri == NS_FIS:
-                # we have a directory
-
-                file_data = {"name": elt["name"], "type": C.FILE_TYPE_DIRECTORY}
-                self.host.trigger.point(
-                    "XEP-0329_parseResult_directory",
-                    client,
-                    elt,
-                    file_data,
-                )
-            else:
-                log.warning(
-                    _("unexpected element, ignoring: {elt}")
-                    .format(elt=elt.toXml())
-                )
-                continue
-            files.append(file_data)
-        return files
-
-    # affiliations #
-
-    async def _parse_element(self, client, iq_elt, element, namespace):
-        peer_jid, owner = client.get_owner_and_peer(iq_elt)
-        elt = next(iq_elt.elements(namespace, element))
-        path = Path("/", elt['path'])
-        if len(path.parts) < 2:
-            raise RootPathException
-        namespace = elt.getAttribute('namespace')
-        files_data = await self.host.memory.get_files(
-            client,
-            peer_jid=peer_jid,
-            path=str(path.parent),
-            name=path.name,
-            namespace=namespace,
-            owner=owner,
-        )
-        if len(files_data) != 1:
-            client.sendError(iq_elt, 'item-not-found')
-            raise exceptions.CancelError
-        file_data = files_data[0]
-        return peer_jid, elt, path, namespace, file_data
-
-    def _affiliations_get(self, service_jid_s, namespace, path, profile):
-        client = self.host.get_client(profile)
-        service = jid.JID(service_jid_s)
-        d = defer.ensureDeferred(self.affiliationsGet(
-            client, service, namespace or None, path))
-        d.addCallback(
-            lambda affiliations: {
-                str(entity): affiliation for entity, affiliation in affiliations.items()
-            }
-        )
-        return d
-
-    async def affiliationsGet(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        namespace: Optional[str],
-        path: str
-    ) -> Dict[jid.JID, str]:
-        if not path:
-            raise ValueError(f"invalid path: {path!r}")
-        iq_elt = client.IQ("get")
-        iq_elt['to'] = service.full()
-        affiliations_elt = iq_elt.addElement((NS_FIS_AFFILIATION, "affiliations"))
-        if namespace:
-            affiliations_elt["namespace"] = namespace
-        affiliations_elt["path"] = path
-        iq_result_elt = await iq_elt.send()
-        try:
-            affiliations_elt = next(iq_result_elt.elements(NS_FIS_AFFILIATION, "affiliations"))
-        except StopIteration:
-            raise exceptions.DataError(f"Invalid result to affiliations request: {iq_result_elt.toXml()}")
-
-        affiliations = {}
-        for affiliation_elt in affiliations_elt.elements(NS_FIS_AFFILIATION, 'affiliation'):
-            try:
-                affiliations[jid.JID(affiliation_elt['jid'])] = affiliation_elt['affiliation']
-            except (KeyError, RuntimeError):
-                raise exceptions.DataError(
-                    f"invalid affiliation element: {affiliation_elt.toXml()}")
-
-        return affiliations
-
-    def _affiliations_set(self, service_jid_s, namespace, path, affiliations, profile):
-        client = self.host.get_client(profile)
-        service = jid.JID(service_jid_s)
-        affiliations = {jid.JID(e): a for e, a in affiliations.items()}
-        return defer.ensureDeferred(self.affiliationsSet(
-            client, service, namespace or None, path, affiliations))
-
-    async def affiliationsSet(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        namespace: Optional[str],
-        path: str,
-        affiliations: Dict[jid.JID, str],
-    ):
-        if not path:
-            raise ValueError(f"invalid path: {path!r}")
-        iq_elt = client.IQ("set")
-        iq_elt['to'] = service.full()
-        affiliations_elt = iq_elt.addElement((NS_FIS_AFFILIATION, "affiliations"))
-        if namespace:
-            affiliations_elt["namespace"] = namespace
-        affiliations_elt["path"] = path
-        for entity_jid, affiliation in affiliations.items():
-            affiliation_elt = affiliations_elt.addElement('affiliation')
-            affiliation_elt['jid'] = entity_jid.full()
-            affiliation_elt['affiliation'] = affiliation
-        await iq_elt.send()
-
-    def _on_component_affiliations_get(self, iq_elt, client):
-        iq_elt.handled = True
-        defer.ensureDeferred(self.on_component_affiliations_get(client, iq_elt))
-
-    async def on_component_affiliations_get(self, client, iq_elt):
-        try:
-            (
-                from_jid, affiliations_elt, path, namespace, file_data
-            ) = await self._parse_element(client, iq_elt, "affiliations", NS_FIS_AFFILIATION)
-        except exceptions.CancelError:
-            return
-        except RootPathException:
-            # if root path is requested, we only get owner affiliation
-            peer_jid, owner = client.get_owner_and_peer(iq_elt)
-            is_owner = peer_jid.userhostJID() == owner
-            affiliations = {owner: 'owner'}
-        except exceptions.NotFound:
-            client.sendError(iq_elt, "item-not-found")
-            return
-        except Exception as e:
-            client.sendError(iq_elt, "internal-server-error", str(e))
-            return
-        else:
-            from_jid_bare = from_jid.userhostJID()
-            is_owner = from_jid_bare == file_data.get('owner')
-            affiliations = self.host.memory.get_file_affiliations(file_data)
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        affiliations_elt = iq_result_elt.addElement((NS_FIS_AFFILIATION, 'affiliations'))
-        for entity_jid, affiliation in affiliations.items():
-            if not is_owner and entity_jid.userhostJID() != from_jid_bare:
-                # only onwer can get all affiliations
-                continue
-            affiliation_elt = affiliations_elt.addElement('affiliation')
-            affiliation_elt['jid'] = entity_jid.userhost()
-            affiliation_elt['affiliation'] = affiliation
-        client.send(iq_result_elt)
-
-    def _on_component_affiliations_set(self, iq_elt, client):
-        iq_elt.handled = True
-        defer.ensureDeferred(self.on_component_affiliations_set(client, iq_elt))
-
-    async def on_component_affiliations_set(self, client, iq_elt):
-        try:
-            (
-                from_jid, affiliations_elt, path, namespace, file_data
-            ) = await self._parse_element(client, iq_elt, "affiliations", NS_FIS_AFFILIATION)
-        except exceptions.CancelError:
-            return
-        except RootPathException:
-            client.sendError(iq_elt, 'bad-request', "Root path can't be used")
-            return
-
-        if from_jid.userhostJID() != file_data['owner']:
-            log.warning(
-                f"{from_jid} tried to modify {path} affiliations while the owner is "
-                f"{file_data['owner']}"
-            )
-            client.sendError(iq_elt, 'forbidden')
-            return
-
-        try:
-            affiliations = {
-                jid.JID(e['jid']): e['affiliation']
-                for e in affiliations_elt.elements(NS_FIS_AFFILIATION, 'affiliation')
-            }
-        except (KeyError, RuntimeError):
-                log.warning(
-                    f"invalid affiliation element: {affiliations_elt.toXml()}"
-                )
-                client.sendError(iq_elt, 'bad-request', "invalid affiliation element")
-                return
-        except Exception as e:
-                log.error(
-                    f"unexepected exception while setting affiliation element: {e}\n"
-                    f"{affiliations_elt.toXml()}"
-                )
-                client.sendError(iq_elt, 'internal-server-error', f"{e}")
-                return
-
-        await self.host.memory.set_file_affiliations(client, file_data, affiliations)
-
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        client.send(iq_result_elt)
-
-    # configuration
-
-    def _configuration_get(self, service_jid_s, namespace, path, profile):
-        client = self.host.get_client(profile)
-        service = jid.JID(service_jid_s)
-        d = defer.ensureDeferred(self.configuration_get(
-            client, service, namespace or None, path))
-        d.addCallback(
-            lambda configuration: {
-                str(entity): affiliation for entity, affiliation in configuration.items()
-            }
-        )
-        return d
-
-    async def configuration_get(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        namespace: Optional[str],
-        path: str
-    ) -> Dict[str, str]:
-        if not path:
-            raise ValueError(f"invalid path: {path!r}")
-        iq_elt = client.IQ("get")
-        iq_elt['to'] = service.full()
-        configuration_elt = iq_elt.addElement((NS_FIS_CONFIGURATION, "configuration"))
-        if namespace:
-            configuration_elt["namespace"] = namespace
-        configuration_elt["path"] = path
-        iq_result_elt = await iq_elt.send()
-        try:
-            configuration_elt = next(iq_result_elt.elements(NS_FIS_CONFIGURATION, "configuration"))
-        except StopIteration:
-            raise exceptions.DataError(f"Invalid result to configuration request: {iq_result_elt.toXml()}")
-
-        form = data_form.findForm(configuration_elt, NS_FIS_CONFIGURATION)
-        configuration = {f.var: f.value for f in form.fields.values()}
-
-        return configuration
-
-    def _configuration_set(self, service_jid_s, namespace, path, configuration, profile):
-        client = self.host.get_client(profile)
-        service = jid.JID(service_jid_s)
-        return defer.ensureDeferred(self.configuration_set(
-            client, service, namespace or None, path, configuration))
-
-    async def configuration_set(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        namespace: Optional[str],
-        path: str,
-        configuration: Dict[str, str],
-    ):
-        if not path:
-            raise ValueError(f"invalid path: {path!r}")
-        iq_elt = client.IQ("set")
-        iq_elt['to'] = service.full()
-        configuration_elt = iq_elt.addElement((NS_FIS_CONFIGURATION, "configuration"))
-        if namespace:
-            configuration_elt["namespace"] = namespace
-        configuration_elt["path"] = path
-        form = data_form.Form(formType="submit", formNamespace=NS_FIS_CONFIGURATION)
-        form.makeFields(configuration)
-        configuration_elt.addChild(form.toElement())
-        await iq_elt.send()
-
-    def _on_component_configuration_get(self, iq_elt, client):
-        iq_elt.handled = True
-        defer.ensureDeferred(self.on_component_configuration_get(client, iq_elt))
-
-    async def on_component_configuration_get(self, client, iq_elt):
-        try:
-            (
-                from_jid, configuration_elt, path, namespace, file_data
-            ) = await self._parse_element(client, iq_elt, "configuration", NS_FIS_CONFIGURATION)
-        except exceptions.CancelError:
-            return
-        except RootPathException:
-            client.sendError(iq_elt, 'bad-request', "Root path can't be used")
-            return
-        try:
-            access_type = file_data['access'][C.ACCESS_PERM_READ]['type']
-        except KeyError:
-            access_model = 'whitelist'
-        else:
-            access_model = 'open' if access_type == C.ACCESS_TYPE_PUBLIC else 'whitelist'
-
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        configuration_elt = iq_result_elt.addElement((NS_FIS_CONFIGURATION, 'configuration'))
-        form = data_form.Form(formType="form", formNamespace=NS_FIS_CONFIGURATION)
-        form.makeFields({'access_model': access_model})
-        configuration_elt.addChild(form.toElement())
-        client.send(iq_result_elt)
-
-    async def _set_configuration(self, client, configuration_elt, file_data):
-        form = data_form.findForm(configuration_elt, NS_FIS_CONFIGURATION)
-        for name, value in form.items():
-            if name == 'access_model':
-                await self.host.memory.set_file_access_model(client, file_data, value)
-            else:
-                # TODO: send a IQ error?
-                log.warning(
-                    f"Trying to set a not implemented configuration option: {name}")
-
-    def _on_component_configuration_set(self, iq_elt, client):
-        iq_elt.handled = True
-        defer.ensureDeferred(self.on_component_configuration_set(client, iq_elt))
-
-    async def on_component_configuration_set(self, client, iq_elt):
-        try:
-            (
-                from_jid, configuration_elt, path, namespace, file_data
-            ) = await self._parse_element(client, iq_elt, "configuration", NS_FIS_CONFIGURATION)
-        except exceptions.CancelError:
-            return
-        except RootPathException:
-            client.sendError(iq_elt, 'bad-request', "Root path can't be used")
-            return
-
-        from_jid_bare = from_jid.userhostJID()
-        is_owner = from_jid_bare == file_data.get('owner')
-        if not is_owner:
-            log.warning(
-                f"{from_jid} tried to modify {path} configuration while the owner is "
-                f"{file_data['owner']}"
-            )
-            client.sendError(iq_elt, 'forbidden')
-            return
-
-        await self._set_configuration(client, configuration_elt, file_data)
-
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        client.send(iq_result_elt)
-
-    # directory creation
-
-    def _create_dir(self, service_jid_s, namespace, path, configuration, profile):
-        client = self.host.get_client(profile)
-        service = jid.JID(service_jid_s)
-        return defer.ensureDeferred(self.create_dir(
-            client, service, namespace or None, path, configuration or None))
-
-    async def create_dir(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        namespace: Optional[str],
-        path: str,
-        configuration: Optional[Dict[str, str]],
-    ):
-        if not path:
-            raise ValueError(f"invalid path: {path!r}")
-        iq_elt = client.IQ("set")
-        iq_elt['to'] = service.full()
-        create_dir_elt = iq_elt.addElement((NS_FIS_CREATE, "dir"))
-        if namespace:
-            create_dir_elt["namespace"] = namespace
-        create_dir_elt["path"] = path
-        if configuration:
-            configuration_elt = create_dir_elt.addElement((NS_FIS_CONFIGURATION, "configuration"))
-            form = data_form.Form(formType="submit", formNamespace=NS_FIS_CONFIGURATION)
-            form.makeFields(configuration)
-            configuration_elt.addChild(form.toElement())
-        await iq_elt.send()
-
-    def _on_component_create_dir(self, iq_elt, client):
-        iq_elt.handled = True
-        defer.ensureDeferred(self.on_component_create_dir(client, iq_elt))
-
-    async def on_component_create_dir(self, client, iq_elt):
-        peer_jid, owner = client.get_owner_and_peer(iq_elt)
-        if peer_jid.host not in client._file_sharing_allowed_hosts:
-            client.sendError(iq_elt, 'forbidden')
-            return
-        create_dir_elt = next(iq_elt.elements(NS_FIS_CREATE, "dir"))
-        namespace = create_dir_elt.getAttribute('namespace')
-        path = Path("/", create_dir_elt['path'])
-        if len(path.parts) < 2:
-            client.sendError(iq_elt, 'bad-request', "Root path can't be used")
-            return
-        # for root directories, we check permission here
-        if len(path.parts) == 2 and owner != peer_jid.userhostJID():
-            log.warning(
-                f"{peer_jid} is trying to create a dir at {owner}'s repository:\n"
-                f"path: {path}\nnamespace: {namespace!r}"
-            )
-            client.sendError(iq_elt, 'forbidden', "You can't create a directory there")
-            return
-        # when going further into the path, the permissions will be checked by get_files
-        files_data = await self.host.memory.get_files(
-            client,
-            peer_jid=peer_jid,
-            path=path.parent,
-            namespace=namespace,
-            owner=owner,
-        )
-        if path.name in [d['name'] for d in files_data]:
-            log.warning(
-                f"Conflict when trying to create a directory (from: {peer_jid} "
-                f"namespace: {namespace!r} path: {path!r})"
-            )
-            client.sendError(
-                iq_elt, 'conflict', "there is already a file or dir at this path")
-            return
-
-        try:
-            configuration_elt = next(
-                create_dir_elt.elements(NS_FIS_CONFIGURATION, 'configuration'))
-        except StopIteration:
-            configuration_elt = None
-
-        await self.host.memory.set_file(
-            client,
-            path.name,
-            path=path.parent,
-            type_=C.FILE_TYPE_DIRECTORY,
-            namespace=namespace,
-            owner=owner,
-            peer_jid=peer_jid
-        )
-
-        if configuration_elt is not None:
-            file_data = (await self.host.memory.get_files(
-                client,
-                peer_jid=peer_jid,
-                path=path.parent,
-                name=path.name,
-                namespace=namespace,
-                owner=owner,
-            ))[0]
-
-            await self._set_configuration(client, configuration_elt, file_data)
-
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        client.send(iq_result_elt)
-
-    # file methods #
-
-    def _serialize_data(self, files_data):
-        for file_data in files_data:
-            for key, value in file_data.items():
-                file_data[key] = (
-                    json.dumps(value) if key in ("extra",) else str(value)
-                )
-        return files_data
-
-    def _list_files(self, target_jid, path, extra, profile):
-        client = self.host.get_client(profile)
-        target_jid = client.jid if not target_jid else jid.JID(target_jid)
-        d = defer.ensureDeferred(self.list_files(client, target_jid, path or None))
-        d.addCallback(self._serialize_data)
-        return d
-
-    async def list_files(self, client, peer_jid, path=None, extra=None):
-        """List file shared by an entity
-
-        @param peer_jid(jid.JID): jid of the sharing entity
-        @param path(unicode, None): path to the directory containing shared files
-            None to get root directories
-        @param extra(dict, None): extra data
-        @return list(dict): shared files
-        """
-        iq_elt = client.IQ("get")
-        iq_elt["to"] = peer_jid.full()
-        query_elt = iq_elt.addElement((NS_FIS, "query"))
-        if path:
-            query_elt["node"] = path
-        iq_result_elt = await iq_elt.send()
-        return await self._parse_result(client, peer_jid, iq_result_elt)
-
-    def _local_shares_get(self, profile):
-        client = self.host.get_client(profile)
-        return self.local_shares_get(client)
-
-    def local_shares_get(self, client):
-        return list(client._XEP_0329_root_node.get_shared_paths().keys())
-
-    def _share_path(self, name, path, access, profile):
-        client = self.host.get_client(profile)
-        access = json.loads(access)
-        return self.share_path(client, name or None, path, access)
-
-    def share_path(self, client, name, path, access):
-        if client.is_component:
-            raise exceptions.ClientTypeError
-        if not os.path.exists(path):
-            raise ValueError(_("This path doesn't exist!"))
-        if not path or not path.strip(" /"):
-            raise ValueError(_("A path need to be specified"))
-        if not isinstance(access, dict):
-            raise ValueError(_("access must be a dict"))
-
-        node = client._XEP_0329_root_node
-        node_type = TYPE_PATH
-        if os.path.isfile(path):
-            # we have a single file, the workflow is diferrent as we store all single
-            # files in the same dir
-            node = node.get_or_create(SINGLE_FILES_DIR)
-
-        if not name:
-            name = os.path.basename(path.rstrip(" /"))
-            if not name:
-                raise exceptions.InternalError(_("Can't find a proper name"))
-
-        if name in node or name == SINGLE_FILES_DIR:
-            idx = 1
-            new_name = name + "_" + str(idx)
-            while new_name in node:
-                idx += 1
-                new_name = name + "_" + str(idx)
-            name = new_name
-            log.info(_(
-                "A directory with this name is already shared, renamed to {new_name} "
-                "[{profile}]".format( new_name=new_name, profile=client.profile)))
-
-        ShareNode(name=name, parent=node, type_=node_type, access=access, path=path)
-        self.host.bridge.fis_shared_path_new(path, name, client.profile)
-        return name
-
-    def _unshare_path(self, path, profile):
-        client = self.host.get_client(profile)
-        return self.unshare_path(client, path)
-
-    def unshare_path(self, client, path):
-        nodes = client._XEP_0329_root_node.find_by_local_path(path)
-        for node in nodes:
-            node.remove_from_parent()
-        self.host.bridge.fis_shared_path_removed(path, client.profile)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0329_handler(xmlstream.XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-
-    def connectionInitialized(self):
-        if self.parent.is_component:
-            self.xmlstream.addObserver(
-                IQ_FIS_REQUEST, self.plugin_parent.on_component_request, client=self.parent
-            )
-            self.xmlstream.addObserver(
-                IQ_FIS_AFFILIATION_GET,
-                self.plugin_parent._on_component_affiliations_get,
-                client=self.parent
-            )
-            self.xmlstream.addObserver(
-                IQ_FIS_AFFILIATION_SET,
-                self.plugin_parent._on_component_affiliations_set,
-                client=self.parent
-            )
-            self.xmlstream.addObserver(
-                IQ_FIS_CONFIGURATION_GET,
-                self.plugin_parent._on_component_configuration_get,
-                client=self.parent
-            )
-            self.xmlstream.addObserver(
-                IQ_FIS_CONFIGURATION_SET,
-                self.plugin_parent._on_component_configuration_set,
-                client=self.parent
-            )
-            self.xmlstream.addObserver(
-                IQ_FIS_CREATE_DIR,
-                self.plugin_parent._on_component_create_dir,
-                client=self.parent
-            )
-        else:
-            self.xmlstream.addObserver(
-                IQ_FIS_REQUEST, self.plugin_parent.on_request, client=self.parent
-            )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_FIS)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0334.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,140 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Delayed Delivery (XEP-0334)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Iterable
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core.constants import Const as C
-
-from sat.tools.common import data_format
-
-from wokkel import disco, iwokkel
-
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.xish import domish
-from zope.interface import implementer
-from textwrap import dedent
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Message Processing Hints",
-    C.PI_IMPORT_NAME: "XEP-0334",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0334"],
-    C.PI_MAIN: "XEP_0334",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: D_("""Implementation of Message Processing Hints"""),
-    C.PI_USAGE: dedent(
-        D_(
-            """\
-             Frontends can use HINT_* constants in mess_data['extra'] in a serialized 'hints' dict.
-             Internal plugins can use directly add_hint([HINT_* constant]).
-             Will set mess_data['extra']['history'] to 'skipped' when no store is requested and message is not saved in history."""
-        )
-    ),
-}
-
-NS_HINTS = "urn:xmpp:hints"
-
-
-class XEP_0334(object):
-    HINT_NO_PERMANENT_STORE = "no-permanent-store"
-    HINT_NO_STORE = "no-store"
-    HINT_NO_COPY = "no-copy"
-    HINT_STORE = "store"
-    HINTS = (HINT_NO_PERMANENT_STORE, HINT_NO_STORE, HINT_NO_COPY, HINT_STORE)
-
-    def __init__(self, host):
-        log.info(_("Message Processing Hints plugin initialization"))
-        self.host = host
-        host.trigger.add("sendMessage", self.send_message_trigger)
-        host.trigger.add("message_received", self.message_received_trigger, priority=-1000)
-
-    def get_handler(self, client):
-        return XEP_0334_handler()
-
-    def add_hint(self, mess_data, hint):
-        if hint == self.HINT_NO_COPY and not mess_data["to"].resource:
-            log.error(
-                "{hint} can only be used with full jids! Ignoring it.".format(hint=hint)
-            )
-            return
-        hints = mess_data.setdefault("hints", set())
-        if hint in self.HINTS:
-            hints.add(hint)
-        else:
-            log.error("Unknown hint: {}".format(hint))
-
-    def add_hint_elements(self, message_elt: domish.Element, hints: Iterable[str]) -> None:
-        """Add hints elements to message stanza
-
-        @param message_elt: stanza where hints must be added
-        @param hints: hints to add
-        """
-        for hint in hints:
-            if not list(message_elt.elements(NS_HINTS, hint)):
-                message_elt.addElement((NS_HINTS, hint))
-            else:
-                log.debug('Not adding {hint!r} hint: it is already present in <message>')
-
-    def _send_post_xml_treatment(self, mess_data):
-        if "hints" in mess_data:
-            self.add_hint_elements(mess_data["xml"], mess_data["hints"])
-        return mess_data
-
-    def send_message_trigger(
-        self, client, mess_data, pre_xml_treatments, post_xml_treatments
-    ):
-        """Add the hints element to the message to be sent"""
-        if "hints" in mess_data["extra"]:
-            for hint in data_format.dict2iter("hints", mess_data["extra"], pop=True):
-                self.add_hint(hint)
-
-        post_xml_treatments.addCallback(self._send_post_xml_treatment)
-        return True
-
-    def _received_skip_history(self, mess_data):
-        mess_data["history"] = C.HISTORY_SKIP
-        return mess_data
-
-    def message_received_trigger(self, client, message_elt, post_treat):
-        """Check for hints in the received message"""
-        for elt in message_elt.elements():
-            if elt.uri == NS_HINTS and elt.name in (
-                self.HINT_NO_PERMANENT_STORE,
-                self.HINT_NO_STORE,
-            ):
-                log.debug("history will be skipped for this message, as requested")
-                post_treat.addCallback(self._received_skip_history)
-                break
-        return True
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0334_handler(xmlstream.XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_HINTS)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0338.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,156 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin
-# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import List
-
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.core_types import SatXMPPEntity
-
-log = getLogger(__name__)
-
-NS_JINGLE_GROUPING = "urn:xmpp:jingle:apps:grouping:0"
-NS_RFC_5888 = "urn:ietf:rfc:5888"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Jingle Grouping Framework",
-    C.PI_IMPORT_NAME: "XEP-0338",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0338"],
-    C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0167"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "XEP_0338",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Jingle mapping of RFC 5888 SDP Grouping Framework"""),
-}
-
-
-class XEP_0338:
-    def __init__(self, host):
-        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
-        self._j = host.plugins["XEP-0166"]
-        host.trigger.add("XEP-0167_parse_sdp_a", self._parse_sdp_a_trigger)
-        host.trigger.add(
-            "XEP-0167_generate_sdp_session", self._generate_sdp_session_trigger
-        )
-        host.trigger.add("XEP-0167_jingle_session_init", self._jingle_session_init_trigger)
-        host.trigger.add("XEP-0167_jingle_handler", self._jingle_handler_trigger)
-
-    def get_handler(self, client):
-        return XEP_0338_handler()
-
-    def _parse_sdp_a_trigger(
-        self,
-        attribute: str,
-        parts: List[str],
-        call_data: dict,
-        metadata: dict,
-        media_type: str,
-        application_data: dict,
-        transport_data: dict,
-    ) -> None:
-        """Parse "group" attributes"""
-        if attribute == "group":
-            semantics = parts[0]
-            content_names = parts[1:]
-            metadata.setdefault("group", {})[semantics] = content_names
-
-    def _generate_sdp_session_trigger(
-        self,
-        session: dict,
-        local: bool,
-        sdp_lines: List[str],
-    ) -> None:
-        """Generate "group" attributes"""
-        key = "metadata" if local else "peer_metadata"
-        group_data = session[key].get("group", {})
-
-        for semantics, content_names in group_data.items():
-            sdp_lines.append(f"a=group:{semantics} {' '.join(content_names)}")
-
-    def parse_group_element(
-        self, jingle_elt: domish.Element, session: dict
-    ) -> None:
-        """Parse the <group> and <content> elements"""
-        for group_elt in jingle_elt.elements(NS_JINGLE_GROUPING, "group"):
-            try:
-                metadata = session["peer_metadata"]
-                semantics = group_elt["semantics"]
-                group_content = metadata.setdefault("group", {})[semantics] = []
-                for content_elt in group_elt.elements(NS_JINGLE_GROUPING, "content"):
-                    group_content.append(content_elt["name"])
-            except KeyError as e:
-                log.warning(f"Error while parsing <group>: {e}\n{group_elt.toXml()}")
-
-    def add_group_element(
-        self, jingle_elt: domish.Element, session: dict
-    ) -> None:
-        """Build the <group> and <content> elements if possible"""
-        for semantics, content_names in session["metadata"].get("group", {}).items():
-            group_elt = jingle_elt.addElement((NS_JINGLE_GROUPING, "group"))
-            group_elt["semantics"] = semantics
-            for content_name in content_names:
-                content_elt = group_elt.addElement((NS_JINGLE_GROUPING, "content"))
-                content_elt["name"] = content_name
-
-    def _jingle_session_init_trigger(
-        self,
-        client: SatXMPPEntity,
-        session: dict,
-        content_name: str,
-        media: str,
-        media_data: dict,
-        desc_elt: domish.Element,
-    ) -> None:
-        jingle_elt = session["jingle_elt"]
-        self.add_group_element(jingle_elt, session)
-
-    def _jingle_handler_trigger(
-        self,
-        client: SatXMPPEntity,
-        action: str,
-        session: dict,
-        content_name: str,
-        desc_elt: domish.Element,
-    ) -> None:
-        # this is a session metadata, so we only generate it on the first content
-        if content_name == next(iter(session["contents"])) and action in (
-            self._j.A_PREPARE_CONFIRMATION,
-            self._j.A_SESSION_INITIATE,
-            self._j.A_PREPARE_INITIATOR,
-        ):
-            jingle_elt = session["jingle_elt"]
-            self.parse_group_element(jingle_elt, session)
-            if action == self._j.A_SESSION_INITIATE:
-                self.add_group_element(jingle_elt, session)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0338_handler(XMPPHandler):
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_RFC_5888)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0339.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,199 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin
-# Copyright (C) 2009-2023 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import List
-
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools import xml_tools
-
-log = getLogger(__name__)
-
-NS_JINGLE_RTP_SSMA = "urn:xmpp:jingle:apps:rtp:ssma:0"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Source-Specific Media Attributes in Jingle",
-    C.PI_IMPORT_NAME: "XEP-0339",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0339"],
-    C.PI_DEPENDENCIES: ["XEP-0092", "XEP-0167"],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "XEP_0339",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Source-Specific Media Attributes in Jingle"""),
-}
-
-
-class XEP_0339:
-    def __init__(self, host):
-        log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
-        self.host = host
-        host.trigger.add("XEP-0167_parse_sdp_a", self._parse_sdp_a_trigger)
-        host.trigger.add(
-            "XEP-0167_generate_sdp_content", self._generate_sdp_content_trigger
-        )
-        host.trigger.add("XEP-0167_parse_description", self._parse_description_trigger)
-        host.trigger.add("XEP-0167_build_description", self._build_description_trigger)
-
-    def get_handler(self, client):
-        return XEP_0339_handler()
-
-    def _parse_sdp_a_trigger(
-        self,
-        attribute: str,
-        parts: List[str],
-        call_data: dict,
-        metadata: dict,
-        media_type: str,
-        application_data: dict,
-        transport_data: dict,
-    ) -> None:
-        """Parse "ssrc" attributes"""
-        if attribute == "ssrc":
-            assert application_data is not None
-            ssrc_id = int(parts[0])
-
-            if len(parts) > 1:
-                name, *values = " ".join(parts[1:]).split(":", 1)
-                if values:
-                    value = values[0] or None
-                else:
-                    value = None
-                application_data.setdefault("ssrc", {}).setdefault(ssrc_id, {})[
-                    name
-                ] = value
-            else:
-                log.warning(f"no attribute in ssrc: {' '.join(parts)}")
-                application_data.setdefault("ssrc", {}).setdefault(ssrc_id, {})
-        elif attribute == "ssrc-group":
-            assert application_data is not None
-            semantics, *ssrc_ids = parts
-            ssrc_ids = [int(ssrc_id) for ssrc_id in ssrc_ids]
-            application_data.setdefault("ssrc-group", {})[semantics] = ssrc_ids
-        elif attribute == "msid":
-            assert application_data is not None
-            application_data["msid"] = " ".join(parts)
-
-
-    def _generate_sdp_content_trigger(
-        self,
-        session: dict,
-        local: bool,
-        idx: int,
-        content_data: dict,
-        sdp_lines: List[str],
-        application_data: dict,
-        app_data_key: str,
-        media_data: dict,
-        media: str
-    ) -> None:
-        """Generate "msid" and "ssrc" attributes"""
-        if "msid" in media_data:
-            sdp_lines.append(f"a=msid:{media_data['msid']}")
-
-        ssrc_data = media_data.get("ssrc", {})
-        ssrc_group_data = media_data.get("ssrc-group", {})
-
-        for ssrc_id, attributes in ssrc_data.items():
-            if not attributes:
-                # there are no attributes for this SSRC ID, we add a simple line with only
-                # the SSRC ID
-                sdp_lines.append(f"a=ssrc:{ssrc_id}")
-            else:
-                for attr_name, attr_value in attributes.items():
-                    if attr_value is not None:
-                        sdp_lines.append(f"a=ssrc:{ssrc_id} {attr_name}:{attr_value}")
-                    else:
-                        sdp_lines.append(f"a=ssrc:{ssrc_id} {attr_name}")
-        for semantics, ssrc_ids in ssrc_group_data.items():
-            ssrc_lines = " ".join(str(ssrc_id) for ssrc_id in ssrc_ids)
-            sdp_lines.append(f"a=ssrc-group:{semantics} {ssrc_lines}")
-
-    def _parse_description_trigger(
-        self, desc_elt: domish.Element, media_data: dict
-    ) -> bool:
-        """Parse the <source> and <ssrc-group> elements"""
-        for source_elt in desc_elt.elements(NS_JINGLE_RTP_SSMA, "source"):
-            try:
-                ssrc_id = int(source_elt["ssrc"])
-                media_data.setdefault("ssrc", {})[ssrc_id] = {}
-                for param_elt in source_elt.elements(NS_JINGLE_RTP_SSMA, "parameter"):
-                    name = param_elt["name"]
-                    value = param_elt.getAttribute("value")
-                    media_data["ssrc"][ssrc_id][name] = value
-                    if name == "msid" and "msid" not in media_data:
-                        media_data["msid"] = value
-            except (KeyError, ValueError) as e:
-                log.warning(f"Error while parsing <source>: {e}\n{source_elt.toXml()}")
-
-        for ssrc_group_elt in desc_elt.elements(NS_JINGLE_RTP_SSMA, "ssrc-group"):
-            try:
-                semantics = ssrc_group_elt["semantics"]
-                semantic_ids = media_data.setdefault("ssrc-group", {})[semantics] = []
-                for source_elt in ssrc_group_elt.elements(NS_JINGLE_RTP_SSMA, "source"):
-                    semantic_ids.append(
-                        int(source_elt["ssrc"])
-                    )
-            except (KeyError, ValueError) as e:
-                log.warning(
-                    f"Error while parsing <ssrc-group>: {e}\n{ssrc_group_elt.toXml()}"
-                )
-
-        return True
-
-    def _build_description_trigger(
-        self, desc_elt: domish.Element, media_data: dict, session: dict
-    ) -> bool:
-        """Build the <source> and <ssrc-group> elements if possible"""
-        for ssrc_id, parameters in media_data.get("ssrc", {}).items():
-            if "msid" not in parameters and "msid" in media_data:
-                parameters["msid"] = media_data["msid"]
-            source_elt = desc_elt.addElement((NS_JINGLE_RTP_SSMA, "source"))
-            source_elt["ssrc"] = str(ssrc_id)
-            for name, value in parameters.items():
-                param_elt = source_elt.addElement((NS_JINGLE_RTP_SSMA, "parameter"))
-                param_elt["name"] = name
-                if value is not None:
-                    param_elt["value"] = value
-
-        for semantics, ssrc_ids in media_data.get("ssrc-group", {}).items():
-            ssrc_group_elt = desc_elt.addElement((NS_JINGLE_RTP_SSMA, "ssrc-group"))
-            ssrc_group_elt["semantics"] = semantics
-            for ssrc_id in ssrc_ids:
-                source_elt = ssrc_group_elt.addElement((NS_JINGLE_RTP_SSMA, "source"))
-                source_elt["ssrc"] = str(ssrc_id)
-
-        return True
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0339_handler(XMPPHandler):
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_JINGLE_RTP_SSMA)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0346.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,750 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin for XEP-0346
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from collections.abc import Iterable
-import itertools
-from typing import Optional
-from zope.interface import implementer
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from twisted.internet import defer
-from wokkel import disco, iwokkel
-from wokkel import data_form
-from wokkel import generic
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.xmpp import SatXMPPEntity
-from sat.tools import xml_tools
-from sat.tools import utils
-from sat.tools.common import date_utils
-from sat.tools.common import data_format
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-NS_FDP = "urn:xmpp:fdp:0"
-TEMPLATE_PREFIX = "fdp/template/"
-SUBMITTED_PREFIX = "fdp/submitted/"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Form Discovery and Publishing",
-    C.PI_IMPORT_NAME: "XEP-0346",
-    C.PI_TYPE: "EXP",
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060", "IDENTITY"],
-    C.PI_MAIN: "PubsubSchema",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Handle Pubsub data schemas"""),
-}
-
-
-class PubsubSchema(object):
-    def __init__(self, host):
-        log.info(_("PubSub Schema initialization"))
-        self.host = host
-        self._p = self.host.plugins["XEP-0060"]
-        self._i = self.host.plugins["IDENTITY"]
-        host.bridge.add_method(
-            "ps_schema_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=self._get_schema,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_schema_set",
-            ".plugin",
-            in_sign="ssss",
-            out_sign="",
-            method=self._set_schema,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_schema_ui_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=lambda service, nodeIdentifier, profile_key: self._get_ui_schema(
-                service, nodeIdentifier, default_node=None, profile_key=profile_key),
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_schema_dict_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=self._get_schema_dict,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_schema_application_ns_get",
-            ".plugin",
-            in_sign="s",
-            out_sign="s",
-            method=self.get_application_ns,
-        )
-        host.bridge.add_method(
-            "ps_schema_template_node_get",
-            ".plugin",
-            in_sign="s",
-            out_sign="s",
-            method=self.get_template_ns,
-        )
-        host.bridge.add_method(
-            "ps_schema_submitted_node_get",
-            ".plugin",
-            in_sign="s",
-            out_sign="s",
-            method=self.get_submitted_ns,
-        )
-        host.bridge.add_method(
-            "ps_items_form_get",
-            ".plugin",
-            in_sign="ssssiassss",
-            out_sign="(asa{ss})",
-            method=self._get_data_form_items,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_item_form_send",
-            ".plugin",
-            in_sign="ssa{sas}ssa{ss}s",
-            out_sign="s",
-            method=self._send_data_form_item,
-            async_=True,
-        )
-
-    def get_handler(self, client):
-        return SchemaHandler()
-
-    def get_application_ns(self, namespace):
-        """Retrieve application namespace, i.e. namespace without FDP prefix"""
-        if namespace.startswith(SUBMITTED_PREFIX):
-            namespace = namespace[len(SUBMITTED_PREFIX):]
-        elif namespace.startswith(TEMPLATE_PREFIX):
-            namespace = namespace[len(TEMPLATE_PREFIX):]
-        return namespace
-
-    def get_template_ns(self, namespace: str) -> str:
-        """Returns node used for data template (i.e. schema)"""
-        app_ns = self.get_application_ns(namespace)
-        return f"{TEMPLATE_PREFIX}{app_ns}"
-
-    def get_submitted_ns(self, namespace: str) -> str:
-        """Returns node to use to submit forms"""
-        return f"{SUBMITTED_PREFIX}{self.get_application_ns(namespace)}"
-
-    def _get_schema_bridge_cb(self, schema_elt):
-        if schema_elt is None:
-            return ""
-        return schema_elt.toXml()
-
-    def _get_schema(self, service, nodeIdentifier, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        service = None if not service else jid.JID(service)
-        d = defer.ensureDeferred(self.get_schema(client, service, nodeIdentifier))
-        d.addCallback(self._get_schema_bridge_cb)
-        return d
-
-    async def get_schema(self, client, service, nodeIdentifier):
-        """retrieve PubSub node schema
-
-        @param service(jid.JID, None): jid of PubSub service
-            None to use our PEP
-        @param nodeIdentifier(unicode): node to get schema from
-        @return (domish.Element, None): schema (<x> element)
-            None if no schema has been set on this node
-        """
-        app_ns = self.get_application_ns(nodeIdentifier)
-        node_id = f"{TEMPLATE_PREFIX}{app_ns}"
-        items_data = await self._p.get_items(client, service, node_id, max_items=1)
-        try:
-            schema = next(items_data[0][0].elements(data_form.NS_X_DATA, 'x'))
-        except IndexError:
-            schema = None
-        except StopIteration:
-            log.warning(
-                f"No schema found in item of {service!r} at node {nodeIdentifier!r}: "
-                f"\n{items_data[0][0].toXml()}"
-            )
-            schema = None
-        return schema
-
-    async def get_schema_form(self, client, service, nodeIdentifier, schema=None,
-                      form_type="form", copy_form=True):
-        """Get data form from node's schema
-
-        @param service(None, jid.JID): PubSub service
-        @param nodeIdentifier(unicode): node
-        @param schema(domish.Element, data_form.Form, None): node schema
-            if domish.Element, will be converted to data form
-            if data_form.Form it will be returned without modification
-            if None, it will be retrieved from node (imply one additional XMPP request)
-        @param form_type(unicode): type of the form
-        @param copy_form(bool): if True and if schema is already a data_form.Form, will deep copy it before returning
-            needed when the form is reused and it will be modified (e.g. in send_data_form_item)
-        @return(data_form.Form): data form
-            the form should not be modified if copy_form is not set
-        """
-        if schema is None:
-            log.debug(_("unspecified schema, we need to request it"))
-            schema = await self.get_schema(client, service, nodeIdentifier)
-            if schema is None:
-                raise exceptions.DataError(
-                    _(
-                        "no schema specified, and this node has no schema either, we can't construct the data form"
-                    )
-                )
-        elif isinstance(schema, data_form.Form):
-            if copy_form:
-                # XXX: we don't use deepcopy as it will do an infinite loop if a
-                #      domish.Element is present in the form fields (happens for
-                #      XEP-0315 data forms XML Element)
-                schema = data_form.Form(
-                    formType = schema.formType,
-                    title = schema.title,
-                    instructions = schema.instructions[:],
-                    formNamespace = schema.formNamespace,
-                    fields = schema.fieldList,
-                )
-            return schema
-
-        try:
-            form = data_form.Form.fromElement(schema)
-        except data_form.Error as e:
-            raise exceptions.DataError(_("Invalid Schema: {msg}").format(msg=e))
-        form.formType = form_type
-        return form
-
-    def schema_2_xmlui(self, schema_elt):
-        form = data_form.Form.fromElement(schema_elt)
-        xmlui = xml_tools.data_form_2_xmlui(form, "")
-        return xmlui
-
-    def _get_ui_schema(self, service, nodeIdentifier, default_node=None,
-                     profile_key=C.PROF_KEY_NONE):
-        if not nodeIdentifier:
-            if not default_node:
-                raise ValueError(_("nodeIndentifier needs to be set"))
-            nodeIdentifier = default_node
-        client = self.host.get_client(profile_key)
-        service = None if not service else jid.JID(service)
-        d = self.get_ui_schema(client, service, nodeIdentifier)
-        d.addCallback(lambda xmlui: xmlui.toXml())
-        return d
-
-    def get_ui_schema(self, client, service, nodeIdentifier):
-        d = defer.ensureDeferred(self.get_schema(client, service, nodeIdentifier))
-        d.addCallback(self.schema_2_xmlui)
-        return d
-
-    def _set_schema(self, service, nodeIdentifier, schema, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        service = None if not service else jid.JID(service)
-        schema = generic.parseXml(schema.encode())
-        return defer.ensureDeferred(
-            self.set_schema(client, service, nodeIdentifier, schema)
-        )
-
-    async def set_schema(self, client, service, nodeIdentifier, schema):
-        """Set or replace PubSub node schema
-
-        @param schema(domish.Element, None): schema to set
-            None if schema need to be removed
-        """
-        node_id = self.get_template_ns(nodeIdentifier)
-        node_options = {
-            self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN,
-            self._p.OPT_PERSIST_ITEMS: 1,
-            self._p.OPT_MAX_ITEMS: 1,
-            self._p.OPT_DELIVER_PAYLOADS: 1,
-            self._p.OPT_SEND_ITEM_SUBSCRIBE: 1,
-            self._p.OPT_PUBLISH_MODEL: self._p.PUBLISH_MODEL_PUBLISHERS,
-        }
-        await self._p.create_if_new_node(client, service, node_id, node_options)
-        await self._p.send_item(client, service, node_id, schema, self._p.ID_SINGLETON)
-
-    def _get_schema_dict(self, service, nodeIdentifier, profile):
-        service = None if not service else jid.JID(service)
-        client = self.host.get_client(profile)
-        d = defer.ensureDeferred(self.get_schema_dict(client, service, nodeIdentifier))
-        d.addCallback(data_format.serialise)
-        return d
-
-    async def get_schema_dict(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        nodeIdentifier: str) -> dict:
-        """Retrieve a node schema and format it a simple dictionary
-
-        The dictionary is made so it can be easily serialisable
-        """
-        schema_form = await self.get_schema_form(client, service, nodeIdentifier)
-        return xml_tools.data_form_2_data_dict(schema_form)
-
-    def _get_data_form_items(self, form_ns="", service="", node="", schema="", max_items=10,
-                          item_ids=None, sub_id=None, extra="",
-                          profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        service = jid.JID(service) if service else None
-        if not node:
-            raise exceptions.DataError(_("empty node is not allowed"))
-        if schema:
-            schema = generic.parseXml(schema.encode("utf-8"))
-        else:
-            schema = None
-        max_items = None if max_items == C.NO_LIMIT else max_items
-        extra = self._p.parse_extra(data_format.deserialise(extra))
-        d = defer.ensureDeferred(
-            self.get_data_form_items(
-                client,
-                service,
-                node,
-                schema,
-                max_items or None,
-                item_ids,
-                sub_id or None,
-                extra.rsm_request,
-                extra.extra,
-                form_ns=form_ns or None,
-            )
-        )
-        d.addCallback(self._p.trans_items_data)
-        return d
-
-    async def get_data_form_items(self, client, service, nodeIdentifier, schema=None,
-                         max_items=None, item_ids=None, sub_id=None, rsm_request=None,
-                         extra=None, default_node=None, form_ns=None, filters=None):
-        """Get items known as being data forms, and convert them to XMLUI
-
-        @param schema(domish.Element, data_form.Form, None): schema of the node if known
-            if None, it will be retrieved from node
-        @param default_node(unicode): node to use if nodeIdentifier is None or empty
-        @param form_ns (unicode, None): namespace of the form
-            None to accept everything, even if form has no namespace
-        @param filters(dict, None): same as for xml_tools.data_form_result_2_xmlui
-        other parameters as the same as for [get_items]
-        @return (list[unicode]): XMLUI of the forms
-            if an item is invalid (not corresponding to form_ns or not a data_form)
-            it will be skipped
-        @raise ValueError: one argument is invalid
-        """
-        if not nodeIdentifier:
-            if not default_node:
-                raise ValueError(
-                    _("default_node must be set if nodeIdentifier is not set")
-                )
-            nodeIdentifier = default_node
-        submitted_ns = self.get_submitted_ns(nodeIdentifier)
-        # we need the initial form to get options of fields when suitable
-        schema_form = await self.get_schema_form(
-            client, service, nodeIdentifier, schema, form_type="result", copy_form=False
-        )
-        items_data = await self._p.get_items(
-            client,
-            service,
-            submitted_ns,
-            max_items,
-            item_ids,
-            sub_id,
-            rsm_request,
-            extra,
-        )
-        items, metadata = items_data
-        items_xmlui = []
-        for item_elt in items:
-            for x_elt in item_elt.elements(data_form.NS_X_DATA, "x"):
-                form = data_form.Form.fromElement(x_elt)
-                if form_ns and form.formNamespace != form_ns:
-                    log.debug(
-                        f"form's namespace ({form.formNamespace!r}) differs from expected"
-                        f"{form_ns!r}"
-                    )
-                    continue
-                prepend = [
-                    ("label", "id"),
-                    ("text", item_elt["id"], "id"),
-                    ("label", "publisher"),
-                ]
-                try:
-                    publisher = jid.JID(item_elt['publisher'])
-                except (KeyError, jid.InvalidFormat):
-                    pass
-                else:
-                    prepend.append(("jid", publisher, "publisher"))
-                xmlui = xml_tools.data_form_result_2_xmlui(
-                    form,
-                    schema_form,
-                    # FIXME: conflicts with schema (i.e. if "id" or "publisher" already exists)
-                    #        are not checked
-                    prepend=prepend,
-                    filters=filters,
-                    read_only=False,
-                )
-                items_xmlui.append(xmlui)
-                break
-        return (items_xmlui, metadata)
-
-    def _send_data_form_item(self, service, nodeIdentifier, values, schema=None,
-                          item_id=None, extra=None, profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        service = None if not service else jid.JID(service)
-        if schema:
-            schema = generic.parseXml(schema.encode("utf-8"))
-        else:
-            schema = None
-        d = defer.ensureDeferred(
-            self.send_data_form_item(
-                client,
-                service,
-                nodeIdentifier,
-                values,
-                schema,
-                item_id or None,
-                extra,
-                deserialise=True,
-            )
-        )
-        d.addCallback(lambda ret: ret or "")
-        return d
-
-    async def send_data_form_item(
-        self, client, service, nodeIdentifier, values, schema=None, item_id=None,
-        extra=None, deserialise=False):
-        """Publish an item as a dataform when we know that there is a schema
-
-        @param values(dict[key(unicode), [iterable[object], object]]): values set for the
-            form. If not iterable, will be put in a list.
-        @param schema(domish.Element, data_form.Form, None): data schema
-            None to retrieve data schema from node (need to do a additional XMPP call)
-            Schema is needed to construct data form to publish
-        @param deserialise(bool): if True, data are list of unicode and must be
-            deserialized according to expected type.
-            This is done in this method and not directly in _send_data_form_item because we
-            need to know the data type which is in the form, not availablable in
-            _send_data_form_item
-        other parameters as the same as for [self._p.send_item]
-        @return (unicode): id of the created item
-        """
-        form = await self.get_schema_form(
-            client, service, nodeIdentifier, schema, form_type="submit"
-        )
-
-        for name, values_list in values.items():
-            try:
-                field = form.fields[name]
-            except KeyError:
-                log.warning(
-                    _("field {name} doesn't exist, ignoring it").format(name=name)
-                )
-                continue
-            if isinstance(values_list, str) or not isinstance(
-                values_list, Iterable
-            ):
-                values_list = [values_list]
-            if deserialise:
-                if field.fieldType == "boolean":
-                    values_list = [C.bool(v) for v in values_list]
-                elif field.fieldType == "text-multi":
-                    # for text-multi, lines must be put on separate values
-                    values_list = list(
-                        itertools.chain(*[v.splitlines() for v in values_list])
-                    )
-                elif xml_tools.is_xhtml_field(field):
-                   values_list = [generic.parseXml(v.encode("utf-8"))
-                                  for v in values_list]
-                elif "jid" in (field.fieldType or ""):
-                    values_list = [jid.JID(v) for v in values_list]
-            if "list" in (field.fieldType or ""):
-                # for lists, we check that given values are allowed in form
-                allowed_values = [o.value for o in field.options]
-                values_list = [v for v in values_list if v in allowed_values]
-                if not values_list:
-                    # if values don't map to allowed values, we use default ones
-                    values_list = field.values
-            elif field.ext_type == 'xml':
-                # FIXME: XML elements are not handled correctly, we need to know if we
-                #        have actual XML/XHTML, or text to escape
-                for idx, value in enumerate(values_list[:]):
-                    if isinstance(value, domish.Element):
-                        if (field.value and (value.name != field.value.name
-                                             or value.uri != field.value.uri)):
-                            # the element is not the one expected in form, so we create the right element
-                            # to wrap the current value
-                            wrapper_elt = domish.Element((field.value.uri, field.value.name))
-                            wrapper_elt.addChild(value)
-                            values_list[idx] = wrapper_elt
-                    else:
-                        # we have to convert the value to a domish.Element
-                        if field.value and field.value.uri == C.NS_XHTML:
-                            div_elt = domish.Element((C.NS_XHTML, 'div'))
-                            div_elt.addContent(str(value))
-                            values_list[idx] = div_elt
-                        else:
-                            # only XHTML fields are handled for now
-                            raise NotImplementedError
-
-            field.values = values_list
-
-        return await self._p.send_item(
-            client, service, nodeIdentifier, form.toElement(), item_id, extra
-        )
-
-    ## filters ##
-    # filters useful for data form to XMLUI conversion #
-
-    def value_or_publisher_filter(self, form_xmlui, widget_type, args, kwargs):
-        """Replace missing value by publisher's user part"""
-        if not args[0]:
-            # value is not filled: we use user part of publisher (if we have it)
-            try:
-                publisher = jid.JID(form_xmlui.named_widgets["publisher"].value)
-            except (KeyError, RuntimeError):
-                pass
-            else:
-                args[0] = publisher.user.capitalize()
-        return widget_type, args, kwargs
-
-    def textbox_2_list_filter(self, form_xmlui, widget_type, args, kwargs):
-        """Split lines of a textbox in a list
-
-        main use case is using a textbox for labels
-        """
-        if widget_type != "textbox":
-            return widget_type, args, kwargs
-        widget_type = "list"
-        options = [o for o in args.pop(0).split("\n") if o]
-        kwargs = {
-            "options": options,
-            "name": kwargs.get("name"),
-            "styles": ("noselect", "extensible", "reducible"),
-        }
-        return widget_type, args, kwargs
-
-    def date_filter(self, form_xmlui, widget_type, args, kwargs):
-        """Convert a string with a date to a unix timestamp"""
-        if widget_type != "string" or not args[0]:
-            return widget_type, args, kwargs
-        # we convert XMPP date to timestamp
-        try:
-            args[0] = str(date_utils.date_parse(args[0]))
-        except Exception as e:
-            log.warning(_("Can't parse date field: {msg}").format(msg=e))
-        return widget_type, args, kwargs
-
-    ## Helper methods ##
-
-    def prepare_bridge_get(self, service, node, max_items, sub_id, extra, profile_key):
-        """Parse arguments received from bridge *Get methods and return higher level data
-
-        @return (tuple): (client, service, node, max_items, extra, sub_id) usable for
-            internal methods
-        """
-        client = self.host.get_client(profile_key)
-        service = jid.JID(service) if service else None
-        if not node:
-            node = None
-        max_items = None if max_items == C.NO_LIMIT else max_items
-        if not sub_id:
-            sub_id = None
-        extra = self._p.parse_extra(extra)
-
-        return client, service, node, max_items, extra, sub_id
-
-    def _get(self, service="", node="", max_items=10, item_ids=None, sub_id=None,
-             extra="", default_node=None, form_ns=None, filters=None,
-             profile_key=C.PROF_KEY_NONE):
-        """bridge method to retrieve data from node with schema
-
-        this method is a helper so dependant plugins can use it directly
-        when adding *Get methods
-        extra can have the key "labels_as_list" which is a hack to convert
-            labels from textbox to list in XMLUI, which usually render better
-            in final UI.
-        """
-        if filters is None:
-            filters = {}
-        extra = data_format.deserialise(extra)
-        # XXX: Q&D way to get list for labels when displaying them, but text when we
-        #      have to modify them
-        if C.bool(extra.get("labels_as_list", C.BOOL_FALSE)):
-            filters = filters.copy()
-            filters["labels"] = self.textbox_2_list_filter
-        client, service, node, max_items, extra, sub_id = self.prepare_bridge_get(
-            service, node, max_items, sub_id, extra, profile_key
-        )
-        d = defer.ensureDeferred(
-            self.get_data_form_items(
-                client,
-                service,
-                node or None,
-                max_items=max_items,
-                item_ids=item_ids,
-                sub_id=sub_id,
-                rsm_request=extra.rsm_request,
-                extra=extra.extra,
-                default_node=default_node,
-                form_ns=form_ns,
-                filters=filters,
-            )
-        )
-        d.addCallback(self._p.trans_items_data)
-        d.addCallback(lambda data: data_format.serialise(data))
-        return d
-
-    def prepare_bridge_set(self, service, node, schema, item_id, extra, profile_key):
-        """Parse arguments received from bridge *Set methods and return higher level data
-
-        @return (tuple): (client, service, node, schema, item_id, extra) usable for
-            internal methods
-        """
-        client = self.host.get_client(profile_key)
-        service = None if not service else jid.JID(service)
-        if schema:
-            schema = generic.parseXml(schema.encode("utf-8"))
-        else:
-            schema = None
-        extra = data_format.deserialise(extra)
-        return client, service, node or None, schema, item_id or None, extra
-
-    async def copy_missing_values(self, client, service, node, item_id, form_ns, values):
-        """Retrieve values existing in original item and missing in update
-
-        Existing item will be retrieve, and values not already specified in values will
-        be filled
-        @param service: same as for [XEP_0060.get_items]
-        @param node: same as for [XEP_0060.get_items]
-        @param item_id(unicode): id of the item to retrieve
-        @param form_ns (unicode, None): namespace of the form
-        @param values(dict): values to fill
-            This dict will be modified *in place* to fill value present in existing
-            item and missing in the dict.
-        """
-        try:
-            # we get previous item
-            items_data = await self._p.get_items(
-                client, service, node, item_ids=[item_id]
-            )
-            item_elt = items_data[0][0]
-        except Exception as e:
-            log.warning(
-                _("Can't get previous item, update ignored: {reason}").format(
-                    reason=e
-                )
-            )
-        else:
-            # and parse it
-            form = data_form.findForm(item_elt, form_ns)
-            if form is None:
-                log.warning(
-                    _("Can't parse previous item, update ignored: data form not found")
-                )
-            else:
-                for name, field in form.fields.items():
-                    if name not in values:
-                        values[name] = "\n".join(str(v) for v in field.values)
-
-    def _set(self, service, node, values, schema=None, item_id=None, extra=None,
-             default_node=None, form_ns=None, fill_author=True,
-             profile_key=C.PROF_KEY_NONE):
-        """bridge method to set item in node with schema
-
-        this method is a helper so dependant plugins can use it directly
-        when adding *Set methods
-        """
-        client, service, node, schema, item_id, extra = self.prepare_bridge_set(
-            service, node, schema, item_id, extra
-        )
-        d = defer.ensureDeferred(self.set(
-            client,
-            service,
-            node,
-            values,
-            schema,
-            item_id,
-            extra,
-            deserialise=True,
-            form_ns=form_ns,
-            default_node=default_node,
-            fill_author=fill_author,
-        ))
-        d.addCallback(lambda ret: ret or "")
-        return d
-
-    async def set(
-            self, client, service, node, values, schema, item_id, extra, deserialise,
-            form_ns, default_node=None, fill_author=True):
-        """Set an item in a node with a schema
-
-        This method can be used directly by *Set methods added by dependant plugin
-        @param values(dict[key(unicode), [iterable[object]|object]]): values of the items
-            if value is not iterable, it will be put in a list
-            'created' and 'updated' will be forced to current time:
-                - 'created' is set if item_id is None, i.e. if it's a new ticket
-                - 'updated' is set everytime
-        @param extra(dict, None): same as for [XEP-0060.send_item] with additional keys:
-            - update(bool): if True, get previous item data to merge with current one
-                if True, item_id must be set
-        @param form_ns (unicode, None): namespace of the form
-            needed when an update is done
-        @param default_node(unicode, None): value to use if node is not set
-        other arguments are same as for [self._s.send_data_form_item]
-        @return (unicode): id of the created item
-        """
-        if extra is None:
-            extra = {}
-        if not node:
-            if default_node is None:
-                raise ValueError(_("default_node must be set if node is not set"))
-            node = default_node
-        node = self.get_submitted_ns(node)
-        now = utils.xmpp_date()
-        if not item_id:
-            values["created"] = now
-        elif extra.get("update", False):
-            if item_id is None:
-                raise exceptions.DataError(
-                    _('if extra["update"] is set, item_id must be set too')
-                )
-            await self.copy_missing_values(client, service, node, item_id, form_ns, values)
-
-        values["updated"] = now
-        if fill_author:
-            if not values.get("author"):
-                id_data = await self._i.get_identity(client, None, ["nicknames"])
-                values["author"] = id_data['nicknames'][0]
-            if not values.get("author_jid"):
-                values["author_jid"] = client.jid.full()
-        item_id = await self.send_data_form_item(
-            client, service, node, values, schema, item_id, extra, deserialise
-        )
-        return item_id
-
-
-@implementer(iwokkel.IDisco)
-class SchemaHandler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_FDP)]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0352.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,83 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Explicit Message Encryption
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from twisted.words.xish import domish
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Client State Indication",
-    C.PI_IMPORT_NAME: "XEP-0352",
-    C.PI_TYPE: C.PLUG_TYPE_XEP,
-    C.PI_PROTOCOLS: ["XEP-0352"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "XEP_0352",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: D_("Notify server when frontend is not actively used, to limit "
-                         "traffic and save bandwidth and battery life"),
-}
-
-NS_CSI = "urn:xmpp:csi:0"
-
-
-class XEP_0352(object):
-
-    def __init__(self, host):
-        log.info(_("Client State Indication plugin initialization"))
-        self.host = host
-        host.register_namespace("csi", NS_CSI)
-
-    def is_active(self, client):
-        try:
-            if not client._xep_0352_enabled:
-                return True
-            return client._xep_0352_active
-        except AttributeError:
-            # _xep_0352_active can not be set if is_active is called before
-            # profile_connected has been called
-            log.debug("is_active called when XEP-0352 plugin has not yet set the "
-                      "attributes")
-            return True
-
-    def profile_connected(self, client):
-        if (NS_CSI, 'csi') in client.xmlstream.features:
-            log.info(_("Client State Indication is available on this server"))
-            client._xep_0352_enabled = True
-            client._xep_0352_active = True
-        else:
-            log.warning(_("Client State Indication is not available on this server, some"
-                          " bandwidth optimisations can't be used."))
-            client._xep_0352_enabled = False
-
-    def set_inactive(self, client):
-        if self.is_active(client):
-            inactive_elt = domish.Element((NS_CSI, 'inactive'))
-            client.send(inactive_elt)
-            client._xep_0352_active = False
-            log.info("inactive state set")
-
-    def set_active(self, client):
-        if not self.is_active(client):
-            active_elt = domish.Element((NS_CSI, 'active'))
-            client.send(active_elt)
-            client._xep_0352_active = True
-            log.info("active state set")
--- a/sat/plugins/plugin_xep_0353.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,263 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for Jingle Message Initiation (XEP-0353)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from twisted.internet import defer
-from twisted.internet import reactor
-from twisted.words.protocols.jabber import error, jid
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import D_, _
-from sat.core.log import getLogger
-from sat.tools import xml_tools
-
-log = getLogger(__name__)
-
-
-NS_JINGLE_MESSAGE = "urn:xmpp:jingle-message:0"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Jingle Message Initiation",
-    C.PI_IMPORT_NAME: "XEP-0353",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: [C.PLUG_MODE_CLIENT],
-    C.PI_PROTOCOLS: ["XEP-0353"],
-    C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0334"],
-    C.PI_MAIN: "XEP_0353",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Jingle Message Initiation"""),
-}
-
-
-class XEP_0353:
-    def __init__(self, host):
-        log.info(_("plugin {name} initialization").format(name=PLUGIN_INFO[C.PI_NAME]))
-        self.host = host
-        host.register_namespace("jingle-message", NS_JINGLE_MESSAGE)
-        self._j = host.plugins["XEP-0166"]
-        self._h = host.plugins["XEP-0334"]
-        host.trigger.add(
-            "XEP-0166_initiate_elt_built",
-            self._on_initiate_trigger,
-            # this plugin set the resource, we want it to happen first to other trigger
-            # can get the full peer JID
-            priority=host.trigger.MAX_PRIORITY,
-        )
-        host.trigger.add("message_received", self._on_message_received)
-
-    def get_handler(self, client):
-        return Handler()
-
-    def profile_connecting(self, client):
-        # mapping from session id to deferred used to wait for destinee answer
-        client._xep_0353_pending_sessions = {}
-
-    def build_message_data(self, client, peer_jid, verb, session_id):
-        mess_data = {
-            "from": client.jid,
-            "to": peer_jid,
-            "uid": "",
-            "message": {},
-            "type": C.MESS_TYPE_CHAT,
-            "subject": {},
-            "extra": {},
-        }
-        client.generate_message_xml(mess_data)
-        message_elt = mess_data["xml"]
-        verb_elt = message_elt.addElement((NS_JINGLE_MESSAGE, verb))
-        verb_elt["id"] = session_id
-        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
-        return mess_data
-
-    async def _on_initiate_trigger(
-        self,
-        client: SatXMPPEntity,
-        session: dict,
-        iq_elt: domish.Element,
-        jingle_elt: domish.Element,
-    ) -> bool:
-        peer_jid = session["peer_jid"]
-        if peer_jid.resource:
-            return True
-
-        try:
-            infos = await self.host.memory.disco.get_infos(client, peer_jid)
-        except error.StanzaError as e:
-            if e.condition == "service-unavailable":
-                categories = {}
-            else:
-                raise e
-        else:
-            categories = {c for c, __ in infos.identities}
-        if "component" in categories:
-            # we don't use message initiation with components
-            return True
-
-        if peer_jid.userhostJID() not in client.roster:
-            # if the contact is not in our roster, we need to send a directed presence
-            # according to XEP-0353 §3.1
-            await client.presence.available(peer_jid)
-
-        mess_data = self.build_message_data(client, peer_jid, "propose", session["id"])
-        message_elt = mess_data["xml"]
-        for content_data in session["contents"].values():
-            # we get the full element build by the application plugin
-            jingle_description_elt = content_data["application_data"]["desc_elt"]
-            # and copy it to only keep the root <description> element, no children
-            description_elt = domish.Element(
-                (jingle_description_elt.uri, jingle_description_elt.name),
-                defaultUri=jingle_description_elt.defaultUri,
-                attribs=jingle_description_elt.attributes,
-                localPrefixes=jingle_description_elt.localPrefixes,
-            )
-            message_elt.propose.addChild(description_elt)
-        response_d = defer.Deferred()
-        # we wait for 2 min before cancelling the session init
-        # FIXME: let's application decide timeout?
-        response_d.addTimeout(2 * 60, reactor)
-        client._xep_0353_pending_sessions[session["id"]] = response_d
-        await client.send_message_data(mess_data)
-        try:
-            accepting_jid = await response_d
-        except defer.TimeoutError:
-            log.warning(
-                _("Message initiation with {peer_jid} timed out").format(
-                    peer_jid=peer_jid
-                )
-            )
-        else:
-            if iq_elt["to"] != accepting_jid.userhost():
-                raise exceptions.InternalError(
-                    f"<jingle> 'to' attribute ({iq_elt['to']!r}) must not differ "
-                    f"from bare JID of the accepting entity ({accepting_jid!r}), this "
-                    "may be a sign of an internal bug, a hack attempt, or a MITM attack!"
-                )
-            iq_elt["to"] = accepting_jid.full()
-            session["peer_jid"] = accepting_jid
-        del client._xep_0353_pending_sessions[session["id"]]
-        return True
-
-    async def _on_message_received(self, client, message_elt, post_treat):
-        for elt in message_elt.elements():
-            if elt.uri == NS_JINGLE_MESSAGE:
-                if elt.name == "propose":
-                    return await self._handle_propose(client, message_elt, elt)
-                elif elt.name == "retract":
-                    return self._handle_retract(client, message_elt, elt)
-                elif elt.name == "proceed":
-                    return self._handle_proceed(client, message_elt, elt)
-                elif elt.name == "accept":
-                    return self._handle_accept(client, message_elt, elt)
-                elif elt.name == "reject":
-                    return self._handle_accept(client, message_elt, elt)
-                else:
-                    log.warning(f"invalid element: {elt.toXml}")
-                    return True
-        return True
-
-    async def _handle_propose(self, client, message_elt, elt):
-        peer_jid = jid.JID(message_elt["from"])
-        session_id = elt["id"]
-        if peer_jid.userhostJID() not in client.roster:
-            app_ns = elt.description.uri
-            try:
-                application = self._j.get_application(app_ns)
-                human_name = getattr(application.handler, "human_name", application.name)
-            except (exceptions.NotFound, AttributeError):
-                if app_ns.startswith("urn:xmpp:jingle:apps:"):
-                    human_name = app_ns[21:].split(":", 1)[0].replace("-", " ").title()
-                else:
-                    splitted_ns = app_ns.split(":")
-                    if len(splitted_ns) > 1:
-                        human_name = splitted_ns[-2].replace("- ", " ").title()
-                    else:
-                        human_name = app_ns
-
-            confirm_msg = D_(
-                "Somebody not in your contact list ({peer_jid}) wants to do a "
-                '"{human_name}" session with you, this would leak your presence and '
-                "possibly you IP (internet localisation), do you accept?"
-            ).format(peer_jid=peer_jid, human_name=human_name)
-            confirm_title = D_("Invitation from an unknown contact")
-            accept = await xml_tools.defer_confirm(
-                self.host,
-                confirm_msg,
-                confirm_title,
-                profile=client.profile,
-                action_extra={
-                    "type": C.META_TYPE_NOT_IN_ROSTER_LEAK,
-                    "session_id": session_id,
-                    "from_jid": peer_jid.full(),
-                },
-            )
-            if not accept:
-                mess_data = self.build_message_data(
-                    client, client.jid.userhostJID(), "reject", session_id
-                )
-                await client.send_message_data(mess_data)
-                # we don't sent anything to sender, to avoid leaking presence
-                return False
-            else:
-                await client.presence.available(peer_jid)
-        session_id = elt["id"]
-        mess_data = self.build_message_data(client, peer_jid, "proceed", session_id)
-        await client.send_message_data(mess_data)
-        return False
-
-    def _handle_retract(self, client, message_elt, proceed_elt):
-        log.warning("retract is not implemented yet")
-        return False
-
-    def _handle_proceed(self, client, message_elt, proceed_elt):
-        try:
-            session_id = proceed_elt["id"]
-        except KeyError:
-            log.warning(f"invalid proceed element in message_elt: {message_elt}")
-            return True
-        try:
-            response_d = client._xep_0353_pending_sessions[session_id]
-        except KeyError:
-            log.warning(
-                _(
-                    "no pending session found with id {session_id}, did it timed out?"
-                ).format(session_id=session_id)
-            )
-            return True
-
-        response_d.callback(jid.JID(message_elt["from"]))
-        return False
-
-    def _handle_accept(self, client, message_elt, accept_elt):
-        pass
-
-    def _handle_reject(self, client, message_elt, accept_elt):
-        pass
-
-
-@implementer(iwokkel.IDisco)
-class Handler(xmlstream.XMPPHandler):
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_JINGLE_MESSAGE)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0359.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,143 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Message Archive Management (XEP-0359)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional
-import uuid
-from zope.interface import implementer
-from twisted.words.protocols.jabber import xmlstream
-from wokkel import disco
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from twisted.words.xish import domish
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Unique and Stable Stanza IDs",
-    C.PI_IMPORT_NAME: "XEP-0359",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0359"],
-    C.PI_MAIN: "XEP_0359",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of Unique and Stable Stanza IDs"""),
-}
-
-NS_SID = "urn:xmpp:sid:0"
-
-
-class XEP_0359(object):
-
-    def __init__(self, host):
-        log.info(_("Unique and Stable Stanza IDs plugin initialization"))
-        self.host = host
-        host.register_namespace("stanza_id", NS_SID)
-        host.trigger.add("message_parse", self._message_parse_trigger)
-        host.trigger.add("send_message_data", self._send_message_data_trigger)
-
-    def _message_parse_trigger(self, client, message_elt, mess_data):
-        """Check if message has a stanza-id"""
-        stanza_id = self.get_stanza_id(message_elt, client.jid.userhostJID())
-        if stanza_id is not None:
-            mess_data['extra']['stanza_id'] = stanza_id
-        origin_id = self.get_origin_id(message_elt)
-        if origin_id is not None:
-            mess_data['extra']['origin_id'] = origin_id
-        return True
-
-    def _send_message_data_trigger(self, client, mess_data):
-        origin_id = mess_data["extra"].get("origin_id")
-        if not origin_id:
-            origin_id = str(uuid.uuid4())
-            mess_data["extra"]["origin_id"] = origin_id
-        message_elt = mess_data["xml"]
-        self.add_origin_id(message_elt, origin_id)
-
-    def get_stanza_id(self, element, by):
-        """Return stanza-id if found in element
-
-        @param element(domish.Element): element to parse
-        @param by(jid.JID): entity which should have set a stanza-id
-        @return (unicode, None): stanza-id if found
-        """
-        stanza_id = None
-        for stanza_elt in element.elements(NS_SID, "stanza-id"):
-            if stanza_elt.getAttribute("by") == by.full():
-                if stanza_id is not None:
-                    # we must not have more than one element (§3 #4)
-                    raise exceptions.DataError(
-                        "More than one corresponding stanza-id found!")
-                stanza_id = stanza_elt.getAttribute("id")
-                # we don't break to be sure that there is no more than one element
-                # with this "by" attribute
-
-        return stanza_id
-
-    def add_stanza_id(self, client, element, stanza_id, by=None):
-        """Add a <stanza-id/> to a stanza
-
-        @param element(domish.Element): stanza where the <stanza-id/> must be added
-        @param stanza_id(unicode): id to use
-        @param by(jid.JID, None): jid to use or None to use client.jid
-        """
-        sid_elt = element.addElement((NS_SID, "stanza-id"))
-        sid_elt["by"] = client.jid.userhost() if by is None else by.userhost()
-        sid_elt["id"] = stanza_id
-
-    def get_origin_id(self, element: domish.Element) -> Optional[str]:
-        """Return origin-id if found in element
-
-        @param element: element to parse
-        @return: origin-id if found
-        """
-        try:
-            origin_elt = next(element.elements(NS_SID, "origin-id"))
-        except StopIteration:
-            return None
-        else:
-            return origin_elt.getAttribute("id")
-
-    def add_origin_id(self, element, origin_id=None):
-        """Add a <origin-id/> to a stanza
-
-        @param element(domish.Element): stanza where the <origin-id/> must be added
-        @param origin_id(str): id to use, None to automatically generate
-        @return (str): origin_id
-        """
-        if origin_id is None:
-            origin_id = str(uuid.uuid4())
-        sid_elt = element.addElement((NS_SID, "origin-id"))
-        sid_elt["id"] = origin_id
-        return origin_id
-
-    def get_handler(self, client):
-        return XEP_0359_handler()
-
-
-@implementer(disco.IDisco)
-class XEP_0359_handler(xmlstream.XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_SID)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0363.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,451 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin for HTTP File Upload (XEP-0363)
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from dataclasses import dataclass
-import mimetypes
-import os.path
-from pathlib import Path
-from typing import Callable, NamedTuple, Optional, Tuple
-from urllib import parse
-
-from twisted.internet import reactor
-from twisted.internet import defer
-from twisted.web import client as http_client
-from twisted.web import http_headers
-from twisted.words.protocols.jabber import error, jid, xmlstream
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.xmpp import SatXMPPComponent
-from sat.tools import utils, web as sat_web
-
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "HTTP File Upload",
-    C.PI_IMPORT_NAME: "XEP-0363",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0363"],
-    C.PI_DEPENDENCIES: ["FILE", "UPLOAD"],
-    C.PI_MAIN: "XEP_0363",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of HTTP File Upload"""),
-}
-
-NS_HTTP_UPLOAD = "urn:xmpp:http:upload:0"
-IQ_HTTP_UPLOAD_REQUEST = C.IQ_GET + '/request[@xmlns="' + NS_HTTP_UPLOAD + '"]'
-ALLOWED_HEADERS = ('authorization', 'cookie', 'expires')
-
-
-@dataclass
-class Slot:
-    """Upload slot"""
-    put: str
-    get: str
-    headers: list
-
-
-class UploadRequest(NamedTuple):
-    from_: jid.JID
-    filename: str
-    size: int
-    content_type: Optional[str]
-
-
-class RequestHandler(NamedTuple):
-    callback: Callable[[SatXMPPComponent, UploadRequest], Optional[Slot]]
-    priority: int
-
-
-class XEP_0363:
-    Slot=Slot
-
-    def __init__(self, host):
-        log.info(_("plugin HTTP File Upload initialization"))
-        self.host = host
-        host.bridge.add_method(
-            "file_http_upload",
-            ".plugin",
-            in_sign="sssbs",
-            out_sign="",
-            method=self._file_http_upload,
-        )
-        host.bridge.add_method(
-            "file_http_upload_get_slot",
-            ".plugin",
-            in_sign="sisss",
-            out_sign="(ssaa{ss})",
-            method=self._get_slot,
-            async_=True,
-        )
-        host.plugins["UPLOAD"].register(
-            "HTTP Upload", self.get_http_upload_entity, self.file_http_upload
-        )
-        # list of callbacks used when a request is done to a component
-        self.handlers = []
-        # XXX: there is not yet official short name, so we use "http_upload"
-        host.register_namespace("http_upload", NS_HTTP_UPLOAD)
-
-    def get_handler(self, client):
-        return XEP_0363_handler(self)
-
-    def register_handler(self, callback, priority=0):
-        """Register a request handler
-
-        @param callack: method to call when a request is done
-            the callback must return a Slot if the request is handled,
-            otherwise, other callbacks will be tried.
-            If the callback raises a StanzaError, its condition will be used if no other
-            callback can handle the request.
-        @param priority: handlers with higher priorities will be called first
-        """
-        assert callback not in self.handlers
-        req_handler = RequestHandler(callback, priority)
-        self.handlers.append(req_handler)
-        self.handlers.sort(key=lambda handler: handler.priority, reverse=True)
-
-    def get_file_too_large_elt(self, max_size: int) -> domish.Element:
-        """Generate <file-too-large> app condition for errors"""
-        file_too_large_elt = domish.Element((NS_HTTP_UPLOAD, "file-too-large"))
-        file_too_large_elt.addElement("max-file-size", str(max_size))
-        return file_too_large_elt
-
-    async def get_http_upload_entity(self, client, upload_jid=None):
-        """Get HTTP upload capable entity
-
-         upload_jid is checked, then its components
-         @param upload_jid(None, jid.JID): entity to check
-         @return(D(jid.JID)): first HTTP upload capable entity
-         @raise exceptions.NotFound: no entity found
-         """
-        try:
-            entity = client.http_upload_service
-        except AttributeError:
-            found_entities = await self.host.find_features_set(client, (NS_HTTP_UPLOAD,))
-            try:
-                entity = client.http_upload_service = next(iter(found_entities))
-            except StopIteration:
-                entity = client.http_upload_service = None
-
-        if entity is None:
-            raise exceptions.NotFound("No HTTP upload entity found")
-
-        return entity
-
-    def _file_http_upload(self, filepath, filename="", upload_jid="",
-                        ignore_tls_errors=False, profile=C.PROF_KEY_NONE):
-        assert os.path.isabs(filepath) and os.path.isfile(filepath)
-        client = self.host.get_client(profile)
-        return defer.ensureDeferred(self.file_http_upload(
-            client,
-            filepath,
-            filename or None,
-            jid.JID(upload_jid) if upload_jid else None,
-            {"ignore_tls_errors": ignore_tls_errors},
-        ))
-
-    async def file_http_upload(
-        self,
-        client: SatXMPPEntity,
-        filepath: Path,
-        filename: Optional[str] = None,
-        upload_jid: Optional[jid.JID] = None,
-        extra: Optional[dict] = None
-    ) -> Tuple[str, defer.Deferred]:
-        """Upload a file through HTTP
-
-        @param filepath: absolute path of the file
-        @param filename: name to use for the upload
-            None to use basename of the path
-        @param upload_jid: upload capable entity jid,
-            or None to use autodetected, if possible
-        @param extra: options where key can be:
-            - ignore_tls_errors(bool): if True, SSL certificate will not be checked
-            - attachment(dict): file attachment data
-        @param profile: %(doc_profile)s
-        @return: progress id and Deferred which fire download URL
-        """
-        if extra is None:
-            extra = {}
-        ignore_tls_errors = extra.get("ignore_tls_errors", False)
-        file_metadata = {
-            "filename": filename or os.path.basename(filepath),
-            "filepath": filepath,
-            "size": os.path.getsize(filepath),
-        }
-
-        #: this trigger can be used to modify the filename or size requested when geting
-        #: the slot, it is notably useful with encryption.
-        self.host.trigger.point(
-            "XEP-0363_upload_pre_slot", client, extra, file_metadata,
-            triggers_no_cancel=True
-        )
-        try:
-            slot = await self.get_slot(
-                client, file_metadata["filename"], file_metadata["size"],
-                upload_jid=upload_jid
-            )
-        except Exception as e:
-            log.warning(_("Can't get upload slot: {reason}").format(reason=e))
-            raise e
-        else:
-            log.debug(f"Got upload slot: {slot}")
-            sat_file = self.host.plugins["FILE"].File(
-                self.host, client, filepath, uid=extra.get("progress_id"),
-                size=file_metadata["size"],
-                auto_end_signals=False
-            )
-            progress_id = sat_file.uid
-
-            file_producer = http_client.FileBodyProducer(sat_file)
-
-            if ignore_tls_errors:
-                agent = http_client.Agent(reactor, sat_web.NoCheckContextFactory())
-            else:
-                agent = http_client.Agent(reactor)
-
-            headers = {"User-Agent": [C.APP_NAME.encode("utf-8")]}
-
-            for name, value in slot.headers:
-                name = name.encode('utf-8')
-                value = value.encode('utf-8')
-                headers[name] = value
-
-
-            await self.host.trigger.async_point(
-                "XEP-0363_upload", client, extra, sat_file, file_producer, slot,
-                triggers_no_cancel=True)
-
-            download_d = agent.request(
-                b"PUT",
-                slot.put.encode("utf-8"),
-                http_headers.Headers(headers),
-                file_producer,
-            )
-            download_d.addCallbacks(
-                self._upload_cb,
-                self._upload_eb,
-                (sat_file, slot),
-                None,
-                (sat_file,),
-            )
-
-            return progress_id, download_d
-
-    def _upload_cb(self, __, sat_file, slot):
-        """Called once file is successfully uploaded
-
-        @param sat_file(SatFile): file used for the upload
-            should be closed, but it is needed to send the progress_finished signal
-        @param slot(Slot): put/get urls
-        """
-        log.info(f"HTTP upload finished ({slot.get})")
-        sat_file.progress_finished({"url": slot.get})
-        return slot.get
-
-    def _upload_eb(self, failure_, sat_file):
-        """Called on unsuccessful upload
-
-        @param sat_file(SatFile): file used for the upload
-            should be closed, be is needed to send the progress_error signal
-        """
-        try:
-            wrapped_fail = failure_.value.reasons[0]
-        except (AttributeError, IndexError) as e:
-            log.warning(_("upload failed: {reason}").format(reason=e))
-            sat_file.progress_error(str(failure_))
-        else:
-            if wrapped_fail.check(sat_web.SSLError):
-                msg = "TLS validation error, can't connect to HTTPS server"
-            else:
-                msg = "can't upload file"
-            log.warning(msg + ": " + str(wrapped_fail.value))
-            sat_file.progress_error(msg)
-        raise failure_
-
-    def _get_slot(self, filename, size, content_type, upload_jid,
-                 profile_key=C.PROF_KEY_NONE):
-        """Get an upload slot
-
-        This method can be used when uploading is done by the frontend
-        @param filename(unicode): name of the file to upload
-        @param size(int): size of the file (must be non null)
-        @param upload_jid(str, ''): HTTP upload capable entity
-        @param content_type(unicode, None): MIME type of the content
-            empty string or None to guess automatically
-        """
-        client = self.host.get_client(profile_key)
-        filename = filename.replace("/", "_")
-        d = defer.ensureDeferred(self.get_slot(
-            client, filename, size, content_type or None, jid.JID(upload_jid) or None
-        ))
-        d.addCallback(lambda slot: (slot.get, slot.put, slot.headers))
-        return d
-
-    async def get_slot(self, client, filename, size, content_type=None, upload_jid=None):
-        """Get a slot (i.e. download/upload links)
-
-        @param filename(unicode): name to use for the upload
-        @param size(int): size of the file to upload (must be >0)
-        @param content_type(None, unicode): MIME type of the content
-            None to autodetect
-        @param upload_jid(jid.JID, None): HTTP upload capable upload_jid
-            or None to use the server component (if any)
-        @param client: %(doc_client)s
-        @return (Slot): the upload (put) and download (get) URLs
-        @raise exceptions.NotFound: no HTTP upload capable upload_jid has been found
-        """
-        assert filename and size
-        if content_type is None:
-            # TODO: manage python magic for file guessing (in a dedicated plugin ?)
-            content_type = mimetypes.guess_type(filename, strict=False)[0]
-
-        if upload_jid is None:
-            try:
-                upload_jid = client.http_upload_service
-            except AttributeError:
-                found_entity = await self.get_http_upload_entity(client)
-                return await self.get_slot(
-                    client, filename, size, content_type, found_entity)
-            else:
-                if upload_jid is None:
-                    raise exceptions.NotFound("No HTTP upload entity found")
-
-        iq_elt = client.IQ("get")
-        iq_elt["to"] = upload_jid.full()
-        request_elt = iq_elt.addElement((NS_HTTP_UPLOAD, "request"))
-        request_elt["filename"] = filename
-        request_elt["size"] = str(size)
-        if content_type is not None:
-            request_elt["content-type"] = content_type
-
-        iq_result_elt = await iq_elt.send()
-
-        try:
-            slot_elt = next(iq_result_elt.elements(NS_HTTP_UPLOAD, "slot"))
-            put_elt = next(slot_elt.elements(NS_HTTP_UPLOAD, "put"))
-            put_url = put_elt['url']
-            get_elt = next(slot_elt.elements(NS_HTTP_UPLOAD, "get"))
-            get_url = get_elt['url']
-        except (StopIteration, KeyError):
-            raise exceptions.DataError("Incorrect stanza received from server")
-
-        headers = []
-        for header_elt in put_elt.elements(NS_HTTP_UPLOAD, "header"):
-            try:
-                name = header_elt["name"]
-                value = str(header_elt)
-            except KeyError:
-                log.warning(_("Invalid header element: {xml}").format(
-                    iq_result_elt.toXml()))
-                continue
-            name = name.replace('\n', '')
-            value = value.replace('\n', '')
-            if name.lower() not in ALLOWED_HEADERS:
-                log.warning(_('Ignoring unauthorised header "{name}": {xml}')
-                    .format(name=name, xml = iq_result_elt.toXml()))
-                continue
-            headers.append((name, value))
-
-        return Slot(put=put_url, get=get_url, headers=headers)
-
-    # component
-
-    def on_component_request(self, iq_elt, client):
-        iq_elt.handled=True
-        defer.ensureDeferred(self.handle_component_request(client, iq_elt))
-
-    async def handle_component_request(self, client, iq_elt):
-        try:
-            request_elt = next(iq_elt.elements(NS_HTTP_UPLOAD, "request"))
-            request = UploadRequest(
-                from_=jid.JID(iq_elt['from']),
-                filename=parse.quote(request_elt['filename'].replace('/', '_'), safe=''),
-                size=int(request_elt['size']),
-                content_type=request_elt.getAttribute('content-type')
-            )
-        except (StopIteration, KeyError, ValueError):
-            client.sendError(iq_elt, "bad-request")
-            return
-
-        err = None
-
-        for handler in self.handlers:
-            try:
-                slot = await utils.as_deferred(handler.callback, client, request)
-            except error.StanzaError as e:
-                log.warning(
-                    "a stanza error has been raised while processing HTTP Upload of "
-                    f"request: {e}"
-                )
-                if err is None:
-                    # we keep the first error to return its condition later,
-                    # if no other callback handle the request
-                    err = e
-            else:
-                if slot:
-                    break
-        else:
-            log.warning(
-                _("no service can handle HTTP Upload request: {elt}")
-                .format(elt=iq_elt.toXml()))
-            if err is None:
-                err = error.StanzaError("feature-not-implemented")
-            client.send(err.toResponse(iq_elt))
-            return
-
-        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
-        slot_elt = iq_result_elt.addElement((NS_HTTP_UPLOAD, 'slot'))
-        put_elt = slot_elt.addElement('put')
-        put_elt['url'] = slot.put
-        get_elt = slot_elt.addElement('get')
-        get_elt['url'] = slot.get
-        client.send(iq_result_elt)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0363_handler(xmlstream.XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-
-    def connectionInitialized(self):
-        if ((self.parent.is_component
-             and PLUGIN_INFO[C.PI_IMPORT_NAME] in self.parent.enabled_features)):
-            self.xmlstream.addObserver(
-                IQ_HTTP_UPLOAD_REQUEST, self.plugin_parent.on_component_request,
-                client=self.parent
-            )
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        if ((self.parent.is_component
-             and not PLUGIN_INFO[C.PI_IMPORT_NAME] in self.parent.enabled_features)):
-            return []
-        else:
-            return [disco.DiscoFeature(NS_HTTP_UPLOAD)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0372.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,230 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for XEP-0372
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional, Dict, Union
-from textwrap import dedent
-from sat.core import exceptions
-from sat.tools.common import uri as xmpp_uri
-
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from zope.interface import implementer
-from wokkel import disco, iwokkel
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.core_types import SatXMPPEntity
-from sat.tools.common import data_format
-
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "References",
-    C.PI_IMPORT_NAME: "XEP-0372",
-    C.PI_TYPE: C.PLUG_TYPE_XEP,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0372"],
-    C.PI_DEPENDENCIES: ["XEP-0334"],
-    C.PI_MAIN: "XEP_0372",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _(dedent("""\
-        XEP-0372 (References) implementation
-
-        This plugin implement generic references and mentions.
-    """)),
-}
-
-NS_REFS = "urn:xmpp:reference:0"
-ALLOWED_TYPES = ("mention", "data")
-
-
-class XEP_0372:
-    namespace = NS_REFS
-
-    def __init__(self, host):
-        log.info(_("References plugin initialization"))
-        host.register_namespace("refs", NS_REFS)
-        self.host = host
-        self._h = host.plugins["XEP-0334"]
-        host.trigger.add("message_received", self._message_received_trigger)
-        host.bridge.add_method(
-            "reference_send",
-            ".plugin",
-            in_sign="sssss",
-            out_sign="",
-            method=self._send_reference,
-            async_=False,
-        )
-
-    def get_handler(self, client):
-        return XEP_0372_Handler()
-
-    def ref_element_to_ref_data(
-        self,
-        reference_elt: domish.Element
-    ) -> Dict[str, Union[str, int, dict]]:
-        ref_data: Dict[str, Union[str, int, dict]] = {
-            "uri": reference_elt["uri"],
-            "type": reference_elt["type"]
-        }
-
-        if ref_data["uri"].startswith("xmpp:"):
-            ref_data["parsed_uri"] = xmpp_uri.parse_xmpp_uri(ref_data["uri"])
-
-        for attr in ("begin", "end"):
-            try:
-                ref_data[attr] = int(reference_elt[attr])
-            except (KeyError, ValueError, TypeError):
-                continue
-
-        anchor = reference_elt.getAttribute("anchor")
-        if anchor is not None:
-            ref_data["anchor"] = anchor
-            if anchor.startswith("xmpp:"):
-                ref_data["parsed_anchor"] = xmpp_uri.parse_xmpp_uri(anchor)
-        return ref_data
-
-    async def _message_received_trigger(
-        self,
-        client: SatXMPPEntity,
-        message_elt: domish.Element,
-        post_treat: defer.Deferred
-    ) -> bool:
-        """Check if a direct invitation is in the message, and handle it"""
-        reference_elt = next(message_elt.elements(NS_REFS, "reference"), None)
-        if reference_elt is None:
-            return True
-        try:
-            ref_data = self.ref_element_to_ref_data(reference_elt)
-        except KeyError:
-            log.warning("invalid <reference> element: {reference_elt.toXml}")
-            return True
-
-        if not await self.host.trigger.async_point(
-            "XEP-0372_ref_received", client, message_elt, ref_data
-        ):
-            return False
-        return True
-
-    def build_ref_element(
-        self,
-        uri: str,
-        type_: str = "mention",
-        begin: Optional[int] = None,
-        end: Optional[int] = None,
-        anchor: Optional[str] = None,
-    ) -> domish.Element:
-        """Build and return the <reference> element"""
-        if type_ not in ALLOWED_TYPES:
-            raise ValueError(f"Unknown type: {type_!r}")
-        reference_elt = domish.Element(
-            (NS_REFS, "reference"),
-            attribs={"uri": uri, "type": type_}
-        )
-        if begin is not None:
-            reference_elt["begin"] = str(begin)
-        if end is not None:
-            reference_elt["end"] = str(end)
-        if anchor is not None:
-            reference_elt["anchor"] = anchor
-        return reference_elt
-
-    def _send_reference(
-        self,
-        recipient: str,
-        anchor: str,
-        type_: str,
-        extra_s: str,
-        profile_key: str
-    ) -> defer.Deferred:
-        recipient_jid = jid.JID(recipient)
-        client = self.host.get_client(profile_key)
-        extra: dict = data_format.deserialise(extra_s, default={})
-        self.send_reference(
-            client,
-            uri=extra.get("uri"),
-            type_=type_,
-            anchor=anchor,
-            to_jid=recipient_jid
-        )
-
-    def send_reference(
-        self,
-        client: "SatXMPPEntity",
-        uri: Optional[str] = None,
-        type_: str = "mention",
-        begin: Optional[int] = None,
-        end: Optional[int] = None,
-        anchor: Optional[str] = None,
-        message_elt: Optional[domish.Element] = None,
-        to_jid: Optional[jid.JID] = None
-    ) -> None:
-        """Build and send a reference_elt
-
-        @param uri: URI pointing to referenced object (XMPP entity, Pubsub Item, etc)
-            if not set, "to_jid" will be used to build an URI to the entity
-        @param type_: type of reference
-            one of [ALLOWED_TYPES]
-        @param begin: optional begin index
-        @param end: optional end index
-        @param anchor: URI of refering object (message, pubsub item), when the refence
-            is not already in the wrapping message element. In other words, it's the
-            object where the reference appears.
-        @param message_elt: wrapping <message> element, if not set a new one will be
-            generated
-        @param to_jid: destinee of the reference. If not specified, "to" attribute of
-            message_elt will be used.
-
-        """
-        if uri is None:
-            if to_jid is None:
-                raise exceptions.InternalError(
-                    '"to_jid" must be set if "uri is None"'
-                )
-            uri = xmpp_uri.build_xmpp_uri(path=to_jid.full())
-        if message_elt is None:
-            message_elt = domish.Element((None, "message"))
-
-        if to_jid is not None:
-            message_elt["to"] = to_jid.full()
-        else:
-            try:
-                to_jid = jid.JID(message_elt["to"])
-            except (KeyError, RuntimeError):
-                raise exceptions.InternalError(
-                    'invalid "to" attribute in given message element: '
-                    '{message_elt.toXml()}'
-                )
-
-        message_elt.addChild(self.build_ref_element(uri, type_, begin, end, anchor))
-        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
-        client.send(message_elt)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0372_Handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_REFS)]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0373.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2102 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for OpenPGP for XMPP
-# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from abc import ABC, abstractmethod
-import base64
-from datetime import datetime, timezone
-import enum
-import secrets
-import string
-from typing import Any, Dict, Iterable, List, Literal, Optional, Set, Tuple, cast
-from xml.sax.saxutils import quoteattr
-
-from typing_extensions import Final, NamedTuple, Never, assert_never
-from wokkel import muc, pubsub
-from wokkel.disco import DiscoFeature, DiscoInfo
-import xmlschema
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger, Logger
-from sat.core.sat_main import SAT
-from sat.core.xmpp import SatXMPPClient
-from sat.memory import persistent
-from sat.plugins.plugin_xep_0045 import XEP_0045
-from sat.plugins.plugin_xep_0060 import XEP_0060
-from sat.plugins.plugin_xep_0163 import XEP_0163
-from sat.tools.xmpp_datetime import format_datetime, parse_datetime
-from sat.tools import xml_tools
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from twisted.words.xish import domish
-
-try:
-    import gpg
-except ImportError as import_error:
-    raise exceptions.MissingModule(
-        "You are missing the 'gpg' package required by the OX plugin. The recommended"
-        " installation method is via your operating system's package manager, since the"
-        " version of the library has to match the version of your GnuPG installation. See"
-        " https://wiki.python.org/moin/GnuPrivacyGuard#Accessing_GnuPG_via_gpgme"
-    ) from import_error
-
-
-__all__ = [  # pylint: disable=unused-variable
-    "PLUGIN_INFO",
-    "NS_OX",
-    "XEP_0373",
-    "VerificationError",
-    "XMPPInteractionFailed",
-    "InvalidPacket",
-    "DecryptionFailed",
-    "VerificationFailed",
-    "UnknownKey",
-    "GPGProviderError",
-    "GPGPublicKey",
-    "GPGSecretKey",
-    "GPGProvider",
-    "PublicKeyMetadata",
-    "gpg_provider",
-    "TrustLevel"
-]
-
-
-log = cast(Logger, getLogger(__name__))  # type: ignore[no-untyped-call]
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "XEP-0373",
-    C.PI_IMPORT_NAME: "XEP-0373",
-    C.PI_TYPE: "SEC",
-    C.PI_PROTOCOLS: [ "XEP-0373" ],
-    C.PI_DEPENDENCIES: [ "XEP-0060", "XEP-0163" ],
-    C.PI_RECOMMENDATIONS: [],
-    C.PI_MAIN: "XEP_0373",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: D_("Implementation of OpenPGP for XMPP"),
-}
-
-
-NS_OX: Final = "urn:xmpp:openpgp:0"
-
-
-PARAM_CATEGORY = "Security"
-PARAM_NAME = "ox_policy"
-STR_KEY_PUBLIC_KEYS_METADATA = "/public-keys-metadata/{}"
-
-
-class VerificationError(Exception):
-    """
-    Raised by verifying methods of :class:`XEP_0373` on semantical verification errors.
-    """
-
-
-class XMPPInteractionFailed(Exception):
-    """
-    Raised by methods of :class:`XEP_0373` on XMPP interaction failure. The reason this
-    exception exists is that the exceptions raised by XMPP interactions are not properly
-    documented for the most part, thus all exceptions are caught and wrapped in instances
-    of this class.
-    """
-
-
-class InvalidPacket(ValueError):
-    """
-    Raised by methods of :class:`GPGProvider` when an invalid packet is encountered.
-    """
-
-
-class DecryptionFailed(Exception):
-    """
-    Raised by methods of :class:`GPGProvider` on decryption failures.
-    """
-
-
-class VerificationFailed(Exception):
-    """
-    Raised by methods of :class:`GPGProvider` on verification failures.
-    """
-
-
-class UnknownKey(ValueError):
-    """
-    Raised by methods of :class:`GPGProvider` when an unknown key is referenced.
-    """
-
-
-class GPGProviderError(Exception):
-    """
-    Raised by methods of :class:`GPGProvider` on internal errors.
-    """
-
-
-class GPGPublicKey(ABC):
-    """
-    Interface describing a GPG public key.
-    """
-
-    @property
-    @abstractmethod
-    def fingerprint(self) -> str:
-        """
-        @return: The OpenPGP v4 fingerprint string of this public key.
-        """
-
-
-class GPGSecretKey(ABC):
-    """
-    Interface descibing a GPG secret key.
-    """
-
-    @property
-    @abstractmethod
-    def public_key(self) -> GPGPublicKey:
-        """
-        @return: The public key corresponding to this secret key.
-        """
-
-
-class GPGProvider(ABC):
-    """
-    Interface describing a GPG provider, i.e. a library or framework providing GPG
-    encryption, signing and key management.
-
-    All methods may raise :class:`GPGProviderError` in addition to those exception types
-    listed explicitly.
-
-    # TODO: Check keys for revoked, disabled and expired everywhere and exclude those (?)
-    """
-
-    @abstractmethod
-    def export_public_key(self, public_key: GPGPublicKey) -> bytes:
-        """Export a public key in a key material packet according to RFC 4880 §5.5.
-
-        Do not use OpenPGP's ASCII Armor.
-
-        @param public_key: The public key to export.
-        @return: The packet containing the exported public key.
-        @raise UnknownKey: if the public key is not available.
-        """
-
-    @abstractmethod
-    def import_public_key(self, packet: bytes) -> GPGPublicKey:
-        """import a public key from a key material packet according to RFC 4880 §5.5.
-
-        OpenPGP's ASCII Armor is not used.
-
-        @param packet: A packet containing an exported public key.
-        @return: The public key imported from the packet.
-        @raise InvalidPacket: if the packet is either syntactically or semantically deemed
-            invalid.
-
-        @warning: Only packets of version 4 or higher may be accepted, packets below
-            version 4 MUST be rejected.
-        """
-
-    @abstractmethod
-    def backup_secret_key(self, secret_key: GPGSecretKey) -> bytes:
-        """Export a secret key for transfer according to RFC 4880 §11.1.
-
-        Do not encrypt the secret data, i.e. set the octet indicating string-to-key usage
-        conventions to zero in the corresponding secret-key packet according to RFC 4880
-        §5.5.3. Do not use OpenPGP's ASCII Armor.
-
-        @param secret_key: The secret key to export.
-        @return: The binary blob containing the exported secret key.
-        @raise UnknownKey: if the secret key is not available.
-        """
-
-    @abstractmethod
-    def restore_secret_keys(self, data: bytes) -> Set[GPGSecretKey]:
-        """Restore secret keys exported for transfer according to RFC 4880 §11.1.
-
-        The secret data is not encrypted, i.e. the octet indicating string-to-key usage
-        conventions in the corresponding secret-key packets according to RFC 4880 §5.5.3
-        are set to zero. OpenPGP's ASCII Armor is not used.
-
-        @param data: Concatenation of one or more secret keys exported for transfer.
-        @return: The secret keys imported from the data.
-        @raise InvalidPacket: if the data or one of the packets included in the data is
-            either syntactically or semantically deemed invalid.
-
-        @warning: Only packets of version 4 or higher may be accepted, packets below
-            version 4 MUST be rejected.
-        """
-
-    @abstractmethod
-    def encrypt_symmetrically(self, plaintext: bytes, password: str) -> bytes:
-        """Encrypt data symmetrically according to RFC 4880 §5.3.
-
-        The password is used to build a Symmetric-Key Encrypted Session Key packet which
-        precedes the Symmetrically Encrypted Data packet that holds the encrypted data.
-
-        @param plaintext: The data to encrypt.
-        @param password: The password to encrypt the data with.
-        @return: The encrypted data.
-        """
-
-    @abstractmethod
-    def decrypt_symmetrically(self, ciphertext: bytes, password: str) -> bytes:
-        """Decrypt data symmetrically according to RFC 4880 §5.3.
-
-        The ciphertext consists of a Symmetrically Encrypted Data packet that holds the
-        encrypted data, preceded by a Symmetric-Key Encrypted Session Key packet using the
-        password.
-
-        @param ciphertext: The ciphertext.
-        @param password: The password to decrypt the data with.
-        @return: The plaintext.
-        @raise DecryptionFailed: on decryption failure.
-        """
-
-    @abstractmethod
-    def sign(self, data: bytes, secret_keys: Set[GPGSecretKey]) -> bytes:
-        """Sign some data.
-
-        OpenPGP's ASCII Armor is not used.
-
-        @param data: The data to sign.
-        @param secret_keys: The secret keys to sign the data with.
-        @return: The OpenPGP message carrying the signed data.
-        """
-
-    @abstractmethod
-    def sign_detached(self, data: bytes, secret_keys: Set[GPGSecretKey]) -> bytes:
-        """Sign some data. Create the signature detached from the data.
-
-        OpenPGP's ASCII Armor is not used.
-
-        @param data: The data to sign.
-        @param secret_keys: The secret keys to sign the data with.
-        @return: The OpenPGP message carrying the detached signature.
-        """
-
-    @abstractmethod
-    def verify(self, signed_data: bytes, public_keys: Set[GPGPublicKey]) -> bytes:
-        """Verify signed data.
-
-        OpenPGP's ASCII Armor is not used.
-
-        @param signed_data: The signed data as an OpenPGP message.
-        @param public_keys: The public keys to verify the signature with.
-        @return: The verified and unpacked data.
-        @raise VerificationFailed: if the data could not be verified.
-
-        @warning: For implementors: it has to be confirmed that a valid signature by one
-            of the public keys is available.
-        """
-
-    @abstractmethod
-    def verify_detached(
-        self,
-        data: bytes,
-        signature: bytes,
-        public_keys: Set[GPGPublicKey]
-    ) -> None:
-        """Verify signed data, where the signature was created detached from the data.
-
-        OpenPGP's ASCII Armor is not used.
-
-        @param data: The data.
-        @param signature: The signature as an OpenPGP message.
-        @param public_keys: The public keys to verify the signature with.
-        @raise VerificationFailed: if the data could not be verified.
-
-        @warning: For implementors: it has to be confirmed that a valid signature by one
-            of the public keys is available.
-        """
-
-    @abstractmethod
-    def encrypt(
-        self,
-        plaintext: bytes,
-        public_keys: Set[GPGPublicKey],
-        signing_keys: Optional[Set[GPGSecretKey]] = None
-    ) -> bytes:
-        """Encrypt and optionally sign some data.
-
-        OpenPGP's ASCII Armor is not used.
-
-        @param plaintext: The data to encrypt and optionally sign.
-        @param public_keys: The public keys to encrypt the data for.
-        @param signing_keys: The secret keys to sign the data with.
-        @return: The OpenPGP message carrying the encrypted and optionally signed data.
-        """
-
-    @abstractmethod
-    def decrypt(
-        self,
-        ciphertext: bytes,
-        secret_keys: Set[GPGSecretKey],
-        public_keys: Optional[Set[GPGPublicKey]] = None
-    ) -> bytes:
-        """Decrypt and optionally verify some data.
-
-        OpenPGP's ASCII Armor is not used.
-
-        @param ciphertext: The encrypted and optionally signed data as an OpenPGP message.
-        @param secret_keys: The secret keys to attempt decryption with.
-        @param public_keys: The public keys to verify the optional signature with.
-        @return: The decrypted, optionally verified and unpacked data.
-        @raise DecryptionFailed: on decryption failure.
-        @raise VerificationFailed: if the data could not be verified.
-
-        @warning: For implementors: it has to be confirmed that the data was decrypted
-            using one of the secret keys and that a valid signature by one of the public
-            keys is available in case the data is signed.
-        """
-
-    @abstractmethod
-    def list_public_keys(self, user_id: str) -> Set[GPGPublicKey]:
-        """List public keys.
-
-        @param user_id: The user id.
-        @return: The set of public keys available for this user id.
-        """
-
-    @abstractmethod
-    def list_secret_keys(self, user_id: str) -> Set[GPGSecretKey]:
-        """List secret keys.
-
-        @param user_id: The user id.
-        @return: The set of secret keys available for this user id.
-        """
-
-    @abstractmethod
-    def can_sign(self, public_key: GPGPublicKey) -> bool:
-        """
-        @return: Whether the public key belongs to a key pair capable of signing.
-        """
-
-    @abstractmethod
-    def can_encrypt(self, public_key: GPGPublicKey) -> bool:
-        """
-        @return: Whether the public key belongs to a key pair capable of encryption.
-        """
-
-    @abstractmethod
-    def create_key(self, user_id: str) -> GPGSecretKey:
-        """Create a new GPG key, capable of signing and encryption.
-
-        The key is generated without password protection and without expiration. If a key
-        with the same user id already exists, a new key is created anyway.
-
-        @param user_id: The user id to assign to the new key.
-        @return: The new key.
-        """
-
-
-class GPGME_GPGPublicKey(GPGPublicKey):
-    """
-    GPG public key implementation based on GnuPG Made Easy (GPGME).
-    """
-
-    def __init__(self, key_obj: Any) -> None:
-        """
-        @param key_obj: The GPGME key object.
-        """
-
-        self.__key_obj = key_obj
-
-    @property
-    def fingerprint(self) -> str:
-        return self.__key_obj.fpr
-
-    @property
-    def key_obj(self) -> Any:
-        return self.__key_obj
-
-
-class GPGME_GPGSecretKey(GPGSecretKey):
-    """
-    GPG secret key implementation based on GnuPG Made Easy (GPGME).
-    """
-
-    def __init__(self, public_key: GPGME_GPGPublicKey) -> None:
-        """
-        @param public_key: The public key corresponding to this secret key.
-        """
-
-        self.__public_key = public_key
-
-    @property
-    def public_key(self) -> GPGME_GPGPublicKey:
-        return self.__public_key
-
-
-class GPGME_GPGProvider(GPGProvider):
-    """
-    GPG provider implementation based on GnuPG Made Easy (GPGME).
-    """
-
-    def __init__(self, home_dir: Optional[str] = None) -> None:
-        """
-        @param home_dir: Optional GPG home directory path to use for all operations.
-        """
-
-        self.__home_dir = home_dir
-
-    def export_public_key(self, public_key: GPGPublicKey) -> bytes:
-        assert isinstance(public_key, GPGME_GPGPublicKey)
-
-        pattern = public_key.fingerprint
-
-        with gpg.Context(home_dir=self.__home_dir) as c:
-            try:
-                result = c.key_export_minimal(pattern)
-            except gpg.errors.GPGMEError as e:
-                raise GPGProviderError("Internal GPGME error") from e
-
-            if result is None:
-                raise UnknownKey(f"Public key {pattern} not found.")
-
-            return result
-
-    def import_public_key(self, packet: bytes) -> GPGPublicKey:
-        # TODO
-        # - Reject packets older than version 4
-        # - Check whether it's actually a public key (through packet inspection?)
-
-        with gpg.Context(home_dir=self.__home_dir) as c:
-            try:
-                result = c.key_import(packet)
-            except gpg.errors.GPGMEError as e:
-                # From looking at the code, `key_import` never raises. The documentation
-                # says it does though, so this is included for future-proofness.
-                raise GPGProviderError("Internal GPGME error") from e
-
-            if not hasattr(result, "considered"):
-                raise InvalidPacket(
-                    f"Data not considered for public key import: {result}"
-                )
-
-            if len(result.imports) != 1:
-                raise InvalidPacket(
-                    "Public key packet does not contain exactly one public key (not"
-                    " counting subkeys)."
-                )
-
-            try:
-                key_obj = c.get_key(result.imports[0].fpr, secret=False)
-            except gpg.errors.GPGMEError as e:
-                raise GPGProviderError("Internal GPGME error") from e
-            except gpg.errors.KeyError as e:
-                raise GPGProviderError("Newly imported public key not found") from e
-
-            return GPGME_GPGPublicKey(key_obj)
-
-    def backup_secret_key(self, secret_key: GPGSecretKey) -> bytes:
-        assert isinstance(secret_key, GPGME_GPGSecretKey)
-        # TODO
-        # - Handle password protection/pinentry
-        # - Make sure the key is exported unencrypted
-
-        pattern = secret_key.public_key.fingerprint
-
-        with gpg.Context(home_dir=self.__home_dir) as c:
-            try:
-                result = c.key_export_secret(pattern)
-            except gpg.errors.GPGMEError as e:
-                raise GPGProviderError("Internal GPGME error") from e
-
-            if result is None:
-                raise UnknownKey(f"Secret key {pattern} not found.")
-
-            return result
-
-    def restore_secret_keys(self, data: bytes) -> Set[GPGSecretKey]:
-        # TODO
-        # - Reject packets older than version 4
-        # - Check whether it's actually secret keys (through packet inspection?)
-
-        with gpg.Context(home_dir=self.__home_dir) as c:
-            try:
-                result = c.key_import(data)
-            except gpg.errors.GPGMEError as e:
-                # From looking at the code, `key_import` never raises. The documentation
-                # says it does though, so this is included for future-proofness.
-                raise GPGProviderError("Internal GPGME error") from e
-
-            if not hasattr(result, "considered"):
-                raise InvalidPacket(
-                    f"Data not considered for secret key import: {result}"
-                )
-
-            if len(result.imports) == 0:
-                raise InvalidPacket("Secret key packet does not contain a secret key.")
-
-            secret_keys = set()
-            for import_status in result.imports:
-                try:
-                    key_obj = c.get_key(import_status.fpr, secret=True)
-                except gpg.errors.GPGMEError as e:
-                    raise GPGProviderError("Internal GPGME error") from e
-                except gpg.errors.KeyError as e:
-                    raise GPGProviderError("Newly imported secret key not found") from e
-
-                secret_keys.add(GPGME_GPGSecretKey(GPGME_GPGPublicKey(key_obj)))
-
-            return secret_keys
-
-    def encrypt_symmetrically(self, plaintext: bytes, password: str) -> bytes:
-        with gpg.Context(home_dir=self.__home_dir) as c:
-            try:
-                ciphertext, __, __ = c.encrypt(plaintext, passphrase=password, sign=False)
-            except gpg.errors.GPGMEError as e:
-                raise GPGProviderError("Internal GPGME error") from e
-
-            return ciphertext
-
-    def decrypt_symmetrically(self, ciphertext: bytes, password: str) -> bytes:
-        with gpg.Context(home_dir=self.__home_dir) as c:
-            try:
-                plaintext, __, __ = c.decrypt(
-                    ciphertext,
-                    passphrase=password,
-                    verify=False
-                )
-            except gpg.errors.GPGMEError as e:
-                # TODO: Find out what kind of error is raised if the password is wrong and
-                # re-raise it as DecryptionFailed instead.
-                raise GPGProviderError("Internal GPGME error") from e
-            except gpg.UnsupportedAlgorithm as e:
-                raise DecryptionFailed("Unsupported algorithm") from e
-
-            return plaintext
-
-    def sign(self, data: bytes, secret_keys: Set[GPGSecretKey]) -> bytes:
-        signers = []
-        for secret_key in secret_keys:
-            assert isinstance(secret_key, GPGME_GPGSecretKey)
-
-            signers.append(secret_key.public_key.key_obj)
-
-        with gpg.Context(home_dir=self.__home_dir, signers=signers) as c:
-            try:
-                signed_data, __ = c.sign(data)
-            except gpg.errors.GPGMEError as e:
-                raise GPGProviderError("Internal GPGME error") from e
-            except gpg.errors.InvalidSigners as e:
-                raise GPGProviderError(
-                    "At least one of the secret keys is invalid for signing"
-                ) from e
-
-            return signed_data
-
-    def sign_detached(self, data: bytes, secret_keys: Set[GPGSecretKey]) -> bytes:
-        signers = []
-        for secret_key in secret_keys:
-            assert isinstance(secret_key, GPGME_GPGSecretKey)
-
-            signers.append(secret_key.public_key.key_obj)
-
-        with gpg.Context(home_dir=self.__home_dir, signers=signers) as c:
-            try:
-                signature, __ = c.sign(data, mode=gpg.constants.sig.mode.DETACH)
-            except gpg.errors.GPGMEError as e:
-                raise GPGProviderError("Internal GPGME error") from e
-            except gpg.errors.InvalidSigners as e:
-                raise GPGProviderError(
-                    "At least one of the secret keys is invalid for signing"
-                ) from e
-
-            return signature
-
-    def verify(self, signed_data: bytes, public_keys: Set[GPGPublicKey]) -> bytes:
-        with gpg.Context(home_dir=self.__home_dir) as c:
-            try:
-                data, result = c.verify(signed_data)
-            except gpg.errors.GPGMEError as e:
-                raise GPGProviderError("Internal GPGME error") from e
-            except gpg.errors.BadSignatures as e:
-                raise VerificationFailed("Bad signatures on signed data") from e
-
-            valid_signature_found = False
-            for public_key in public_keys:
-                assert isinstance(public_key, GPGME_GPGPublicKey)
-
-                for subkey in public_key.key_obj.subkeys:
-                    for sig in result.signatures:
-                        if subkey.can_sign and subkey.fpr == sig.fpr:
-                            valid_signature_found = True
-
-            if not valid_signature_found:
-                raise VerificationFailed(
-                    "Data not signed by one of the expected public keys"
-                )
-
-            return data
-
-    def verify_detached(
-        self,
-        data: bytes,
-        signature: bytes,
-        public_keys: Set[GPGPublicKey]
-    ) -> None:
-        with gpg.Context(home_dir=self.__home_dir) as c:
-            try:
-                __, result = c.verify(data, signature=signature)
-            except gpg.errors.GPGMEError as e:
-                raise GPGProviderError("Internal GPGME error") from e
-            except gpg.errors.BadSignatures as e:
-                raise VerificationFailed("Bad signatures on signed data") from e
-
-            valid_signature_found = False
-            for public_key in public_keys:
-                assert isinstance(public_key, GPGME_GPGPublicKey)
-
-                for subkey in public_key.key_obj.subkeys:
-                    for sig in result.signatures:
-                        if subkey.can_sign and subkey.fpr == sig.fpr:
-                            valid_signature_found = True
-
-            if not valid_signature_found:
-                raise VerificationFailed(
-                    "Data not signed by one of the expected public keys"
-                )
-
-    def encrypt(
-        self,
-        plaintext: bytes,
-        public_keys: Set[GPGPublicKey],
-        signing_keys: Optional[Set[GPGSecretKey]] = None
-    ) -> bytes:
-        recipients = []
-        for public_key in public_keys:
-            assert isinstance(public_key, GPGME_GPGPublicKey)
-
-            recipients.append(public_key.key_obj)
-
-        signers = []
-        if signing_keys is not None:
-            for secret_key in signing_keys:
-                assert isinstance(secret_key, GPGME_GPGSecretKey)
-
-                signers.append(secret_key.public_key.key_obj)
-
-        sign = signing_keys is not None
-
-        with gpg.Context(home_dir=self.__home_dir, signers=signers) as c:
-            try:
-                ciphertext, __, __ = c.encrypt(
-                    plaintext,
-                    recipients=recipients,
-                    sign=sign,
-                    always_trust=True,
-                    add_encrypt_to=True
-                )
-            except gpg.errors.GPGMEError as e:
-                raise GPGProviderError("Internal GPGME error") from e
-            except gpg.errors.InvalidRecipients as e:
-                raise GPGProviderError(
-                    "At least one of the public keys is invalid for encryption"
-                ) from e
-            except gpg.errors.InvalidSigners as e:
-                raise GPGProviderError(
-                    "At least one of the signing keys is invalid for signing"
-                ) from e
-
-            return ciphertext
-
-    def decrypt(
-        self,
-        ciphertext: bytes,
-        secret_keys: Set[GPGSecretKey],
-        public_keys: Optional[Set[GPGPublicKey]] = None
-    ) -> bytes:
-        verify = public_keys is not None
-
-        with gpg.Context(home_dir=self.__home_dir) as c:
-            try:
-                plaintext, result, verify_result = c.decrypt(
-                    ciphertext,
-                    verify=verify
-                )
-            except gpg.errors.GPGMEError as e:
-                raise GPGProviderError("Internal GPGME error") from e
-            except gpg.UnsupportedAlgorithm as e:
-                raise DecryptionFailed("Unsupported algorithm") from e
-
-            # TODO: Check whether the data was decrypted using one of the expected secret
-            # keys
-
-            if public_keys is not None:
-                valid_signature_found = False
-                for public_key in public_keys:
-                    assert isinstance(public_key, GPGME_GPGPublicKey)
-
-                    for subkey in public_key.key_obj.subkeys:
-                        for sig in verify_result.signatures:
-                            if subkey.can_sign and subkey.fpr == sig.fpr:
-                                valid_signature_found = True
-
-                if not valid_signature_found:
-                    raise VerificationFailed(
-                        "Data not signed by one of the expected public keys"
-                    )
-
-            return plaintext
-
-    def list_public_keys(self, user_id: str) -> Set[GPGPublicKey]:
-        with gpg.Context(home_dir=self.__home_dir) as c:
-            try:
-                return {
-                    GPGME_GPGPublicKey(key)
-                    for key
-                    in c.keylist(pattern=user_id, secret=False)
-                }
-            except gpg.errors.GPGMEError as e:
-                raise GPGProviderError("Internal GPGME error") from e
-
-    def list_secret_keys(self, user_id: str) -> Set[GPGSecretKey]:
-        with gpg.Context(home_dir=self.__home_dir) as c:
-            try:
-                return {
-                    GPGME_GPGSecretKey(GPGME_GPGPublicKey(key))
-                    for key
-                    in c.keylist(pattern=user_id, secret=True)
-                }
-            except gpg.errors.GPGMEError as e:
-                raise GPGProviderError("Internal GPGME error") from e
-
-    def can_sign(self, public_key: GPGPublicKey) -> bool:
-        assert isinstance(public_key, GPGME_GPGPublicKey)
-
-        return any(subkey.can_sign for subkey in public_key.key_obj.subkeys)
-
-    def can_encrypt(self, public_key: GPGPublicKey) -> bool:
-        assert isinstance(public_key, GPGME_GPGPublicKey)
-
-        return any(subkey.can_encrypt for subkey in public_key.key_obj.subkeys)
-
-    def create_key(self, user_id: str) -> GPGSecretKey:
-        with gpg.Context(home_dir=self.__home_dir) as c:
-            try:
-                result = c.create_key(
-                    user_id,
-                    expires=False,
-                    sign=True,
-                    encrypt=True,
-                    certify=False,
-                    authenticate=False,
-                    force=True
-                )
-
-                key_obj = c.get_key(result.fpr, secret=True)
-            except gpg.errors.GPGMEError as e:
-                raise GPGProviderError("Internal GPGME error") from e
-            except gpg.errors.KeyError as e:
-                raise GPGProviderError("Newly created key not found") from e
-
-            return GPGME_GPGSecretKey(GPGME_GPGPublicKey(key_obj))
-
-
-class PublicKeyMetadata(NamedTuple):
-    """
-    Metadata about a published public key.
-    """
-
-    fingerprint: str
-    timestamp: datetime
-
-
-@enum.unique
-class TrustLevel(enum.Enum):
-    """
-    The trust levels required for BTBV and manual trust.
-    """
-
-    TRUSTED: str = "TRUSTED"
-    BLINDLY_TRUSTED: str = "BLINDLY_TRUSTED"
-    UNDECIDED: str = "UNDECIDED"
-    DISTRUSTED: str = "DISTRUSTED"
-
-
-OPENPGP_SCHEMA = xmlschema.XMLSchema("""<?xml version="1.0" encoding="utf8"?>
-<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
-    targetNamespace="urn:xmpp:openpgp:0"
-    xmlns="urn:xmpp:openpgp:0">
-
-    <xs:element name="openpgp" type="xs:base64Binary"/>
-</xs:schema>
-""")
-
-
-# The following schema needs verion 1.1 of XML Schema, which is not supported by lxml.
-# Luckily, xmlschema exists, which is a clean, well maintained, cross-platform
-# implementation of XML Schema, including version 1.1.
-CONTENT_SCHEMA = xmlschema.XMLSchema11("""<?xml version="1.1" encoding="utf8"?>
-<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
-    targetNamespace="urn:xmpp:openpgp:0"
-    xmlns="urn:xmpp:openpgp:0">
-
-    <xs:element name="signcrypt">
-        <xs:complexType>
-            <xs:all>
-                <xs:element ref="to" maxOccurs="unbounded"/>
-                <xs:element ref="time"/>
-                <xs:element ref="rpad" minOccurs="0"/>
-                <xs:element ref="payload"/>
-            </xs:all>
-        </xs:complexType>
-    </xs:element>
-
-    <xs:element name="sign">
-        <xs:complexType>
-            <xs:all>
-                <xs:element ref="to" maxOccurs="unbounded"/>
-                <xs:element ref="time"/>
-                <xs:element ref="rpad" minOccurs="0"/>
-                <xs:element ref="payload"/>
-            </xs:all>
-        </xs:complexType>
-    </xs:element>
-
-    <xs:element name="crypt">
-        <xs:complexType>
-            <xs:all>
-                <xs:element ref="to" minOccurs="0" maxOccurs="unbounded"/>
-                <xs:element ref="time"/>
-                <xs:element ref="rpad" minOccurs="0"/>
-                <xs:element ref="payload"/>
-            </xs:all>
-        </xs:complexType>
-    </xs:element>
-
-    <xs:element name="to">
-        <xs:complexType>
-            <xs:attribute name="jid" type="xs:string"/>
-        </xs:complexType>
-    </xs:element>
-
-    <xs:element name="time">
-        <xs:complexType>
-            <xs:attribute name="stamp" type="xs:dateTime"/>
-        </xs:complexType>
-    </xs:element>
-
-    <xs:element name="rpad" type="xs:string"/>
-
-    <xs:element name="payload">
-        <xs:complexType>
-            <xs:sequence>
-                <xs:any minOccurs="0" maxOccurs="unbounded" processContents="skip"/>
-            </xs:sequence>
-        </xs:complexType>
-    </xs:element>
-</xs:schema>
-""")
-
-
-PUBLIC_KEYS_LIST_NODE = "urn:xmpp:openpgp:0:public-keys"
-PUBLIC_KEYS_LIST_SCHEMA = xmlschema.XMLSchema("""<?xml version="1.0" encoding="utf8"?>
-<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
-    targetNamespace="urn:xmpp:openpgp:0"
-    xmlns="urn:xmpp:openpgp:0">
-
-    <xs:element name="public-keys-list">
-        <xs:complexType>
-            <xs:sequence>
-                <xs:element ref="pubkey-metadata" minOccurs="0" maxOccurs="unbounded"/>
-            </xs:sequence>
-        </xs:complexType>
-    </xs:element>
-
-    <xs:element name="pubkey-metadata">
-        <xs:complexType>
-            <xs:attribute name="v4-fingerprint" type="xs:string"/>
-            <xs:attribute name="date" type="xs:dateTime"/>
-        </xs:complexType>
-    </xs:element>
-</xs:schema>
-""")
-
-
-PUBKEY_SCHEMA = xmlschema.XMLSchema("""<?xml version="1.0" encoding="utf8"?>
-<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
-    targetNamespace="urn:xmpp:openpgp:0"
-    xmlns="urn:xmpp:openpgp:0">
-
-    <xs:element name="pubkey">
-        <xs:complexType>
-            <xs:all>
-                <xs:element ref="data"/>
-            </xs:all>
-            <xs:anyAttribute processContents="skip"/>
-        </xs:complexType>
-    </xs:element>
-
-    <xs:element name="data" type="xs:base64Binary"/>
-</xs:schema>
-""")
-
-
-SECRETKEY_SCHEMA = xmlschema.XMLSchema("""<?xml version="1.0" encoding="utf8"?>
-<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
-    targetNamespace="urn:xmpp:openpgp:0"
-    xmlns="urn:xmpp:openpgp:0">
-
-    <xs:element name="secretkey" type="xs:base64Binary"/>
-</xs:schema>
-""")
-
-
-DEFAULT_TRUST_MODEL_PARAM = f"""
-<params>
-<individual>
-<category name="{PARAM_CATEGORY}" label={quoteattr(D_('Security'))}>
-    <param name="{PARAM_NAME}"
-        label={quoteattr(D_('OMEMO default trust policy'))}
-        type="list" security="3">
-        <option value="manual" label={quoteattr(D_('Manual trust (more secure)'))} />
-        <option value="btbv"
-            label={quoteattr(D_('Blind Trust Before Verification (more user friendly)'))}
-            selected="true" />
-    </param>
-</category>
-</individual>
-</params>
-"""
-
-
-def get_gpg_provider(sat: SAT, client: SatXMPPClient) -> GPGProvider:
-    """Get the GPG provider for a client.
-
-    @param sat: The SAT instance.
-    @param client: The client.
-    @return: The GPG provider specifically for that client.
-    """
-
-    return GPGME_GPGProvider(str(sat.get_local_path(client, "gnupg-home")))
-
-
-def generate_passphrase() -> str:
-    """Generate a secure passphrase for symmetric encryption.
-
-    @return: The passphrase.
-    """
-
-    return "-".join("".join(
-        secrets.choice("123456789ABCDEFGHIJKLMNPQRSTUVWXYZ") for __ in range(4)
-    ) for __ in range(6))
-
-
-# TODO: Handle the user id mess
-class XEP_0373:
-    """
-    Implementation of XEP-0373: OpenPGP for XMPP under namespace ``urn:xmpp:openpgp:0``.
-    """
-
-    def __init__(self, host: SAT) -> None:
-        """
-        @param sat: The SAT instance.
-        """
-
-        self.host = host
-
-        # Add configuration option to choose between manual trust and BTBV as the trust
-        # model
-        host.memory.update_params(DEFAULT_TRUST_MODEL_PARAM)
-
-        self.__xep_0045 = cast(Optional[XEP_0045], host.plugins.get("XEP-0045"))
-        self.__xep_0060 = cast(XEP_0060, host.plugins["XEP-0060"])
-
-        self.__storage: Dict[str, persistent.LazyPersistentBinaryDict] = {}
-
-        xep_0163 = cast(XEP_0163, host.plugins["XEP-0163"])
-        xep_0163.add_pep_event(
-            "OX_PUBLIC_KEYS_LIST",
-            PUBLIC_KEYS_LIST_NODE,
-            lambda items_event, profile: defer.ensureDeferred(
-                self.__on_public_keys_list_update(items_event, profile)
-            )
-        )
-
-    async def profile_connecting(self, client):
-        client.gpg_provider = get_gpg_provider(self.host, client)
-
-    async def profile_connected(  # pylint: disable=invalid-name
-        self,
-        client: SatXMPPClient
-    ) -> None:
-        """
-        @param client: The client.
-        """
-
-        profile = cast(str, client.profile)
-
-        if not profile in self.__storage:
-            self.__storage[profile] = \
-                persistent.LazyPersistentBinaryDict("XEP-0373", client.profile)
-
-        if len(self.list_secret_keys(client)) == 0:
-            log.debug(f"Generating first GPG key for {client.jid.userhost()}.")
-            await self.create_key(client)
-
-    async def __on_public_keys_list_update(
-        self,
-        items_event: pubsub.ItemsEvent,
-        profile: str
-    ) -> None:
-        """Handle public keys list updates fired by PEP.
-
-        @param items_event: The event.
-        @param profile: The profile this event belongs to.
-        """
-
-        client = self.host.get_client(profile)
-
-        sender = cast(jid.JID, items_event.sender)
-        items = cast(List[domish.Element], items_event.items)
-
-        if len(items) > 1:
-            log.warning("Ignoring public keys list update with more than one element.")
-            return
-
-        item_elt = next(iter(items), None)
-        if item_elt is None:
-            log.debug("Ignoring empty public keys list update.")
-            return
-
-        public_keys_list_elt = cast(
-            Optional[domish.Element],
-            next(item_elt.elements(NS_OX, "public-keys-list"), None)
-        )
-
-        pubkey_metadata_elts: Optional[List[domish.Element]] = None
-
-        if public_keys_list_elt is not None:
-            try:
-                PUBLIC_KEYS_LIST_SCHEMA.validate(public_keys_list_elt.toXml())
-            except xmlschema.XMLSchemaValidationError:
-                pass
-            else:
-                pubkey_metadata_elts = \
-                    list(public_keys_list_elt.elements(NS_OX, "pubkey-metadata"))
-
-        if pubkey_metadata_elts is None:
-            log.warning(f"Malformed public keys list update item: {item_elt.toXml()}")
-            return
-
-        new_public_keys_metadata = { PublicKeyMetadata(
-            fingerprint=cast(str, pubkey_metadata_elt["v4-fingerprint"]),
-            timestamp=parse_datetime(cast(str, pubkey_metadata_elt["date"]))
-        ) for pubkey_metadata_elt in pubkey_metadata_elts }
-
-        storage_key = STR_KEY_PUBLIC_KEYS_METADATA.format(sender.userhost())
-
-        local_public_keys_metadata = cast(
-            Set[PublicKeyMetadata],
-            await self.__storage[profile].get(storage_key, set())
-        )
-
-        unchanged_keys = new_public_keys_metadata & local_public_keys_metadata
-        changed_or_new_keys = new_public_keys_metadata - unchanged_keys
-        available_keys = self.list_public_keys(client, sender)
-
-        for key_metadata in changed_or_new_keys:
-            # Check whether the changed or new key has been imported before
-            if any(key.fingerprint == key_metadata.fingerprint for key in available_keys):
-                try:
-                    # If it has been imported before, try to update it
-                    await self.import_public_key(client, sender, key_metadata.fingerprint)
-                except Exception as e:
-                    log.warning(f"Public key import failed: {e}")
-
-                    # If the update fails, remove the key from the local metadata list
-                    # such that the update is attempted again next time
-                    new_public_keys_metadata.remove(key_metadata)
-
-        # Check whether this update was for our account and make sure all of our keys are
-        # included in the update
-        if sender.userhost() == client.jid.userhost():
-            secret_keys = self.list_secret_keys(client)
-            missing_keys = set(filter(lambda secret_key: all(
-                key_metadata.fingerprint != secret_key.public_key.fingerprint
-                for key_metadata
-                in new_public_keys_metadata
-            ), secret_keys))
-
-            if len(missing_keys) > 0:
-                log.warning(
-                    "Public keys list update did not contain at least one of our keys."
-                    f" {new_public_keys_metadata}"
-                )
-
-                for missing_key in missing_keys:
-                    log.warning(missing_key.public_key.fingerprint)
-                    new_public_keys_metadata.add(PublicKeyMetadata(
-                        fingerprint=missing_key.public_key.fingerprint,
-                        timestamp=datetime.now(timezone.utc)
-                    ))
-
-                await self.publish_public_keys_list(client, new_public_keys_metadata)
-
-        await self.__storage[profile].force(storage_key, new_public_keys_metadata)
-
-    def list_public_keys(self, client: SatXMPPClient, jid: jid.JID) -> Set[GPGPublicKey]:
-        """List GPG public keys available for a JID.
-
-        @param client: The client to perform this operation with.
-        @param jid: The JID. Can be a bare JID.
-        @return: The set of public keys available for this JID.
-        """
-
-        gpg_provider = get_gpg_provider(self.host, client)
-
-        return gpg_provider.list_public_keys(f"xmpp:{jid.userhost()}")
-
-    def list_secret_keys(self, client: SatXMPPClient) -> Set[GPGSecretKey]:
-        """List GPG secret keys available for a JID.
-
-        @param client: The client to perform this operation with.
-        @return: The set of secret keys available for this JID.
-        """
-
-        gpg_provider = get_gpg_provider(self.host, client)
-
-        return gpg_provider.list_secret_keys(f"xmpp:{client.jid.userhost()}")
-
-    async def create_key(self, client: SatXMPPClient) -> GPGSecretKey:
-        """Create a new GPG key, capable of signing and encryption.
-
-        The key is generated without password protection and without expiration.
-
-        @param client: The client to perform this operation with.
-        @return: The new key.
-        """
-
-        gpg_provider = get_gpg_provider(self.host, client)
-
-        secret_key = gpg_provider.create_key(f"xmpp:{client.jid.userhost()}")
-
-        await self.publish_public_key(client, secret_key.public_key)
-
-        storage_key = STR_KEY_PUBLIC_KEYS_METADATA.format(client.jid.userhost())
-
-        public_keys_list = cast(
-            Set[PublicKeyMetadata],
-            await self.__storage[client.profile].get(storage_key, set())
-        )
-
-        public_keys_list.add(PublicKeyMetadata(
-            fingerprint=secret_key.public_key.fingerprint,
-            timestamp=datetime.now(timezone.utc)
-        ))
-
-        await self.publish_public_keys_list(client, public_keys_list)
-
-        await self.__storage[client.profile].force(storage_key, public_keys_list)
-
-        return secret_key
-
-    @staticmethod
-    def __build_content_element(
-        element_name: Literal["signcrypt", "sign", "crypt"],
-        recipient_jids: Iterable[jid.JID],
-        include_rpad: bool
-    ) -> Tuple[domish.Element, domish.Element]:
-        """Build a content element.
-
-        @param element_name: The name of the content element.
-        @param recipient_jids: The intended recipients of this content element. Can be
-            bare JIDs.
-        @param include_rpad: Whether to include random-length random-content padding.
-        @return: The content element and the ``<payload/>`` element to add the stanza
-            extension elements to.
-        """
-
-        content_elt = domish.Element((NS_OX, element_name))
-
-        for recipient_jid in recipient_jids:
-            content_elt.addElement("to")["jid"] = recipient_jid.userhost()
-
-        content_elt.addElement("time")["stamp"] = format_datetime()
-
-        if include_rpad:
-            # XEP-0373 doesn't specify bounds for the length of the random padding. This
-            # uses the bounds specified in XEP-0420 for the closely related rpad affix.
-            rpad_length = secrets.randbelow(201)
-            rpad_content = "".join(
-                secrets.choice(string.digits + string.ascii_letters + string.punctuation)
-                for __
-                in range(rpad_length)
-            )
-            content_elt.addElement("rpad", content=rpad_content)
-
-        payload_elt = content_elt.addElement("payload")
-
-        return content_elt, payload_elt
-
-    @staticmethod
-    def build_signcrypt_element(
-        recipient_jids: Iterable[jid.JID]
-    ) -> Tuple[domish.Element, domish.Element]:
-        """Build a ``<signcrypt/>`` content element.
-
-        @param recipient_jids: The intended recipients of this content element. Can be
-            bare JIDs.
-        @return: The ``<signcrypt/>`` element and the ``<payload/>`` element to add the
-            stanza extension elements to.
-        """
-
-        if len(recipient_jids) == 0:
-            raise ValueError("Recipient JIDs must be provided.")
-
-        return XEP_0373.__build_content_element("signcrypt", recipient_jids, True)
-
-    @staticmethod
-    def build_sign_element(
-        recipient_jids: Iterable[jid.JID],
-        include_rpad: bool
-    ) -> Tuple[domish.Element, domish.Element]:
-        """Build a ``<sign/>`` content element.
-
-        @param recipient_jids: The intended recipients of this content element. Can be
-            bare JIDs.
-        @param include_rpad: Whether to include random-length random-content padding,
-            which is OPTIONAL for the ``<sign/>`` content element.
-        @return: The ``<sign/>`` element and the ``<payload/>`` element to add the stanza
-            extension elements to.
-        """
-
-        if len(recipient_jids) == 0:
-            raise ValueError("Recipient JIDs must be provided.")
-
-        return XEP_0373.__build_content_element("sign", recipient_jids, include_rpad)
-
-    @staticmethod
-    def build_crypt_element(
-        recipient_jids: Iterable[jid.JID]
-    ) -> Tuple[domish.Element, domish.Element]:
-        """Build a ``<crypt/>`` content element.
-
-        @param recipient_jids: The intended recipients of this content element. Specifying
-            the intended recipients is OPTIONAL for the ``<crypt/>`` content element. Can
-            be bare JIDs.
-        @return: The ``<crypt/>`` element and the ``<payload/>`` element to add the stanza
-            extension elements to.
-        """
-
-        return XEP_0373.__build_content_element("crypt", recipient_jids, True)
-
-    async def build_openpgp_element(
-        self,
-        client: SatXMPPClient,
-        content_elt: domish.Element,
-        recipient_jids: Set[jid.JID]
-    ) -> domish.Element:
-        """Build an ``<openpgp/>`` element.
-
-        @param client: The client to perform this operation with.
-        @param content_elt: The content element to contain in the ``<openpgp/>`` element.
-        @param recipient_jids: The recipient's JIDs. Can be bare JIDs.
-        @return: The ``<openpgp/>`` element.
-        """
-
-        gpg_provider = get_gpg_provider(self.host, client)
-
-        # TODO: I'm not sure whether we want to sign with all keys by default or choose
-        # just one key/a subset of keys to sign with.
-        signing_keys = set(filter(
-            lambda secret_key: gpg_provider.can_sign(secret_key.public_key),
-            self.list_secret_keys(client)
-        ))
-
-        encryption_keys: Set[GPGPublicKey] = set()
-
-        for recipient_jid in recipient_jids:
-            # import all keys of the recipient
-            all_public_keys = await self.import_all_public_keys(client, recipient_jid)
-
-            # Filter for keys that can encrypt
-            encryption_keys |= set(filter(gpg_provider.can_encrypt, all_public_keys))
-
-        # TODO: Handle trust
-
-        content = content_elt.toXml().encode("utf-8")
-        data: bytes
-
-        if content_elt.name == "signcrypt":
-            data = gpg_provider.encrypt(content, encryption_keys, signing_keys)
-        elif content_elt.name == "sign":
-            data = gpg_provider.sign(content, signing_keys)
-        elif content_elt.name == "crypt":
-            data = gpg_provider.encrypt(content, encryption_keys)
-        else:
-            raise ValueError(f"Unknown content element <{content_elt.name}/>")
-
-        openpgp_elt = domish.Element((NS_OX, "openpgp"))
-        openpgp_elt.addContent(base64.b64encode(data).decode("ASCII"))
-        return openpgp_elt
-
-    async def unpack_openpgp_element(
-        self,
-        client: SatXMPPClient,
-        openpgp_elt: domish.Element,
-        element_name: Literal["signcrypt", "sign", "crypt"],
-        sender_jid: jid.JID
-    ) -> Tuple[domish.Element, datetime]:
-        """Verify, decrypt and unpack an ``<openpgp/>`` element.
-
-        @param client: The client to perform this operation with.
-        @param openpgp_elt: The ``<openpgp/>`` element.
-        @param element_name: The name of the content element.
-        @param sender_jid: The sender's JID. Can be a bare JID.
-        @return: The ``<payload/>`` element containing the decrypted/verified stanza
-            extension elements carried by this ``<openpgp/>`` element, and the timestamp
-            contained in the content element.
-        @raise exceptions.ParsingError: on syntactical verification errors.
-        @raise VerificationError: on semantical verification errors accoding to XEP-0373.
-        @raise DecryptionFailed: on decryption failure.
-        @raise VerificationFailed: if the data could not be verified.
-
-        @warning: The timestamp is not verified for plausibility; this SHOULD be done by
-            the calling code.
-        """
-
-        gpg_provider = get_gpg_provider(self.host, client)
-
-        decryption_keys = set(filter(
-            lambda secret_key: gpg_provider.can_encrypt(secret_key.public_key),
-            self.list_secret_keys(client)
-        ))
-
-        # import all keys of the sender
-        all_public_keys = await self.import_all_public_keys(client, sender_jid)
-
-        # Filter for keys that can sign
-        verification_keys = set(filter(gpg_provider.can_sign, all_public_keys))
-
-        # TODO: Handle trust
-
-        try:
-            OPENPGP_SCHEMA.validate(openpgp_elt.toXml())
-        except xmlschema.XMLSchemaValidationError as e:
-            raise exceptions.ParsingError(
-                "<openpgp/> element doesn't pass schema validation."
-            ) from e
-
-        openpgp_message = base64.b64decode(str(openpgp_elt))
-        content: bytes
-
-        if element_name == "signcrypt":
-            content = gpg_provider.decrypt(
-                openpgp_message,
-                decryption_keys,
-                public_keys=verification_keys
-            )
-        elif element_name == "sign":
-            content = gpg_provider.verify(openpgp_message, verification_keys)
-        elif element_name == "crypt":
-            content = gpg_provider.decrypt(openpgp_message, decryption_keys)
-        else:
-            assert_never(element_name)
-
-        try:
-            content_elt = cast(
-                domish.Element,
-                xml_tools.ElementParser()(content.decode("utf-8"))
-            )
-        except UnicodeDecodeError as e:
-            raise exceptions.ParsingError("UTF-8 decoding error") from e
-
-        try:
-            CONTENT_SCHEMA.validate(content_elt.toXml())
-        except xmlschema.XMLSchemaValidationError as e:
-            raise exceptions.ParsingError(
-                f"<{element_name}/> element doesn't pass schema validation."
-            ) from e
-
-        if content_elt.name != element_name:
-            raise exceptions.ParsingError(f"Not a <{element_name}/> element.")
-
-        recipient_jids = \
-            { jid.JID(to_elt["jid"]) for to_elt in content_elt.elements(NS_OX, "to") }
-
-        if (
-            client.jid.userhostJID() not in { jid.userhostJID() for jid in recipient_jids }
-            and element_name != "crypt"
-        ):
-            raise VerificationError(
-                f"Recipient list in <{element_name}/> element does not list our (bare)"
-                f" JID."
-            )
-
-        time_elt = next(content_elt.elements(NS_OX, "time"))
-
-        timestamp = parse_datetime(time_elt["stamp"])
-
-        payload_elt = next(content_elt.elements(NS_OX, "payload"))
-
-        return payload_elt, timestamp
-
-    async def publish_public_key(
-        self,
-        client: SatXMPPClient,
-        public_key: GPGPublicKey
-    ) -> None:
-        """Publish a public key.
-
-        @param client: The client.
-        @param public_key: The public key to publish.
-        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
-        """
-
-        gpg_provider = get_gpg_provider(self.host, client)
-
-        packet = gpg_provider.export_public_key(public_key)
-
-        node = f"urn:xmpp:openpgp:0:public-keys:{public_key.fingerprint}"
-
-        pubkey_elt = domish.Element((NS_OX, "pubkey"))
-
-        pubkey_elt.addElement("data", content=base64.b64encode(packet).decode("ASCII"))
-
-        try:
-            await self.__xep_0060.send_item(
-                client,
-                client.jid.userhostJID(),
-                node,
-                pubkey_elt,
-                format_datetime(),
-                extra={
-                    XEP_0060.EXTRA_PUBLISH_OPTIONS: {
-                        XEP_0060.OPT_PERSIST_ITEMS: "true",
-                        XEP_0060.OPT_ACCESS_MODEL: "open",
-                        XEP_0060.OPT_MAX_ITEMS: 1
-                    },
-                    # TODO: Do we really want publish_without_options here?
-                    XEP_0060.EXTRA_ON_PRECOND_NOT_MET: "publish_without_options"
-                }
-            )
-        except Exception as e:
-            raise XMPPInteractionFailed("Publishing the public key failed.") from e
-
-    async def import_all_public_keys(
-        self,
-        client: SatXMPPClient,
-        entity_jid: jid.JID
-    ) -> Set[GPGPublicKey]:
-        """import all public keys of a JID that have not been imported before.
-
-        @param client: The client.
-        @param jid: The JID. Can be a bare JID.
-        @return: The public keys.
-        @note: Failure to import a key simply results in the key not being included in the
-            result.
-        """
-
-        available_public_keys = self.list_public_keys(client, entity_jid)
-
-        storage_key = STR_KEY_PUBLIC_KEYS_METADATA.format(entity_jid.userhost())
-
-        public_keys_metadata = cast(
-            Set[PublicKeyMetadata],
-            await self.__storage[client.profile].get(storage_key, set())
-        )
-        if not public_keys_metadata:
-            public_keys_metadata = await self.download_public_keys_list(
-                client, entity_jid
-            )
-            if not public_keys_metadata:
-                raise exceptions.NotFound(
-                    f"Can't find public keys for {entity_jid}"
-                )
-            else:
-                await self.__storage[client.profile].aset(
-                    storage_key, public_keys_metadata
-                )
-
-
-        missing_keys = set(filter(lambda public_key_metadata: all(
-            public_key_metadata.fingerprint != public_key.fingerprint
-            for public_key
-            in available_public_keys
-        ), public_keys_metadata))
-
-        for missing_key in missing_keys:
-            try:
-                available_public_keys.add(
-                    await self.import_public_key(client, entity_jid, missing_key.fingerprint)
-                )
-            except Exception as e:
-                log.warning(
-                    f"import of public key {missing_key.fingerprint} owned by"
-                    f" {entity_jid.userhost()} failed, ignoring: {e}"
-                )
-
-        return available_public_keys
-
-    async def import_public_key(
-        self,
-        client: SatXMPPClient,
-        jid: jid.JID,
-        fingerprint: str
-    ) -> GPGPublicKey:
-        """import a public key.
-
-        @param client: The client.
-        @param jid: The JID owning the public key. Can be a bare JID.
-        @param fingerprint: The fingerprint of the public key.
-        @return: The public key.
-        @raise exceptions.NotFound: if the public key was not found.
-        @raise exceptions.ParsingError: on XML-level parsing errors.
-        @raise InvalidPacket: if the packet is either syntactically or semantically deemed
-            invalid.
-        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
-        """
-
-        gpg_provider = get_gpg_provider(self.host, client)
-
-        node = f"urn:xmpp:openpgp:0:public-keys:{fingerprint}"
-
-        try:
-            items, __ = await self.__xep_0060.get_items(
-                client,
-                jid.userhostJID(),
-                node,
-                max_items=1
-            )
-        except exceptions.NotFound as e:
-            raise exceptions.NotFound(
-                f"No public key with fingerprint {fingerprint} published by JID"
-                f" {jid.userhost()}."
-            ) from e
-        except Exception as e:
-            raise XMPPInteractionFailed("Fetching the public keys list failed.") from e
-
-        try:
-            item_elt = cast(domish.Element, items[0])
-        except IndexError as e:
-            raise exceptions.NotFound(
-                f"No public key with fingerprint {fingerprint} published by JID"
-                f" {jid.userhost()}."
-            ) from e
-
-        pubkey_elt = cast(
-            Optional[domish.Element],
-            next(item_elt.elements(NS_OX, "pubkey"), None)
-        )
-
-        if pubkey_elt is None:
-            raise exceptions.ParsingError(
-                f"Publish-Subscribe item of JID {jid.userhost()} doesn't contain pubkey"
-                f" element."
-            )
-
-        try:
-            PUBKEY_SCHEMA.validate(pubkey_elt.toXml())
-        except xmlschema.XMLSchemaValidationError as e:
-            raise exceptions.ParsingError(
-                f"Publish-Subscribe item of JID {jid.userhost()} doesn't pass pubkey"
-                f" schema validation."
-            ) from e
-
-        public_key = gpg_provider.import_public_key(base64.b64decode(str(
-            next(pubkey_elt.elements(NS_OX, "data"))
-        )))
-
-        return public_key
-
-    async def publish_public_keys_list(
-        self,
-        client: SatXMPPClient,
-        public_keys_list: Iterable[PublicKeyMetadata]
-    ) -> None:
-        """Publish/update the own public keys list.
-
-        @param client: The client.
-        @param public_keys_list: The public keys list.
-        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
-
-        @warning: All public keys referenced in the public keys list MUST be published
-            beforehand.
-        """
-
-        if len({ pkm.fingerprint for pkm in public_keys_list }) != len(public_keys_list):
-            raise ValueError("Public keys list contains duplicate fingerprints.")
-
-        node = "urn:xmpp:openpgp:0:public-keys"
-
-        public_keys_list_elt = domish.Element((NS_OX, "public-keys-list"))
-
-        for public_key_metadata in public_keys_list:
-            pubkey_metadata_elt = public_keys_list_elt.addElement("pubkey-metadata")
-            pubkey_metadata_elt["v4-fingerprint"] = public_key_metadata.fingerprint
-            pubkey_metadata_elt["date"] = format_datetime(public_key_metadata.timestamp)
-
-        try:
-            await self.__xep_0060.send_item(
-                client,
-                client.jid.userhostJID(),
-                node,
-                public_keys_list_elt,
-                item_id=XEP_0060.ID_SINGLETON,
-                extra={
-                    XEP_0060.EXTRA_PUBLISH_OPTIONS: {
-                        XEP_0060.OPT_PERSIST_ITEMS: "true",
-                        XEP_0060.OPT_ACCESS_MODEL: "open",
-                        XEP_0060.OPT_MAX_ITEMS: 1
-                    },
-                    # TODO: Do we really want publish_without_options here?
-                    XEP_0060.EXTRA_ON_PRECOND_NOT_MET: "publish_without_options"
-                }
-            )
-        except Exception as e:
-            raise XMPPInteractionFailed("Publishing the public keys list failed.") from e
-
-    async def download_public_keys_list(
-        self,
-        client: SatXMPPClient,
-        jid: jid.JID
-    ) -> Optional[Set[PublicKeyMetadata]]:
-        """Download the public keys list of a JID.
-
-        @param client: The client.
-        @param jid: The JID. Can be a bare JID.
-        @return: The public keys list or ``None`` if the JID hasn't published a public
-            keys list. An empty list means the JID has published an empty list.
-        @raise exceptions.ParsingError: on XML-level parsing errors.
-        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
-        """
-
-        node = "urn:xmpp:openpgp:0:public-keys"
-
-        try:
-            items, __ = await self.__xep_0060.get_items(
-                client,
-                jid.userhostJID(),
-                node,
-                max_items=1
-            )
-        except exceptions.NotFound:
-            return None
-        except Exception as e:
-            raise XMPPInteractionFailed() from e
-
-        try:
-            item_elt = cast(domish.Element, items[0])
-        except IndexError:
-            return None
-
-        public_keys_list_elt = cast(
-            Optional[domish.Element],
-            next(item_elt.elements(NS_OX, "public-keys-list"), None)
-        )
-
-        if public_keys_list_elt is None:
-            return None
-
-        try:
-            PUBLIC_KEYS_LIST_SCHEMA.validate(public_keys_list_elt.toXml())
-        except xmlschema.XMLSchemaValidationError as e:
-            raise exceptions.ParsingError(
-                f"Publish-Subscribe item of JID {jid.userhost()} doesn't pass public keys"
-                f" list schema validation."
-            ) from e
-
-        return {
-            PublicKeyMetadata(
-                fingerprint=pubkey_metadata_elt["v4-fingerprint"],
-                timestamp=parse_datetime(pubkey_metadata_elt["date"])
-            )
-            for pubkey_metadata_elt
-            in public_keys_list_elt.elements(NS_OX, "pubkey-metadata")
-        }
-
-    async def __prepare_secret_key_synchronization(
-        self,
-        client: SatXMPPClient
-    ) -> Optional[domish.Element]:
-        """Prepare for secret key synchronization.
-
-        Makes sure the relative protocols and protocol extensions are supported by the
-        server and makes sure that the PEP node for secret synchronization exists and is
-        configured correctly. The node is created if necessary.
-
-        @param client: The client.
-        @return: As part of the preparations, the secret key synchronization PEP node is
-            fetched. The result of that fetch is returned here.
-        @raise exceptions.FeatureNotFound: if the server lacks support for the required
-            protocols or protocol extensions.
-        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
-        """
-
-        try:
-            infos = cast(DiscoInfo, await self.host.memory.disco.get_infos(
-                client,
-                client.jid.userhostJID()
-            ))
-        except Exception as e:
-            raise XMPPInteractionFailed(
-                "Error performing service discovery on the own bare JID."
-            ) from e
-
-        identities = cast(Dict[Tuple[str, str], str], infos.identities)
-        features = cast(Set[DiscoFeature], infos.features)
-
-        if ("pubsub", "pep") not in identities:
-            raise exceptions.FeatureNotFound("Server doesn't support PEP.")
-
-        if "http://jabber.org/protocol/pubsub#access-whitelist" not in features:
-            raise exceptions.FeatureNotFound(
-                "Server doesn't support the whitelist access model."
-            )
-
-        persistent_items_supported = \
-            "http://jabber.org/protocol/pubsub#persistent-items" in features
-
-        # TODO: persistent-items is a SHOULD, how do we handle the feature missing?
-
-        node = "urn:xmpp:openpgp:0:secret-key"
-
-        try:
-            items, __ = await self.__xep_0060.get_items(
-                client,
-                client.jid.userhostJID(),
-                node,
-                max_items=1
-            )
-        except exceptions.NotFound:
-            try:
-                await self.__xep_0060.createNode(
-                    client,
-                    client.jid.userhostJID(),
-                    node,
-                    {
-                        XEP_0060.OPT_PERSIST_ITEMS: "true",
-                        XEP_0060.OPT_ACCESS_MODEL: "whitelist",
-                        XEP_0060.OPT_MAX_ITEMS: "1"
-                    }
-                )
-            except Exception as e:
-                raise XMPPInteractionFailed(
-                    "Error creating the secret key synchronization node."
-                ) from e
-        except Exception as e:
-            raise XMPPInteractionFailed(
-                "Error fetching the secret key synchronization node."
-            ) from e
-
-        try:
-            return cast(domish.Element, items[0])
-        except IndexError:
-            return None
-
-    async def export_secret_keys(
-        self,
-        client: SatXMPPClient,
-        secret_keys: Iterable[GPGSecretKey]
-    ) -> str:
-        """Export secret keys to synchronize them with other devices.
-
-        @param client: The client.
-        @param secret_keys: The secret keys to export.
-        @return: The backup code needed to decrypt the exported secret keys.
-        @raise exceptions.FeatureNotFound: if the server lacks support for the required
-            protocols or protocol extensions.
-        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
-        """
-
-        gpg_provider = get_gpg_provider(self.host, client)
-
-        await self.__prepare_secret_key_synchronization(client)
-
-        backup_code = generate_passphrase()
-
-        plaintext = b"".join(
-            gpg_provider.backup_secret_key(secret_key) for secret_key in secret_keys
-        )
-
-        ciphertext = gpg_provider.encrypt_symmetrically(plaintext, backup_code)
-
-        node = "urn:xmpp:openpgp:0:secret-key"
-
-        secretkey_elt = domish.Element((NS_OX, "secretkey"))
-        secretkey_elt.addContent(base64.b64encode(ciphertext).decode("ASCII"))
-
-        try:
-            await self.__xep_0060.send_item(
-                client,
-                client.jid.userhostJID(),
-                node,
-                secretkey_elt
-            )
-        except Exception as e:
-            raise XMPPInteractionFailed("Publishing the secret keys failed.") from e
-
-        return backup_code
-
-    async def download_secret_keys(self, client: SatXMPPClient) -> Optional[bytes]:
-        """Download previously exported secret keys to import them in a second step.
-
-        The downloading and importing steps are separate since a backup code is required
-        for the import and it should be possible to try multiple backup codes without
-        redownloading the data every time. The second half of the import procedure is
-        provided by :meth:`import_secret_keys`.
-
-        @param client: The client.
-        @return: The encrypted secret keys previously exported, if any.
-        @raise exceptions.FeatureNotFound: if the server lacks support for the required
-            protocols or protocol extensions.
-        @raise exceptions.ParsingError: on XML-level parsing errors.
-        @raise XMPPInteractionFailed: if any interaction via XMPP failed.
-        """
-
-        item_elt = await self.__prepare_secret_key_synchronization(client)
-        if item_elt is None:
-            return None
-
-        secretkey_elt = cast(
-            Optional[domish.Element],
-            next(item_elt.elements(NS_OX, "secretkey"), None)
-        )
-
-        if secretkey_elt is None:
-            return None
-
-        try:
-            SECRETKEY_SCHEMA.validate(secretkey_elt.toXml())
-        except xmlschema.XMLSchemaValidationError as e:
-            raise exceptions.ParsingError(
-                "Publish-Subscribe item doesn't pass secretkey schema validation."
-            ) from e
-
-        return base64.b64decode(str(secretkey_elt))
-
-    def import_secret_keys(
-        self,
-        client: SatXMPPClient,
-        ciphertext: bytes,
-        backup_code: str
-    ) -> Set[GPGSecretKey]:
-        """import previously downloaded secret keys.
-
-        The downloading and importing steps are separate since a backup code is required
-        for the import and it should be possible to try multiple backup codes without
-        redownloading the data every time. The first half of the import procedure is
-        provided by :meth:`download_secret_keys`.
-
-        @param client: The client to perform this operation with.
-        @param ciphertext: The ciphertext, i.e. the data returned by
-            :meth:`download_secret_keys`.
-        @param backup_code: The backup code needed to decrypt the data.
-        @raise InvalidPacket: if one of the GPG packets building the secret key data is
-            either syntactically or semantically deemed invalid.
-        @raise DecryptionFailed: on decryption failure.
-        """
-
-        gpg_provider = get_gpg_provider(self.host, client)
-
-        return gpg_provider.restore_secret_keys(gpg_provider.decrypt_symmetrically(
-            ciphertext,
-            backup_code
-        ))
-
-    @staticmethod
-    def __get_joined_muc_users(
-        client: SatXMPPClient,
-        xep_0045: XEP_0045,
-        room_jid: jid.JID
-    ) -> Set[jid.JID]:
-        """
-        @param client: The client.
-        @param xep_0045: A MUC plugin instance.
-        @param room_jid: The room JID.
-        @return: A set containing the bare JIDs of the MUC participants.
-        @raise InternalError: if the MUC is not joined or the entity information of a
-            participant isn't available.
-        """
-        # TODO: This should probably be a global helper somewhere
-
-        bare_jids: Set[jid.JID] = set()
-
-        try:
-            room = cast(muc.Room, xep_0045.get_room(client, room_jid))
-        except exceptions.NotFound as e:
-            raise exceptions.InternalError(
-                "Participant list of unjoined MUC requested."
-            ) from e
-
-        for user in cast(Dict[str, muc.User], room.roster).values():
-            entity = cast(Optional[SatXMPPEntity], user.entity)
-            if entity is None:
-                raise exceptions.InternalError(
-                    f"Participant list of MUC requested, but the entity information of"
-                    f" the participant {user} is not available."
-                )
-
-            bare_jids.add(entity.jid.userhostJID())
-
-        return bare_jids
-
-    async def get_trust(
-        self,
-        client: SatXMPPClient,
-        public_key: GPGPublicKey,
-        owner: jid.JID
-    ) -> TrustLevel:
-        """Query the trust level of a public key.
-
-        @param client: The client to perform this operation under.
-        @param public_key: The public key.
-        @param owner: The owner of the public key. Can be a bare JID.
-        @return: The trust level.
-        """
-
-        key = f"/trust/{owner.userhost()}/{public_key.fingerprint}"
-
-        try:
-            return TrustLevel(await self.__storage[client.profile][key])
-        except KeyError:
-            return TrustLevel.UNDECIDED
-
-    async def set_trust(
-        self,
-        client: SatXMPPClient,
-        public_key: GPGPublicKey,
-        owner: jid.JID,
-        trust_level: TrustLevel
-    ) -> None:
-        """Set the trust level of a public key.
-
-        @param client: The client to perform this operation under.
-        @param public_key: The public key.
-        @param owner: The owner of the public key. Can be a bare JID.
-        @param trust_leve: The trust level.
-        """
-
-        key = f"/trust/{owner.userhost()}/{public_key.fingerprint}"
-
-        await self.__storage[client.profile].force(key, trust_level.name)
-
-    async def get_trust_ui(  # pylint: disable=invalid-name
-        self,
-        client: SatXMPPClient,
-        entity: jid.JID
-    ) -> xml_tools.XMLUI:
-        """
-        @param client: The client.
-        @param entity: The entity whose device trust levels to manage.
-        @return: An XMLUI instance which opens a form to manage the trust level of all
-            devices belonging to the entity.
-        """
-
-        if entity.resource:
-            raise ValueError("A bare JID is expected.")
-
-        bare_jids: Set[jid.JID]
-        if self.__xep_0045 is not None and self.__xep_0045.is_joined_room(client, entity):
-            bare_jids = self.__get_joined_muc_users(client, self.__xep_0045, entity)
-        else:
-            bare_jids = { entity.userhostJID() }
-
-        all_public_keys = list({
-            bare_jid: list(self.list_public_keys(client, bare_jid))
-            for bare_jid
-            in bare_jids
-        }.items())
-
-        async def callback(
-            data: Any,
-            profile: str  # pylint: disable=unused-argument
-        ) -> Dict[Never, Never]:
-            """
-            @param data: The XMLUI result produces by the trust UI form.
-            @param profile: The profile.
-            @return: An empty dictionary. The type of the return value was chosen
-                conservatively since the exact options are neither known not needed here.
-            """
-
-            if C.bool(data.get("cancelled", "false")):
-                return {}
-
-            data_form_result = cast(
-                Dict[str, str],
-                xml_tools.xmlui_result_2_data_form_result(data)
-            )
-            for key, value in data_form_result.items():
-                if not key.startswith("trust_"):
-                    continue
-
-                outer_index, inner_index = key.split("_")[1:]
-
-                owner, public_keys = all_public_keys[int(outer_index)]
-                public_key = public_keys[int(inner_index)]
-                trust = TrustLevel(value)
-
-                if (await self.get_trust(client, public_key, owner)) is not trust:
-                    await self.set_trust(client, public_key, owner, value)
-
-            return {}
-
-        submit_id = self.host.register_callback(callback, with_data=True, one_shot=True)
-
-        result = xml_tools.XMLUI(
-            panel_type=C.XMLUI_FORM,
-            title=D_("OX trust management"),
-            submit_id=submit_id
-        )
-        # Casting this to Any, otherwise all calls on the variable cause type errors
-        # pylint: disable=no-member
-        trust_ui = cast(Any, result)
-        trust_ui.addText(D_(
-            "This is OX trusting system. You'll see below the GPG keys of your "
-            "contacts, and a list selection to trust them or not. A trusted key "
-            "can read your messages in plain text, so be sure to only validate "
-            "keys that you are sure are belonging to your contact. It's better "
-            "to do this when you are next to your contact, so "
-            "you can check the \"fingerprint\" of the key "
-            "yourself. Do *not* validate a key if the fingerprint is wrong!"
-        ))
-
-        own_secret_keys = self.list_secret_keys(client)
-
-        trust_ui.change_container("label")
-        for index, secret_key in enumerate(own_secret_keys):
-            trust_ui.addLabel(D_(f"Own secret key {index} fingerprint"))
-            trust_ui.addText(secret_key.public_key.fingerprint)
-            trust_ui.addEmpty()
-            trust_ui.addEmpty()
-
-        for outer_index, [ owner, public_keys ] in enumerate(all_public_keys):
-            for inner_index, public_key in enumerate(public_keys):
-                trust_ui.addLabel(D_("Contact"))
-                trust_ui.addJid(jid.JID(owner))
-                trust_ui.addLabel(D_("Fingerprint"))
-                trust_ui.addText(public_key.fingerprint)
-                trust_ui.addLabel(D_("Trust this device?"))
-
-                current_trust_level = await self.get_trust(client, public_key, owner)
-                avaiable_trust_levels = \
-                    { TrustLevel.DISTRUSTED, TrustLevel.TRUSTED, current_trust_level }
-
-                trust_ui.addList(
-                    f"trust_{outer_index}_{inner_index}",
-                    options=[ trust_level.name for trust_level in avaiable_trust_levels ],
-                    selected=current_trust_level.name,
-                    styles=[ "inline" ]
-                )
-
-                trust_ui.addEmpty()
-                trust_ui.addEmpty()
-
-        return result
--- a/sat/plugins/plugin_xep_0374.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,425 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for OpenPGP for XMPP Instant Messaging
-# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Dict, Optional, Set, cast
-
-from typing_extensions import Final
-from wokkel import muc  # type: ignore[import]
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger, Logger
-from sat.core.sat_main import SAT
-from sat.core.xmpp import SatXMPPClient
-from sat.plugins.plugin_xep_0045 import XEP_0045
-from sat.plugins.plugin_xep_0334 import XEP_0334
-from sat.plugins.plugin_xep_0373 import NS_OX, XEP_0373, TrustLevel
-from sat.tools import xml_tools
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from twisted.words.xish import domish
-
-
-__all__ = [  # pylint: disable=unused-variable
-    "PLUGIN_INFO",
-    "XEP_0374",
-    "NS_OXIM"
-]
-
-
-log = cast(Logger, getLogger(__name__))  # type: ignore[no-untyped-call]
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "OXIM",
-    C.PI_IMPORT_NAME: "XEP-0374",
-    C.PI_TYPE: "SEC",
-    C.PI_PROTOCOLS: [ "XEP-0374" ],
-    C.PI_DEPENDENCIES: [ "XEP-0334", "XEP-0373" ],
-    C.PI_RECOMMENDATIONS: [ "XEP-0045" ],
-    C.PI_MAIN: "XEP_0374",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of OXIM"""),
-}
-
-
-# The disco feature
-NS_OXIM: Final = "urn:xmpp:openpgp:im:0"
-
-
-class XEP_0374:
-    """
-    Plugin equipping Libervia with OXIM capabilities under the ``urn:xmpp:openpgp:im:0``
-    namespace. MUC messages are supported next to one to one messages. For trust
-    management, the two trust models "BTBV" and "manual" are supported.
-    """
-
-    def __init__(self, sat: SAT) -> None:
-        """
-        @param sat: The SAT instance.
-        """
-
-        self.__sat = sat
-
-        # Plugins
-        self.__xep_0045 = cast(Optional[XEP_0045], sat.plugins.get("XEP-0045"))
-        self.__xep_0334 = cast(XEP_0334, sat.plugins["XEP-0334"])
-        self.__xep_0373 = cast(XEP_0373, sat.plugins["XEP-0373"])
-
-        # Triggers
-        sat.trigger.add(
-            "message_received",
-            self.__message_received_trigger,
-            priority=100050
-        )
-        sat.trigger.add("send", self.__send_trigger, priority=0)
-
-        # Register the encryption plugin
-        sat.register_encryption_plugin(self, "OXIM", NS_OX, 102)
-
-    async def get_trust_ui(  # pylint: disable=invalid-name
-        self,
-        client: SatXMPPClient,
-        entity: jid.JID
-    ) -> xml_tools.XMLUI:
-        """
-        @param client: The client.
-        @param entity: The entity whose device trust levels to manage.
-        @return: An XMLUI instance which opens a form to manage the trust level of all
-            devices belonging to the entity.
-        """
-
-        return await self.__xep_0373.get_trust_ui(client, entity)
-
-    @staticmethod
-    def __get_joined_muc_users(
-        client: SatXMPPClient,
-        xep_0045: XEP_0045,
-        room_jid: jid.JID
-    ) -> Set[jid.JID]:
-        """
-        @param client: The client.
-        @param xep_0045: A MUC plugin instance.
-        @param room_jid: The room JID.
-        @return: A set containing the bare JIDs of the MUC participants.
-        @raise InternalError: if the MUC is not joined or the entity information of a
-            participant isn't available.
-        """
-
-        bare_jids: Set[jid.JID] = set()
-
-        try:
-            room = cast(muc.Room, xep_0045.get_room(client, room_jid))
-        except exceptions.NotFound as e:
-            raise exceptions.InternalError(
-                "Participant list of unjoined MUC requested."
-            ) from e
-
-        for user in cast(Dict[str, muc.User], room.roster).values():
-            entity = cast(Optional[SatXMPPEntity], user.entity)
-            if entity is None:
-                raise exceptions.InternalError(
-                    f"Participant list of MUC requested, but the entity information of"
-                    f" the participant {user} is not available."
-                )
-
-            bare_jids.add(entity.jid.userhostJID())
-
-        return bare_jids
-
-    async def __message_received_trigger(
-        self,
-        client: SatXMPPClient,
-        message_elt: domish.Element,
-        post_treat: defer.Deferred
-    ) -> bool:
-        """
-        @param client: The client which received the message.
-        @param message_elt: The message element. Can be modified.
-        @param post_treat: A deferred which evaluates to a :class:`MessageData` once the
-            message has fully progressed through the message receiving flow. Can be used
-            to apply treatments to the fully processed message, like marking it as
-            encrypted.
-        @return: Whether to continue the message received flow.
-        """
-        sender_jid = jid.JID(message_elt["from"])
-        feedback_jid: jid.JID
-
-        message_type = message_elt.getAttribute("type", "unknown")
-        is_muc_message = message_type == C.MESS_TYPE_GROUPCHAT
-        if is_muc_message:
-            if self.__xep_0045 is None:
-                log.warning(
-                    "Ignoring MUC message since plugin XEP-0045 is not available."
-                )
-                # Can't handle a MUC message without XEP-0045, let the flow continue
-                # normally
-                return True
-
-            room_jid = feedback_jid = sender_jid.userhostJID()
-
-            try:
-                room = cast(muc.Room, self.__xep_0045.get_room(client, room_jid))
-            except exceptions.NotFound:
-                log.warning(
-                    f"Ignoring MUC message from a room that has not been joined:"
-                    f" {room_jid}"
-                )
-                # Whatever, let the flow continue
-                return True
-
-            sender_user = cast(Optional[muc.User], room.getUser(sender_jid.resource))
-            if sender_user is None:
-                log.warning(
-                    f"Ignoring MUC message from room {room_jid} since the sender's user"
-                    f" wasn't found {sender_jid.resource}"
-                )
-                # Whatever, let the flow continue
-                return True
-
-            sender_user_jid = cast(Optional[jid.JID], sender_user.entity)
-            if sender_user_jid is None:
-                log.warning(
-                    f"Ignoring MUC message from room {room_jid} since the sender's bare"
-                    f" JID couldn't be found from its user information: {sender_user}"
-                )
-                # Whatever, let the flow continue
-                return True
-
-            sender_jid = sender_user_jid
-        else:
-            # I'm not sure why this check is required, this code is copied from XEP-0384
-            if sender_jid.userhostJID() == client.jid.userhostJID():
-                try:
-                    feedback_jid = jid.JID(message_elt["to"])
-                except KeyError:
-                    feedback_jid = client.server_jid
-            else:
-                feedback_jid = sender_jid
-
-        sender_bare_jid = sender_jid.userhost()
-
-        openpgp_elt = cast(Optional[domish.Element], next(
-            message_elt.elements(NS_OX, "openpgp"),
-            None
-        ))
-
-        if openpgp_elt is None:
-            # None of our business, let the flow continue
-            return True
-
-        try:
-            payload_elt, timestamp = await self.__xep_0373.unpack_openpgp_element(
-                client,
-                openpgp_elt,
-                "signcrypt",
-                jid.JID(sender_bare_jid)
-            )
-        except Exception as e:
-            # TODO: More specific exception handling
-            log.warning(_("Can't decrypt message: {reason}\n{xml}").format(
-                reason=e,
-                xml=message_elt.toXml()
-            ))
-            client.feedback(
-                feedback_jid,
-                D_(
-                    f"An OXIM message from {sender_jid.full()} can't be decrypted:"
-                    f" {e}"
-                ),
-                { C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR }
-            )
-            # No point in further processing this message
-            return False
-
-        message_elt.children.remove(openpgp_elt)
-
-        log.debug(f"OXIM message of type {message_type} received from {sender_bare_jid}")
-
-        # Remove all body elements from the original element, since those act as
-        # fallbacks in case the encryption protocol is not supported
-        for child in message_elt.elements():
-            if child.name == "body":
-                message_elt.children.remove(child)
-
-        # Move all extension elements from the payload to the stanza root
-        # TODO: There should probably be explicitly forbidden elements here too, just as
-        # for XEP-0420
-        for child in list(payload_elt.elements()):
-            # Remove the child from the content element
-            payload_elt.children.remove(child)
-
-            # Add the child to the stanza
-            message_elt.addChild(child)
-
-        # Mark the message as trusted or untrusted. Undecided counts as untrusted here.
-        trust_level = TrustLevel.UNDECIDED  # TODO: Load the actual trust level
-        if trust_level is TrustLevel.TRUSTED:
-            post_treat.addCallback(client.encryption.mark_as_trusted)
-        else:
-            post_treat.addCallback(client.encryption.mark_as_untrusted)
-
-        # Mark the message as originally encrypted
-        post_treat.addCallback(
-            client.encryption.mark_as_encrypted,
-            namespace=NS_OX
-        )
-
-        # Message processed successfully, continue with the flow
-        return True
-
-    async def __send_trigger(self, client: SatXMPPClient, stanza: domish.Element) -> bool:
-        """
-        @param client: The client sending this message.
-        @param stanza: The stanza that is about to be sent. Can be modified.
-        @return: Whether the send message flow should continue or not.
-        """
-        # OXIM only handles message stanzas
-        if stanza.name != "message":
-            return True
-
-        # Get the intended recipient
-        recipient = stanza.getAttribute("to", None)
-        if recipient is None:
-            raise exceptions.InternalError(
-                f"Message without recipient encountered. Blocking further processing to"
-                f" avoid leaking plaintext data: {stanza.toXml()}"
-            )
-
-        # Parse the JID
-        recipient_bare_jid = jid.JID(recipient).userhostJID()
-
-        # Check whether encryption with OXIM is requested
-        encryption = client.encryption.getSession(recipient_bare_jid)
-
-        if encryption is None:
-            # Encryption is not requested for this recipient
-            return True
-
-        if encryption["plugin"].namespace != NS_OX:
-            # Encryption is requested for this recipient, but not with OXIM
-            return True
-
-        # All pre-checks done, we can start encrypting!
-        await self.__encrypt(
-            client,
-            stanza,
-            recipient_bare_jid,
-            stanza.getAttribute("type", "unkown") == C.MESS_TYPE_GROUPCHAT
-        )
-
-        # Add a store hint if this is a message stanza
-        self.__xep_0334.add_hint_elements(stanza, [ "store" ])
-
-        # Let the flow continue.
-        return True
-
-    async def __encrypt(
-        self,
-        client: SatXMPPClient,
-        stanza: domish.Element,
-        recipient_jid: jid.JID,
-        is_muc_message: bool
-    ) -> None:
-        """
-        @param client: The client.
-        @param stanza: The stanza, which is modified by this call.
-        @param recipient_jid: The JID of the recipient. Can be a bare (aka "userhost") JID
-            but doesn't have to.
-        @param is_muc_message: Whether the stanza is a message stanza to a MUC room.
-
-        @warning: The calling code MUST take care of adding the store message processing
-            hint to the stanza if applicable! This can be done before or after this call,
-            the order doesn't matter.
-        """
-
-        recipient_bare_jids: Set[jid.JID]
-        feedback_jid: jid.JID
-
-        if is_muc_message:
-            if self.__xep_0045 is None:
-                raise exceptions.InternalError(
-                    "Encryption of MUC message requested, but plugin XEP-0045 is not"
-                    " available."
-                )
-
-            room_jid = feedback_jid = recipient_jid.userhostJID()
-
-            recipient_bare_jids = self.__get_joined_muc_users(
-                client,
-                self.__xep_0045,
-                room_jid
-            )
-        else:
-            recipient_bare_jids = { recipient_jid.userhostJID() }
-            feedback_jid = recipient_jid.userhostJID()
-
-        log.debug(
-            f"Intercepting message that is to be encrypted by {NS_OX} for"
-            f" {recipient_bare_jids}"
-        )
-
-        signcrypt_elt, payload_elt = \
-            self.__xep_0373.build_signcrypt_element(recipient_bare_jids)
-
-        # Move elements from the stanza to the content element.
-        # TODO: There should probably be explicitly forbidden elements here too, just as
-        # for XEP-0420
-        for child in list(stanza.elements()):
-            if child.name == "openpgp" and child.uri == NS_OX:
-                log.debug("not re-encrypting encrypted OX element")
-                continue
-            # Remove the child from the stanza
-            stanza.children.remove(child)
-
-            # A namespace of ``None`` can be used on domish elements to inherit the
-            # namespace from the parent. When moving elements from the stanza root to
-            # the content element, however, we don't want elements to inherit the
-            # namespace of the content element. Thus, check for elements with ``None``
-            # for their namespace and set the namespace to jabber:client, which is the
-            # namespace of the parent element.
-            if child.uri is None:
-                child.uri = C.NS_CLIENT
-                child.defaultUri = C.NS_CLIENT
-
-            # Add the child with corrected namespaces to the content element
-            payload_elt.addChild(child)
-
-        try:
-            openpgp_elt = await self.__xep_0373.build_openpgp_element(
-                client,
-                signcrypt_elt,
-                recipient_bare_jids
-            )
-        except Exception as e:
-            msg = _(
-                # pylint: disable=consider-using-f-string
-                "Can't encrypt message for {entities}: {reason}".format(
-                    entities=', '.join(jid.userhost() for jid in recipient_bare_jids),
-                    reason=e
-                )
-            )
-            log.warning(msg)
-            client.feedback(feedback_jid, msg, {
-                C.MESS_EXTRA_INFO: C.EXTRA_INFO_ENCR_ERR
-            })
-            raise e
-
-        stanza.addChild(openpgp_elt)
--- a/sat/plugins/plugin_xep_0376.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,188 +0,0 @@
-#!/usr/bin/env python3
-
-# SàT plugin for XEP-0376
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Dict, List, Tuple, Optional, Any
-from zope.interface import implementer
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from wokkel import disco, iwokkel, pubsub, data_form
-from sat.core.i18n import _
-from sat.core.constants import Const as C
-from sat.core import exceptions
-from sat.core.xmpp import SatXMPPEntity
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Pubsub Account Management",
-    C.PI_IMPORT_NAME: "XEP-0376",
-    C.PI_TYPE: C.PLUG_TYPE_XEP,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0376"],
-    C.PI_DEPENDENCIES: ["XEP-0060"],
-    C.PI_MAIN: "XEP_0376",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Pubsub Account Management"""),
-}
-
-NS_PAM = "urn:xmpp:pam:0"
-
-
-class XEP_0376:
-
-    def __init__(self, host):
-        log.info(_("Pubsub Account Management initialization"))
-        self.host = host
-        host.register_namespace("pam", NS_PAM)
-        self._p = self.host.plugins["XEP-0060"]
-        host.trigger.add("XEP-0060_subscribe", self.subscribe)
-        host.trigger.add("XEP-0060_unsubscribe", self.unsubscribe)
-        host.trigger.add("XEP-0060_subscriptions", self.subscriptions)
-
-    def get_handler(self, client):
-        return XEP_0376_Handler()
-
-    async def profile_connected(self, client):
-        if not self.host.hasFeature(client, NS_PAM):
-            log.warning(
-                "Your server doesn't support Pubsub Account Management, this is used to "
-                "track all your subscriptions. You may ask your server administrator to "
-                "install it."
-            )
-
-    async def _sub_request(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        nodeIdentifier: str,
-        sub_jid: Optional[jid.JID],
-        options: Optional[dict],
-        subscribe: bool
-    ) -> None:
-        if sub_jid is None:
-            sub_jid = client.jid.userhostJID()
-        iq_elt = client.IQ()
-        pam_elt = iq_elt.addElement((NS_PAM, "pam"))
-        pam_elt["jid"] = service.full()
-        subscribe_elt = pam_elt.addElement(
-            (pubsub.NS_PUBSUB, "subscribe" if subscribe else "unsubscribe")
-        )
-        subscribe_elt["node"] = nodeIdentifier
-        subscribe_elt["jid"] = sub_jid.full()
-        if options:
-            options_elt = pam_elt.addElement((pubsub.NS_PUBSUB, "options"))
-            options_elt["node"] = nodeIdentifier
-            options_elt["jid"] = sub_jid.full()
-            form = data_form.Form(
-                formType='submit',
-                formNamespace=pubsub.NS_PUBSUB_SUBSCRIBE_OPTIONS
-            )
-            form.makeFields(options)
-            options_elt.addChild(form.toElement())
-
-        await iq_elt.send(client.server_jid.full())
-
-    async def subscribe(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        nodeIdentifier: str,
-        sub_jid: Optional[jid.JID] = None,
-        options: Optional[dict] = None
-    ) -> Tuple[bool, Optional[pubsub.Subscription]]:
-        if not self.host.hasFeature(client, NS_PAM) or client.is_component:
-            return True, None
-
-        await self._sub_request(client, service, nodeIdentifier, sub_jid, options, True)
-
-        # TODO: actual result is sent with <message> stanza, we have to get and use them
-        # to known the actual result. XEP-0376 returns an empty <iq> result, thus we don't
-        # know here is the subscription actually succeeded
-
-        sub_id = None
-        sub = pubsub.Subscription(nodeIdentifier, sub_jid, "subscribed", options, sub_id)
-        return False, sub
-
-    async def unsubscribe(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        nodeIdentifier: str,
-        sub_jid: Optional[jid.JID],
-        subscriptionIdentifier: Optional[str],
-        sender: Optional[jid.JID] = None,
-    ) -> bool:
-        if not self.host.hasFeature(client, NS_PAM) or client.is_component:
-            return True
-        await self._sub_request(client, service, nodeIdentifier, sub_jid, None, False)
-        return False
-
-    async def subscriptions(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: str,
-    ) -> Tuple[bool, Optional[List[Dict[str, Any]]]]:
-        if not self.host.hasFeature(client, NS_PAM):
-            return True, None
-        if service is not None or node is not None:
-            # if we have service and/or node subscriptions, it's a regular XEP-0060
-            # subscriptions request
-            return True, None
-
-        iq_elt = client.IQ("get")
-        subscriptions_elt = iq_elt.addElement((NS_PAM, "subscriptions"))
-        result_elt = await iq_elt.send()
-        try:
-            subscriptions_elt = next(result_elt.elements(NS_PAM, "subscriptions"))
-        except StopIteration:
-            raise ValueError(f"invalid PAM response: {result_elt.toXml()}")
-        subs = []
-        for subscription_elt in subscriptions_elt.elements(NS_PAM, "subscription"):
-            sub = {}
-            try:
-                for attr, key in (
-                    ("service", "service"),
-                    ("node", "node"),
-                    ("jid", "subscriber"),
-                    ("subscription", "state")
-                ):
-                    sub[key] = subscription_elt[attr]
-            except KeyError as e:
-                log.warning(
-                    f"Invalid <subscription> element (missing {e.args[0]!r} attribute): "
-                    f"{subscription_elt.toXml()}"
-                )
-                continue
-            sub_id = subscription_elt.getAttribute("subid")
-            if sub_id:
-                sub["id"] = sub_id
-            subs.append(sub)
-
-        return False, subs
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0376_Handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_PAM)]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0380.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,102 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT plugin for Explicit Message Encryption
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from twisted.words.protocols.jabber import jid
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Explicit Message Encryption",
-    C.PI_IMPORT_NAME: "XEP-0380",
-    C.PI_TYPE: "SEC",
-    C.PI_PROTOCOLS: ["XEP-0380"],
-    C.PI_DEPENDENCIES: [],
-    C.PI_MAIN: "XEP_0380",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of Explicit Message Encryption"""),
-}
-
-NS_EME = "urn:xmpp:eme:0"
-KNOWN_NAMESPACES = {
-    "urn:xmpp:otr:0": "OTR",
-    "jabber:x:encrypted": "Legacy OpenPGP",
-    "urn:xmpp:openpgp:0": "OpenPGP for XMPP",
-}
-
-
-class XEP_0380(object):
-
-    def __init__(self, host):
-        self.host = host
-        host.trigger.add("sendMessage", self._send_message_trigger)
-        host.trigger.add("message_received", self._message_received_trigger, priority=100)
-        host.register_namespace("eme", NS_EME)
-
-    def _add_eme_element(self, mess_data, namespace, name):
-        message_elt = mess_data['xml']
-        encryption_elt = message_elt.addElement((NS_EME, 'encryption'))
-        encryption_elt['namespace'] = namespace
-        if name is not None:
-            encryption_elt['name'] = name
-        return mess_data
-
-    def _send_message_trigger(self, client, mess_data, __, post_xml_treatments):
-        encryption = mess_data.get(C.MESS_KEY_ENCRYPTION)
-        if encryption is not None:
-            namespace = encryption['plugin'].namespace
-            if namespace not in KNOWN_NAMESPACES:
-                name = encryption['plugin'].name
-            else:
-                name = None
-            post_xml_treatments.addCallback(
-                self._add_eme_element, namespace=namespace, name=name)
-        return True
-
-    def _message_received_trigger(self, client, message_elt, post_treat):
-        try:
-            encryption_elt = next(message_elt.elements(NS_EME, 'encryption'))
-        except StopIteration:
-            return True
-
-        namespace = encryption_elt['namespace']
-        if namespace in client.encryption.get_namespaces():
-            # message is encrypted and we can decrypt it
-            return True
-
-        name = KNOWN_NAMESPACES.get(namespace, encryption_elt.getAttribute("name"))
-
-        # at this point, message is encrypted but we know that we can't decrypt it,
-        # we need to notify the user
-        sender_s = message_elt['from']
-        to_jid = jid.JID(message_elt['from'])
-        algorithm = "{} [{}]".format(name, namespace) if name else namespace
-        log.warning(
-            _("Message from {sender} is encrypted with {algorithm} and we can't "
-              "decrypt it.".format(sender=message_elt['from'], algorithm=algorithm)))
-
-        user_msg = D_(
-            "User {sender} sent you an encrypted message (encrypted with {algorithm}), "
-            "and we can't decrypt it.").format(sender=sender_s, algorithm=algorithm)
-
-        extra = {C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR}
-        client.feedback(to_jid, user_msg, extra)
-        return False
--- a/sat/plugins/plugin_xep_0384.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2724 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for OMEMO encryption
-# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import base64
-from datetime import datetime
-import enum
-import logging
-import time
-from typing import \
-    Any, Dict, FrozenSet, List, Literal, NamedTuple, Optional, Set, Type, Union, cast
-import uuid
-import xml.etree.ElementTree as ET
-from xml.sax.saxutils import quoteattr
-
-from typing_extensions import Final, Never, assert_never
-from wokkel import muc, pubsub  # type: ignore[import]
-import xmlschema
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import MessageData, SatXMPPEntity
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger, Logger
-from sat.core.sat_main import SAT
-from sat.core.xmpp import SatXMPPClient
-from sat.memory import persistent
-from sat.plugins.plugin_misc_text_commands import TextCommands
-from sat.plugins.plugin_xep_0045 import XEP_0045
-from sat.plugins.plugin_xep_0060 import XEP_0060
-from sat.plugins.plugin_xep_0163 import XEP_0163
-from sat.plugins.plugin_xep_0334 import XEP_0334
-from sat.plugins.plugin_xep_0359 import XEP_0359
-from sat.plugins.plugin_xep_0420 import \
-    XEP_0420, SCEAffixPolicy, SCEAffixValues, SCEProfile
-from sat.tools import xml_tools
-from twisted.internet import defer
-from twisted.words.protocols.jabber import error, jid
-from twisted.words.xish import domish
-
-try:
-    import omemo
-    import omemo.identity_key_pair
-    import twomemo
-    import twomemo.etree
-    import oldmemo
-    import oldmemo.etree
-    import oldmemo.migrations
-    from xmlschema import XMLSchemaValidationError
-
-    # An explicit version check of the OMEMO libraries should not be required here, since
-    # the stored data is fully versioned and the library will complain if a downgrade is
-    # attempted.
-except ImportError as import_error:
-    raise exceptions.MissingModule(
-        "You are missing one or more package required by the OMEMO plugin. Please"
-        " download/install the pip packages 'omemo', 'twomemo', 'oldmemo' and"
-        f" 'xmlschema'.\nexception: {import_error}"
-    ) from import_error
-
-
-__all__ = [  # pylint: disable=unused-variable
-    "PLUGIN_INFO",
-    "OMEMO"
-]
-
-log = cast(Logger, getLogger(__name__))  # type: ignore[no-untyped-call]
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "OMEMO",
-    C.PI_IMPORT_NAME: "XEP-0384",
-    C.PI_TYPE: "SEC",
-    C.PI_PROTOCOLS: [ "XEP-0384" ],
-    C.PI_DEPENDENCIES: [ "XEP-0163", "XEP-0280", "XEP-0334", "XEP-0060", "XEP-0420" ],
-    C.PI_RECOMMENDATIONS: [ "XEP-0045", "XEP-0359", C.TEXT_CMDS ],
-    C.PI_MAIN: "OMEMO",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of OMEMO"""),
-}
-
-
-PARAM_CATEGORY = "Security"
-PARAM_NAME = "omemo_policy"
-
-
-class LogHandler(logging.Handler):
-    """
-    Redirect python-omemo's log output to Libervia's log system.
-    """
-
-    def emit(self, record: logging.LogRecord) -> None:
-        log.log(record.levelname, record.getMessage())
-
-
-sm_logger = logging.getLogger(omemo.SessionManager.LOG_TAG)
-sm_logger.setLevel(logging.DEBUG)
-sm_logger.propagate = False
-sm_logger.addHandler(LogHandler())
-
-
-ikp_logger = logging.getLogger(omemo.identity_key_pair.IdentityKeyPair.LOG_TAG)
-ikp_logger.setLevel(logging.DEBUG)
-ikp_logger.propagate = False
-ikp_logger.addHandler(LogHandler())
-
-
-# TODO: Add handling for device labels, i.e. show device labels in the trust UI and give
-# the user a way to change their own device label.
-
-
-class MUCPlaintextCacheKey(NamedTuple):
-    # pylint: disable=invalid-name
-    """
-    Structure identifying an encrypted message sent to a MUC.
-    """
-
-    client: SatXMPPClient
-    room_jid: jid.JID
-    message_uid: str
-
-
-@enum.unique
-class TrustLevel(enum.Enum):
-    """
-    The trust levels required for ATM and BTBV.
-    """
-
-    TRUSTED: str = "TRUSTED"
-    BLINDLY_TRUSTED: str = "BLINDLY_TRUSTED"
-    UNDECIDED: str = "UNDECIDED"
-    DISTRUSTED: str = "DISTRUSTED"
-
-
-TWOMEMO_DEVICE_LIST_NODE = "urn:xmpp:omemo:2:devices"
-OLDMEMO_DEVICE_LIST_NODE = "eu.siacs.conversations.axolotl.devicelist"
-
-
-class StorageImpl(omemo.Storage):
-    """
-    Storage implementation for OMEMO based on :class:`persistent.LazyPersistentBinaryDict`
-    """
-
-    def __init__(self, profile: str) -> None:
-        """
-        @param profile: The profile this OMEMO data belongs to.
-        """
-
-        # persistent.LazyPersistentBinaryDict does not cache at all, so keep the caching
-        # option of omemo.Storage enabled.
-        super().__init__()
-
-        self.__storage = persistent.LazyPersistentBinaryDict("XEP-0384", profile)
-
-    async def _load(self, key: str) -> omemo.Maybe[omemo.JSONType]:
-        try:
-            return omemo.Just(await self.__storage[key])
-        except KeyError:
-            return omemo.Nothing()
-        except Exception as e:
-            raise omemo.StorageException(f"Error while loading key {key}") from e
-
-    async def _store(self, key: str, value: omemo.JSONType) -> None:
-        try:
-            await self.__storage.force(key, value)
-        except Exception as e:
-            raise omemo.StorageException(f"Error while storing key {key}: {value}") from e
-
-    async def _delete(self, key: str) -> None:
-        try:
-            await self.__storage.remove(key)
-        except KeyError:
-            pass
-        except Exception as e:
-            raise omemo.StorageException(f"Error while deleting key {key}") from e
-
-
-class LegacyStorageImpl(oldmemo.migrations.LegacyStorage):
-    """
-    Legacy storage implementation to migrate data from the old XEP-0384 plugin.
-    """
-
-    KEY_DEVICE_ID = "DEVICE_ID"
-    KEY_STATE = "STATE"
-    KEY_SESSION = "SESSION"
-    KEY_ACTIVE_DEVICES = "DEVICES"
-    KEY_INACTIVE_DEVICES = "INACTIVE_DEVICES"
-    KEY_TRUST = "TRUST"
-    KEY_ALL_JIDS = "ALL_JIDS"
-
-    def __init__(self, profile: str, own_bare_jid: str) -> None:
-        """
-        @param profile: The profile this OMEMO data belongs to.
-        @param own_bare_jid: The own bare JID, to return by the :meth:`load_own_data` call.
-        """
-
-        self.__storage = persistent.LazyPersistentBinaryDict("XEP-0384", profile)
-        self.__own_bare_jid = own_bare_jid
-
-    async def loadOwnData(self) -> Optional[oldmemo.migrations.OwnData]:
-        own_device_id = await self.__storage.get(LegacyStorageImpl.KEY_DEVICE_ID, None)
-        if own_device_id is None:
-            return None
-
-        return oldmemo.migrations.OwnData(
-            own_bare_jid=self.__own_bare_jid,
-            own_device_id=own_device_id
-        )
-
-    async def deleteOwnData(self) -> None:
-        try:
-            await self.__storage.remove(LegacyStorageImpl.KEY_DEVICE_ID)
-        except KeyError:
-            pass
-
-    async def loadState(self) -> Optional[oldmemo.migrations.State]:
-        return cast(
-            Optional[oldmemo.migrations.State],
-            await self.__storage.get(LegacyStorageImpl.KEY_STATE, None)
-        )
-
-    async def deleteState(self) -> None:
-        try:
-            await self.__storage.remove(LegacyStorageImpl.KEY_STATE)
-        except KeyError:
-            pass
-
-    async def loadSession(
-        self,
-        bare_jid: str,
-        device_id: int
-    ) -> Optional[oldmemo.migrations.Session]:
-        key = "\n".join([ LegacyStorageImpl.KEY_SESSION, bare_jid, str(device_id) ])
-
-        return cast(
-            Optional[oldmemo.migrations.Session],
-            await self.__storage.get(key, None)
-        )
-
-    async def deleteSession(self, bare_jid: str, device_id: int) -> None:
-        key = "\n".join([ LegacyStorageImpl.KEY_SESSION, bare_jid, str(device_id) ])
-
-        try:
-            await self.__storage.remove(key)
-        except KeyError:
-            pass
-
-    async def loadActiveDevices(self, bare_jid: str) -> Optional[List[int]]:
-        key = "\n".join([ LegacyStorageImpl.KEY_ACTIVE_DEVICES, bare_jid ])
-
-        return cast(
-            Optional[List[int]],
-            await self.__storage.get(key, None)
-        )
-
-    async def loadInactiveDevices(self, bare_jid: str) -> Optional[Dict[int, int]]:
-        key = "\n".join([ LegacyStorageImpl.KEY_INACTIVE_DEVICES, bare_jid ])
-
-        return cast(
-            Optional[Dict[int, int]],
-            await self.__storage.get(key, None)
-        )
-
-    async def deleteActiveDevices(self, bare_jid: str) -> None:
-        key = "\n".join([ LegacyStorageImpl.KEY_ACTIVE_DEVICES, bare_jid ])
-
-        try:
-            await self.__storage.remove(key)
-        except KeyError:
-            pass
-
-    async def deleteInactiveDevices(self, bare_jid: str) -> None:
-        key = "\n".join([ LegacyStorageImpl.KEY_INACTIVE_DEVICES, bare_jid ])
-
-        try:
-            await self.__storage.remove(key)
-        except KeyError:
-            pass
-
-    async def loadTrust(
-        self,
-        bare_jid: str,
-        device_id: int
-    ) -> Optional[oldmemo.migrations.Trust]:
-        key = "\n".join([ LegacyStorageImpl.KEY_TRUST, bare_jid, str(device_id) ])
-
-        return cast(
-            Optional[oldmemo.migrations.Trust],
-            await self.__storage.get(key, None)
-        )
-
-    async def deleteTrust(self, bare_jid: str, device_id: int) -> None:
-        key = "\n".join([ LegacyStorageImpl.KEY_TRUST, bare_jid, str(device_id) ])
-
-        try:
-            await self.__storage.remove(key)
-        except KeyError:
-            pass
-
-    async def listJIDs(self) -> Optional[List[str]]:
-        bare_jids = await self.__storage.get(LegacyStorageImpl.KEY_ALL_JIDS, None)
-
-        return None if bare_jids is None else list(bare_jids)
-
-    async def deleteJIDList(self) -> None:
-        try:
-            await self.__storage.remove(LegacyStorageImpl.KEY_ALL_JIDS)
-        except KeyError:
-            pass
-
-
-async def download_oldmemo_bundle(
-    client: SatXMPPClient,
-    xep_0060: XEP_0060,
-    bare_jid: str,
-    device_id: int
-) -> oldmemo.oldmemo.BundleImpl:
-    """Download the oldmemo bundle corresponding to a specific device.
-
-    @param client: The client.
-    @param xep_0060: The XEP-0060 plugin instance to use for pubsub interactions.
-    @param bare_jid: The bare JID the device belongs to.
-    @param device_id: The id of the device.
-    @return: The bundle.
-    @raise BundleDownloadFailed: if the download failed. Feel free to raise a subclass
-        instead.
-    """
-    # Bundle downloads are needed by the session manager and for migrations from legacy,
-    # thus it is made a separate function.
-
-    namespace = oldmemo.oldmemo.NAMESPACE
-    node = f"eu.siacs.conversations.axolotl.bundles:{device_id}"
-
-    try:
-        items, __ = await xep_0060.get_items(client, jid.JID(bare_jid), node, max_items=1)
-    except Exception as e:
-        raise omemo.BundleDownloadFailed(
-            f"Bundle download failed for {bare_jid}: {device_id} under namespace"
-            f" {namespace}"
-        ) from e
-
-    if len(items) != 1:
-        raise omemo.BundleDownloadFailed(
-            f"Bundle download failed for {bare_jid}: {device_id} under namespace"
-            f" {namespace}: Unexpected number of items retrieved: {len(items)}."
-        )
-
-    element = \
-        next(iter(xml_tools.domish_elt_2_et_elt(cast(domish.Element, items[0]))), None)
-    if element is None:
-        raise omemo.BundleDownloadFailed(
-            f"Bundle download failed for {bare_jid}: {device_id} under namespace"
-            f" {namespace}: Item download succeeded but parsing failed: {element}."
-        )
-
-    try:
-        return oldmemo.etree.parse_bundle(element, bare_jid, device_id)
-    except Exception as e:
-        raise omemo.BundleDownloadFailed(
-            f"Bundle parsing failed for {bare_jid}: {device_id} under namespace"
-            f" {namespace}"
-        ) from e
-
-
-# ATM only supports protocols based on SCE, which is currently only omemo:2, and relies on
-# so many implementation details of the encryption protocol that it makes more sense to
-# add ATM to the OMEMO plugin directly instead of having it a separate Libervia plugin.
-NS_TM: Final = "urn:xmpp:tm:1"
-NS_ATM: Final = "urn:xmpp:atm:1"
-
-
-TRUST_MESSAGE_SCHEMA = xmlschema.XMLSchema("""<?xml version='1.0' encoding='UTF-8'?>
-<xs:schema xmlns:xs='http://www.w3.org/2001/XMLSchema'
-           targetNamespace='urn:xmpp:tm:1'
-           xmlns='urn:xmpp:tm:1'
-           elementFormDefault='qualified'>
-
-  <xs:element name='trust-message'>
-    <xs:complexType>
-      <xs:sequence>
-        <xs:element ref='key-owner' minOccurs='1' maxOccurs='unbounded'/>
-      </xs:sequence>
-      <xs:attribute name='usage' type='xs:string' use='required'/>
-      <xs:attribute name='encryption' type='xs:string' use='required'/>
-    </xs:complexType>
-  </xs:element>
-
-  <xs:element name='key-owner'>
-    <xs:complexType>
-      <xs:sequence>
-        <xs:element
-            name='trust' type='xs:base64Binary' minOccurs='0' maxOccurs='unbounded'/>
-        <xs:element
-            name='distrust' type='xs:base64Binary' minOccurs='0' maxOccurs='unbounded'/>
-      </xs:sequence>
-      <xs:attribute name='jid' type='xs:string' use='required'/>
-    </xs:complexType>
-  </xs:element>
-</xs:schema>
-""")
-
-
-# This is compatible with omemo:2's SCE profile
-TM_SCE_PROFILE = SCEProfile(
-    rpad_policy=SCEAffixPolicy.REQUIRED,
-    time_policy=SCEAffixPolicy.REQUIRED,
-    to_policy=SCEAffixPolicy.OPTIONAL,
-    from_policy=SCEAffixPolicy.OPTIONAL,
-    custom_policies={}
-)
-
-
-class TrustUpdate(NamedTuple):
-    # pylint: disable=invalid-name
-    """
-    An update to the trust status of an identity key, used by Automatic Trust Management.
-    """
-
-    target_jid: jid.JID
-    target_key: bytes
-    target_trust: bool
-
-
-class TrustMessageCacheEntry(NamedTuple):
-    # pylint: disable=invalid-name
-    """
-    An entry in the trust message cache used by ATM.
-    """
-
-    sender_jid: jid.JID
-    sender_key: bytes
-    timestamp: datetime
-    trust_update: TrustUpdate
-
-
-class PartialTrustMessage(NamedTuple):
-    # pylint: disable=invalid-name
-    """
-    A structure representing a partial trust message, used by :func:`send_trust_messages`
-    to build trust messages.
-    """
-
-    recipient_jid: jid.JID
-    updated_jid: jid.JID
-    trust_updates: FrozenSet[TrustUpdate]
-
-
-async def manage_trust_message_cache(
-    client: SatXMPPClient,
-    session_manager: omemo.SessionManager,
-    applied_trust_updates: FrozenSet[TrustUpdate]
-) -> None:
-    """Manage the ATM trust message cache after trust updates have been applied.
-
-    @param client: The client this operation runs under.
-    @param session_manager: The session manager to use.
-    @param applied_trust_updates: The trust updates that have already been applied,
-        triggering this cache management run.
-    """
-
-    trust_message_cache = persistent.LazyPersistentBinaryDict(
-        "XEP-0384/TM",
-        client.profile
-    )
-
-    # Load cache entries
-    cache_entries = cast(
-        Set[TrustMessageCacheEntry],
-        await trust_message_cache.get("cache", set())
-    )
-
-    # Expire cache entries that were overwritten by the applied trust updates
-    cache_entries_by_target = {
-        (
-            cache_entry.trust_update.target_jid.userhostJID(),
-            cache_entry.trust_update.target_key
-        ): cache_entry
-        for cache_entry
-        in cache_entries
-    }
-
-    for trust_update in applied_trust_updates:
-        cache_entry = cache_entries_by_target.get(
-            (trust_update.target_jid.userhostJID(), trust_update.target_key),
-            None
-        )
-
-        if cache_entry is not None:
-            cache_entries.remove(cache_entry)
-
-    # Apply cached Trust Messages by newly trusted devices
-    new_trust_updates: Set[TrustUpdate] = set()
-
-    for trust_update in applied_trust_updates:
-        if trust_update.target_trust:
-            # Iterate over a copy such that cache_entries can be modified
-            for cache_entry in set(cache_entries):
-                if (
-                    cache_entry.sender_jid.userhostJID()
-                    == trust_update.target_jid.userhostJID()
-                    and cache_entry.sender_key == trust_update.target_key
-                ):
-                    trust_level = (
-                        TrustLevel.TRUSTED
-                        if cache_entry.trust_update.target_trust
-                        else TrustLevel.DISTRUSTED
-                    )
-
-                    # Apply the trust update
-                    await session_manager.set_trust(
-                        cache_entry.trust_update.target_jid.userhost(),
-                        cache_entry.trust_update.target_key,
-                        trust_level.name
-                    )
-
-                    # Track the fact that this trust update has been applied
-                    new_trust_updates.add(cache_entry.trust_update)
-
-                    # Remove the corresponding cache entry
-                    cache_entries.remove(cache_entry)
-
-    # Store the updated cache entries
-    await trust_message_cache.force("cache", cache_entries)
-
-    # TODO: Notify the user ("feedback") about automatically updated trust?
-
-    if len(new_trust_updates) > 0:
-        # If any trust has been updated, recursively perform another run of cache
-        # management
-        await manage_trust_message_cache(
-            client,
-            session_manager,
-            frozenset(new_trust_updates)
-        )
-
-
-async def get_trust_as_trust_updates(
-    session_manager: omemo.SessionManager,
-    target_jid: jid.JID
-) -> FrozenSet[TrustUpdate]:
-    """Get the trust status of all known keys of a JID as trust updates for use with ATM.
-
-    @param session_manager: The session manager to load the trust from.
-    @param target_jid: The JID to load the trust for.
-    @return: The trust updates encoding the trust status of all known keys of the JID that
-        are either explicitly trusted or distrusted. Undecided keys are not included in
-        the trust updates.
-    """
-
-    devices = await session_manager.get_device_information(target_jid.userhost())
-
-    trust_updates: Set[TrustUpdate] = set()
-
-    for device in devices:
-        trust_level = TrustLevel(device.trust_level_name)
-        target_trust: bool
-
-        if trust_level is TrustLevel.TRUSTED:
-            target_trust = True
-        elif trust_level is TrustLevel.DISTRUSTED:
-            target_trust = False
-        else:
-            # Skip devices that are not explicitly trusted or distrusted
-            continue
-
-        trust_updates.add(TrustUpdate(
-            target_jid=target_jid.userhostJID(),
-            target_key=device.identity_key,
-            target_trust=target_trust
-        ))
-
-    return frozenset(trust_updates)
-
-
-async def send_trust_messages(
-    client: SatXMPPClient,
-    session_manager: omemo.SessionManager,
-    applied_trust_updates: FrozenSet[TrustUpdate]
-) -> None:
-    """Send information about updated trust to peers via ATM (XEP-0450).
-
-    @param client: The client.
-    @param session_manager: The session manager.
-    @param applied_trust_updates: The trust updates that have already been applied, to
-        notify other peers about.
-    """
-    # NOTE: This currently sends information about oldmemo trust too. This is not
-    # specified and experimental, but since twomemo and oldmemo share the same identity
-    # keys and trust systems, this could be a cool side effect.
-
-    # Send Trust Messages for newly trusted and distrusted devices
-    own_jid = client.jid.userhostJID()
-    own_trust_updates = await get_trust_as_trust_updates(session_manager, own_jid)
-
-    # JIDs of which at least one device's trust has been updated
-    updated_jids = frozenset({
-        trust_update.target_jid.userhostJID()
-        for trust_update
-        in applied_trust_updates
-    })
-
-    trust_messages: Set[PartialTrustMessage] = set()
-
-    for updated_jid in updated_jids:
-        # Get the trust updates for that JID
-        trust_updates = frozenset({
-            trust_update for trust_update in applied_trust_updates
-            if trust_update.target_jid.userhostJID() == updated_jid
-        })
-
-        if updated_jid == own_jid:
-            # If the own JID is updated, _all_ peers have to be notified
-            # TODO: Using my author's privilege here to shamelessly access private fields
-            # and storage keys until I've added public API to get a list of peers to
-            # python-omemo.
-            storage: omemo.Storage = getattr(session_manager, "_SessionManager__storage")
-            peer_jids = frozenset({
-                jid.JID(bare_jid).userhostJID() for bare_jid in (await storage.load_list(
-                    f"/{OMEMO.NS_TWOMEMO}/bare_jids",
-                    str
-                )).maybe([])
-            })
-
-            if len(peer_jids) == 0:
-                # If there are no peers to notify, notify our other devices about the
-                # changes directly
-                trust_messages.add(PartialTrustMessage(
-                    recipient_jid=own_jid,
-                    updated_jid=own_jid,
-                    trust_updates=trust_updates
-                ))
-            else:
-                # Otherwise, notify all peers about the changes in trust and let carbons
-                # handle the copy to our own JID
-                for peer_jid in peer_jids:
-                    trust_messages.add(PartialTrustMessage(
-                        recipient_jid=peer_jid,
-                        updated_jid=own_jid,
-                        trust_updates=trust_updates
-                    ))
-
-                    # Also send full trust information about _every_ peer to our newly
-                    # trusted devices
-                    peer_trust_updates = \
-                        await get_trust_as_trust_updates(session_manager, peer_jid)
-
-                    trust_messages.add(PartialTrustMessage(
-                        recipient_jid=own_jid,
-                        updated_jid=peer_jid,
-                        trust_updates=peer_trust_updates
-                    ))
-
-            # Send information about our own devices to our newly trusted devices
-            trust_messages.add(PartialTrustMessage(
-                recipient_jid=own_jid,
-                updated_jid=own_jid,
-                trust_updates=own_trust_updates
-            ))
-        else:
-            # Notify our other devices about the changes in trust
-            trust_messages.add(PartialTrustMessage(
-                recipient_jid=own_jid,
-                updated_jid=updated_jid,
-                trust_updates=trust_updates
-            ))
-
-            # Send a summary of our own trust to newly trusted devices
-            trust_messages.add(PartialTrustMessage(
-                recipient_jid=updated_jid,
-                updated_jid=own_jid,
-                trust_updates=own_trust_updates
-            ))
-
-    # All trust messages prepared. Merge all trust messages directed at the same
-    # recipient.
-    recipient_jids = { trust_message.recipient_jid for trust_message in trust_messages }
-
-    for recipient_jid in recipient_jids:
-        updated: Dict[jid.JID, Set[TrustUpdate]] = {}
-
-        for trust_message in trust_messages:
-            # Merge trust messages directed at that recipient
-            if trust_message.recipient_jid == recipient_jid:
-                # Merge the trust updates
-                updated[trust_message.updated_jid] = \
-                    updated.get(trust_message.updated_jid, set())
-
-                updated[trust_message.updated_jid] |= trust_message.trust_updates
-
-        # Build the trust message
-        trust_message_elt = domish.Element((NS_TM, "trust-message"))
-        trust_message_elt["usage"] = NS_ATM
-        trust_message_elt["encryption"] = twomemo.twomemo.NAMESPACE
-
-        for updated_jid, trust_updates in updated.items():
-            key_owner_elt = trust_message_elt.addElement((NS_TM, "key-owner"))
-            key_owner_elt["jid"] = updated_jid.userhost()
-
-            for trust_update in trust_updates:
-                serialized_identity_key = \
-                    base64.b64encode(trust_update.target_key).decode("ASCII")
-
-                if trust_update.target_trust:
-                    key_owner_elt.addElement(
-                        (NS_TM, "trust"),
-                        content=serialized_identity_key
-                    )
-                else:
-                    key_owner_elt.addElement(
-                        (NS_TM, "distrust"),
-                        content=serialized_identity_key
-                    )
-
-        # Finally, encrypt and send the trust message!
-        message_data = client.generate_message_xml(MessageData({
-            "from": own_jid,
-            "to": recipient_jid,
-            "uid": str(uuid.uuid4()),
-            "message": {},
-            "subject": {},
-            "type": C.MESS_TYPE_CHAT,
-            "extra": {},
-            "timestamp": time.time()
-        }))
-
-        message_data["xml"].addChild(trust_message_elt)
-
-        plaintext = XEP_0420.pack_stanza(TM_SCE_PROFILE, message_data["xml"])
-
-        feedback_jid = recipient_jid
-
-        # TODO: The following is mostly duplicate code
-        try:
-            messages, encryption_errors = await session_manager.encrypt(
-                frozenset({ own_jid.userhost(), recipient_jid.userhost() }),
-                { OMEMO.NS_TWOMEMO: plaintext },
-                backend_priority_order=[ OMEMO.NS_TWOMEMO ],
-                identifier=feedback_jid.userhost()
-            )
-        except Exception as e:
-            msg = _(
-                # pylint: disable=consider-using-f-string
-                "Can't encrypt message for {entities}: {reason}".format(
-                    entities=', '.join({ own_jid.userhost(), recipient_jid.userhost() }),
-                    reason=e
-                )
-            )
-            log.warning(msg)
-            client.feedback(feedback_jid, msg, {
-                C.MESS_EXTRA_INFO: C.EXTRA_INFO_ENCR_ERR
-            })
-            raise e
-
-        if len(encryption_errors) > 0:
-            log.warning(
-                f"Ignored the following non-critical encryption errors:"
-                f" {encryption_errors}"
-            )
-
-            encrypted_errors_stringified = ", ".join([
-                f"device {err.device_id} of {err.bare_jid} under namespace"
-                f" {err.namespace}"
-                for err
-                in encryption_errors
-            ])
-
-            client.feedback(
-                feedback_jid,
-                D_(
-                    "There were non-critical errors during encryption resulting in some"
-                    " of your destinees' devices potentially not receiving the message."
-                    " This happens when the encryption data/key material of a device is"
-                    " incomplete or broken, which shouldn't happen for actively used"
-                    " devices, and can usually be ignored. The following devices are"
-                    f" affected: {encrypted_errors_stringified}."
-                )
-            )
-
-        message = next(
-            message for message in messages
-            if message.namespace == OMEMO.NS_TWOMEMO
-        )
-
-        # Add the encrypted element
-        message_data["xml"].addChild(xml_tools.et_elt_2_domish_elt(
-            twomemo.etree.serialize_message(message)
-        ))
-
-        await client.a_send(message_data["xml"])
-
-
-def make_session_manager(sat: SAT, profile: str) -> Type[omemo.SessionManager]:
-    """
-    @param sat: The SAT instance.
-    @param profile: The profile.
-    @return: A non-abstract subclass of :class:`~omemo.session_manager.SessionManager`
-        with XMPP interactions and trust handled via the SAT instance.
-    """
-
-    client = sat.get_client(profile)
-    xep_0060 = cast(XEP_0060, sat.plugins["XEP-0060"])
-
-    class SessionManagerImpl(omemo.SessionManager):
-        """
-        Session manager implementation handling XMPP interactions and trust via an
-        instance of :class:`~sat.core.sat_main.SAT`.
-        """
-
-        @staticmethod
-        async def _upload_bundle(bundle: omemo.Bundle) -> None:
-            if isinstance(bundle, twomemo.twomemo.BundleImpl):
-                element = twomemo.etree.serialize_bundle(bundle)
-
-                node = "urn:xmpp:omemo:2:bundles"
-                try:
-                    await xep_0060.send_item(
-                        client,
-                        client.jid.userhostJID(),
-                        node,
-                        xml_tools.et_elt_2_domish_elt(element),
-                        item_id=str(bundle.device_id),
-                        extra={
-                            XEP_0060.EXTRA_PUBLISH_OPTIONS: {
-                                XEP_0060.OPT_MAX_ITEMS: "max"
-                            },
-                            XEP_0060.EXTRA_ON_PRECOND_NOT_MET: "raise"
-                        }
-                    )
-                except (error.StanzaError, Exception) as e:
-                    if (
-                        isinstance(e, error.StanzaError)
-                        and e.condition == "conflict"
-                        and e.appCondition is not None
-                        # pylint: disable=no-member
-                        and e.appCondition.name == "precondition-not-met"
-                    ):
-                        # publish options couldn't be set on the fly, manually reconfigure
-                        # the node and publish again
-                        raise omemo.BundleUploadFailed(
-                            f"precondition-not-met: {bundle}"
-                        ) from e
-                        # TODO: What can I do here? The correct node configuration is a
-                        # MUST in the XEP.
-
-                    raise omemo.BundleUploadFailed(
-                        f"Bundle upload failed: {bundle}"
-                    ) from e
-
-                return
-
-            if isinstance(bundle, oldmemo.oldmemo.BundleImpl):
-                element = oldmemo.etree.serialize_bundle(bundle)
-
-                node = f"eu.siacs.conversations.axolotl.bundles:{bundle.device_id}"
-                try:
-                    await xep_0060.send_item(
-                        client,
-                        client.jid.userhostJID(),
-                        node,
-                        xml_tools.et_elt_2_domish_elt(element),
-                        item_id=xep_0060.ID_SINGLETON,
-                        extra={
-                            XEP_0060.EXTRA_PUBLISH_OPTIONS: { XEP_0060.OPT_MAX_ITEMS: 1 },
-                            XEP_0060.EXTRA_ON_PRECOND_NOT_MET: "publish_without_options"
-                        }
-                    )
-                except Exception as e:
-                    raise omemo.BundleUploadFailed(
-                        f"Bundle upload failed: {bundle}"
-                    ) from e
-
-                return
-
-            raise omemo.UnknownNamespace(f"Unknown namespace: {bundle.namespace}")
-
-        @staticmethod
-        async def _download_bundle(
-            namespace: str,
-            bare_jid: str,
-            device_id: int
-        ) -> omemo.Bundle:
-            if namespace == twomemo.twomemo.NAMESPACE:
-                node = "urn:xmpp:omemo:2:bundles"
-
-                try:
-                    items, __ = await xep_0060.get_items(
-                        client,
-                        jid.JID(bare_jid),
-                        node,
-                        item_ids=[ str(device_id) ]
-                    )
-                except Exception as e:
-                    raise omemo.BundleDownloadFailed(
-                        f"Bundle download failed for {bare_jid}: {device_id} under"
-                        f" namespace {namespace}"
-                    ) from e
-
-                if len(items) != 1:
-                    raise omemo.BundleDownloadFailed(
-                        f"Bundle download failed for {bare_jid}: {device_id} under"
-                        f" namespace {namespace}: Unexpected number of items retrieved:"
-                        f" {len(items)}."
-                    )
-
-                element = next(
-                    iter(xml_tools.domish_elt_2_et_elt(cast(domish.Element, items[0]))),
-                    None
-                )
-                if element is None:
-                    raise omemo.BundleDownloadFailed(
-                        f"Bundle download failed for {bare_jid}: {device_id} under"
-                        f" namespace {namespace}: Item download succeeded but parsing"
-                        f" failed: {element}."
-                    )
-
-                try:
-                    return twomemo.etree.parse_bundle(element, bare_jid, device_id)
-                except Exception as e:
-                    raise omemo.BundleDownloadFailed(
-                        f"Bundle parsing failed for {bare_jid}: {device_id} under"
-                        f" namespace {namespace}"
-                    ) from e
-
-            if namespace == oldmemo.oldmemo.NAMESPACE:
-                return await download_oldmemo_bundle(
-                    client,
-                    xep_0060,
-                    bare_jid,
-                    device_id
-                )
-
-            raise omemo.UnknownNamespace(f"Unknown namespace: {namespace}")
-
-        @staticmethod
-        async def _delete_bundle(namespace: str, device_id: int) -> None:
-            if namespace == twomemo.twomemo.NAMESPACE:
-                node = "urn:xmpp:omemo:2:bundles"
-
-                try:
-                    await xep_0060.retract_items(
-                        client,
-                        client.jid.userhostJID(),
-                        node,
-                        [ str(device_id) ],
-                        notify=False
-                    )
-                except Exception as e:
-                    raise omemo.BundleDeletionFailed(
-                        f"Bundle deletion failed for {device_id} under namespace"
-                        f" {namespace}"
-                    ) from e
-
-                return
-
-            if namespace == oldmemo.oldmemo.NAMESPACE:
-                node = f"eu.siacs.conversations.axolotl.bundles:{device_id}"
-
-                try:
-                    await xep_0060.deleteNode(client, client.jid.userhostJID(), node)
-                except Exception as e:
-                    raise omemo.BundleDeletionFailed(
-                        f"Bundle deletion failed for {device_id} under namespace"
-                        f" {namespace}"
-                    ) from e
-
-                return
-
-            raise omemo.UnknownNamespace(f"Unknown namespace: {namespace}")
-
-        @staticmethod
-        async def _upload_device_list(
-            namespace: str,
-            device_list: Dict[int, Optional[str]]
-        ) -> None:
-            element: Optional[ET.Element] = None
-            node: Optional[str] = None
-
-            if namespace == twomemo.twomemo.NAMESPACE:
-                element = twomemo.etree.serialize_device_list(device_list)
-                node = TWOMEMO_DEVICE_LIST_NODE
-            if namespace == oldmemo.oldmemo.NAMESPACE:
-                element = oldmemo.etree.serialize_device_list(device_list)
-                node = OLDMEMO_DEVICE_LIST_NODE
-
-            if element is None or node is None:
-                raise omemo.UnknownNamespace(f"Unknown namespace: {namespace}")
-
-            try:
-                await xep_0060.send_item(
-                    client,
-                    client.jid.userhostJID(),
-                    node,
-                    xml_tools.et_elt_2_domish_elt(element),
-                    item_id=xep_0060.ID_SINGLETON,
-                    extra={
-                        XEP_0060.EXTRA_PUBLISH_OPTIONS: {
-                            XEP_0060.OPT_MAX_ITEMS: 1,
-                            XEP_0060.OPT_ACCESS_MODEL: "open"
-                        },
-                        XEP_0060.EXTRA_ON_PRECOND_NOT_MET: "raise"
-                    }
-                )
-            except (error.StanzaError, Exception) as e:
-                if (
-                    isinstance(e, error.StanzaError)
-                    and e.condition == "conflict"
-                    and e.appCondition is not None
-                    # pylint: disable=no-member
-                    and e.appCondition.name == "precondition-not-met"
-                ):
-                    # publish options couldn't be set on the fly, manually reconfigure the
-                    # node and publish again
-                    raise omemo.DeviceListUploadFailed(
-                        f"precondition-not-met for namespace {namespace}"
-                    ) from e
-                    # TODO: What can I do here? The correct node configuration is a MUST
-                    # in the XEP.
-
-                raise omemo.DeviceListUploadFailed(
-                    f"Device list upload failed for namespace {namespace}"
-                ) from e
-
-        @staticmethod
-        async def _download_device_list(
-            namespace: str,
-            bare_jid: str
-        ) -> Dict[int, Optional[str]]:
-            node: Optional[str] = None
-
-            if namespace == twomemo.twomemo.NAMESPACE:
-                node = TWOMEMO_DEVICE_LIST_NODE
-            if namespace == oldmemo.oldmemo.NAMESPACE:
-                node = OLDMEMO_DEVICE_LIST_NODE
-
-            if node is None:
-                raise omemo.UnknownNamespace(f"Unknown namespace: {namespace}")
-
-            try:
-                items, __ = await xep_0060.get_items(client, jid.JID(bare_jid), node)
-            except exceptions.NotFound:
-                return {}
-            except Exception as e:
-                raise omemo.DeviceListDownloadFailed(
-                    f"Device list download failed for {bare_jid} under namespace"
-                    f" {namespace}"
-                ) from e
-
-            if len(items) == 0:
-                return {}
-
-            if len(items) != 1:
-                raise omemo.DeviceListDownloadFailed(
-                    f"Device list download failed for {bare_jid} under namespace"
-                    f" {namespace}: Unexpected number of items retrieved: {len(items)}."
-                )
-
-            element = next(
-                iter(xml_tools.domish_elt_2_et_elt(cast(domish.Element, items[0]))),
-                None
-            )
-
-            if element is None:
-                raise omemo.DeviceListDownloadFailed(
-                    f"Device list download failed for {bare_jid} under namespace"
-                    f" {namespace}: Item download succeeded but parsing failed:"
-                    f" {element}."
-                )
-
-            try:
-                if namespace == twomemo.twomemo.NAMESPACE:
-                    return twomemo.etree.parse_device_list(element)
-                if namespace == oldmemo.oldmemo.NAMESPACE:
-                    return oldmemo.etree.parse_device_list(element)
-            except Exception as e:
-                raise omemo.DeviceListDownloadFailed(
-                    f"Device list download failed for {bare_jid} under namespace"
-                    f" {namespace}"
-                ) from e
-
-            raise omemo.UnknownNamespace(f"Unknown namespace: {namespace}")
-
-        async def _evaluate_custom_trust_level(
-            self,
-            device: omemo.DeviceInformation
-        ) -> omemo.TrustLevel:
-            # Get the custom trust level
-            try:
-                trust_level = TrustLevel(device.trust_level_name)
-            except ValueError as e:
-                raise omemo.UnknownTrustLevel(
-                    f"Unknown trust level name {device.trust_level_name}"
-                ) from e
-
-            # The first three cases are a straight-forward mapping
-            if trust_level is TrustLevel.TRUSTED:
-                return omemo.TrustLevel.TRUSTED
-            if trust_level is TrustLevel.UNDECIDED:
-                return omemo.TrustLevel.UNDECIDED
-            if trust_level is TrustLevel.DISTRUSTED:
-                return omemo.TrustLevel.DISTRUSTED
-
-            # The blindly trusted case is more complicated, since its evaluation depends
-            # on the trust system and phase
-            if trust_level is TrustLevel.BLINDLY_TRUSTED:
-                # Get the name of the active trust system
-                trust_system = cast(str, sat.memory.param_get_a(
-                    PARAM_NAME,
-                    PARAM_CATEGORY,
-                    profile_key=profile
-                ))
-
-                # If the trust model is BTBV, blind trust is always enabled
-                if trust_system == "btbv":
-                    return omemo.TrustLevel.TRUSTED
-
-                # If the trust model is ATM, blind trust is disabled in the second phase
-                # and counts as undecided
-                if trust_system == "atm":
-                    # Find out whether we are in phase one or two
-                    devices = await self.get_device_information(device.bare_jid)
-
-                    phase_one = all(TrustLevel(device.trust_level_name) in {
-                        TrustLevel.UNDECIDED,
-                        TrustLevel.BLINDLY_TRUSTED
-                    } for device in devices)
-
-                    if phase_one:
-                        return omemo.TrustLevel.TRUSTED
-
-                    return omemo.TrustLevel.UNDECIDED
-
-                raise exceptions.InternalError(
-                    f"Unknown trust system active: {trust_system}"
-                )
-
-            assert_never(trust_level)
-
-        async def _make_trust_decision(
-            self,
-            undecided: FrozenSet[omemo.DeviceInformation],
-            identifier: Optional[str]
-        ) -> None:
-            if identifier is None:
-                raise omemo.TrustDecisionFailed(
-                    "The identifier must contain the feedback JID."
-                )
-
-            # The feedback JID is transferred via the identifier
-            feedback_jid = jid.JID(identifier).userhostJID()
-
-            # Both the ATM and the BTBV trust models work with blind trust before the
-            # first manual verification is performed. Thus, we can separate bare JIDs into
-            # two pools here, one pool of bare JIDs for which blind trust is active, and
-            # one pool of bare JIDs for which manual trust is used instead.
-            bare_jids = { device.bare_jid for device in undecided }
-
-            blind_trust_bare_jids: Set[str] = set()
-            manual_trust_bare_jids: Set[str] = set()
-
-            # For each bare JID, decide whether blind trust applies
-            for bare_jid in bare_jids:
-                # Get all known devices belonging to the bare JID
-                devices = await self.get_device_information(bare_jid)
-
-                # If the trust levels of all devices correspond to those used by blind
-                # trust, blind trust applies. Otherwise, fall back to manual trust.
-                if all(TrustLevel(device.trust_level_name) in {
-                    TrustLevel.UNDECIDED,
-                    TrustLevel.BLINDLY_TRUSTED
-                } for device in devices):
-                    blind_trust_bare_jids.add(bare_jid)
-                else:
-                    manual_trust_bare_jids.add(bare_jid)
-
-            # With the JIDs sorted into their respective pools, the undecided devices can
-            # be categorized too
-            blindly_trusted_devices = \
-                { dev for dev in undecided if dev.bare_jid in blind_trust_bare_jids }
-            manually_trusted_devices = \
-                { dev for dev in undecided if dev.bare_jid in manual_trust_bare_jids }
-
-            # Blindly trust devices handled by blind trust
-            if len(blindly_trusted_devices) > 0:
-                for device in blindly_trusted_devices:
-                    await self.set_trust(
-                        device.bare_jid,
-                        device.identity_key,
-                        TrustLevel.BLINDLY_TRUSTED.name
-                    )
-
-                blindly_trusted_devices_stringified = ", ".join([
-                    f"device {device.device_id} of {device.bare_jid} under namespace"
-                    f" {device.namespaces}"
-                    for device
-                    in blindly_trusted_devices
-                ])
-
-                client.feedback(
-                    feedback_jid,
-                    D_(
-                        "Not all destination devices are trusted, unknown devices will be"
-                        " blindly trusted.\nFollowing devices have been automatically"
-                        f" trusted: {blindly_trusted_devices_stringified}."
-                    )
-                )
-
-            # Prompt the user for manual trust decisions on the devices handled by manual
-            # trust
-            if len(manually_trusted_devices) > 0:
-                client.feedback(
-                    feedback_jid,
-                    D_(
-                        "Not all destination devices are trusted, we can't encrypt"
-                        " message in such a situation. Please indicate if you trust"
-                        " those devices or not in the trust manager before we can"
-                        " send this message."
-                    )
-                )
-                await self.__prompt_manual_trust(
-                    frozenset(manually_trusted_devices),
-                    feedback_jid
-                )
-
-        @staticmethod
-        async def _send_message(message: omemo.Message, bare_jid: str) -> None:
-            element: Optional[ET.Element] = None
-
-            if message.namespace == twomemo.twomemo.NAMESPACE:
-                element = twomemo.etree.serialize_message(message)
-            if message.namespace == oldmemo.oldmemo.NAMESPACE:
-                element = oldmemo.etree.serialize_message(message)
-
-            if element is None:
-                raise omemo.UnknownNamespace(f"Unknown namespace: {message.namespace}")
-
-            message_data = client.generate_message_xml(MessageData({
-                "from": client.jid,
-                "to": jid.JID(bare_jid),
-                "uid": str(uuid.uuid4()),
-                "message": {},
-                "subject": {},
-                "type": C.MESS_TYPE_CHAT,
-                "extra": {},
-                "timestamp": time.time()
-            }))
-
-            message_data["xml"].addChild(xml_tools.et_elt_2_domish_elt(element))
-
-            try:
-                await client.a_send(message_data["xml"])
-            except Exception as e:
-                raise omemo.MessageSendingFailed() from e
-
-        async def __prompt_manual_trust(
-            self,
-            undecided: FrozenSet[omemo.DeviceInformation],
-            feedback_jid: jid.JID
-        ) -> None:
-            """Asks the user to decide on the manual trust level of a set of devices.
-
-            Blocks until the user has made a decision and updates the trust levels of all
-            devices using :meth:`set_trust`.
-
-            @param undecided: The set of devices to prompt manual trust for.
-            @param feedback_jid: The bare JID to redirect feedback to. In case of a one to
-                one message, the recipient JID. In case of a MUC message, the room JID.
-            @raise TrustDecisionFailed: if the user cancels the prompt.
-            """
-
-            # This session manager handles encryption with both twomemo and oldmemo, but
-            # both are currently registered as different plugins and the `defer_xmlui`
-            # below requires a single namespace identifying the encryption plugin. Thus,
-            # get the namespace of the requested encryption method from the encryption
-            # session using the feedback JID.
-            encryption = client.encryption.getSession(feedback_jid)
-            if encryption is None:
-                raise omemo.TrustDecisionFailed(
-                    f"Encryption not requested for {feedback_jid.userhost()}."
-                )
-
-            namespace = encryption["plugin"].namespace
-
-            # Casting this to Any, otherwise all calls on the variable cause type errors
-            # pylint: disable=no-member
-            trust_ui = cast(Any, xml_tools.XMLUI(
-                panel_type=C.XMLUI_FORM,
-                title=D_("OMEMO trust management"),
-                submit_id=""
-            ))
-            trust_ui.addText(D_(
-                "This is OMEMO trusting system. You'll see below the devices of your "
-                "contacts, and a checkbox to trust them or not. A trusted device "
-                "can read your messages in plain text, so be sure to only validate "
-                "devices that you are sure are belonging to your contact. It's better "
-                "to do this when you are next to your contact and their device, so "
-                "you can check the \"fingerprint\" (the number next to the device) "
-                "yourself. Do *not* validate a device if the fingerprint is wrong!"
-            ))
-
-            own_device, __ = await self.get_own_device_information()
-
-            trust_ui.change_container("label")
-            trust_ui.addLabel(D_("This device ID"))
-            trust_ui.addText(str(own_device.device_id))
-            trust_ui.addLabel(D_("This device's fingerprint"))
-            trust_ui.addText(" ".join(self.format_identity_key(own_device.identity_key)))
-            trust_ui.addEmpty()
-            trust_ui.addEmpty()
-
-            # At least sort the devices by bare JID such that they aren't listed
-            # completely random
-            undecided_ordered = sorted(undecided, key=lambda device: device.bare_jid)
-
-            for index, device in enumerate(undecided_ordered):
-                trust_ui.addLabel(D_("Contact"))
-                trust_ui.addJid(jid.JID(device.bare_jid))
-                trust_ui.addLabel(D_("Device ID"))
-                trust_ui.addText(str(device.device_id))
-                trust_ui.addLabel(D_("Fingerprint"))
-                trust_ui.addText(" ".join(self.format_identity_key(device.identity_key)))
-                trust_ui.addLabel(D_("Trust this device?"))
-                trust_ui.addBool(f"trust_{index}", value=C.bool_const(False))
-                trust_ui.addEmpty()
-                trust_ui.addEmpty()
-
-            trust_ui_result = await xml_tools.defer_xmlui(
-                sat,
-                trust_ui,
-                action_extra={ "meta_encryption_trust": namespace },
-                profile=profile
-            )
-
-            if C.bool(trust_ui_result.get("cancelled", "false")):
-                raise omemo.TrustDecisionFailed("Trust UI cancelled.")
-
-            data_form_result = cast(Dict[str, str], xml_tools.xmlui_result_2_data_form_result(
-                trust_ui_result
-            ))
-
-            trust_updates: Set[TrustUpdate] = set()
-
-            for key, value in data_form_result.items():
-                if not key.startswith("trust_"):
-                    continue
-
-                device = undecided_ordered[int(key[len("trust_"):])]
-                target_trust = C.bool(value)
-                trust_level = \
-                    TrustLevel.TRUSTED if target_trust else TrustLevel.DISTRUSTED
-
-                await self.set_trust(
-                    device.bare_jid,
-                    device.identity_key,
-                    trust_level.name
-                )
-
-                trust_updates.add(TrustUpdate(
-                    target_jid=jid.JID(device.bare_jid).userhostJID(),
-                    target_key=device.identity_key,
-                    target_trust=target_trust
-                ))
-
-            # Check whether ATM is enabled and handle everything in case it is
-            trust_system = cast(str, sat.memory.param_get_a(
-                PARAM_NAME,
-                PARAM_CATEGORY,
-                profile_key=profile
-            ))
-
-            if trust_system == "atm":
-                await manage_trust_message_cache(client, self, frozenset(trust_updates))
-                await send_trust_messages(client, self, frozenset(trust_updates))
-
-    return SessionManagerImpl
-
-
-async def prepare_for_profile(
-    sat: SAT,
-    profile: str,
-    initial_own_label: Optional[str],
-    signed_pre_key_rotation_period: int = 7 * 24 * 60 * 60,
-    pre_key_refill_threshold: int = 99,
-    max_num_per_session_skipped_keys: int = 1000,
-    max_num_per_message_skipped_keys: Optional[int] = None
-) -> omemo.SessionManager:
-    """Prepare the OMEMO library (storage, backends, core) for a specific profile.
-
-    @param sat: The SAT instance.
-    @param profile: The profile.
-    @param initial_own_label: The initial (optional) label to assign to this device if
-        supported by any of the backends.
-    @param signed_pre_key_rotation_period: The rotation period for the signed pre key, in
-        seconds. The rotation period is recommended to be between one week (the default)
-        and one month.
-    @param pre_key_refill_threshold: The number of pre keys that triggers a refill to 100.
-        Defaults to 99, which means that each pre key gets replaced with a new one right
-        away. The threshold can not be configured to lower than 25.
-    @param max_num_per_session_skipped_keys: The maximum number of skipped message keys to
-        keep around per session. Once the maximum is reached, old message keys are deleted
-        to make space for newer ones. Accessible via
-        :attr:`max_num_per_session_skipped_keys`.
-    @param max_num_per_message_skipped_keys: The maximum number of skipped message keys to
-        accept in a single message. When set to ``None`` (the default), this parameter
-        defaults to the per-session maximum (i.e. the value of the
-        ``max_num_per_session_skipped_keys`` parameter). This parameter may only be 0 if
-        the per-session maximum is 0, otherwise it must be a number between 1 and the
-        per-session maximum. Accessible via :attr:`max_num_per_message_skipped_keys`.
-    @return: A session manager with ``urn:xmpp:omemo:2`` and
-        ``eu.siacs.conversations.axolotl`` capabilities, specifically for the given
-        profile.
-    @raise BundleUploadFailed: if a bundle upload failed. Forwarded from
-        :meth:`~omemo.session_manager.SessionManager.create`.
-    @raise BundleDownloadFailed: if a bundle download failed. Forwarded from
-        :meth:`~omemo.session_manager.SessionManager.create`.
-    @raise BundleDeletionFailed: if a bundle deletion failed. Forwarded from
-        :meth:`~omemo.session_manager.SessionManager.create`.
-    @raise DeviceListUploadFailed: if a device list upload failed. Forwarded from
-        :meth:`~omemo.session_manager.SessionManager.create`.
-    @raise DeviceListDownloadFailed: if a device list download failed. Forwarded from
-        :meth:`~omemo.session_manager.SessionManager.create`.
-    """
-
-    client = sat.get_client(profile)
-    xep_0060 = cast(XEP_0060, sat.plugins["XEP-0060"])
-
-    storage = StorageImpl(profile)
-
-    # TODO: Untested
-    await oldmemo.migrations.migrate(
-        LegacyStorageImpl(profile, client.jid.userhost()),
-        storage,
-        # TODO: Do we want BLINDLY_TRUSTED or TRUSTED here?
-        TrustLevel.BLINDLY_TRUSTED.name,
-        TrustLevel.UNDECIDED.name,
-        TrustLevel.DISTRUSTED.name,
-        lambda bare_jid, device_id: download_oldmemo_bundle(
-            client,
-            xep_0060,
-            bare_jid,
-            device_id
-        )
-    )
-
-    session_manager = await make_session_manager(sat, profile).create(
-        [
-            twomemo.Twomemo(
-                storage,
-                max_num_per_session_skipped_keys,
-                max_num_per_message_skipped_keys
-            ),
-            oldmemo.Oldmemo(
-                storage,
-                max_num_per_session_skipped_keys,
-                max_num_per_message_skipped_keys
-            )
-        ],
-        storage,
-        client.jid.userhost(),
-        initial_own_label,
-        TrustLevel.UNDECIDED.value,
-        signed_pre_key_rotation_period,
-        pre_key_refill_threshold,
-        omemo.AsyncFramework.TWISTED
-    )
-
-    # This shouldn't hurt here since we're not running on overly constrainted devices.
-    # TODO: Consider ensuring data consistency regularly/in response to certain events
-    await session_manager.ensure_data_consistency()
-
-    # TODO: Correct entering/leaving of the history synchronization mode isn't terribly
-    # important for now, since it only prevents an extremely unlikely race condition of
-    # multiple devices choosing the same pre key for new sessions while the device was
-    # offline. I don't believe other clients seriously defend against that race condition
-    # either. In the long run, it might still be cool to have triggers for when history
-    # sync starts and ends (MAM, MUC catch-up, etc.) and to react to those triggers.
-    await session_manager.after_history_sync()
-
-    return session_manager
-
-
-DEFAULT_TRUST_MODEL_PARAM = f"""
-<params>
-<individual>
-<category name="{PARAM_CATEGORY}" label={quoteattr(D_('Security'))}>
-    <param name="{PARAM_NAME}"
-        label={quoteattr(D_('OMEMO default trust policy'))}
-        type="list" security="3">
-        <option value="atm"
-            label={quoteattr(D_('Automatic Trust Management (more secure)'))} />
-        <option value="btbv"
-            label={quoteattr(D_('Blind Trust Before Verification (more user friendly)'))}
-            selected="true" />
-    </param>
-</category>
-</individual>
-</params>
-"""
-
-
-class OMEMO:
-    """
-    Plugin equipping Libervia with OMEMO capabilities under the (modern)
-    ``urn:xmpp:omemo:2`` namespace and the (legacy) ``eu.siacs.conversations.axolotl``
-    namespace. Both versions of the protocol are handled by this plugin and compatibility
-    between the two is maintained. MUC messages are supported next to one to one messages.
-    For trust management, the two trust models "ATM" and "BTBV" are supported.
-    """
-    NS_TWOMEMO = twomemo.twomemo.NAMESPACE
-    NS_OLDMEMO = oldmemo.oldmemo.NAMESPACE
-
-    # For MUC/MIX message stanzas, the <to/> affix is a MUST
-    SCE_PROFILE_GROUPCHAT = SCEProfile(
-        rpad_policy=SCEAffixPolicy.REQUIRED,
-        time_policy=SCEAffixPolicy.OPTIONAL,
-        to_policy=SCEAffixPolicy.REQUIRED,
-        from_policy=SCEAffixPolicy.OPTIONAL,
-        custom_policies={}
-    )
-
-    # For everything but MUC/MIX message stanzas, the <to/> affix is a MAY
-    SCE_PROFILE = SCEProfile(
-        rpad_policy=SCEAffixPolicy.REQUIRED,
-        time_policy=SCEAffixPolicy.OPTIONAL,
-        to_policy=SCEAffixPolicy.OPTIONAL,
-        from_policy=SCEAffixPolicy.OPTIONAL,
-        custom_policies={}
-    )
-
-    def __init__(self, sat: SAT) -> None:
-        """
-        @param sat: The SAT instance.
-        """
-
-        self.__sat = sat
-
-        # Add configuration option to choose between manual trust and BTBV as the trust
-        # model
-        sat.memory.update_params(DEFAULT_TRUST_MODEL_PARAM)
-
-        # Plugins
-        self.__xep_0045 = cast(Optional[XEP_0045], sat.plugins.get("XEP-0045"))
-        self.__xep_0334 = cast(XEP_0334, sat.plugins["XEP-0334"])
-        self.__xep_0359 = cast(Optional[XEP_0359], sat.plugins.get("XEP-0359"))
-        self.__xep_0420 = cast(XEP_0420, sat.plugins["XEP-0420"])
-
-        # In contrast to one to one messages, MUC messages are reflected to the sender.
-        # Thus, the sender does not add messages to their local message log when sending
-        # them, but when the reflection is received. This approach does not pair well with
-        # OMEMO, since for security reasons it is forbidden to encrypt messages for the
-        # own device. Thus, when the reflection of an OMEMO message is received, it can't
-        # be decrypted and added to the local message log as usual. To counteract this,
-        # the plaintext of encrypted messages sent to MUCs are cached in this field, such
-        # that when the reflection is received, the plaintext can be looked up from the
-        # cache and added to the local message log.
-        # TODO: The old plugin expired this cache after some time. I'm not sure that's
-        # really necessary.
-        self.__muc_plaintext_cache: Dict[MUCPlaintextCacheKey, bytes] = {}
-
-        # Mapping from profile name to corresponding session manager
-        self.__session_managers: Dict[str, omemo.SessionManager] = {}
-
-        # Calls waiting for a specific session manager to be built
-        self.__session_manager_waiters: Dict[str, List[defer.Deferred]] = {}
-
-        # These triggers are used by oldmemo, which doesn't do SCE and only applies to
-        # messages. Temporarily, until a more fitting trigger for SCE-based encryption is
-        # added, the message_received trigger is also used for twomemo.
-        sat.trigger.add(
-            "message_received",
-            self._message_received_trigger,
-            priority=100050
-        )
-        sat.trigger.add(
-            "send_message_data",
-            self.__send_message_data_trigger,
-            priority=100050
-        )
-
-        # These triggers are used by twomemo, which does do SCE
-        sat.trigger.add("send", self.__send_trigger, priority=0)
-        # TODO: Add new triggers here for freshly received and about-to-be-sent stanzas,
-        # including IQs.
-
-        # Give twomemo a (slightly) higher priority than oldmemo
-        sat.register_encryption_plugin(self, "TWOMEMO", twomemo.twomemo.NAMESPACE, 101)
-        sat.register_encryption_plugin(self, "OLDMEMO", oldmemo.oldmemo.NAMESPACE, 100)
-
-        xep_0163 = cast(XEP_0163, sat.plugins["XEP-0163"])
-        xep_0163.add_pep_event(
-            "TWOMEMO_DEVICES",
-            TWOMEMO_DEVICE_LIST_NODE,
-            lambda items_event, profile: defer.ensureDeferred(
-                self.__on_device_list_update(items_event, profile)
-            )
-        )
-        xep_0163.add_pep_event(
-            "OLDMEMO_DEVICES",
-            OLDMEMO_DEVICE_LIST_NODE,
-            lambda items_event, profile: defer.ensureDeferred(
-                self.__on_device_list_update(items_event, profile)
-            )
-        )
-
-        try:
-            self.__text_commands = cast(TextCommands, sat.plugins[C.TEXT_CMDS])
-        except KeyError:
-            log.info(_("Text commands not available"))
-        else:
-            self.__text_commands.register_text_commands(self)
-
-    def profile_connected(  # pylint: disable=invalid-name
-        self,
-        client: SatXMPPClient
-    ) -> None:
-        """
-        @param client: The client.
-        """
-
-        defer.ensureDeferred(self.get_session_manager(
-            cast(str, client.profile)
-        ))
-
-    async def cmd_omemo_reset(
-        self,
-        client: SatXMPPClient,
-        mess_data: MessageData
-    ) -> Literal[False]:
-        """Reset all sessions of devices that belong to the recipient of ``mess_data``.
-
-        This must only be callable manually by the user. Use this when a session is
-        apparently broken, i.e. sending and receiving encrypted messages doesn't work and
-        something being wrong has been confirmed manually with the recipient.
-
-        @param client: The client.
-        @param mess_data: The message data, whose ``to`` attribute will be the bare JID to
-            reset all sessions with.
-        @return: The constant value ``False``, indicating to the text commands plugin that
-            the message is not supposed to be sent.
-        """
-
-        twomemo_requested = \
-            client.encryption.is_encryption_requested(mess_data, twomemo.twomemo.NAMESPACE)
-        oldmemo_requested = \
-            client.encryption.is_encryption_requested(mess_data, oldmemo.oldmemo.NAMESPACE)
-
-        if not (twomemo_requested or oldmemo_requested):
-            self.__text_commands.feed_back(
-                client,
-                _("You need to have OMEMO encryption activated to reset the session"),
-                mess_data
-            )
-            return False
-
-        bare_jid = mess_data["to"].userhost()
-
-        session_manager = await self.get_session_manager(client.profile)
-        devices = await session_manager.get_device_information(bare_jid)
-
-        for device in devices:
-            log.debug(f"Replacing sessions with device {device}")
-            await session_manager.replace_sessions(device)
-
-        self.__text_commands.feed_back(
-            client,
-            _("OMEMO session has been reset"),
-            mess_data
-        )
-
-        return False
-
-    async def get_trust_ui(  # pylint: disable=invalid-name
-        self,
-        client: SatXMPPClient,
-        entity: jid.JID
-    ) -> xml_tools.XMLUI:
-        """
-        @param client: The client.
-        @param entity: The entity whose device trust levels to manage.
-        @return: An XMLUI instance which opens a form to manage the trust level of all
-            devices belonging to the entity.
-        """
-
-        if entity.resource:
-            raise ValueError("A bare JID is expected.")
-
-        bare_jids: Set[str]
-        if self.__xep_0045 is not None and self.__xep_0045.is_joined_room(client, entity):
-            bare_jids = self.__get_joined_muc_users(client, self.__xep_0045, entity)
-        else:
-            bare_jids = { entity.userhost() }
-
-        session_manager = await self.get_session_manager(client.profile)
-
-        # At least sort the devices by bare JID such that they aren't listed completely
-        # random
-        devices = sorted(cast(Set[omemo.DeviceInformation], set()).union(*[
-            await session_manager.get_device_information(bare_jid)
-            for bare_jid
-            in bare_jids
-        ]), key=lambda device: device.bare_jid)
-
-        async def callback(
-            data: Any,
-            profile: str
-        ) -> Dict[Never, Never]:
-            """
-            @param data: The XMLUI result produces by the trust UI form.
-            @param profile: The profile.
-            @return: An empty dictionary. The type of the return value was chosen
-                conservatively since the exact options are neither known not needed here.
-            """
-
-            if C.bool(data.get("cancelled", "false")):
-                return {}
-
-            data_form_result = cast(
-                Dict[str, str],
-                xml_tools.xmlui_result_2_data_form_result(data)
-            )
-
-            trust_updates: Set[TrustUpdate] = set()
-
-            for key, value in data_form_result.items():
-                if not key.startswith("trust_"):
-                    continue
-
-                device = devices[int(key[len("trust_"):])]
-                trust_level_name = value
-
-                if device.trust_level_name != trust_level_name:
-                    await session_manager.set_trust(
-                        device.bare_jid,
-                        device.identity_key,
-                        trust_level_name
-                    )
-
-                    target_trust: Optional[bool] = None
-
-                    if TrustLevel(trust_level_name) is TrustLevel.TRUSTED:
-                        target_trust = True
-                    if TrustLevel(trust_level_name) is TrustLevel.DISTRUSTED:
-                        target_trust = False
-
-                    if target_trust is not None:
-                        trust_updates.add(TrustUpdate(
-                            target_jid=jid.JID(device.bare_jid).userhostJID(),
-                            target_key=device.identity_key,
-                            target_trust=target_trust
-                        ))
-
-            # Check whether ATM is enabled and handle everything in case it is
-            trust_system = cast(str, self.__sat.memory.param_get_a(
-                PARAM_NAME,
-                PARAM_CATEGORY,
-                profile_key=profile
-            ))
-
-            if trust_system == "atm":
-                if len(trust_updates) > 0:
-                    await manage_trust_message_cache(
-                        client,
-                        session_manager,
-                        frozenset(trust_updates)
-                    )
-
-                    await send_trust_messages(
-                        client,
-                        session_manager,
-                        frozenset(trust_updates)
-                    )
-
-            return {}
-
-        submit_id = self.__sat.register_callback(callback, with_data=True, one_shot=True)
-
-        result = xml_tools.XMLUI(
-            panel_type=C.XMLUI_FORM,
-            title=D_("OMEMO trust management"),
-            submit_id=submit_id
-        )
-        # Casting this to Any, otherwise all calls on the variable cause type errors
-        # pylint: disable=no-member
-        trust_ui = cast(Any, result)
-        trust_ui.addText(D_(
-            "This is OMEMO trusting system. You'll see below the devices of your"
-            " contacts, and a list selection to trust them or not. A trusted device"
-            " can read your messages in plain text, so be sure to only validate"
-            " devices that you are sure are belonging to your contact. It's better"
-            " to do this when you are next to your contact and their device, so"
-            " you can check the \"fingerprint\" (the number next to the device)"
-            " yourself. Do *not* validate a device if the fingerprint is wrong!"
-            " Note that manually validating a fingerprint disables any form of automatic"
-            " trust."
-        ))
-
-        own_device, __ = await session_manager.get_own_device_information()
-
-        trust_ui.change_container("label")
-        trust_ui.addLabel(D_("This device ID"))
-        trust_ui.addText(str(own_device.device_id))
-        trust_ui.addLabel(D_("This device's fingerprint"))
-        trust_ui.addText(" ".join(session_manager.format_identity_key(
-            own_device.identity_key
-        )))
-        trust_ui.addEmpty()
-        trust_ui.addEmpty()
-
-        for index, device in enumerate(devices):
-            trust_ui.addLabel(D_("Contact"))
-            trust_ui.addJid(jid.JID(device.bare_jid))
-            trust_ui.addLabel(D_("Device ID"))
-            trust_ui.addText(str(device.device_id))
-            trust_ui.addLabel(D_("Fingerprint"))
-            trust_ui.addText(" ".join(session_manager.format_identity_key(
-                device.identity_key
-            )))
-            trust_ui.addLabel(D_("Trust this device?"))
-
-            current_trust_level = TrustLevel(device.trust_level_name)
-            avaiable_trust_levels = \
-                { TrustLevel.DISTRUSTED, TrustLevel.TRUSTED, current_trust_level }
-
-            trust_ui.addList(
-                f"trust_{index}",
-                options=[ trust_level.name for trust_level in avaiable_trust_levels ],
-                selected=current_trust_level.name,
-                styles=[ "inline" ]
-            )
-
-            twomemo_active = dict(device.active).get(twomemo.twomemo.NAMESPACE)
-            if twomemo_active is None:
-                trust_ui.addEmpty()
-                trust_ui.addLabel(D_("(not available for Twomemo)"))
-            if twomemo_active is False:
-                trust_ui.addEmpty()
-                trust_ui.addLabel(D_("(inactive for Twomemo)"))
-
-            oldmemo_active = dict(device.active).get(oldmemo.oldmemo.NAMESPACE)
-            if oldmemo_active is None:
-                trust_ui.addEmpty()
-                trust_ui.addLabel(D_("(not available for Oldmemo)"))
-            if oldmemo_active is False:
-                trust_ui.addEmpty()
-                trust_ui.addLabel(D_("(inactive for Oldmemo)"))
-
-            trust_ui.addEmpty()
-            trust_ui.addEmpty()
-
-        return result
-
-    @staticmethod
-    def __get_joined_muc_users(
-        client: SatXMPPClient,
-        xep_0045: XEP_0045,
-        room_jid: jid.JID
-    ) -> Set[str]:
-        """
-        @param client: The client.
-        @param xep_0045: A MUC plugin instance.
-        @param room_jid: The room JID.
-        @return: A set containing the bare JIDs of the MUC participants.
-        @raise InternalError: if the MUC is not joined or the entity information of a
-            participant isn't available.
-        """
-
-        bare_jids: Set[str] = set()
-
-        try:
-            room = cast(muc.Room, xep_0045.get_room(client, room_jid))
-        except exceptions.NotFound as e:
-            raise exceptions.InternalError(
-                "Participant list of unjoined MUC requested."
-            ) from e
-
-        for user in cast(Dict[str, muc.User], room.roster).values():
-            entity = cast(Optional[SatXMPPEntity], user.entity)
-            if entity is None:
-                raise exceptions.InternalError(
-                    f"Participant list of MUC requested, but the entity information of"
-                    f" the participant {user} is not available."
-                )
-
-            bare_jids.add(entity.jid.userhost())
-
-        return bare_jids
-
-    async def get_session_manager(self, profile: str) -> omemo.SessionManager:
-        """
-        @param profile: The profile to prepare for.
-        @return: A session manager instance for this profile. Creates a new instance if
-            none was prepared before.
-        """
-
-        try:
-            # Try to return the session manager
-            return self.__session_managers[profile]
-        except KeyError:
-            # If a session manager for that profile doesn't exist yet, check whether it is
-            # currently being built. A session manager being built is signified by the
-            # profile key existing on __session_manager_waiters.
-            if profile in self.__session_manager_waiters:
-                # If the session manager is being built, add ourselves to the waiting
-                # queue
-                deferred = defer.Deferred()
-                self.__session_manager_waiters[profile].append(deferred)
-                return cast(omemo.SessionManager, await deferred)
-
-            # If the session manager is not being built, do so here.
-            self.__session_manager_waiters[profile] = []
-
-            # Build and store the session manager
-            try:
-                session_manager = await prepare_for_profile(
-                    self.__sat,
-                    profile,
-                    initial_own_label="Libervia"
-                )
-            except Exception as e:
-                # In case of an error during initalization, notify the waiters accordingly
-                # and delete them
-                for waiter in self.__session_manager_waiters[profile]:
-                    waiter.errback(e)
-                del self.__session_manager_waiters[profile]
-
-                # Re-raise the exception
-                raise
-
-            self.__session_managers[profile] = session_manager
-
-            # Notify the waiters and delete them
-            for waiter in self.__session_manager_waiters[profile]:
-                waiter.callback(session_manager)
-            del self.__session_manager_waiters[profile]
-
-            return session_manager
-
-    async def __message_received_trigger_atm(
-        self,
-        client: SatXMPPClient,
-        message_elt: domish.Element,
-        session_manager: omemo.SessionManager,
-        sender_device_information: omemo.DeviceInformation,
-        timestamp: datetime
-    ) -> None:
-        """Check a newly decrypted message stanza for ATM content and perform ATM in case.
-
-        @param client: The client which received the message.
-        @param message_elt: The message element. Can be modified.
-        @param session_manager: The session manager.
-        @param sender_device_information: Information about the device that sent/encrypted
-            the message.
-        @param timestamp: Timestamp extracted from the SCE time affix.
-        """
-
-        trust_message_cache = persistent.LazyPersistentBinaryDict(
-            "XEP-0384/TM",
-            client.profile
-        )
-
-        new_cache_entries: Set[TrustMessageCacheEntry] = set()
-
-        for trust_message_elt in message_elt.elements(NS_TM, "trust-message"):
-            assert isinstance(trust_message_elt, domish.Element)
-
-            try:
-                TRUST_MESSAGE_SCHEMA.validate(trust_message_elt.toXml())
-            except xmlschema.XMLSchemaValidationError as e:
-                raise exceptions.ParsingError(
-                    "<trust-message/> element doesn't pass schema validation."
-                ) from e
-
-            if trust_message_elt["usage"] != NS_ATM:
-                # Skip non-ATM trust message
-                continue
-
-            if trust_message_elt["encryption"] != OMEMO.NS_TWOMEMO:
-                # Skip non-twomemo trust message
-                continue
-
-            for key_owner_elt in trust_message_elt.elements(NS_TM, "key-owner"):
-                assert isinstance(key_owner_elt, domish.Element)
-
-                key_owner_jid = jid.JID(key_owner_elt["jid"]).userhostJID()
-
-                for trust_elt in key_owner_elt.elements(NS_TM, "trust"):
-                    assert isinstance(trust_elt, domish.Element)
-
-                    new_cache_entries.add(TrustMessageCacheEntry(
-                        sender_jid=jid.JID(sender_device_information.bare_jid),
-                        sender_key=sender_device_information.identity_key,
-                        timestamp=timestamp,
-                        trust_update=TrustUpdate(
-                            target_jid=key_owner_jid,
-                            target_key=base64.b64decode(str(trust_elt)),
-                            target_trust=True
-                        )
-                    ))
-
-                for distrust_elt in key_owner_elt.elements(NS_TM, "distrust"):
-                    assert isinstance(distrust_elt, domish.Element)
-
-                    new_cache_entries.add(TrustMessageCacheEntry(
-                        sender_jid=jid.JID(sender_device_information.bare_jid),
-                        sender_key=sender_device_information.identity_key,
-                        timestamp=timestamp,
-                        trust_update=TrustUpdate(
-                            target_jid=key_owner_jid,
-                            target_key=base64.b64decode(str(distrust_elt)),
-                            target_trust=False
-                        )
-                    ))
-
-        # Load existing cache entries
-        existing_cache_entries = cast(
-            Set[TrustMessageCacheEntry],
-            await trust_message_cache.get("cache", set())
-        )
-
-        # Discard cache entries by timestamp comparison
-        existing_by_target = {
-            (
-                cache_entry.trust_update.target_jid.userhostJID(),
-                cache_entry.trust_update.target_key
-            ): cache_entry
-            for cache_entry
-            in existing_cache_entries
-        }
-
-        # Iterate over a copy here, such that new_cache_entries can be modified
-        for new_cache_entry in set(new_cache_entries):
-            existing_cache_entry = existing_by_target.get(
-                (
-                    new_cache_entry.trust_update.target_jid.userhostJID(),
-                    new_cache_entry.trust_update.target_key
-                ),
-                None
-            )
-
-            if existing_cache_entry is not None:
-                if existing_cache_entry.timestamp > new_cache_entry.timestamp:
-                    # If the existing cache entry is newer than the new cache entry,
-                    # discard the new one in favor of the existing one
-                    new_cache_entries.remove(new_cache_entry)
-                else:
-                    # Otherwise, discard the existing cache entry. This includes the case
-                    # when both cache entries have matching timestamps.
-                    existing_cache_entries.remove(existing_cache_entry)
-
-        # If the sending device is trusted, apply the new cache entries
-        applied_trust_updates: Set[TrustUpdate] = set()
-
-        if TrustLevel(sender_device_information.trust_level_name) is TrustLevel.TRUSTED:
-            # Iterate over a copy such that new_cache_entries can be modified
-            for cache_entry in set(new_cache_entries):
-                trust_update = cache_entry.trust_update
-
-                trust_level = (
-                    TrustLevel.TRUSTED
-                    if trust_update.target_trust
-                    else TrustLevel.DISTRUSTED
-                )
-
-                await session_manager.set_trust(
-                    trust_update.target_jid.userhost(),
-                    trust_update.target_key,
-                    trust_level.name
-                )
-
-                applied_trust_updates.add(trust_update)
-
-                new_cache_entries.remove(cache_entry)
-
-        # Store the remaining existing and new cache entries
-        await trust_message_cache.force(
-            "cache",
-            existing_cache_entries | new_cache_entries
-        )
-
-        # If the trust of at least one device was modified, run the ATM cache update logic
-        if len(applied_trust_updates) > 0:
-            await manage_trust_message_cache(
-                client,
-                session_manager,
-                frozenset(applied_trust_updates)
-            )
-
-    async def _message_received_trigger(
-        self,
-        client: SatXMPPClient,
-        message_elt: domish.Element,
-        post_treat: defer.Deferred
-    ) -> bool:
-        """
-        @param client: The client which received the message.
-        @param message_elt: The message element. Can be modified.
-        @param post_treat: A deferred which evaluates to a :class:`MessageData` once the
-            message has fully progressed through the message receiving flow. Can be used
-            to apply treatments to the fully processed message, like marking it as
-            encrypted.
-        @return: Whether to continue the message received flow.
-        """
-        if client.is_component:
-            return True
-        muc_plaintext_cache_key: Optional[MUCPlaintextCacheKey] = None
-
-        sender_jid = jid.JID(message_elt["from"])
-        feedback_jid: jid.JID
-
-        message_type = message_elt.getAttribute("type", C.MESS_TYPE_NORMAL)
-        is_muc_message = message_type == C.MESS_TYPE_GROUPCHAT
-        if is_muc_message:
-            if self.__xep_0045 is None:
-                log.warning(
-                    "Ignoring MUC message since plugin XEP-0045 is not available."
-                )
-                # Can't handle a MUC message without XEP-0045, let the flow continue
-                # normally
-                return True
-
-            room_jid = feedback_jid = sender_jid.userhostJID()
-
-            try:
-                room = cast(muc.Room, self.__xep_0045.get_room(client, room_jid))
-            except exceptions.NotFound:
-                log.warning(
-                    f"Ignoring MUC message from a room that has not been joined:"
-                    f" {room_jid}"
-                )
-                # Whatever, let the flow continue
-                return True
-
-            sender_user = cast(Optional[muc.User], room.getUser(sender_jid.resource))
-            if sender_user is None:
-                log.warning(
-                    f"Ignoring MUC message from room {room_jid} since the sender's user"
-                    f" wasn't found {sender_jid.resource}"
-                )
-                # Whatever, let the flow continue
-                return True
-
-            sender_user_jid = cast(Optional[jid.JID], sender_user.entity)
-            if sender_user_jid is None:
-                log.warning(
-                    f"Ignoring MUC message from room {room_jid} since the sender's bare"
-                    f" JID couldn't be found from its user information: {sender_user}"
-                )
-                # Whatever, let the flow continue
-                return True
-
-            sender_jid = sender_user_jid
-
-            message_uid: Optional[str] = None
-            if self.__xep_0359 is not None:
-                message_uid = self.__xep_0359.get_origin_id(message_elt)
-            if message_uid is None:
-                message_uid = message_elt.getAttribute("id")
-            if message_uid is not None:
-                muc_plaintext_cache_key = MUCPlaintextCacheKey(
-                    client,
-                    room_jid,
-                    message_uid
-                )
-        else:
-            # I'm not sure why this check is required, this code is copied from the old
-            # plugin.
-            if sender_jid.userhostJID() == client.jid.userhostJID():
-                try:
-                    feedback_jid = jid.JID(message_elt["to"])
-                except KeyError:
-                    feedback_jid = client.server_jid
-            else:
-                feedback_jid = sender_jid
-
-        sender_bare_jid = sender_jid.userhost()
-
-        message: Optional[omemo.Message] = None
-        encrypted_elt: Optional[domish.Element] = None
-
-        twomemo_encrypted_elt = cast(Optional[domish.Element], next(
-            message_elt.elements(twomemo.twomemo.NAMESPACE, "encrypted"),
-            None
-        ))
-
-        oldmemo_encrypted_elt = cast(Optional[domish.Element], next(
-            message_elt.elements(oldmemo.oldmemo.NAMESPACE, "encrypted"),
-            None
-        ))
-
-        try:
-            session_manager = await self.get_session_manager(cast(str, client.profile))
-        except Exception as e:
-            log.error(f"error while preparing profile for {client.profile}: {e}")
-            # we don't want to block the workflow
-            return True
-
-        if twomemo_encrypted_elt is not None:
-            try:
-                message = twomemo.etree.parse_message(
-                    xml_tools.domish_elt_2_et_elt(twomemo_encrypted_elt),
-                    sender_bare_jid
-                )
-            except (ValueError, XMLSchemaValidationError):
-                log.warning(
-                    f"Ingoring malformed encrypted message for namespace"
-                    f" {twomemo.twomemo.NAMESPACE}: {twomemo_encrypted_elt.toXml()}"
-                )
-            else:
-                encrypted_elt = twomemo_encrypted_elt
-
-        if oldmemo_encrypted_elt is not None:
-            try:
-                message = await oldmemo.etree.parse_message(
-                    xml_tools.domish_elt_2_et_elt(oldmemo_encrypted_elt),
-                    sender_bare_jid,
-                    client.jid.userhost(),
-                    session_manager
-                )
-            except (ValueError, XMLSchemaValidationError):
-                log.warning(
-                    f"Ingoring malformed encrypted message for namespace"
-                    f" {oldmemo.oldmemo.NAMESPACE}: {oldmemo_encrypted_elt.toXml()}"
-                )
-            except omemo.SenderNotFound:
-                log.warning(
-                    f"Ingoring encrypted message for namespace"
-                    f" {oldmemo.oldmemo.NAMESPACE} by unknown sender:"
-                    f" {oldmemo_encrypted_elt.toXml()}"
-                )
-            else:
-                encrypted_elt = oldmemo_encrypted_elt
-
-        if message is None or encrypted_elt is None:
-            # None of our business, let the flow continue
-            return True
-
-        message_elt.children.remove(encrypted_elt)
-
-        log.debug(
-            f"{message.namespace} message of type {message_type} received from"
-            f" {sender_bare_jid}"
-        )
-
-        plaintext: Optional[bytes]
-        device_information: omemo.DeviceInformation
-
-        if (
-            muc_plaintext_cache_key is not None
-            and muc_plaintext_cache_key in self.__muc_plaintext_cache
-        ):
-            # Use the cached plaintext
-            plaintext = self.__muc_plaintext_cache.pop(muc_plaintext_cache_key)
-
-            # Since this message was sent by us, use the own device information here
-            device_information, __ = await session_manager.get_own_device_information()
-        else:
-            try:
-                plaintext, device_information, __ = await session_manager.decrypt(message)
-            except omemo.MessageNotForUs:
-                # The difference between this being a debug or a warning is whether there
-                # is a body included in the message. Without a body, we can assume that
-                # it's an empty OMEMO message used for protocol stability reasons, which
-                # is not expected to be sent to all devices of all recipients. If a body
-                # is included, we can assume that the message carries content and we
-                # missed out on something.
-                if len(list(message_elt.elements(C.NS_CLIENT, "body"))) > 0:
-                    client.feedback(
-                        feedback_jid,
-                        D_(
-                            f"An OMEMO message from {sender_jid.full()} has not been"
-                            f" encrypted for our device, we can't decrypt it."
-                        ),
-                        { C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR }
-                    )
-                    log.warning("Message not encrypted for us.")
-                else:
-                    log.debug("Message not encrypted for us.")
-
-                # No point in further processing this message.
-                return False
-            except Exception as e:
-                log.warning(_("Can't decrypt message: {reason}\n{xml}").format(
-                    reason=e,
-                    xml=message_elt.toXml()
-                ))
-                client.feedback(
-                    feedback_jid,
-                    D_(
-                        f"An OMEMO message from {sender_jid.full()} can't be decrypted:"
-                        f" {e}"
-                    ),
-                    { C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR }
-                )
-                # No point in further processing this message
-                return False
-
-        affix_values: Optional[SCEAffixValues] = None
-
-        if message.namespace == twomemo.twomemo.NAMESPACE:
-            if plaintext is not None:
-                # XEP_0420.unpack_stanza handles the whole unpacking, including the
-                # relevant modifications to the element
-                sce_profile = \
-                    OMEMO.SCE_PROFILE_GROUPCHAT if is_muc_message else OMEMO.SCE_PROFILE
-                try:
-                    affix_values = self.__xep_0420.unpack_stanza(
-                        sce_profile,
-                        message_elt,
-                        plaintext
-                    )
-                except Exception as e:
-                    log.warning(D_(
-                        f"Error unpacking SCE-encrypted message: {e}\n{plaintext}"
-                    ))
-                    client.feedback(
-                        feedback_jid,
-                        D_(
-                            f"An OMEMO message from {sender_jid.full()} was rejected:"
-                            f" {e}"
-                        ),
-                        { C.MESS_EXTRA_INFO: C.EXTRA_INFO_DECR_ERR }
-                    )
-                    # No point in further processing this message
-                    return False
-                else:
-                    if affix_values.timestamp is not None:
-                        # TODO: affix_values.timestamp contains the timestamp included in
-                        # the encrypted element here. The XEP says it SHOULD be displayed
-                        # with the plaintext by clients.
-                        pass
-
-        if message.namespace == oldmemo.oldmemo.NAMESPACE:
-            # Remove all body elements from the original element, since those act as
-            # fallbacks in case the encryption protocol is not supported
-            for child in message_elt.elements():
-                if child.name == "body":
-                    message_elt.children.remove(child)
-
-            if plaintext is not None:
-                # Add the decrypted body
-                message_elt.addElement("body", content=plaintext.decode("utf-8"))
-
-        # Mark the message as trusted or untrusted. Undecided counts as untrusted here.
-        trust_level = \
-            await session_manager._evaluate_custom_trust_level(device_information)
-
-        if trust_level is omemo.TrustLevel.TRUSTED:
-            post_treat.addCallback(client.encryption.mark_as_trusted)
-        else:
-            post_treat.addCallback(client.encryption.mark_as_untrusted)
-
-        # Mark the message as originally encrypted
-        post_treat.addCallback(
-            client.encryption.mark_as_encrypted,
-            namespace=message.namespace
-        )
-
-        # Handle potential ATM trust updates
-        if affix_values is not None and affix_values.timestamp is not None:
-            await self.__message_received_trigger_atm(
-                client,
-                message_elt,
-                session_manager,
-                device_information,
-                affix_values.timestamp
-            )
-
-        # Message processed successfully, continue with the flow
-        return True
-
-    async def __send_trigger(self, client: SatXMPPClient, stanza: domish.Element) -> bool:
-        """
-        @param client: The client sending this message.
-        @param stanza: The stanza that is about to be sent. Can be modified.
-        @return: Whether the send message flow should continue or not.
-        """
-        # SCE is only applicable to message and IQ stanzas
-        # FIXME: temporary disabling IQ stanza encryption
-        if stanza.name not in { "message" }:  # , "iq" }:
-            return True
-
-        # Get the intended recipient
-        recipient = stanza.getAttribute("to", None)
-        if recipient is None:
-            if stanza.name == "message":
-                # Message stanzas must have a recipient
-                raise exceptions.InternalError(
-                    f"Message without recipient encountered. Blocking further processing"
-                    f" to avoid leaking plaintext data: {stanza.toXml()}"
-                )
-
-            # IQs without a recipient are a thing, I believe those simply target the
-            # server and are thus not eligible for e2ee anyway.
-            return True
-
-        # Parse the JID
-        recipient_bare_jid = jid.JID(recipient).userhostJID()
-
-        # Check whether encryption with twomemo is requested
-        encryption = client.encryption.getSession(recipient_bare_jid)
-
-        if encryption is None:
-            # Encryption is not requested for this recipient
-            return True
-
-        if encryption["plugin"].namespace != twomemo.twomemo.NAMESPACE:
-            # Encryption is requested for this recipient, but not with twomemo
-            return True
-
-        # All pre-checks done, we can start encrypting!
-        await self.encrypt(
-            client,
-            twomemo.twomemo.NAMESPACE,
-            stanza,
-            recipient_bare_jid,
-            stanza.getAttribute("type", C.MESS_TYPE_NORMAL) == C.MESS_TYPE_GROUPCHAT,
-            stanza.getAttribute("id", None)
-        )
-
-        # Add a store hint if this is a message stanza
-        if stanza.name == "message":
-            self.__xep_0334.add_hint_elements(stanza, [ "store" ])
-
-        # Let the flow continue.
-        return True
-
-    async def __send_message_data_trigger(
-        self,
-        client: SatXMPPClient,
-        mess_data: MessageData
-    ) -> None:
-        """
-        @param client: The client sending this message.
-        @param mess_data: The message data that is about to be sent. Can be modified.
-        """
-
-        # Check whether encryption is requested for this message
-        try:
-            namespace = mess_data[C.MESS_KEY_ENCRYPTION]["plugin"].namespace
-        except KeyError:
-            return
-
-        # If encryption is requested, check whether it's oldmemo
-        if namespace != oldmemo.oldmemo.NAMESPACE:
-            return
-
-        # All pre-checks done, we can start encrypting!
-        stanza = mess_data["xml"]
-        recipient_jid = mess_data["to"]
-        is_muc_message = mess_data["type"] == C.MESS_TYPE_GROUPCHAT
-        stanza_id = mess_data["uid"]
-
-        await self.encrypt(
-            client,
-            oldmemo.oldmemo.NAMESPACE,
-            stanza,
-            recipient_jid,
-            is_muc_message,
-            stanza_id
-        )
-
-        # Add a store hint
-        self.__xep_0334.add_hint_elements(stanza, [ "store" ])
-
-    async def encrypt(
-        self,
-        client: SatXMPPClient,
-        namespace: Literal["urn:xmpp:omemo:2", "eu.siacs.conversations.axolotl"],
-        stanza: domish.Element,
-        recipient_jids: Union[jid.JID, Set[jid.JID]],
-        is_muc_message: bool,
-        stanza_id: Optional[str]
-    ) -> None:
-        """
-        @param client: The client.
-        @param namespace: The namespace of the OMEMO version to use.
-        @param stanza: The stanza. Twomemo will encrypt the whole stanza using SCE,
-            oldmemo will encrypt only the body. The stanza is modified by this call.
-        @param recipient_jid: The JID of the recipients.
-            Can be a bare (aka "userhost") JIDs but doesn't have to.
-            A single JID can be used.
-        @param is_muc_message: Whether the stanza is a message stanza to a MUC room.
-        @param stanza_id: The id of this stanza. Especially relevant for message stanzas
-            to MUC rooms such that the outgoing plaintext can be cached for MUC message
-            reflection handling.
-
-        @warning: The calling code MUST take care of adding the store message processing
-            hint to the stanza if applicable! This can be done before or after this call,
-            the order doesn't matter.
-        """
-        if isinstance(recipient_jids, jid.JID):
-            recipient_jids = {recipient_jids}
-        if not recipient_jids:
-            raise exceptions.InternalError("At least one JID must be specified")
-        recipient_jid = next(iter(recipient_jids))
-
-        muc_plaintext_cache_key: Optional[MUCPlaintextCacheKey] = None
-
-        recipient_bare_jids: Set[str]
-        feedback_jid: jid.JID
-
-        if is_muc_message:
-            if len(recipient_jids) != 1:
-                raise exceptions.InternalError(
-                    'Only one JID can be set when "is_muc_message" is set'
-                )
-            if self.__xep_0045 is None:
-                raise exceptions.InternalError(
-                    "Encryption of MUC message requested, but plugin XEP-0045 is not"
-                    " available."
-                )
-
-            if stanza_id is None:
-                raise exceptions.InternalError(
-                    "Encryption of MUC message requested, but stanza id not available."
-                )
-
-            room_jid = feedback_jid = recipient_jid.userhostJID()
-
-            recipient_bare_jids = self.__get_joined_muc_users(
-                client,
-                self.__xep_0045,
-                room_jid
-            )
-
-            muc_plaintext_cache_key = MUCPlaintextCacheKey(
-                client=client,
-                room_jid=room_jid,
-                message_uid=stanza_id
-            )
-        else:
-            recipient_bare_jids = {r.userhost() for r in recipient_jids}
-            feedback_jid = recipient_jid.userhostJID()
-
-        log.debug(
-            f"Intercepting message that is to be encrypted by {namespace} for"
-            f" {recipient_bare_jids}"
-        )
-
-        def prepare_stanza() -> Optional[bytes]:
-            """Prepares the stanza for encryption.
-
-            Does so by removing all parts that are not supposed to be sent in plain. Also
-            extracts/prepares the plaintext to encrypt.
-
-            @return: The plaintext to encrypt. Returns ``None`` in case body-only
-                encryption is requested and no body was found. The function should
-                gracefully return in that case, i.e. it's not a critical error that should
-                abort the message sending flow.
-            """
-
-            if namespace == twomemo.twomemo.NAMESPACE:
-                return self.__xep_0420.pack_stanza(
-                    OMEMO.SCE_PROFILE_GROUPCHAT if is_muc_message else OMEMO.SCE_PROFILE,
-                    stanza
-                )
-
-            if namespace == oldmemo.oldmemo.NAMESPACE:
-                plaintext: Optional[bytes] = None
-
-                for child in stanza.elements():
-                    if child.name == "body" and plaintext is None:
-                        plaintext = str(child).encode("utf-8")
-
-                    # Any other sensitive elements to remove here?
-                    if child.name in { "body", "html" }:
-                        stanza.children.remove(child)
-
-                if plaintext is None:
-                    log.warning(
-                        "No body found in intercepted message to be encrypted with"
-                        " oldmemo."
-                    )
-
-                return plaintext
-
-            return assert_never(namespace)
-
-        # The stanza/plaintext preparation was moved into its own little function for type
-        # safety reasons.
-        plaintext = prepare_stanza()
-        if plaintext is None:
-            return
-
-        log.debug(f"Plaintext to encrypt: {plaintext}")
-
-        session_manager = await self.get_session_manager(client.profile)
-
-        try:
-            messages, encryption_errors = await session_manager.encrypt(
-                frozenset(recipient_bare_jids),
-                { namespace: plaintext },
-                backend_priority_order=[ namespace ],
-                identifier=feedback_jid.userhost()
-            )
-        except Exception as e:
-            msg = _(
-                # pylint: disable=consider-using-f-string
-                "Can't encrypt message for {entities}: {reason}".format(
-                    entities=', '.join(recipient_bare_jids),
-                    reason=e
-                )
-            )
-            log.warning(msg)
-            client.feedback(feedback_jid, msg, {
-                C.MESS_EXTRA_INFO: C.EXTRA_INFO_ENCR_ERR
-            })
-            raise e
-
-        if len(encryption_errors) > 0:
-            log.warning(
-                f"Ignored the following non-critical encryption errors:"
-                f" {encryption_errors}"
-            )
-
-            encrypted_errors_stringified = ", ".join([
-                f"device {err.device_id} of {err.bare_jid} under namespace"
-                f" {err.namespace}"
-                for err
-                in encryption_errors
-            ])
-
-            client.feedback(
-                feedback_jid,
-                D_(
-                    "There were non-critical errors during encryption resulting in some"
-                    " of your destinees' devices potentially not receiving the message."
-                    " This happens when the encryption data/key material of a device is"
-                    " incomplete or broken, which shouldn't happen for actively used"
-                    " devices, and can usually be ignored. The following devices are"
-                    f" affected: {encrypted_errors_stringified}."
-                )
-            )
-
-        message = next(message for message in messages if message.namespace == namespace)
-
-        if namespace == twomemo.twomemo.NAMESPACE:
-            # Add the encrypted element
-            stanza.addChild(xml_tools.et_elt_2_domish_elt(
-                twomemo.etree.serialize_message(message)
-            ))
-
-        if namespace == oldmemo.oldmemo.NAMESPACE:
-            # Add the encrypted element
-            stanza.addChild(xml_tools.et_elt_2_domish_elt(
-                oldmemo.etree.serialize_message(message)
-            ))
-
-        if muc_plaintext_cache_key is not None:
-            self.__muc_plaintext_cache[muc_plaintext_cache_key] = plaintext
-
-    async def __on_device_list_update(
-        self,
-        items_event: pubsub.ItemsEvent,
-        profile: str
-    ) -> None:
-        """Handle device list updates fired by PEP.
-
-        @param items_event: The event.
-        @param profile: The profile this event belongs to.
-        """
-
-        sender = cast(jid.JID, items_event.sender)
-        items = cast(List[domish.Element], items_event.items)
-
-        if len(items) > 1:
-            log.warning("Ignoring device list update with more than one element.")
-            return
-
-        item = next(iter(items), None)
-        if item is None:
-            log.debug("Ignoring empty device list update.")
-            return
-
-        item_elt = xml_tools.domish_elt_2_et_elt(item)
-
-        device_list: Dict[int, Optional[str]] = {}
-        namespace: Optional[str] = None
-
-        list_elt = item_elt.find(f"{{{twomemo.twomemo.NAMESPACE}}}devices")
-        if list_elt is not None:
-            try:
-                device_list = twomemo.etree.parse_device_list(list_elt)
-            except XMLSchemaValidationError:
-                pass
-            else:
-                namespace = twomemo.twomemo.NAMESPACE
-
-        list_elt = item_elt.find(f"{{{oldmemo.oldmemo.NAMESPACE}}}list")
-        if list_elt is not None:
-            try:
-                device_list = oldmemo.etree.parse_device_list(list_elt)
-            except XMLSchemaValidationError:
-                pass
-            else:
-                namespace = oldmemo.oldmemo.NAMESPACE
-
-        if namespace is None:
-            log.warning(
-                f"Malformed device list update item:"
-                f" {ET.tostring(item_elt, encoding='unicode')}"
-            )
-            return
-
-        session_manager = await self.get_session_manager(profile)
-
-        await session_manager.update_device_list(
-            namespace,
-            sender.userhost(),
-            device_list
-        )
--- a/sat/plugins/plugin_xep_0391.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,295 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for Jingle Encrypted Transports
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from base64 import b64encode
-from functools import partial
-import io
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union
-
-from twisted.words.protocols.jabber import error, jid, xmlstream
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-from cryptography.exceptions import AlreadyFinalized
-from cryptography.hazmat import backends
-from cryptography.hazmat.primitives import ciphers
-from cryptography.hazmat.primitives.ciphers import Cipher, CipherContext, modes
-from cryptography.hazmat.primitives.padding import PKCS7, PaddingContext
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools import xml_tools
-
-try:
-    import oldmemo
-    import oldmemo.etree
-except ImportError as import_error:
-    raise exceptions.MissingModule(
-        "You are missing one or more package required by the OMEMO plugin. Please"
-        " download/install the pip packages 'oldmemo'."
-    ) from import_error
-
-
-log = getLogger(__name__)
-
-IMPORT_NAME = "XEP-0391"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Jingle Encrypted Transports",
-    C.PI_IMPORT_NAME: IMPORT_NAME,
-    C.PI_TYPE: C.PLUG_TYPE_XEP,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0391", "XEP-0396"],
-    C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0384"],
-    C.PI_MAIN: "JET",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""End-to-end encryption of Jingle transports"""),
-}
-
-NS_JET = "urn:xmpp:jingle:jet:0"
-NS_JET_OMEMO = "urn:xmpp:jingle:jet-omemo:0"
-
-
-class JET:
-    namespace = NS_JET
-
-    def __init__(self, host):
-        log.info(_("XEP-0391 (Pubsub Attachments) plugin initialization"))
-        host.register_namespace("jet", NS_JET)
-        self.host = host
-        self._o = host.plugins["XEP-0384"]
-        self._j = host.plugins["XEP-0166"]
-        host.trigger.add(
-            "XEP-0166_initiate_elt_built",
-            self._on_initiate_elt_build
-        )
-        host.trigger.add(
-            "XEP-0166_on_session_initiate",
-            self._on_session_initiate
-        )
-        host.trigger.add(
-            "XEP-0234_jingle_handler",
-            self._add_encryption_filter
-        )
-        host.trigger.add(
-            "XEP-0234_file_receiving_request_conf",
-            self._add_encryption_filter
-        )
-
-    def get_handler(self, client):
-        return JET_Handler()
-
-    async def _on_initiate_elt_build(
-        self,
-        client: SatXMPPEntity,
-        session: Dict[str, Any],
-        iq_elt: domish.Element,
-        jingle_elt: domish.Element
-    ) -> bool:
-        if client.encryption.get_namespace(
-               session["peer_jid"].userhostJID()
-           ) != self._o.NS_OLDMEMO:
-            return True
-        for content_elt in jingle_elt.elements(self._j.namespace, "content"):
-            content_data = session["contents"][content_elt["name"]]
-            security_elt = content_elt.addElement((NS_JET, "security"))
-            security_elt["name"] = content_elt["name"]
-            # XXX: for now only OLDMEMO is supported, thus we do it directly here. If some
-            #   other are supported in the future, a plugin registering mechanism will be
-            #   implemented.
-            cipher = "urn:xmpp:ciphers:aes-128-gcm-nopadding"
-            enc_type = "eu.siacs.conversations.axolotl"
-            security_elt["cipher"] = cipher
-            security_elt["type"] = enc_type
-            encryption_data = content_data["encryption"] = {
-                "cipher": cipher,
-                "type": enc_type
-            }
-            session_manager = await self._o.get_session_manager(client.profile)
-            try:
-                messages, encryption_errors = await session_manager.encrypt(
-                    frozenset({session["peer_jid"].userhost()}),
-                    # the value seems to be the commonly used value
-                    { self._o.NS_OLDMEMO: b" " },
-                    backend_priority_order=[ self._o.NS_OLDMEMO ],
-                    identifier = client.jid.userhost()
-                )
-            except Exception as e:
-                log.error("Can't generate IV and keys: {e}")
-                raise e
-            message, plain_key_material = next(iter(messages.items()))
-            iv, key = message.content.initialization_vector, plain_key_material.key
-            content_data["encryption"].update({
-                "iv": iv,
-                "key": key
-            })
-            encrypted_elt = xml_tools.et_elt_2_domish_elt(
-                oldmemo.etree.serialize_message(message)
-            )
-            security_elt.addChild(encrypted_elt)
-        return True
-
-    async def _on_session_initiate(
-        self,
-        client: SatXMPPEntity,
-        session: Dict[str, Any],
-        iq_elt: domish.Element,
-        jingle_elt: domish.Element
-    ) -> bool:
-        if client.encryption.get_namespace(
-               session["peer_jid"].userhostJID()
-           ) != self._o.NS_OLDMEMO:
-            return True
-        for content_elt in jingle_elt.elements(self._j.namespace, "content"):
-            content_data = session["contents"][content_elt["name"]]
-            security_elt = next(content_elt.elements(NS_JET, "security"), None)
-            if security_elt is None:
-                continue
-            encrypted_elt = next(
-                security_elt.elements(self._o.NS_OLDMEMO, "encrypted"), None
-            )
-            if encrypted_elt is None:
-                log.warning(
-                    "missing <encrypted> element, can't decrypt: {security_elt.toXml()}"
-                )
-                continue
-            session_manager = await self._o.get_session_manager(client.profile)
-            try:
-                message = await oldmemo.etree.parse_message(
-                    xml_tools.domish_elt_2_et_elt(encrypted_elt, False),
-                    session["peer_jid"].userhost(),
-                    client.jid.userhost(),
-                    session_manager
-                )
-                __, __, plain_key_material = await session_manager.decrypt(message)
-            except Exception as e:
-                log.warning(f"Can't get IV and key: {e}\n{security_elt.toXml()}")
-                continue
-            try:
-                content_data["encryption"] = {
-                    "cipher": security_elt["cipher"],
-                    "type": security_elt["type"],
-                    "iv": message.content.initialization_vector,
-                    "key": plain_key_material.key
-                }
-            except KeyError as e:
-                log.warning(f"missing data, can't decrypt: {e}")
-                continue
-
-        return True
-
-    def __encrypt(
-        self,
-        data: bytes,
-        encryptor: CipherContext,
-        data_cb: Callable
-    ) -> bytes:
-        data_cb(data)
-        if data:
-            return encryptor.update(data)
-        else:
-            try:
-                return encryptor.finalize() + encryptor.tag
-            except AlreadyFinalized:
-                return b''
-
-    def __decrypt(
-        self,
-        data: bytes,
-        buffer: list[bytes],
-        decryptor: CipherContext,
-        data_cb: Callable
-    ) -> bytes:
-        buffer.append(data)
-        data = b''.join(buffer)
-        buffer.clear()
-        if len(data) > 16:
-            decrypted = decryptor.update(data[:-16])
-            data_cb(decrypted)
-        else:
-            decrypted = b''
-        buffer.append(data[-16:])
-        return decrypted
-
-    def __decrypt_finalize(
-        self,
-        file_obj: io.BytesIO,
-        buffer: list[bytes],
-        decryptor: CipherContext,
-    ) -> None:
-        tag = b''.join(buffer)
-        file_obj.write(decryptor.finalize_with_tag(tag))
-
-    async def _add_encryption_filter(
-        self,
-        client: SatXMPPEntity,
-        session: Dict[str, Any],
-        content_data: Dict[str, Any],
-        elt: domish.Element
-    ) -> bool:
-        file_obj = content_data["stream_object"].file_obj
-        try:
-            encryption_data=content_data["encryption"]
-        except KeyError:
-            return True
-        cipher = ciphers.Cipher(
-            ciphers.algorithms.AES(encryption_data["key"]),
-            modes.GCM(encryption_data["iv"]),
-            backend=backends.default_backend(),
-        )
-        if file_obj.mode == "wb":
-            # we are receiving a file
-            buffer = []
-            decryptor = cipher.decryptor()
-            file_obj.pre_close_cb = partial(
-                self.__decrypt_finalize,
-                file_obj=file_obj,
-                buffer=buffer,
-                decryptor=decryptor
-            )
-            file_obj.data_cb = partial(
-                self.__decrypt,
-                buffer=buffer,
-                decryptor=decryptor,
-                data_cb=file_obj.data_cb
-            )
-        else:
-            # we are sending a file
-            file_obj.data_cb = partial(
-                self.__encrypt,
-                encryptor=cipher.encryptor(),
-                data_cb=file_obj.data_cb
-            )
-
-        return True
-
-
-@implementer(iwokkel.IDisco)
-class JET_Handler(xmlstream.XMPPHandler):
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [
-            disco.DiscoFeature(NS_JET),
-            disco.DiscoFeature(NS_JET_OMEMO),
-        ]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0420.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,578 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for Stanza Content Encryption
-# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from abc import ABC, abstractmethod
-from datetime import datetime
-import enum
-import secrets
-import string
-from typing import Dict, NamedTuple, Optional, Set, Tuple, cast
-from typing_extensions import Final
-
-from lxml import etree
-from sat.core import exceptions
-
-from sat.core.constants import Const as C
-from sat.core.i18n import D_
-from sat.core.log import Logger, getLogger
-from sat.core.sat_main import SAT
-from sat.tools.xml_tools import ElementParser
-from sat.plugins.plugin_xep_0033 import NS_ADDRESS
-from sat.plugins.plugin_xep_0082 import XEP_0082
-from sat.plugins.plugin_xep_0334 import NS_HINTS
-from sat.plugins.plugin_xep_0359 import NS_SID
-from sat.plugins.plugin_xep_0380 import NS_EME
-from twisted.words.protocols.jabber import jid
-from twisted.words.xish import domish
-
-
-__all__ = [  # pylint: disable=unused-variable
-    "PLUGIN_INFO",
-    "NS_SCE",
-    "XEP_0420",
-    "ProfileRequirementsNotMet",
-    "AffixVerificationFailed",
-    "SCECustomAffix",
-    "SCEAffixPolicy",
-    "SCEProfile",
-    "SCEAffixValues"
-]
-
-
-log = cast(Logger, getLogger(__name__))  # type: ignore[no-untyped-call]
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "SCE",
-    C.PI_IMPORT_NAME: "XEP-0420",
-    C.PI_TYPE: "SEC",
-    C.PI_PROTOCOLS: [ "XEP-0420" ],
-    C.PI_DEPENDENCIES: [ "XEP-0334", "XEP-0082" ],
-    C.PI_RECOMMENDATIONS: [ "XEP-0045", "XEP-0033", "XEP-0359" ],
-    C.PI_MAIN: "XEP_0420",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: D_("Implementation of Stanza Content Encryption"),
-}
-
-
-NS_SCE: Final = "urn:xmpp:sce:1"
-
-
-class ProfileRequirementsNotMet(Exception):
-    """
-    Raised by :meth:`XEP_0420.unpack_stanza` in case the requirements formulated by the
-    profile are not met.
-    """
-
-
-class AffixVerificationFailed(Exception):
-    """
-    Raised by :meth:`XEP_0420.unpack_stanza` in case of affix verification failure.
-    """
-
-
-class SCECustomAffix(ABC):
-    """
-    Interface for custom affixes of SCE profiles.
-    """
-
-    @property
-    @abstractmethod
-    def element_name(self) -> str:
-        """
-        @return: The name of the affix's XML element.
-        """
-
-    @property
-    @abstractmethod
-    def element_schema(self) -> str:
-        """
-        @return: The XML schema definition of the affix element's XML structure, i.e. the
-            ``<xs:element/>`` schema element. This element will be referenced using
-            ``<xs:element ref="{element_name}"/>``.
-        """
-
-    @abstractmethod
-    def create(self, stanza: domish.Element) -> domish.Element:
-        """
-        @param stanza: The stanza element which has been processed by
-            :meth:`XEP_0420.pack_stanza`, i.e. all encryptable children have been removed
-            and only the root ``<message/>`` or ``<iq/>`` and unencryptable children
-            remain. Do not modify.
-        @return: An affix element to include in the envelope. The element must have the
-            name :attr:`element_name` and must validate using :attr:`element_schema`.
-        @raise ValueError: if the affix couldn't be built due to missing information on
-            the stanza.
-        """
-
-    @abstractmethod
-    def verify(self, stanza: domish.Element, element: domish.Element) -> None:
-        """
-        @param stanza: The stanza element before being processed by
-            :meth:`XEP_0420.unpack_stanza`, i.e. all encryptable children have been
-            removed and only the root ``<message/>`` or ``<iq/>`` and unencryptable
-            children remain. Do not modify.
-        @param element: The affix element to verify.
-        @raise AffixVerificationFailed: on verification failure.
-        """
-
-
-@enum.unique
-class SCEAffixPolicy(enum.Enum):
-    """
-    Policy for the presence of an affix in an SCE envelope.
-    """
-
-    REQUIRED: str = "REQUIRED"
-    OPTIONAL: str = "OPTIONAL"
-    NOT_NEEDED: str = "NOT_NEEDED"
-
-
-class SCEProfile(NamedTuple):
-    # pylint: disable=invalid-name
-    """
-    An SCE profile, i.e. the definition which affixes are required, optional or not needed
-    at all by an SCE-enabled encryption protocol.
-    """
-
-    rpad_policy: SCEAffixPolicy
-    time_policy: SCEAffixPolicy
-    to_policy: SCEAffixPolicy
-    from_policy: SCEAffixPolicy
-    custom_policies: Dict[SCECustomAffix, SCEAffixPolicy]
-
-
-class SCEAffixValues(NamedTuple):
-    # pylint: disable=invalid-name
-    """
-    Structure returned by :meth:`XEP_0420.unpack_stanza` with the parsed/processes values
-    of all affixes included in the envelope. For custom affixes, the whole affix element
-    is returned.
-    """
-
-    rpad: Optional[str]
-    timestamp: Optional[datetime]
-    recipient: Optional[jid.JID]
-    sender: Optional[jid.JID]
-    custom: Dict[SCECustomAffix, domish.Element]
-
-
-ENVELOPE_SCHEMA = """<?xml version="1.0" encoding="utf8"?>
-<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
-    targetNamespace="urn:xmpp:sce:1"
-    xmlns="urn:xmpp:sce:1">
-
-    <xs:element name="envelope">
-        <xs:complexType>
-            <xs:all>
-                <xs:element ref="content"/>
-                <xs:element ref="rpad" minOccurs="0"/>
-                <xs:element ref="time" minOccurs="0"/>
-                <xs:element ref="to" minOccurs="0"/>
-                <xs:element ref="from" minOccurs="0"/>
-                {custom_affix_references}
-            </xs:all>
-        </xs:complexType>
-    </xs:element>
-
-    <xs:element name="content">
-        <xs:complexType>
-            <xs:sequence>
-                <xs:any minOccurs="0" maxOccurs="unbounded" processContents="skip"/>
-            </xs:sequence>
-        </xs:complexType>
-    </xs:element>
-
-    <xs:element name="rpad" type="xs:string"/>
-
-    <xs:element name="time">
-        <xs:complexType>
-            <xs:attribute name="stamp" type="xs:dateTime"/>
-        </xs:complexType>
-    </xs:element>
-
-    <xs:element name="to">
-        <xs:complexType>
-            <xs:attribute name="jid" type="xs:string"/>
-        </xs:complexType>
-    </xs:element>
-
-    <xs:element name="from">
-        <xs:complexType>
-            <xs:attribute name="jid" type="xs:string"/>
-        </xs:complexType>
-    </xs:element>
-
-    {custom_affix_definitions}
-</xs:schema>
-"""
-
-
-class XEP_0420:  # pylint: disable=invalid-name
-    """
-    Implementation of XEP-0420: Stanza Content Encryption under namespace
-    ``urn:xmpp:sce:1``.
-
-    This is a passive plugin, i.e. it doesn't hook into any triggers to process stanzas
-    actively, but offers API for other plugins to use.
-    """
-
-    # Set of namespaces whose elements are never allowed to be transferred in an encrypted
-    # envelope.
-    MUST_BE_PLAINTEXT_NAMESPACES: Set[str] = {
-        NS_HINTS,
-        NS_SID,  # TODO: Not sure whether this ban applies to both stanza-id and origin-id
-        NS_ADDRESS,
-        # Not part of the specification (yet), but just doesn't make sense in an encrypted
-        # envelope:
-        NS_EME
-    }
-
-    # Set of (namespace, element name) tuples that define elements which are never allowed
-    # to be transferred in an encrypted envelope. If all elements under a certain
-    # namespace are forbidden, the namespace can be added to
-    # :attr:`MUST_BE_PLAINTEXT_NAMESPACES` instead.
-    # Note: only full namespaces are forbidden by the spec for now, the following is for
-    # potential future use.
-    MUST_BE_PLAINTEXT_ELEMENTS: Set[Tuple[str, str]] = set()
-
-    def __init__(self, sat: SAT) -> None:
-        """
-        @param sat: The SAT instance.
-        """
-
-    @staticmethod
-    def pack_stanza(profile: SCEProfile, stanza: domish.Element) -> bytes:
-        """Pack a stanza according to Stanza Content Encryption.
-
-        Removes all elements from the stanza except for a few exceptions that explicitly
-        need to be transferred in plaintext, e.g. because they contain hints/instructions
-        for the server on how to process the stanza. Together with the affix elements as
-        requested by the profile, the removed elements are added to an envelope XML
-        structure that builds the plaintext to be encrypted by the SCE-enabled encryption
-        scheme. Optional affixes are always added to the structure, i.e. they are treated
-        by the packing code as if they were required.
-
-        Once built, the envelope structure is serialized to a byte string and returned for
-        the encryption scheme to encrypt and add to the stanza.
-
-        @param profile: The SCE profile, i.e. the definition of affixes to include in the
-            envelope.
-        @param stanza: The stanza to process. Will be modified by the call.
-        @return: The serialized envelope structure that builds the plaintext for the
-            encryption scheme to process.
-        @raise ValueError: if the <to/> or <from/> affixes are requested but the stanza
-            doesn't have the "to"/"from" attribute set to extract the value from. Can also
-            be raised by custom affixes.
-
-        @warning: It is up to the calling code to add a <store/> message processing hint
-            if applicable.
-        """
-
-        # Prepare the envelope and content elements
-        envelope = domish.Element((NS_SCE, "envelope"))
-        content = envelope.addElement((NS_SCE, "content"))
-
-        # Note the serialized byte size of the content element before adding any children
-        empty_content_byte_size = len(content.toXml().encode("utf-8"))
-
-        # Move elements that are not explicitly forbidden from being encrypted from the
-        # stanza to the content element.
-        for child in list(stanza.elements()):
-            if (
-                child.uri not in XEP_0420.MUST_BE_PLAINTEXT_NAMESPACES
-                and (child.uri, child.name) not in XEP_0420.MUST_BE_PLAINTEXT_ELEMENTS
-            ):
-                # Remove the child from the stanza
-                stanza.children.remove(child)
-
-                # A namespace of ``None`` can be used on domish elements to inherit the
-                # namespace from the parent. When moving elements from the stanza root to
-                # the content element, however, we don't want elements to inherit the
-                # namespace of the content element. Thus, check for elements with ``None``
-                # for their namespace and set the namespace to jabber:client, which is the
-                # namespace of the parent element.
-                if child.uri is None:
-                    child.uri = C.NS_CLIENT
-                    child.defaultUri = C.NS_CLIENT
-
-                # Add the child with corrected namespaces to the content element
-                content.addChild(child)
-
-        # Add the affixes requested by the profile
-        if profile.rpad_policy is not SCEAffixPolicy.NOT_NEEDED:
-            # The specification defines the rpad affix to contain "[...] a randomly
-            # generated sequence of random length between 0 and 200 characters." This
-            # implementation differs a bit from the specification in that a minimum size
-            # other than 0 is chosen depending on the serialized size of the content
-            # element. This is to prevent the scenario where the encrypted content is
-            # short and the rpad is also randomly chosen to be short, which could allow
-            # guessing the content of a short message. To do so, the rpad length is first
-            # chosen to pad the content to at least 53 bytes, then afterwards another 0 to
-            # 200 bytes are added. Note that single-byte characters are used by this
-            # implementation, thus the number of characters equals the number of bytes.
-            content_byte_size = len(content.toXml().encode("utf-8"))
-            content_byte_size_diff = content_byte_size - empty_content_byte_size
-            rpad_length = max(0, 53 - content_byte_size_diff) + secrets.randbelow(201)
-            rpad_content = "".join(
-                secrets.choice(string.digits + string.ascii_letters + string.punctuation)
-                for __
-                in range(rpad_length)
-            )
-            envelope.addElement((NS_SCE, "rpad"), content=rpad_content)
-
-        if profile.time_policy is not SCEAffixPolicy.NOT_NEEDED:
-            time_element = envelope.addElement((NS_SCE, "time"))
-            time_element["stamp"] = XEP_0082.format_datetime()
-
-        if profile.to_policy is not SCEAffixPolicy.NOT_NEEDED:
-            recipient = stanza.getAttribute("to", None)
-            if recipient is not None:
-                to_element = envelope.addElement((NS_SCE, "to"))
-                to_element["jid"] = jid.JID(recipient).userhost()
-            elif profile.to_policy is SCEAffixPolicy.REQUIRED:
-                raise ValueError(
-                    "<to/> affix requested, but stanza doesn't have the 'to' attribute"
-                    " set."
-                )
-
-        if profile.from_policy is not SCEAffixPolicy.NOT_NEEDED:
-            sender = stanza.getAttribute("from", None)
-            if sender is not None:
-                from_element = envelope.addElement((NS_SCE, "from"))
-                from_element["jid"] = jid.JID(sender).userhost()
-            elif profile.from_policy is SCEAffixPolicy.REQUIRED:
-                raise ValueError(
-                    "<from/> affix requested, but stanza doesn't have the 'from'"
-                    " attribute set."
-                )
-
-        for affix, policy in profile.custom_policies.items():
-            if policy is not SCEAffixPolicy.NOT_NEEDED:
-                envelope.addChild(affix.create(stanza))
-
-        return envelope.toXml().encode("utf-8")
-
-    @staticmethod
-    def unpack_stanza(
-        profile: SCEProfile,
-        stanza: domish.Element,
-        envelope_serialized: bytes
-    ) -> SCEAffixValues:
-        """Unpack a stanza packed according to Stanza Content Encryption.
-
-        Parses the serialized envelope as XML, verifies included affixes and makes sure
-        the requirements of the profile are met, and restores the stanza by moving
-        decrypted elements from the envelope back to the stanza top level.
-
-        @param profile: The SCE profile, i.e. the definition of affixes that have to/may
-            be included in the envelope.
-        @param stanza: The stanza to process. Will be modified by the call.
-        @param envelope_serialized: The serialized envelope, i.e. the plaintext produced
-            by the decryption scheme utilizing SCE.
-        @return: The parsed and processed values of all affixes that were present on the
-            envelope, notably including the timestamp.
-        @raise exceptions.ParsingError: if the serialized envelope element is malformed.
-        @raise ProfileRequirementsNotMet: if one or more affixes required by the profile
-            are missing from the envelope.
-        @raise AffixVerificationFailed: if an affix included in the envelope fails to
-            validate. It doesn't matter whether the affix is required by the profile or
-            not, all affixes included in the envelope are validated and cause this
-            exception to be raised on failure.
-
-        @warning: It is up to the calling code to verify the timestamp, if returned, since
-            the requirements on the timestamp may vary between SCE-enabled protocols.
-        """
-
-        try:
-            envelope_serialized_string = envelope_serialized.decode("utf-8")
-        except UnicodeError as e:
-            raise exceptions.ParsingError(
-                "Serialized envelope can't bare parsed as utf-8."
-            ) from e
-
-        custom_affixes = set(profile.custom_policies.keys())
-
-        # Make sure the envelope adheres to the schema
-        parser = etree.XMLParser(schema=etree.XMLSchema(etree.XML(ENVELOPE_SCHEMA.format(
-            custom_affix_references="".join(
-                f'<xs:element ref="{custom_affix.element_name}" minOccurs="0"/>'
-                for custom_affix
-                in custom_affixes
-            ),
-            custom_affix_definitions="".join(
-                custom_affix.element_schema
-                for custom_affix
-                in custom_affixes
-            )
-        ).encode("utf-8"))))
-
-        try:
-            etree.fromstring(envelope_serialized_string, parser)
-        except etree.XMLSyntaxError as e:
-            raise exceptions.ParsingError(
-                "Serialized envelope doesn't pass schema validation."
-            ) from e
-
-        # Prepare the envelope and content elements
-        envelope = cast(domish.Element, ElementParser()(envelope_serialized_string))
-        content = next(envelope.elements(NS_SCE, "content"))
-
-        # Verify the affixes
-        rpad_element = cast(
-            Optional[domish.Element],
-            next(envelope.elements(NS_SCE, "rpad"), None)
-        )
-        time_element = cast(
-            Optional[domish.Element],
-            next(envelope.elements(NS_SCE, "time"), None)
-        )
-        to_element = cast(
-            Optional[domish.Element],
-            next(envelope.elements(NS_SCE, "to"), None)
-        )
-        from_element = cast(
-            Optional[domish.Element],
-            next(envelope.elements(NS_SCE, "from"), None)
-        )
-
-        # The rpad doesn't need verification.
-        rpad_value = None if rpad_element is None else str(rpad_element)
-
-        # The time affix isn't verified other than that the timestamp is parseable.
-        try:
-            timestamp_value = None if time_element is None else \
-                XEP_0082.parse_datetime(time_element["stamp"])
-        except ValueError as e:
-            raise AffixVerificationFailed("Malformed time affix.") from e
-
-        # The to affix is verified by comparing the to attribute of the stanza with the
-        # JID referenced by the affix. Note that only bare JIDs are compared as per the
-        # specification.
-        recipient_value: Optional[jid.JID] = None
-        if to_element is not None:
-            recipient_value = jid.JID(to_element["jid"])
-
-            recipient_actual = stanza.getAttribute("to", None)
-            if recipient_actual is None:
-                raise AffixVerificationFailed(
-                    "'To' affix is included in the envelope, but the stanza is lacking a"
-                    " 'to' attribute to compare the value to."
-                )
-
-            recipient_actual_bare_jid = jid.JID(recipient_actual).userhost()
-            recipient_target_bare_jid = recipient_value.userhost()
-
-            if recipient_actual_bare_jid != recipient_target_bare_jid:
-                raise AffixVerificationFailed(
-                    f"Mismatch between actual and target recipient bare JIDs:"
-                    f" {recipient_actual_bare_jid} vs {recipient_target_bare_jid}."
-                )
-
-        # The from affix is verified by comparing the from attribute of the stanza with
-        # the JID referenced by the affix. Note that only bare JIDs are compared as per
-        # the specification.
-        sender_value: Optional[jid.JID] = None
-        if from_element is not None:
-            sender_value = jid.JID(from_element["jid"])
-
-            sender_actual = stanza.getAttribute("from", None)
-            if sender_actual is None:
-                raise AffixVerificationFailed(
-                    "'From' affix is included in the envelope, but the stanza is lacking"
-                    " a 'from' attribute to compare the value to."
-                )
-
-            sender_actual_bare_jid = jid.JID(sender_actual).userhost()
-            sender_target_bare_jid = sender_value.userhost()
-
-            if sender_actual_bare_jid != sender_target_bare_jid:
-                raise AffixVerificationFailed(
-                    f"Mismatch between actual and target sender bare JIDs:"
-                    f" {sender_actual_bare_jid} vs {sender_target_bare_jid}."
-                )
-
-        # Find and verify custom affixes
-        custom_values: Dict[SCECustomAffix, domish.Element] = {}
-        for affix in custom_affixes:
-            element_name = affix.element_name
-            element = cast(
-                Optional[domish.Element],
-                next(envelope.elements(NS_SCE, element_name), None)
-            )
-            if element is not None:
-                affix.verify(stanza, element)
-                custom_values[affix] = element
-
-        # Check whether all affixes required by the profile are present
-        rpad_missing = \
-            profile.rpad_policy is SCEAffixPolicy.REQUIRED and rpad_element is None
-        time_missing = \
-            profile.time_policy is SCEAffixPolicy.REQUIRED and time_element is None
-        to_missing = \
-            profile.to_policy is SCEAffixPolicy.REQUIRED and to_element is None
-        from_missing = \
-            profile.from_policy is SCEAffixPolicy.REQUIRED and from_element is None
-        custom_missing = any(
-            affix not in custom_values
-            for affix, policy
-            in profile.custom_policies.items()
-            if policy is SCEAffixPolicy.REQUIRED
-        )
-
-        if rpad_missing or time_missing or to_missing or from_missing or custom_missing:
-            custom_missing_string = ""
-            for custom_affix in custom_affixes:
-                value = "present" if custom_affix in custom_values else "missing"
-                custom_missing_string += f", [custom]{custom_affix.element_name}={value}"
-
-            raise ProfileRequirementsNotMet(
-                f"SCE envelope is missing affixes required by the profile {profile}."
-                f" Affix presence:"
-                f" rpad={'missing' if rpad_missing else 'present'}"
-                f", time={'missing' if time_missing else 'present'}"
-                f", to={'missing' if to_missing else 'present'}"
-                f", from={'missing' if from_missing else 'present'}"
-                + custom_missing_string
-            )
-
-        # Move elements that are not explicitly forbidden from being encrypted from the
-        # content element to the stanza.
-        for child in list(content.elements()):
-            if (
-                child.uri in XEP_0420.MUST_BE_PLAINTEXT_NAMESPACES
-                or (child.uri, child.name) in XEP_0420.MUST_BE_PLAINTEXT_ELEMENTS
-            ):
-                log.warning(
-                    f"An element that MUST be transferred in plaintext was found in an"
-                    f" SCE envelope: {child.toXml()}"
-                )
-            else:
-                # Remove the child from the content element
-                content.children.remove(child)
-
-                # Add the child to the stanza
-                stanza.addChild(child)
-
-        return SCEAffixValues(
-            rpad_value,
-            timestamp_value,
-            recipient_value,
-            sender_value,
-            custom_values
-        )
--- a/sat/plugins/plugin_xep_0422.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,161 +0,0 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional, List, Tuple, Union, NamedTuple
-from collections import namedtuple
-
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.xish import domish
-from wokkel import disco
-from zope.interface import implementer
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.core_types import SatXMPPEntity
-from sat.memory.sqla_mapping import History
-from sat.tools.common.async_utils import async_lru
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Message Fastening",
-    C.PI_IMPORT_NAME: "XEP-0422",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0359", "XEP-0422"],
-    C.PI_MAIN: "XEP_0422",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation Message Fastening"""),
-}
-
-NS_FASTEN = "urn:xmpp:fasten:0"
-
-
-class FastenMetadata(NamedTuple):
-    elements: List[domish.Element]
-    id: str
-    history: Optional[History]
-    clear: bool
-    shell: bool
-
-
-class XEP_0422(object):
-
-    def __init__(self, host):
-        log.info(_("XEP-0422 (Message Fastening) plugin initialization"))
-        self.host = host
-        host.register_namespace("fasten", NS_FASTEN)
-
-    def get_handler(self, __):
-        return XEP_0422_handler()
-
-    def apply_to_elt(
-        self,
-        message_elt: domish.Element,
-        origin_id: str,
-        clear: Optional[bool] = None,
-        shell: Optional[bool] = None,
-        children: Optional[List[domish.Element]] = None,
-        external: Optional[List[Union[str, Tuple[str, str]]]] = None
-    ) -> domish.Element:
-        """Generate, add and return <apply-to> element
-
-        @param message_elt: wrapping <message> element
-        @param origin_id: origin ID of the target message
-        @param clear: set to True to remove a fastening
-        @param shell: set to True when using e2ee shell
-            cf. https://xmpp.org/extensions/xep-0422.html#encryption
-        @param children: element to fasten to the target message
-            <apply-to> element is returned, thus children can also easily be added
-            afterwards
-        @param external: <external> element to add
-            cf. https://xmpp.org/extensions/xep-0422.html#external-payloads
-            the list items can either be a str with only the element name,
-            or a tuple which must then be (namespace, name)
-        @return: <apply-to> element, which is already added to the wrapping message_elt
-        """
-        apply_to_elt = message_elt.addElement((NS_FASTEN, "apply-to"))
-        apply_to_elt["id"] = origin_id
-        if clear is not None:
-            apply_to_elt["clear"] = C.bool_const(clear)
-        if shell is not None:
-            apply_to_elt["shell"] = C.bool_const(shell)
-        if children is not None:
-            for child in children:
-                apply_to_elt.addChild(child)
-        if external is not None:
-            for ext in external:
-                external_elt = apply_to_elt.addElement("external")
-                if isinstance(ext, str):
-                    external_elt["name"] = ext
-                else:
-                    ns, name = ext
-                    external_elt["name"] = name
-                    external_elt["element-namespace"] = ns
-        return apply_to_elt
-
-    @async_lru(maxsize=5)
-    async def get_fastened_elts(
-        self,
-        client: SatXMPPEntity,
-        message_elt: domish.Element
-    ) -> Optional[FastenMetadata]:
-        """Get fastened elements
-
-        if the message contains no <apply-to> element, None is returned
-        """
-        try:
-            apply_to_elt = next(message_elt.elements(NS_FASTEN, "apply-to"))
-        except StopIteration:
-            return None
-        else:
-            origin_id = apply_to_elt.getAttribute("id")
-            if not origin_id:
-                log.warning(
-                    f"Received invalid fastening message: {message_elt.toXml()}"
-                )
-                return None
-            elements = apply_to_elt.children
-            if not elements:
-                log.warning(f"No element to fasten: {message_elt.toXml()}")
-                return None
-            history = await self.host.memory.storage.get(
-                client,
-                History,
-                History.origin_id,
-                origin_id,
-                (History.messages, History.subjects, History.thread)
-            )
-            return FastenMetadata(
-                elements,
-                origin_id,
-                history,
-                C.bool(apply_to_elt.getAttribute("clear", C.BOOL_FALSE)),
-                C.bool(apply_to_elt.getAttribute("shell", C.BOOL_FALSE)),
-            )
-
-
-@implementer(disco.IDisco)
-class XEP_0422_handler(xmlstream.XMPPHandler):
-
-    def getDiscoInfo(self, __, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_FASTEN)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0424.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,246 +0,0 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Dict, Any
-import time
-from copy import deepcopy
-
-from twisted.words.protocols.jabber import xmlstream, jid
-from twisted.words.xish import domish
-from twisted.internet import defer
-from wokkel import disco
-from zope.interface import implementer
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _, D_
-from sat.core import exceptions
-from sat.core.core_types import SatXMPPEntity
-from sat.core.log import getLogger
-from sat.memory.sqla_mapping import History
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Message Retraction",
-    C.PI_IMPORT_NAME: "XEP-0424",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0334", "XEP-0424", "XEP-0428"],
-    C.PI_DEPENDENCIES: ["XEP-0422"],
-    C.PI_MAIN: "XEP_0424",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation Message Retraction"""),
-}
-
-NS_MESSAGE_RETRACT = "urn:xmpp:message-retract:0"
-
-CATEGORY = "Privacy"
-NAME = "retract_history"
-LABEL = D_("Keep History of Retracted Messages")
-PARAMS = """
-    <params>
-    <individual>
-    <category name="{category_name}">
-        <param name="{name}" label="{label}" type="bool" value="false" />
-    </category>
-    </individual>
-    </params>
-    """.format(
-    category_name=CATEGORY, name=NAME, label=_(LABEL)
-)
-
-
-class XEP_0424(object):
-
-    def __init__(self, host):
-        log.info(_("XEP-0424 (Message Retraction) plugin initialization"))
-        self.host = host
-        host.memory.update_params(PARAMS)
-        self._h = host.plugins["XEP-0334"]
-        self._f = host.plugins["XEP-0422"]
-        host.register_namespace("message-retract", NS_MESSAGE_RETRACT)
-        host.trigger.add("message_received", self._message_received_trigger, 100)
-        host.bridge.add_method(
-            "message_retract",
-            ".plugin",
-            in_sign="ss",
-            out_sign="",
-            method=self._retract,
-            async_=True,
-        )
-
-    def get_handler(self, __):
-        return XEP_0424_handler()
-
-    def _retract(self, message_id: str, profile: str) -> None:
-        client = self.host.get_client(profile)
-        return defer.ensureDeferred(
-            self.retract(client, message_id)
-        )
-
-    def retract_by_origin_id(
-        self,
-        client: SatXMPPEntity,
-        dest_jid: jid.JID,
-        origin_id: str
-    ) -> None:
-        """Send a message retraction using origin-id
-
-        [retract] should be prefered: internal ID should be used as it is independant of
-        XEPs changes. However, in some case messages may not be stored in database
-        (notably for some components), and then this method can be used
-        @param origin_id: origin-id as specified in XEP-0359
-        """
-        message_elt = domish.Element((None, "message"))
-        message_elt["from"] = client.jid.full()
-        message_elt["to"] = dest_jid.full()
-        apply_to_elt = self._f.apply_to_elt(message_elt, origin_id)
-        apply_to_elt.addElement((NS_MESSAGE_RETRACT, "retract"))
-        self.host.plugins["XEP-0428"].add_fallback_elt(
-            message_elt,
-            "[A message retraction has been requested, but your client doesn't support "
-            "it]"
-        )
-        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
-        client.send(message_elt)
-
-    async def retract_by_history(
-        self,
-        client: SatXMPPEntity,
-        history: History
-    ) -> None:
-        """Send a message retraction using History instance
-
-        This method is to use instead of [retract] when the history instance is already
-        retrieved. Note that the instance must have messages and subjets loaded
-        @param history: history instance of the message to retract
-        """
-        try:
-            origin_id = history.origin_id
-        except KeyError:
-            raise exceptions.FeatureNotFound(
-                f"message to retract doesn't have the necessary origin-id, the sending "
-                "client is probably not supporting message retraction."
-            )
-        else:
-            self.retract_by_origin_id(client, history.dest_jid, origin_id)
-            await self.retract_db_history(client, history)
-
-    async def retract(
-        self,
-        client: SatXMPPEntity,
-        message_id: str,
-    ) -> None:
-        """Send a message retraction request
-
-        @param message_id: ID of the message
-            This ID is the Libervia internal ID of the message. It will be retrieve from
-            database to find the ID used by XMPP (i.e. XEP-0359's "origin ID"). If the
-            message is not found in database, an exception will be raised
-        """
-        if not message_id:
-            raise ValueError("message_id can't be empty")
-        history = await self.host.memory.storage.get(
-            client, History, History.uid, message_id,
-            joined_loads=[History.messages, History.subjects]
-        )
-        if history is None:
-            raise exceptions.NotFound(
-                f"message to retract not found in database ({message_id})"
-            )
-        await self.retract_by_history(client, history)
-
-    async def retract_db_history(self, client, history: History) -> None:
-        """Mark an history instance in database as retracted
-
-        @param history: history instance
-            "messages" and "subjects" must be loaded too
-        """
-        # FIXME: should be keep history? This is useful to check why a message has been
-        #   retracted, but if may be bad if the user think it's really deleted
-        # we assign a new object to be sure to trigger an update
-        history.extra = deepcopy(history.extra) if history.extra else {}
-        history.extra["retracted"] = True
-        keep_history = self.host.memory.param_get_a(
-            NAME, CATEGORY, profile_key=client.profile
-        )
-        old_version: Dict[str, Any] = {
-            "timestamp": time.time()
-        }
-        if keep_history:
-            old_version.update({
-                "messages": [m.serialise() for m in history.messages],
-                "subjects": [s.serialise() for s in history.subjects]
-            })
-
-        history.extra.setdefault("old_versions", []).append(old_version)
-        await self.host.memory.storage.delete(
-            history.messages + history.subjects,
-            session_add=[history]
-        )
-
-    async def _message_received_trigger(
-        self,
-        client: SatXMPPEntity,
-        message_elt: domish.Element,
-        post_treat: defer.Deferred
-    ) -> bool:
-        fastened_elts = await self._f.get_fastened_elts(client, message_elt)
-        if fastened_elts is None:
-            return True
-        for elt in fastened_elts.elements:
-            if elt.name == "retract" and elt.uri == NS_MESSAGE_RETRACT:
-                if fastened_elts.history is not None:
-                    source_jid = fastened_elts.history.source_jid
-                    from_jid = jid.JID(message_elt["from"])
-                    if source_jid.userhostJID() != from_jid.userhostJID():
-                        log.warning(
-                            f"Received message retraction from {from_jid.full()}, but "
-                            f"the message to retract is from {source_jid.full()}. This "
-                            f"maybe a hack attempt.\n{message_elt.toXml()}"
-                        )
-                        return False
-                break
-        else:
-            return True
-        if not await self.host.trigger.async_point(
-            "XEP-0424_retractReceived", client, message_elt, elt, fastened_elts
-        ):
-            return False
-        if fastened_elts.history is None:
-            # we check history after the trigger because we may be in a component which
-            # doesn't store messages in database.
-            log.warning(
-                f"No message found with given origin-id: {message_elt.toXml()}"
-            )
-            return False
-        log.info(f"[{client.profile}] retracting message {fastened_elts.id!r}")
-        await self.retract_db_history(client, fastened_elts.history)
-        # TODO: send bridge signal
-
-        return False
-
-
-@implementer(disco.IDisco)
-class XEP_0424_handler(xmlstream.XMPPHandler):
-
-    def getDiscoInfo(self, __, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_MESSAGE_RETRACT)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0428.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,87 +0,0 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional
-
-from twisted.words.protocols.jabber import xmlstream
-from twisted.words.xish import domish
-from wokkel import disco
-from zope.interface import implementer
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Fallback Indication",
-    C.PI_IMPORT_NAME: "XEP-0428",
-    C.PI_TYPE: "XEP",
-    C.PI_PROTOCOLS: ["XEP-0428"],
-    C.PI_MAIN: "XEP_0428",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Implementation of XEP-0428 (Fallback Indication)"""),
-}
-
-NS_FALLBACK = "urn:xmpp:fallback:0"
-
-
-class XEP_0428(object):
-
-    def __init__(self, host):
-        log.info(_("XEP-0428 (Fallback Indication) plugin initialization"))
-        host.register_namespace("fallback", NS_FALLBACK)
-
-    def add_fallback_elt(
-        self,
-        message_elt: domish.Element,
-        msg: Optional[str] = None
-    ) -> None:
-        """Add the fallback indication element
-
-        @param message_elt: <message> element where the indication must be
-            set
-        @param msg: message to show as fallback
-            Will be added as <body>
-        """
-        message_elt.addElement((NS_FALLBACK, "fallback"))
-        if msg is not None:
-            message_elt.addElement("body", content=msg)
-
-    def has_fallback(self, message_elt: domish.Element) -> bool:
-        """Tell if a message has a fallback indication"""
-        try:
-            next(message_elt.elements(NS_FALLBACK, "fallback"))
-        except StopIteration:
-            return False
-        else:
-            return True
-
-    def get_handler(self, __):
-        return XEP_0428_handler()
-
-
-@implementer(disco.IDisco)
-class XEP_0428_handler(xmlstream.XMPPHandler):
-
-    def getDiscoInfo(self, __, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_FALLBACK)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0444.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,171 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for XEP-0444
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import List, Iterable
-from copy import deepcopy
-
-from twisted.words.protocols.jabber import jid, xmlstream
-from twisted.words.xish import domish
-from twisted.internet import defer
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.core.core_types import SatXMPPEntity
-from sat.memory.sqla_mapping import History
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Message Reactions",
-    C.PI_IMPORT_NAME: "XEP-0444",
-    C.PI_TYPE: C.PLUG_TYPE_XEP,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0444"],
-    C.PI_DEPENDENCIES: ["XEP-0334"],
-    C.PI_MAIN: "XEP_0444",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Message Reactions implementation"""),
-}
-
-NS_REACTIONS = "urn:xmpp:reactions:0"
-
-
-class XEP_0444:
-
-    def __init__(self, host):
-        log.info(_("Message Reactions initialization"))
-        host.register_namespace("reactions", NS_REACTIONS)
-        self.host = host
-        self._h = host.plugins["XEP-0334"]
-        host.bridge.add_method(
-            "message_reactions_set",
-            ".plugin",
-            in_sign="ssas",
-            out_sign="",
-            method=self._reactions_set,
-            async_=True,
-        )
-        host.trigger.add("message_received", self._message_received_trigger)
-
-    def get_handler(self, client):
-        return XEP_0444_Handler()
-
-    async def _message_received_trigger(
-        self,
-        client: SatXMPPEntity,
-        message_elt: domish.Element,
-        post_treat: defer.Deferred
-    ) -> bool:
-        return True
-
-    def _reactions_set(self, message_id: str, profile: str, reactions: List[str]) -> None:
-        client = self.host.get_client(profile)
-        return defer.ensureDeferred(
-            self.set_reactions(client, message_id)
-        )
-
-    def send_reactions(
-        self,
-        client: SatXMPPEntity,
-        dest_jid: jid.JID,
-        message_id: str,
-        reactions: Iterable[str]
-    ) -> None:
-        """Send the <message> stanza containing the reactions
-
-        @param dest_jid: recipient of the reaction
-        @param message_id: either <origin-id> or message's ID
-            see https://xmpp.org/extensions/xep-0444.html#business-id
-        """
-        message_elt = domish.Element((None, "message"))
-        message_elt["from"] = client.jid.full()
-        message_elt["to"] = dest_jid.full()
-        reactions_elt = message_elt.addElement((NS_REACTIONS, "reactions"))
-        reactions_elt["id"] = message_id
-        for r in set(reactions):
-            reactions_elt.addElement("reaction", content=r)
-        self._h.add_hint_elements(message_elt, [self._h.HINT_STORE])
-        client.send(message_elt)
-
-    async def add_reactions_to_history(
-        self,
-        history: History,
-        from_jid: jid.JID,
-        reactions: Iterable[str]
-    ) -> None:
-        """Update History instance with given reactions
-
-        @param history: storage History instance
-            will be updated in DB
-            "summary" field of history.extra["reactions"] will also be updated
-        @param from_jid: author of the reactions
-        @param reactions: list of reactions
-        """
-        history.extra = deepcopy(history.extra) if history.extra else {}
-        h_reactions = history.extra.setdefault("reactions", {})
-        # reactions mapped by originating JID
-        by_jid = h_reactions.setdefault("by_jid", {})
-        # reactions are sorted to in summary to keep a consistent order
-        h_reactions["by_jid"][from_jid.userhost()] = sorted(list(set(reactions)))
-        h_reactions["summary"] = sorted(list(set().union(*by_jid.values())))
-        await self.host.memory.storage.session_add(history)
-
-    async def set_reactions(
-        self,
-        client: SatXMPPEntity,
-        message_id: str,
-        reactions: Iterable[str]
-    ) -> None:
-        """Set and replace reactions to a message
-
-        @param message_id: internal ID of the message
-        @param rections: lsit of emojis to used to react to the message
-            use empty list to remove all reactions
-        """
-        if not message_id:
-            raise ValueError("message_id can't be empty")
-        history = await self.host.memory.storage.get(
-            client, History, History.uid, message_id,
-            joined_loads=[History.messages, History.subjects]
-        )
-        if history is None:
-            raise exceptions.NotFound(
-                f"message to retract not found in database ({message_id})"
-            )
-        mess_id = history.origin_id or history.stanza_id
-        if not mess_id:
-            raise exceptions.DataError(
-                "target message has neither origin-id nor message-id, we can't send a "
-                "reaction"
-            )
-        await self.add_reactions_to_history(history, client.jid, reactions)
-        self.send_reactions(client, history.dest_jid, mess_id, reactions)
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0444_Handler(xmlstream.XMPPHandler):
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_REACTIONS)]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0446.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,165 +0,0 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from logging import exception
-from typing import Optional, Union, Tuple, Dict, Any
-from pathlib import Path
-
-from twisted.words.xish import domish
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.tools import utils
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "File Metadata Element",
-    C.PI_IMPORT_NAME: "XEP-0446",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0446"],
-    C.PI_DEPENDENCIES: ["XEP-0300"],
-    C.PI_MAIN: "XEP_0446",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of XEP-0446 (File Metadata Element)"""),
-}
-
-NS_FILE_METADATA = "urn:xmpp:file:metadata:0"
-
-
-class XEP_0446:
-
-    def __init__(self, host):
-        log.info(_("XEP-0446 (File Metadata Element) plugin initialization"))
-        host.register_namespace("file-metadata", NS_FILE_METADATA)
-        self._hash = host.plugins["XEP-0300"]
-
-    def get_file_metadata_elt(
-        self,
-        name: Optional[str] = None,
-        media_type: Optional[str] = None,
-        desc: Optional[str] = None,
-        size: Optional[int] = None,
-        file_hash: Optional[Tuple[str, str]] = None,
-        date: Optional[Union[float, int]] = None,
-        width: Optional[int] = None,
-        height: Optional[int] = None,
-        length: Optional[int] = None,
-        thumbnail: Optional[str] = None,
-    ) -> domish.Element:
-        """Generate the element describing a file
-
-        @param name: name of the file
-        @param media_type: media-type
-        @param desc: description
-        @param size: size in bytes
-        @param file_hash: (algo, hash)
-        @param date: timestamp of the last modification datetime
-        @param width: image width in pixels
-        @param height: image height in pixels
-        @param length: video length in seconds
-        @param thumbnail: URL to a thumbnail
-        @return: ``<file/>`` element
-        """
-        if name:
-            name = Path(name).name
-        file_elt = domish.Element((NS_FILE_METADATA, "file"))
-        for name, value in (
-            ("name", name),
-            ("media-type", media_type),
-            ("desc", desc),
-            ("size", size),
-            ("width", width),
-            ("height", height),
-            ("length", length),
-        ):
-            if value is not None:
-                file_elt.addElement(name, content=str(value))
-        if file_hash is not None:
-            hash_algo, hash_ = file_hash
-            file_elt.addChild(self._hash.build_hash_elt(hash_, hash_algo))
-        if date is not None:
-            file_elt.addElement("date", utils.xmpp_date(date))
-        if thumbnail is not None:
-            # TODO: implement thumbnails
-            log.warning("thumbnail is not implemented yet")
-        return file_elt
-
-    def parse_file_metadata_elt(
-        self,
-        file_metadata_elt: domish.Element
-    ) -> Dict[str, Any]:
-        """Parse <file/> element
-
-        @param file_metadata_elt: <file/> element
-            a parent element can also be used
-        @return: file metadata. It's a dict whose keys correspond to
-            [get_file_metadata_elt] parameters
-        @raise exceptions.NotFound: no <file/> element has been found
-        """
-
-        if file_metadata_elt.name != "file":
-            try:
-                file_metadata_elt = next(
-                    file_metadata_elt.elements(NS_FILE_METADATA, "file")
-                )
-            except StopIteration:
-                raise exceptions.NotFound
-        data: Dict[str, Any] = {}
-
-        for key, type_ in (
-            ("name", str),
-            ("media-type", str),
-            ("desc", str),
-            ("size", int),
-            ("date", "timestamp"),
-            ("width", int),
-            ("height", int),
-            ("length", int),
-        ):
-            elt = next(file_metadata_elt.elements(NS_FILE_METADATA, key), None)
-            if elt is not None:
-                if type_ in (str, int):
-                    content = str(elt)
-                    if key == "name":
-                        # we avoid malformed names or names containing path elements
-                        content = Path(content).name
-                    elif key == "media-type":
-                        key = "media_type"
-                    data[key] = type_(content)
-                elif type == "timestamp":
-                    data[key] = utils.parse_xmpp_date(str(elt))
-                else:
-                    raise exceptions.InternalError
-
-        try:
-            algo, hash_ = self._hash.parse_hash_elt(file_metadata_elt)
-        except exceptions.NotFound:
-            pass
-        except exceptions.DataError:
-            from sat.tools.xml_tools import p_fmt_elt
-            log.warning("invalid <hash/> element:\n{p_fmt_elt(file_metadata_elt)}")
-        else:
-            data["file_hash"] = (algo, hash_)
-
-        # TODO: thumbnails
-
-        return data
--- a/sat/plugins/plugin_xep_0447.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,376 +0,0 @@
-#!/usr/bin/env python3
-
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from collections import namedtuple
-from functools import partial
-import mimetypes
-from pathlib import Path
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union
-
-import treq
-from twisted.internet import defer
-from twisted.words.xish import domish
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools import stream
-from sat.tools.web import treq_client_no_ssl
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Stateless File Sharing",
-    C.PI_IMPORT_NAME: "XEP-0447",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0447"],
-    C.PI_DEPENDENCIES: ["XEP-0103", "XEP-0334", "XEP-0446", "ATTACH", "DOWNLOAD"],
-    C.PI_RECOMMENDATIONS: ["XEP-0363"],
-    C.PI_MAIN: "XEP_0447",
-    C.PI_HANDLER: "no",
-    C.PI_DESCRIPTION: _("""Implementation of XEP-0447 (Stateless File Sharing)"""),
-}
-
-NS_SFS = "urn:xmpp:sfs:0"
-SourceHandler = namedtuple("SourceHandler", ["callback", "encrypted"])
-
-
-class XEP_0447:
-    namespace = NS_SFS
-
-    def __init__(self, host):
-        self.host = host
-        log.info(_("XEP-0447 (Stateless File Sharing) plugin initialization"))
-        host.register_namespace("sfs", NS_SFS)
-        self._sources_handlers = {}
-        self._u = host.plugins["XEP-0103"]
-        self._hints = host.plugins["XEP-0334"]
-        self._m = host.plugins["XEP-0446"]
-        self._http_upload = host.plugins.get("XEP-0363")
-        self._attach = host.plugins["ATTACH"]
-        self._attach.register(
-            self.can_handle_attachment, self.attach, priority=1000
-        )
-        self.register_source_handler(
-            self._u.namespace, "url-data", self._u.parse_url_data_elt
-        )
-        host.plugins["DOWNLOAD"].register_download_handler(self._u.namespace, self.download)
-        host.trigger.add("message_received", self._message_received_trigger)
-
-    def register_source_handler(
-        self, namespace: str, element_name: str,
-        callback: Callable[[domish.Element], Dict[str, Any]],
-        encrypted: bool = False
-    ) -> None:
-        """Register a handler for file source
-
-        @param namespace: namespace of the element supported
-        @param element_name: name of the element supported
-        @param callback: method to call to parse the element
-            get the matching element as argument, must return the parsed data
-        @param encrypted: if True, the source is encrypted (the transmitting channel
-            should then be end2end encrypted to avoir leaking decrypting data to servers).
-        """
-        key = (namespace, element_name)
-        if key in self._sources_handlers:
-            raise exceptions.ConflictError(
-                f"There is already a resource handler for namespace {namespace!r} and "
-                f"name {element_name!r}"
-            )
-        self._sources_handlers[key] = SourceHandler(callback, encrypted)
-
-    async def download(
-        self,
-        client: SatXMPPEntity,
-        attachment: Dict[str, Any],
-        source: Dict[str, Any],
-        dest_path: Union[Path, str],
-        extra: Optional[Dict[str, Any]] = None
-    ) -> Tuple[str, defer.Deferred]:
-        # TODO: handle url-data headers
-        if extra is None:
-            extra = {}
-        try:
-            download_url = source["url"]
-        except KeyError:
-            raise ValueError(f"{source} has missing URL")
-
-        if extra.get('ignore_tls_errors', False):
-            log.warning(
-                "TLS certificate check disabled, this is highly insecure"
-            )
-            treq_client = treq_client_no_ssl
-        else:
-            treq_client = treq
-
-        try:
-            file_size = int(attachment["size"])
-        except (KeyError, ValueError):
-            head_data = await treq_client.head(download_url)
-            file_size = int(head_data.headers.getRawHeaders('content-length')[0])
-
-        file_obj = stream.SatFile(
-            self.host,
-            client,
-            dest_path,
-            mode="wb",
-            size = file_size,
-        )
-
-        progress_id = file_obj.uid
-
-        resp = await treq_client.get(download_url, unbuffered=True)
-        if resp.code == 200:
-            d = treq.collect(resp, file_obj.write)
-            d.addCallback(lambda __: file_obj.close())
-        else:
-            d = defer.Deferred()
-            self.host.plugins["DOWNLOAD"].errback_download(file_obj, d, resp)
-        return progress_id, d
-
-    async def can_handle_attachment(self, client, data):
-        if self._http_upload is None:
-            return False
-        try:
-            await self._http_upload.get_http_upload_entity(client)
-        except exceptions.NotFound:
-            return False
-        else:
-            return True
-
-    def get_sources_elt(
-        self,
-        children: Optional[List[domish.Element]] = None
-    ) -> domish.Element:
-        """Generate <sources> element"""
-        sources_elt = domish.Element((NS_SFS, "sources"))
-        if children:
-            for child in children:
-                sources_elt.addChild(child)
-        return sources_elt
-
-    def get_file_sharing_elt(
-        self,
-        sources: List[Dict[str, Any]],
-        disposition: Optional[str] = None,
-        name: Optional[str] = None,
-        media_type: Optional[str] = None,
-        desc: Optional[str] = None,
-        size: Optional[int] = None,
-        file_hash: Optional[Tuple[str, str]] = None,
-        date: Optional[Union[float, int]] = None,
-        width: Optional[int] = None,
-        height: Optional[int] = None,
-        length: Optional[int] = None,
-        thumbnail: Optional[str] = None,
-        **kwargs,
-    ) -> domish.Element:
-        """Generate the <file-sharing/> element
-
-        @param extra: extra metadata describing how to access the URL
-        @return: ``<sfs/>`` element
-        """
-        file_sharing_elt = domish.Element((NS_SFS, "file-sharing"))
-        if disposition is not None:
-            file_sharing_elt["disposition"] = disposition
-        if media_type is None and name:
-            media_type = mimetypes.guess_type(name, strict=False)[0]
-        file_sharing_elt.addChild(
-            self._m.get_file_metadata_elt(
-                name=name,
-                media_type=media_type,
-                desc=desc,
-                size=size,
-                file_hash=file_hash,
-                date=date,
-                width=width,
-                height=height,
-                length=length,
-                thumbnail=thumbnail,
-            )
-        )
-        sources_elt = self.get_sources_elt()
-        file_sharing_elt.addChild(sources_elt)
-        for source_data in sources:
-            if "url" in source_data:
-                sources_elt.addChild(
-                    self._u.get_url_data_elt(**source_data)
-                )
-            else:
-                raise NotImplementedError(
-                    f"source data not implemented: {source_data}"
-                )
-
-        return file_sharing_elt
-
-    def parse_sources_elt(
-        self,
-        sources_elt: domish.Element
-    ) -> List[Dict[str, Any]]:
-        """Parse <sources/> element
-
-        @param sources_elt: <sources/> element, or a direct parent element
-        @return: list of found sources data
-        @raise: exceptions.NotFound: Can't find <sources/> element
-        """
-        if sources_elt.name != "sources" or sources_elt.uri != NS_SFS:
-            try:
-                sources_elt = next(sources_elt.elements(NS_SFS, "sources"))
-            except StopIteration:
-                raise exceptions.NotFound(
-                    f"<sources/> element is missing: {sources_elt.toXml()}")
-        sources = []
-        for elt in sources_elt.elements():
-            if not elt.uri:
-                log.warning("ignoring source element {elt.toXml()}")
-                continue
-            key = (elt.uri, elt.name)
-            try:
-                source_handler = self._sources_handlers[key]
-            except KeyError:
-                log.warning(f"unmanaged file sharing element: {elt.toXml}")
-                continue
-            else:
-                source_data = source_handler.callback(elt)
-                if source_handler.encrypted:
-                    source_data[C.MESS_KEY_ENCRYPTED] = True
-                if "type" not in source_data:
-                    source_data["type"] = elt.uri
-                sources.append(source_data)
-        return sources
-
-    def parse_file_sharing_elt(
-        self,
-        file_sharing_elt: domish.Element
-    ) -> Dict[str, Any]:
-        """Parse <file-sharing/> element and return file-sharing data
-
-        @param file_sharing_elt: <file-sharing/> element
-        @return: file-sharing data. It a dict whose keys correspond to
-            [get_file_sharing_elt] parameters
-        """
-        if file_sharing_elt.name != "file-sharing" or file_sharing_elt.uri != NS_SFS:
-            try:
-                file_sharing_elt = next(
-                    file_sharing_elt.elements(NS_SFS, "file-sharing")
-                )
-            except StopIteration:
-                raise exceptions.NotFound
-        try:
-            data = self._m.parse_file_metadata_elt(file_sharing_elt)
-        except exceptions.NotFound:
-            data = {}
-        disposition = file_sharing_elt.getAttribute("disposition")
-        if disposition is not None:
-            data["disposition"] = disposition
-        try:
-            data["sources"] = self.parse_sources_elt(file_sharing_elt)
-        except exceptions.NotFound as e:
-            raise ValueError(str(e))
-
-        return data
-
-    def _add_file_sharing_attachments(
-            self,
-            client: SatXMPPEntity,
-            message_elt: domish.Element,
-            data: Dict[str, Any]
-    ) -> Dict[str, Any]:
-        """Check <message> for a shared file, and add it as an attachment"""
-        # XXX: XEP-0447 doesn't support several attachments in a single message, for now
-        #   however that should be fixed in future version, and so we accept several
-        #   <file-sharing> element in a message.
-        for file_sharing_elt in message_elt.elements(NS_SFS, "file-sharing"):
-            attachment = self.parse_file_sharing_elt(message_elt)
-
-            if any(
-                    s.get(C.MESS_KEY_ENCRYPTED, False)
-                    for s in attachment["sources"]
-            ) and client.encryption.isEncrypted(data):
-                # we don't add the encrypted flag if the message itself is not encrypted,
-                # because the decryption key is part of the link, so sending it over
-                # unencrypted channel is like having no encryption at all.
-                attachment[C.MESS_KEY_ENCRYPTED] = True
-
-            attachments = data['extra'].setdefault(C.KEY_ATTACHMENTS, [])
-            attachments.append(attachment)
-
-        return data
-
-    async def attach(self, client, data):
-        # XXX: for now, XEP-0447 only allow to send one file per <message/>, thus we need
-        #   to send each file in a separate message
-        attachments = data["extra"][C.KEY_ATTACHMENTS]
-        if not data['message'] or data['message'] == {'': ''}:
-            extra_attachments = attachments[1:]
-            del attachments[1:]
-        else:
-            # we have a message, we must send first attachment separately
-            extra_attachments = attachments[:]
-            attachments.clear()
-            del data["extra"][C.KEY_ATTACHMENTS]
-
-        if attachments:
-            if len(attachments) > 1:
-                raise exceptions.InternalError(
-                    "There should not be more that one attachment at this point"
-                )
-            await self._attach.upload_files(client, data)
-            self._hints.add_hint_elements(data["xml"], [self._hints.HINT_STORE])
-            for attachment in attachments:
-                try:
-                    file_hash = (attachment["hash_algo"], attachment["hash"])
-                except KeyError:
-                    file_hash = None
-                file_sharing_elt = self.get_file_sharing_elt(
-                    [{"url": attachment["url"]}],
-                    name=attachment.get("name"),
-                    size=attachment.get("size"),
-                    desc=attachment.get("desc"),
-                    media_type=attachment.get("media_type"),
-                    file_hash=file_hash
-                )
-                data["xml"].addChild(file_sharing_elt)
-
-        for attachment in extra_attachments:
-            # we send all remaining attachment in a separate message
-            await client.sendMessage(
-                to_jid=data['to'],
-                message={'': ''},
-                subject=data['subject'],
-                mess_type=data['type'],
-                extra={C.KEY_ATTACHMENTS: [attachment]},
-            )
-
-        if ((not data['extra']
-             and (not data['message'] or data['message'] == {'': ''})
-             and not data['subject'])):
-            # nothing left to send, we can cancel the message
-            raise exceptions.CancelError("Cancelled by XEP_0447 attachment handling")
-
-    def _message_received_trigger(self, client, message_elt, post_treat):
-        # we use a post_treat callback instead of "message_parse" trigger because we need
-        # to check if the "encrypted" flag is set to decide if we add the same flag to the
-        # attachment
-        post_treat.addCallback(
-            partial(self._add_file_sharing_attachments, client, message_elt)
-        )
-        return True
--- a/sat/plugins/plugin_xep_0448.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,468 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for handling stateless file sharing encryption
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import base64
-from functools import partial
-from pathlib import Path
-import secrets
-from textwrap import dedent
-from typing import Any, Dict, Optional, Tuple, Union
-
-from cryptography.exceptions import AlreadyFinalized
-from cryptography.hazmat import backends
-from cryptography.hazmat.primitives import ciphers
-from cryptography.hazmat.primitives.ciphers import CipherContext, modes
-from cryptography.hazmat.primitives.padding import PKCS7, PaddingContext
-import treq
-from twisted.internet import defer
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools import stream
-from sat.tools.web import treq_client_no_ssl
-
-log = getLogger(__name__)
-
-IMPORT_NAME = "XEP-0448"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Encryption for Stateless File Sharing",
-    C.PI_IMPORT_NAME: IMPORT_NAME,
-    C.PI_TYPE: C.PLUG_TYPE_EXP,
-    C.PI_PROTOCOLS: ["XEP-0448"],
-    C.PI_DEPENDENCIES: [
-        "XEP-0103", "XEP-0300", "XEP-0334", "XEP-0363", "XEP-0384", "XEP-0447",
-        "DOWNLOAD", "ATTACH"
-    ],
-    C.PI_MAIN: "XEP_0448",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: dedent(_("""\
-    Implementation of e2e encryption for media sharing
-    """)),
-}
-
-NS_ESFS = "urn:xmpp:esfs:0"
-NS_AES_128_GCM = "urn:xmpp:ciphers:aes-128-gcm-nopadding:0"
-NS_AES_256_GCM = "urn:xmpp:ciphers:aes-256-gcm-nopadding:0"
-NS_AES_256_CBC = "urn:xmpp:ciphers:aes-256-cbc-pkcs7:0"
-
-
-class XEP_0448:
-
-    def __init__(self, host):
-        self.host = host
-        log.info(_("XEP_0448 plugin initialization"))
-        host.register_namespace("esfs", NS_ESFS)
-        self._u = host.plugins["XEP-0103"]
-        self._h = host.plugins["XEP-0300"]
-        self._hints = host.plugins["XEP-0334"]
-        self._http_upload = host.plugins["XEP-0363"]
-        self._o = host.plugins["XEP-0384"]
-        self._sfs = host.plugins["XEP-0447"]
-        self._sfs.register_source_handler(
-            NS_ESFS, "encrypted", self.parse_encrypted_elt, encrypted=True
-        )
-        self._attach = host.plugins["ATTACH"]
-        self._attach.register(
-            self.can_handle_attachment, self.attach, encrypted=True, priority=1000
-        )
-        host.plugins["DOWNLOAD"].register_download_handler(NS_ESFS, self.download)
-        host.trigger.add("XEP-0363_upload_pre_slot", self._upload_pre_slot)
-        host.trigger.add("XEP-0363_upload", self._upload_trigger)
-
-    def get_handler(self, client):
-        return XEP0448Handler()
-
-    def parse_encrypted_elt(self, encrypted_elt: domish.Element) -> Dict[str, Any]:
-        """Parse an <encrypted> element and return corresponding source data
-
-        @param encrypted_elt: element to parse
-        @raise exceptions.DataError: the element is invalid
-
-        """
-        sources = self._sfs.parse_sources_elt(encrypted_elt)
-        if not sources:
-            raise exceptions.NotFound("sources are missing in {encrypted_elt.toXml()}")
-        if len(sources) > 1:
-            log.debug(
-                "more that one sources has been found, this is not expected, only the "
-                "first one will be used"
-            )
-        source = sources[0]
-        source["type"] = NS_ESFS
-        try:
-            encrypted_data = source["encrypted_data"] = {
-                "cipher": encrypted_elt["cipher"],
-                "key": str(next(encrypted_elt.elements(NS_ESFS, "key"))),
-                "iv": str(next(encrypted_elt.elements(NS_ESFS, "iv"))),
-            }
-        except (KeyError, StopIteration):
-            raise exceptions.DataError(
-                "invalid <encrypted/> element: {encrypted_elt.toXml()}"
-            )
-        try:
-            hash_algo, hash_value = self._h.parse_hash_elt(encrypted_elt)
-        except exceptions.NotFound:
-            pass
-        else:
-            encrypted_data["hash_algo"] = hash_algo
-            encrypted_data["hash"] = base64.b64encode(hash_value.encode()).decode()
-        return source
-
-    async def download(
-        self,
-        client: SatXMPPEntity,
-        attachment: Dict[str, Any],
-        source: Dict[str, Any],
-        dest_path: Union[Path, str],
-        extra: Optional[Dict[str, Any]] = None
-    ) -> Tuple[str, defer.Deferred]:
-        # TODO: check hash
-        if extra is None:
-            extra = {}
-        try:
-            encrypted_data = source["encrypted_data"]
-            cipher = encrypted_data["cipher"]
-            iv = base64.b64decode(encrypted_data["iv"])
-            key = base64.b64decode(encrypted_data["key"])
-        except KeyError as e:
-            raise ValueError(f"{source} has incomplete encryption data: {e}")
-        try:
-            download_url = source["url"]
-        except KeyError:
-            raise ValueError(f"{source} has missing URL")
-
-        if extra.get('ignore_tls_errors', False):
-            log.warning(
-                "TLS certificate check disabled, this is highly insecure"
-            )
-            treq_client = treq_client_no_ssl
-        else:
-            treq_client = treq
-
-        try:
-            file_size = int(attachment["size"])
-        except (KeyError, ValueError):
-            head_data = await treq_client.head(download_url)
-            content_length = int(head_data.headers.getRawHeaders('content-length')[0])
-            # the 128 bits tag is put at the end
-            file_size = content_length - 16
-
-        file_obj = stream.SatFile(
-            self.host,
-            client,
-            dest_path,
-            mode="wb",
-            size = file_size,
-        )
-
-        if cipher in (NS_AES_128_GCM, NS_AES_256_GCM):
-            decryptor = ciphers.Cipher(
-                ciphers.algorithms.AES(key),
-                modes.GCM(iv),
-                backend=backends.default_backend(),
-            ).decryptor()
-            decrypt_cb = partial(
-                self.gcm_decrypt,
-                client=client,
-                file_obj=file_obj,
-                decryptor=decryptor,
-            )
-            finalize_cb = None
-        elif cipher == NS_AES_256_CBC:
-            cipher_algo = ciphers.algorithms.AES(key)
-            decryptor = ciphers.Cipher(
-                cipher_algo,
-                modes.CBC(iv),
-                backend=backends.default_backend(),
-            ).decryptor()
-            unpadder = PKCS7(cipher_algo.block_size).unpadder()
-            decrypt_cb = partial(
-                self.cbc_decrypt,
-                client=client,
-                file_obj=file_obj,
-                decryptor=decryptor,
-                unpadder=unpadder
-            )
-            finalize_cb = partial(
-                self.cbc_decrypt_finalize,
-                file_obj=file_obj,
-                decryptor=decryptor,
-                unpadder=unpadder
-            )
-        else:
-            msg = f"cipher {cipher!r} is not supported"
-            file_obj.close(error=msg)
-            log.warning(msg)
-            raise exceptions.CancelError(msg)
-
-        progress_id = file_obj.uid
-
-        resp = await treq_client.get(download_url, unbuffered=True)
-        if resp.code == 200:
-            d = treq.collect(resp, partial(decrypt_cb))
-            if finalize_cb is not None:
-                d.addCallback(lambda __: finalize_cb())
-        else:
-            d = defer.Deferred()
-            self.host.plugins["DOWNLOAD"].errback_download(file_obj, d, resp)
-        return progress_id, d
-
-    async def can_handle_attachment(self, client, data):
-        # FIXME: check if SCE is supported without checking which e2ee algo is used
-        if client.encryption.get_namespace(data["to"]) != self._o.NS_TWOMEMO:
-            # we need SCE, and it is currently supported only by TWOMEMO, thus we can't
-            # handle the attachment if it's not activated
-            return False
-        try:
-            await self._http_upload.get_http_upload_entity(client)
-        except exceptions.NotFound:
-            return False
-        else:
-            return True
-
-    async def _upload_cb(self, client, filepath, filename, extra):
-        attachment = extra["attachment"]
-        extra["encryption"] = IMPORT_NAME
-        attachment["encryption_data"] = extra["encryption_data"] = {
-            "algorithm": C.ENC_AES_GCM,
-            "iv": secrets.token_bytes(12),
-            "key": secrets.token_bytes(32),
-        }
-        attachment["filename"] = filename
-        return await self._http_upload.file_http_upload(
-            client=client,
-            filepath=filepath,
-            filename="encrypted",
-            extra=extra
-        )
-
-    async def attach(self, client, data):
-        # XXX: for now, XEP-0447/XEP-0448 only allow to send one file per <message/>, thus
-        #   we need to send each file in a separate message, in the same way as for
-        #   plugin_sec_aesgcm.
-        attachments = data["extra"][C.KEY_ATTACHMENTS]
-        if not data['message'] or data['message'] == {'': ''}:
-            extra_attachments = attachments[1:]
-            del attachments[1:]
-        else:
-            # we have a message, we must send first attachment separately
-            extra_attachments = attachments[:]
-            attachments.clear()
-            del data["extra"][C.KEY_ATTACHMENTS]
-
-        if attachments:
-            if len(attachments) > 1:
-                raise exceptions.InternalError(
-                    "There should not be more that one attachment at this point"
-                )
-            await self._attach.upload_files(client, data, upload_cb=self._upload_cb)
-            self._hints.add_hint_elements(data["xml"], [self._hints.HINT_STORE])
-            for attachment in attachments:
-                encryption_data = attachment.pop("encryption_data")
-                file_hash = (attachment["hash_algo"], attachment["hash"])
-                file_sharing_elt = self._sfs.get_file_sharing_elt(
-                    [],
-                    name=attachment["filename"],
-                    size=attachment["size"],
-                    file_hash=file_hash
-                )
-                encrypted_elt = file_sharing_elt.sources.addElement(
-                    (NS_ESFS, "encrypted")
-                )
-                encrypted_elt["cipher"] = NS_AES_256_GCM
-                encrypted_elt.addElement(
-                    "key",
-                    content=base64.b64encode(encryption_data["key"]).decode()
-                )
-                encrypted_elt.addElement(
-                    "iv",
-                    content=base64.b64encode(encryption_data["iv"]).decode()
-                )
-                encrypted_elt.addChild(self._h.build_hash_elt(
-                    attachment["encrypted_hash"],
-                    attachment["encrypted_hash_algo"]
-                ))
-                encrypted_elt.addChild(
-                    self._sfs.get_sources_elt(
-                        [self._u.get_url_data_elt(attachment["url"])]
-                    )
-                )
-                data["xml"].addChild(file_sharing_elt)
-
-        for attachment in extra_attachments:
-            # we send all remaining attachment in a separate message
-            await client.sendMessage(
-                to_jid=data['to'],
-                message={'': ''},
-                subject=data['subject'],
-                mess_type=data['type'],
-                extra={C.KEY_ATTACHMENTS: [attachment]},
-            )
-
-        if ((not data['extra']
-             and (not data['message'] or data['message'] == {'': ''})
-             and not data['subject'])):
-            # nothing left to send, we can cancel the message
-            raise exceptions.CancelError("Cancelled by XEP_0448 attachment handling")
-
-    def gcm_decrypt(
-        self,
-        data: bytes,
-        client: SatXMPPEntity,
-        file_obj: stream.SatFile,
-        decryptor: CipherContext
-    ) -> None:
-        if file_obj.tell() + len(data) > file_obj.size:  # type: ignore
-            # we're reaching end of file with this bunch of data
-            # we may still have a last bunch if the tag is incomplete
-            bytes_left = file_obj.size - file_obj.tell()  # type: ignore
-            if bytes_left > 0:
-                decrypted = decryptor.update(data[:bytes_left])
-                file_obj.write(decrypted)
-                tag = data[bytes_left:]
-            else:
-                tag = data
-            if len(tag) < 16:
-                # the tag is incomplete, either we'll get the rest in next data bunch
-                # or we have already the other part from last bunch of data
-                try:
-                    # we store partial tag in decryptor._sat_tag
-                    tag = decryptor._sat_tag + tag
-                except AttributeError:
-                    # no other part, we'll get the rest at next bunch
-                    decryptor.sat_tag = tag
-                else:
-                    # we have the complete tag, it must be 128 bits
-                    if len(tag) != 16:
-                        raise ValueError(f"Invalid tag: {tag}")
-            remain = decryptor.finalize_with_tag(tag)
-            file_obj.write(remain)
-            file_obj.close()
-        else:
-            decrypted = decryptor.update(data)
-            file_obj.write(decrypted)
-
-    def cbc_decrypt(
-        self,
-        data: bytes,
-        client: SatXMPPEntity,
-        file_obj: stream.SatFile,
-        decryptor: CipherContext,
-        unpadder: PaddingContext
-    ) -> None:
-        decrypted = decryptor.update(data)
-        file_obj.write(unpadder.update(decrypted))
-
-    def cbc_decrypt_finalize(
-        self,
-        file_obj: stream.SatFile,
-        decryptor: CipherContext,
-        unpadder: PaddingContext
-    ) -> None:
-        decrypted = decryptor.finalize()
-        file_obj.write(unpadder.update(decrypted))
-        file_obj.write(unpadder.finalize())
-        file_obj.close()
-
-    def _upload_pre_slot(self, client, extra, file_metadata):
-        if extra.get('encryption') != IMPORT_NAME:
-            return True
-        # the tag is appended to the file
-        file_metadata["size"] += 16
-        return True
-
-    def _encrypt(self, data: bytes, encryptor: CipherContext, attachment: dict) -> bytes:
-        if data:
-            attachment["hasher"].update(data)
-            ret = encryptor.update(data)
-            attachment["encrypted_hasher"].update(ret)
-            return ret
-        else:
-            try:
-                # end of file is reached, me must finalize
-                fin = encryptor.finalize()
-                tag = encryptor.tag
-                ret = fin + tag
-                hasher = attachment.pop("hasher")
-                attachment["hash"] = hasher.hexdigest()
-                encrypted_hasher = attachment.pop("encrypted_hasher")
-                encrypted_hasher.update(ret)
-                attachment["encrypted_hash"] = encrypted_hasher.hexdigest()
-                return ret
-            except AlreadyFinalized:
-                # as we have already finalized, we can now send EOF
-                return b''
-
-    def _upload_trigger(self, client, extra, sat_file, file_producer, slot):
-        if extra.get('encryption') != IMPORT_NAME:
-            return True
-        attachment = extra["attachment"]
-        encryption_data = extra["encryption_data"]
-        log.debug("encrypting file with AES-GCM")
-        iv = encryption_data["iv"]
-        key = encryption_data["key"]
-
-        # encrypted data size will be bigger than original file size
-        # so we need to check with final data length to avoid a warning on close()
-        sat_file.check_size_with_read = True
-
-        # file_producer get length directly from file, and this cause trouble as
-        # we have to change the size because of encryption. So we adapt it here,
-        # else the producer would stop reading prematurely
-        file_producer.length = sat_file.size
-
-        encryptor = ciphers.Cipher(
-            ciphers.algorithms.AES(key),
-            modes.GCM(iv),
-            backend=backends.default_backend(),
-        ).encryptor()
-
-        if sat_file.data_cb is not None:
-            raise exceptions.InternalError(
-                f"data_cb was expected to be None, it is set to {sat_file.data_cb}")
-
-        attachment.update({
-            "hash_algo": self._h.ALGO_DEFAULT,
-            "hasher": self._h.get_hasher(),
-            "encrypted_hash_algo": self._h.ALGO_DEFAULT,
-            "encrypted_hasher": self._h.get_hasher(),
-        })
-
-        # with data_cb we encrypt the file on the fly
-        sat_file.data_cb = partial(
-            self._encrypt, encryptor=encryptor, attachment=attachment
-        )
-        return True
-
-
-@implementer(iwokkel.IDisco)
-class XEP0448Handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_ESFS)]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0465.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,267 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for XEP-0465
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional, List, Dict, Union
-
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from twisted.words.protocols.jabber import jid
-from twisted.words.protocols.jabber import error
-from twisted.words.xish import domish
-from zope.interface import implementer
-from wokkel import disco, iwokkel
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.core.core_types import SatXMPPEntity
-from sat.tools import utils
-from sat.tools.common import data_format
-
-log = getLogger(__name__)
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Pubsub Public Subscriptions",
-    C.PI_IMPORT_NAME: "XEP-0465",
-    C.PI_TYPE: C.PLUG_TYPE_XEP,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: ["XEP-0465"],
-    C.PI_DEPENDENCIES: ["XEP-0060", "XEP-0376"],
-    C.PI_MAIN: "XEP_0465",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Pubsub Public Subscriptions implementation"""),
-}
-
-NS_PPS = "urn:xmpp:pps:0"
-NS_PPS_SUBSCRIPTIONS = "urn:xmpp:pps:subscriptions:0"
-NS_PPS_SUBSCRIBERS = "urn:xmpp:pps:subscribers:0"
-SUBSCRIBERS_NODE_PREFIX = f"{NS_PPS_SUBSCRIBERS}/"
-NOT_IMPLEMENTED_MSG = (
-    "The service at {service!s} doesn't seem to support Pubsub Public Subscriptions "
-    "(XEP-0465), please request support from your service administrator."
-)
-
-
-class XEP_0465:
-
-    def __init__(self, host):
-        log.info(_("Pubsub Public Subscriptions initialization"))
-        host.register_namespace("pps", NS_PPS)
-        self.host = host
-        host.bridge.add_method(
-            "ps_public_subscriptions_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=self._subscriptions,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_public_subscriptions_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="s",
-            method=self._subscriptions,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_public_node_subscriptions_get",
-            ".plugin",
-            in_sign="sss",
-            out_sign="a{ss}",
-            method=self._get_public_node_subscriptions,
-            async_=True,
-        )
-
-    def get_handler(self, client):
-        return XEP_0465_Handler()
-
-    @property
-    def subscriptions_node(self) -> str:
-        return NS_PPS_SUBSCRIPTIONS
-
-    @property
-    def subscribers_node_prefix(self) -> str:
-        return SUBSCRIBERS_NODE_PREFIX
-
-    def build_subscription_elt(self, node: str, service: jid.JID) -> domish.Element:
-        """Generate a <subscriptions> element
-
-        This is the element that a service returns on public subscriptions request
-        """
-        subscription_elt = domish.Element((NS_PPS, "subscription"))
-        subscription_elt["node"] = node
-        subscription_elt["service"] = service.full()
-        return subscription_elt
-
-    def build_subscriber_elt(self, subscriber: jid.JID) -> domish.Element:
-        """Generate a <subscriber> element
-
-        This is the element that a service returns on node public subscriptions request
-        """
-        subscriber_elt = domish.Element((NS_PPS, "subscriber"))
-        subscriber_elt["jid"] = subscriber.full()
-        return subscriber_elt
-
-    @utils.ensure_deferred
-    async def _subscriptions(
-        self,
-        service="",
-        nodeIdentifier="",
-        profile_key=C.PROF_KEY_NONE
-    ) -> str:
-        client = self.host.get_client(profile_key)
-        service = None if not service else jid.JID(service)
-        subs = await self.subscriptions(client, service, nodeIdentifier or None)
-        return data_format.serialise(subs)
-
-    async def subscriptions(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID] = None,
-        node: Optional[str] = None
-    ) -> List[Dict[str, Union[str, bool]]]:
-        """Retrieve public subscriptions from a service
-
-        @param service(jid.JID): PubSub service
-        @param nodeIdentifier(unicode, None): node to filter
-            None to get all subscriptions
-        """
-        if service is None:
-            service = client.jid.userhostJID()
-        try:
-            items, __ = await self.host.plugins["XEP-0060"].get_items(
-                client, service, NS_PPS_SUBSCRIPTIONS
-            )
-        except error.StanzaError as e:
-            if e.condition == "forbidden":
-                log.warning(NOT_IMPLEMENTED_MSG.format(service=service))
-                return []
-            else:
-                raise e
-        ret = []
-        for item in items:
-            try:
-                subscription_elt = next(item.elements(NS_PPS, "subscription"))
-            except StopIteration:
-                log.warning(f"no <subscription> element found: {item.toXml()}")
-                continue
-
-            try:
-                sub_dict = {
-                    "service": subscription_elt["service"],
-                    "node": subscription_elt["node"],
-                    "subscriber": service.full(),
-                    "state": subscription_elt.getAttribute("subscription", "subscribed"),
-                }
-            except KeyError:
-                log.warning(
-                    f"invalid <subscription> element: {subscription_elt.toXml()}"
-                )
-                continue
-            if node is not None and sub_dict["node"] != node:
-                # if not is specified, we filter out any other node
-                # FIXME: should node filtering be done by server?
-                continue
-            ret.append(sub_dict)
-        return ret
-
-    @utils.ensure_deferred
-    async def _get_public_node_subscriptions(
-        self,
-        service: str,
-        node: str,
-        profile_key: str
-    ) -> Dict[str, str]:
-        client = self.host.get_client(profile_key)
-        subs = await self.get_public_node_subscriptions(
-            client, jid.JID(service) if service else None, node
-        )
-        return {j.full(): a for j, a in subs.items()}
-
-    def get_public_subscribers_node(self, node: str) -> str:
-        """Return prefixed node to retrieve public subscribers"""
-        return f"{NS_PPS_SUBSCRIBERS}/{node}"
-
-    async def get_public_node_subscriptions(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        nodeIdentifier: str
-    ) -> Dict[jid.JID, str]:
-        """Retrieve public subscriptions to a node
-
-        @param nodeIdentifier(unicode): node to get subscriptions from
-        """
-        if not nodeIdentifier:
-            raise exceptions.DataError("node identifier can't be empty")
-
-        if service is None:
-            service = client.jid.userhostJID()
-
-        subscribers_node = self.get_public_subscribers_node(nodeIdentifier)
-
-        try:
-            items, __ = await self.host.plugins["XEP-0060"].get_items(
-                client, service, subscribers_node
-            )
-        except error.StanzaError as e:
-            if e.condition == "forbidden":
-                log.warning(NOT_IMPLEMENTED_MSG.format(service=service))
-                return {}
-            else:
-                raise e
-        ret = {}
-        for item in items:
-            try:
-                subscriber_elt = next(item.elements(NS_PPS, "subscriber"))
-            except StopIteration:
-                log.warning(f"no <subscriber> element found: {item.toXml()}")
-                continue
-
-            try:
-                ret[jid.JID(subscriber_elt["jid"])] = "subscribed"
-            except (KeyError, RuntimeError):
-                log.warning(
-                    f"invalid <subscriber> element: {subscriber_elt.toXml()}"
-                )
-                continue
-        return ret
-
-    def set_public_opt(self, options: Optional[dict] = None) -> dict:
-        """Set option to make a subscription public
-
-        @param options: dict where the option must be set
-            if None, a new dict will be created
-
-        @return: the options dict
-        """
-        if options is None:
-            options = {}
-        options[f'{{{NS_PPS}}}public'] = True
-        return options
-
-
-@implementer(iwokkel.IDisco)
-class XEP_0465_Handler(XMPPHandler):
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_PPS)]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0470.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,591 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia plugin for Pubsub Attachments
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import List, Tuple, Dict, Any, Callable, Optional
-
-from twisted.words.protocols.jabber import jid, xmlstream, error
-from twisted.words.xish import domish
-from twisted.internet import defer
-from zope.interface import implementer
-from wokkel import pubsub, disco, iwokkel
-
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core.core_types import SatXMPPEntity
-from sat.core import exceptions
-from sat.tools.common import uri, data_format, date_utils
-from sat.tools.utils import as_deferred, xmpp_date
-
-
-log = getLogger(__name__)
-
-IMPORT_NAME = "XEP-0470"
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Pubsub Attachments",
-    C.PI_IMPORT_NAME: IMPORT_NAME,
-    C.PI_TYPE: C.PLUG_TYPE_XEP,
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: ["XEP-0060"],
-    C.PI_MAIN: "PubsubAttachments",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Pubsub Attachments implementation"""),
-}
-NS_PREFIX = "urn:xmpp:pubsub-attachments:"
-NS_PUBSUB_ATTACHMENTS = f"{NS_PREFIX}1"
-NS_PUBSUB_ATTACHMENTS_SUM = f"{NS_PREFIX}summary:1"
-
-
-class PubsubAttachments:
-    namespace = NS_PUBSUB_ATTACHMENTS
-
-    def __init__(self, host):
-        log.info(_("XEP-0470 (Pubsub Attachments) plugin initialization"))
-        host.register_namespace("pubsub-attachments", NS_PUBSUB_ATTACHMENTS)
-        self.host = host
-        self._p = host.plugins["XEP-0060"]
-        self.handlers: Dict[Tuple[str, str], dict[str, Any]] = {}
-        host.trigger.add("XEP-0277_send", self.on_mb_send)
-        self.register_attachment_handler(
-            "noticed", NS_PUBSUB_ATTACHMENTS, self.noticed_get, self.noticed_set
-        )
-        self.register_attachment_handler(
-            "reactions", NS_PUBSUB_ATTACHMENTS, self.reactions_get, self.reactions_set
-        )
-        host.bridge.add_method(
-            "ps_attachments_get",
-            ".plugin",
-            in_sign="sssasss",
-            out_sign="(ss)",
-            method=self._get,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "ps_attachments_set",
-            ".plugin",
-            in_sign="ss",
-            out_sign="",
-            method=self._set,
-            async_=True,
-        )
-
-    def get_handler(self, client):
-        return PubsubAttachments_Handler()
-
-    def register_attachment_handler(
-        self,
-        name: str,
-        namespace: str,
-        get_cb: Callable[
-            [SatXMPPEntity, domish.Element, Dict[str, Any]],
-            None],
-        set_cb: Callable[
-            [SatXMPPEntity, Dict[str, Any], Optional[domish.Element]],
-            Optional[domish.Element]],
-    ) -> None:
-        """Register callbacks to handle an attachment
-
-        @param name: name of the element
-        @param namespace: namespace of the element
-            (name, namespace) couple must be unique
-        @param get: method to call when attachments are retrieved
-            it will be called with (client, element, data) where element is the
-            <attachments> element to parse, and data must be updated in place with
-            parsed data
-        @param set: method to call when the attachment need to be set or udpated
-            it will be called with (client, data, former_elt of None if there was no
-            former element). When suitable, ``operation`` should be used to check if we
-            request an ``update`` or a ``replace``.
-            The callback can be either a blocking method, a Deferred or a coroutine
-        """
-        key = (name, namespace)
-        if key in self.handlers:
-            raise exceptions.ConflictError(
-                f"({name}, {namespace}) attachment handlers are already registered"
-            )
-        self.handlers[(name, namespace)] = {
-            "get": get_cb,
-            "set": set_cb
-        }
-
-    def get_attachment_node_name(self, service: jid.JID, node: str, item: str) -> str:
-        """Generate name to use for attachment node"""
-        target_item_uri = uri.build_xmpp_uri(
-            "pubsub",
-            path=service.userhost(),
-            node=node,
-            item=item
-        )
-        return f"{NS_PUBSUB_ATTACHMENTS}/{target_item_uri}"
-
-    def is_attachment_node(self, node: str) -> bool:
-        """Return True if node name is an attachment node"""
-        return node.startswith(f"{NS_PUBSUB_ATTACHMENTS}/")
-
-    def attachment_node_2_item(self, node: str) -> Tuple[jid.JID, str, str]:
-        """Retrieve service, node and item from attachement node's name"""
-        if not self.is_attachment_node(node):
-            raise ValueError("this is not an attachment node!")
-        prefix_len = len(f"{NS_PUBSUB_ATTACHMENTS}/")
-        item_uri = node[prefix_len:]
-        parsed_uri = uri.parse_xmpp_uri(item_uri)
-        if parsed_uri["type"] != "pubsub":
-            raise ValueError(f"unexpected URI type, it must be a pubsub URI: {item_uri}")
-        try:
-            service = jid.JID(parsed_uri["path"])
-        except RuntimeError:
-            raise ValueError(f"invalid service in pubsub URI: {item_uri}")
-        node = parsed_uri["node"]
-        item = parsed_uri["item"]
-        return (service, node, item)
-
-    async def on_mb_send(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        node: str,
-        item: domish.Element,
-        data: dict
-    ) -> bool:
-        """trigger to create attachment node on each publication"""
-        await self.create_attachments_node(
-            client, service, node, item["id"], autocreate=True
-        )
-        return True
-
-    async def create_attachments_node(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        node: str,
-        item_id: str,
-        autocreate: bool = False
-    ):
-        """Create node for attachements if necessary
-
-        @param service: service of target node
-        @param node: node where target item is published
-        @param item_id: ID of target item
-        @param autocrate: if True, target node is create if it doesn't exist
-        """
-        try:
-            node_config = await self._p.getConfiguration(client, service, node)
-        except error.StanzaError as e:
-            if e.condition == "item-not-found" and autocreate:
-                # we auto-create the missing node
-                await self._p.createNode(
-                    client, service, node
-                )
-                node_config = await self._p.getConfiguration(client, service, node)
-            elif e.condition == "forbidden":
-                node_config = self._p.make_configuration_form({})
-            else:
-                raise e
-        try:
-            # FIXME: check if this is the best publish_model option
-            node_config.fields["pubsub#publish_model"].value = "open"
-        except KeyError:
-            log.warning("pubsub#publish_model field is missing")
-        attachment_node = self.get_attachment_node_name(service, node, item_id)
-        # we use the same options as target node
-        try:
-            await self._p.create_if_new_node(
-                client, service, attachment_node, options=dict(node_config)
-            )
-        except Exception as e:
-            log.warning(f"Can't create attachment node {attachment_node}: {e}")
-
-    def items_2_attachment_data(
-        self,
-        client: SatXMPPEntity,
-        items: List[domish.Element]
-    ) -> List[Dict[str, Any]]:
-        """Convert items from attachment node to attachment data"""
-        list_data = []
-        for item in items:
-            try:
-                attachments_elt = next(
-                    item.elements(NS_PUBSUB_ATTACHMENTS, "attachments")
-                )
-            except StopIteration:
-                log.warning(
-                    "item is missing <attachments> elements, ignoring it: {item.toXml()}"
-                )
-                continue
-            item_id = item["id"]
-            publisher_s = item.getAttribute("publisher")
-            # publisher is not filled by all pubsub service, so we can't count on it
-            if publisher_s:
-                publisher = jid.JID(publisher_s)
-                if publisher.userhost() != item_id:
-                    log.warning(
-                        f"publisher {publisher.userhost()!r} doesn't correspond to item "
-                        f"id {item['id']!r}, ignoring. This may be a hack attempt.\n"
-                        f"{item.toXml()}"
-                    )
-                    continue
-            try:
-                jid.JID(item_id)
-            except RuntimeError:
-                log.warning(
-                    "item ID is not a JID, this is not compliant and is ignored: "
-                    f"{item.toXml}"
-                )
-                continue
-            data = {
-                "from": item_id
-            }
-            for handler in self.handlers.values():
-                handler["get"](client, attachments_elt, data)
-            if len(data) > 1:
-                list_data.append(data)
-        return list_data
-
-    def _get(
-        self,
-        service_s: str,
-        node: str,
-        item: str,
-        senders_s: List[str],
-        extra_s: str,
-        profile_key: str
-    ) -> defer.Deferred:
-        client = self.host.get_client(profile_key)
-        extra = data_format.deserialise(extra_s)
-        senders = [jid.JID(s) for s in senders_s]
-        d = defer.ensureDeferred(
-            self.get_attachments(client, jid.JID(service_s), node, item, senders)
-        )
-        d.addCallback(
-            lambda ret:
-            (data_format.serialise(ret[0]),
-             data_format.serialise(ret[1]))
-        )
-        return d
-
-    async def get_attachments(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        node: str,
-        item: str,
-        senders: Optional[List[jid.JID]],
-        extra: Optional[dict] = None
-    ) -> Tuple[List[Dict[str, Any]], dict]:
-        """Retrieve data attached to a pubsub item
-
-        @param service: pubsub service where the node is
-        @param node: pubsub node containing the item
-        @param item: ID of the item for which attachments will be retrieved
-        @param senders: bare JIDs of entities that are checked. Attachments from those
-            entities will be retrieved.
-            If None, attachments from all entities will be retrieved
-        @param extra: extra data, will be used as ``extra`` argument when doing
-        ``get_items`` call.
-        @return: A tuple with:
-            - the list of attachments data, one item per found sender. The attachments
-              data are dict containing attachment, no ``extra`` field is used here
-              (contrarily to attachments data used with ``set_attachements``).
-            - metadata returned by the call to ``get_items``
-        """
-        if extra is None:
-            extra = {}
-        attachment_node = self.get_attachment_node_name(service, node, item)
-        item_ids = [e.userhost() for e in senders] if senders else None
-        items, metadata = await self._p.get_items(
-            client, service, attachment_node, item_ids=item_ids, extra=extra
-        )
-        list_data = self.items_2_attachment_data(client, items)
-
-        return list_data, metadata
-
-    def _set(
-        self,
-        attachments_s: str,
-        profile_key: str
-    ) -> None:
-        client = self.host.get_client(profile_key)
-        attachments = data_format.deserialise(attachments_s)  or {}
-        return defer.ensureDeferred(self.set_attachements(client, attachments))
-
-    async def apply_set_handler(
-        self,
-        client: SatXMPPEntity,
-        attachments_data: dict,
-        item_elt: Optional[domish.Element],
-        handlers: Optional[List[Tuple[str, str]]] = None,
-        from_jid: Optional[jid.JID] = None,
-    ) -> domish.Element:
-        """Apply all ``set`` callbacks to an attachments item
-
-        @param attachments_data: data describing the attachments
-            ``extra`` key will be used, and created if not found
-        @param from_jid: jid of the author of the attachments
-            ``client.jid.userhostJID()`` will be used if not specified
-        @param item_elt: item containing an <attachments> element
-            will be modified in place
-            if None, a new element will be created
-        @param handlers: list of (name, namespace) of handlers to use.
-            if None, all registered handlers will be used.
-        @return: updated item_elt if given, otherwise a new item_elt
-        """
-        attachments_data.setdefault("extra", {})
-        if item_elt is None:
-            item_id = client.jid.userhost() if from_jid is None else from_jid.userhost()
-            item_elt = pubsub.Item(item_id)
-            item_elt.addElement((NS_PUBSUB_ATTACHMENTS, "attachments"))
-
-        try:
-            attachments_elt = next(
-                item_elt.elements(NS_PUBSUB_ATTACHMENTS, "attachments")
-            )
-        except StopIteration:
-            log.warning(
-                f"no <attachments> element found, creating a new one: {item_elt.toXml()}"
-            )
-            attachments_elt = item_elt.addElement((NS_PUBSUB_ATTACHMENTS, "attachments"))
-
-        if handlers is None:
-            handlers = list(self.handlers.keys())
-
-        for name, namespace in handlers:
-            try:
-                handler = self.handlers[(name, namespace)]
-            except KeyError:
-                log.error(
-                    f"unregistered handler ({name!r}, {namespace!r}) is requested, "
-                    "ignoring"
-                )
-                continue
-            try:
-                former_elt = next(attachments_elt.elements(namespace, name))
-            except StopIteration:
-                former_elt = None
-            new_elt = await as_deferred(
-                handler["set"], client, attachments_data, former_elt
-            )
-            if new_elt != former_elt:
-                if former_elt is not None:
-                    attachments_elt.children.remove(former_elt)
-                if new_elt is not None:
-                    attachments_elt.addChild(new_elt)
-        return item_elt
-
-    async def set_attachements(
-        self,
-        client: SatXMPPEntity,
-        attachments_data: Dict[str, Any]
-    ) -> None:
-        """Set or update attachments
-
-        Former <attachments> element will be retrieved and updated. Individual
-        attachments replace or update their elements individually, according to the
-        "operation" key.
-
-        "operation" key may be "update" or "replace", and defaults to update, it is only
-        used in attachments where "update" makes sense (e.g. it's used for "reactions"
-        but not for "noticed").
-
-        @param attachments_data: data describing attachments. Various keys (usually stored
-            in attachments_data["extra"]) may be used depending on the attachments
-            handlers registered. The keys "service", "node" and "id" MUST be set.
-            ``attachments_data`` is thought to be compatible with microblog data.
-
-        """
-        try:
-            service = jid.JID(attachments_data["service"])
-            node = attachments_data["node"]
-            item = attachments_data["id"]
-        except (KeyError, RuntimeError):
-            raise ValueError(
-                'data must have "service", "node" and "id" set'
-            )
-        attachment_node = self.get_attachment_node_name(service, node, item)
-        try:
-            items, __ = await self._p.get_items(
-                client, service, attachment_node, item_ids=[client.jid.userhost()]
-            )
-        except exceptions.NotFound:
-            item_elt = None
-        else:
-            if not items:
-                item_elt = None
-            else:
-                item_elt = items[0]
-
-        item_elt = await self.apply_set_handler(
-            client,
-            attachments_data,
-            item_elt=item_elt,
-        )
-
-        try:
-            await self._p.send_items(client, service, attachment_node, [item_elt])
-        except error.StanzaError as e:
-            if e.condition == "item-not-found":
-                # the node doesn't exist, we can't publish attachments
-                log.warning(
-                    f"no attachment node found at {service} on {node!r} for item "
-                    f"{item!r}, we can't update attachments."
-                )
-                raise exceptions.NotFound("No attachment node available")
-            else:
-                raise e
-
-    async def subscribe(
-        self,
-        client: SatXMPPEntity,
-        service: jid.JID,
-        node: str,
-        item: str,
-    ) -> None:
-        """Subscribe to attachment node targeting the item
-
-        @param service: service of target item (will also be used for attachment node)
-        @param node: node of target item (used to get attachment node's name)
-        @param item: name of target item (used to get attachment node's name)
-        """
-        attachment_node = self.get_attachment_node_name(service, node, item)
-        await self._p.subscribe(client, service, attachment_node)
-
-
-    def set_timestamp(self, attachment_elt: domish.Element, data: dict) -> None:
-        """Check if a ``timestamp`` attribute is set, parse it, and fill data
-
-        @param attachments_elt: element where the ``timestamp`` attribute may be set
-        @param data: data specific to the attachment (i.e. not the whole microblog data)
-            ``timestamp`` field will be set there if timestamp exists and is parsable
-        """
-        timestamp_raw = attachment_elt.getAttribute("timestamp")
-        if timestamp_raw:
-            try:
-                timestamp = date_utils.date_parse(timestamp_raw)
-            except date_utils.ParserError:
-                log.warning(f"can't parse timestamp: {timestamp_raw}")
-            else:
-                data["timestamp"] = timestamp
-
-    def noticed_get(
-        self,
-        client: SatXMPPEntity,
-        attachments_elt: domish.Element,
-        data: Dict[str, Any],
-    ) -> None:
-        try:
-            noticed_elt = next(
-                attachments_elt.elements(NS_PUBSUB_ATTACHMENTS, "noticed")
-            )
-        except StopIteration:
-            pass
-        else:
-            noticed_data = {
-                "noticed": True
-            }
-            self.set_timestamp(noticed_elt, noticed_data)
-            data["noticed"] = noticed_data
-
-    def noticed_set(
-        self,
-        client: SatXMPPEntity,
-        data: Dict[str, Any],
-        former_elt: Optional[domish.Element]
-    ) -> Optional[domish.Element]:
-        """add or remove a <noticed> attachment
-
-        if data["noticed"] is True, element is added, if it's False, it's removed, and
-        it's not present or None, the former element is kept.
-        """
-        noticed = data["extra"].get("noticed")
-        if noticed is None:
-            return former_elt
-        elif noticed:
-            return domish.Element(
-                (NS_PUBSUB_ATTACHMENTS, "noticed"),
-                attribs = {
-                    "timestamp": xmpp_date()
-                }
-            )
-        else:
-            return None
-
-    def reactions_get(
-        self,
-        client: SatXMPPEntity,
-        attachments_elt: domish.Element,
-        data: Dict[str, Any],
-    ) -> None:
-        try:
-            reactions_elt = next(
-                attachments_elt.elements(NS_PUBSUB_ATTACHMENTS, "reactions")
-            )
-        except StopIteration:
-            pass
-        else:
-            reactions_data = {"reactions": []}
-            reactions = reactions_data["reactions"]
-            for reaction_elt in reactions_elt.elements(NS_PUBSUB_ATTACHMENTS, "reaction"):
-                reactions.append(str(reaction_elt))
-            self.set_timestamp(reactions_elt, reactions_data)
-            data["reactions"] = reactions_data
-
-    def reactions_set(
-        self,
-        client: SatXMPPEntity,
-        data: Dict[str, Any],
-        former_elt: Optional[domish.Element]
-    ) -> Optional[domish.Element]:
-        """update the <reaction> attachment"""
-        reactions_data = data["extra"].get("reactions")
-        if reactions_data is None:
-            return former_elt
-        operation_type = reactions_data.get("operation", "update")
-        if operation_type == "update":
-            former_reactions = {
-                str(r) for r in former_elt.elements(NS_PUBSUB_ATTACHMENTS, "reaction")
-            } if former_elt is not None else set()
-            added_reactions = set(reactions_data.get("add") or [])
-            removed_reactions = set(reactions_data.get("remove") or [])
-            reactions = list((former_reactions | added_reactions) - removed_reactions)
-        elif operation_type == "replace":
-            reactions = reactions_data.get("reactions") or []
-        else:
-            raise exceptions.DataError(f"invalid reaction operation: {operation_type!r}")
-        if reactions:
-            reactions_elt = domish.Element(
-                (NS_PUBSUB_ATTACHMENTS, "reactions"),
-                attribs = {
-                    "timestamp": xmpp_date()
-                }
-            )
-            for reactions_data in reactions:
-                reactions_elt.addElement("reaction", content=reactions_data)
-            return reactions_elt
-        else:
-            return None
-
-
-@implementer(iwokkel.IDisco)
-class PubsubAttachments_Handler(xmlstream.XMPPHandler):
-
-    def getDiscoInfo(self, requestor, service, nodeIdentifier=""):
-        return [disco.DiscoFeature(NS_PUBSUB_ATTACHMENTS)]
-
-    def getDiscoItems(self, requestor, service, nodeIdentifier=""):
-        return []
--- a/sat/plugins/plugin_xep_0471.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1173 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia plugin to handle events
-# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from random import seed
-from typing import Optional, Final, Dict, List, Union, Any, Optional
-from attr import attr
-
-import shortuuid
-from sqlalchemy.orm.events import event
-from sat.core.xmpp import SatXMPPClient
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.core.xmpp import SatXMPPEntity
-from sat.core.core_types import SatXMPPEntity
-from sat.tools import utils
-from sat.tools import xml_tools
-from sat.tools.common import uri as xmpp_uri
-from sat.tools.common import date_utils
-from sat.tools.common import data_format
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid, error
-from twisted.words.xish import domish
-from wokkel import disco, iwokkel
-from zope.interface import implementer
-from twisted.words.protocols.jabber.xmlstream import XMPPHandler
-from wokkel import pubsub, data_form
-
-log = getLogger(__name__)
-
-
-PLUGIN_INFO = {
-    C.PI_NAME: "Events",
-    C.PI_IMPORT_NAME: "XEP-0471",
-    C.PI_TYPE: "XEP",
-    C.PI_MODES: C.PLUG_MODE_BOTH,
-    C.PI_PROTOCOLS: [],
-    C.PI_DEPENDENCIES: [
-        "XEP-0060", "XEP-0080", "XEP-0447", "XEP-0470", # "INVITATION", "PUBSUB_INVITATION",
-        # "LIST_INTEREST"
-    ],
-    C.PI_RECOMMENDATIONS: ["XEP-0277", "EMAIL_INVITATION"],
-    C.PI_MAIN: "XEP_0471",
-    C.PI_HANDLER: "yes",
-    C.PI_DESCRIPTION: _("""Calendar Events"""),
-}
-
-NS_EVENT = "org.salut-a-toi.event:0"
-NS_EVENTS: Final = "urn:xmpp:events:0"
-NS_RSVP: Final = "urn:xmpp:events:rsvp:0"
-NS_EXTRA: Final = "urn:xmpp:events:extra:0"
-
-
-class XEP_0471:
-    namespace = NS_EVENTS
-
-    def __init__(self, host):
-        log.info(_("Events plugin initialization"))
-        self.host = host
-        self._p = host.plugins["XEP-0060"]
-        self._g = host.plugins["XEP-0080"]
-        self._b = host.plugins.get("XEP-0277")
-        self._sfs = host.plugins["XEP-0447"]
-        self._a = host.plugins["XEP-0470"]
-        # self._i = host.plugins.get("EMAIL_INVITATION")
-        host.register_namespace("events", NS_EVENTS)
-        self._a.register_attachment_handler("rsvp", NS_EVENTS, self.rsvp_get, self.rsvp_set)
-        # host.plugins["PUBSUB_INVITATION"].register(NS_EVENTS, self)
-        host.bridge.add_method(
-            "events_get",
-            ".plugin",
-            in_sign="ssasss",
-            out_sign="s",
-            method=self._events_get,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "event_create",
-            ".plugin",
-            in_sign="sssss",
-            out_sign="",
-            method=self._event_create,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "event_modify",
-            ".plugin",
-            in_sign="sssss",
-            out_sign="",
-            method=self._event_modify,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "event_invitee_get",
-            ".plugin",
-            in_sign="sssasss",
-            out_sign="s",
-            method=self._event_invitee_get,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "event_invitee_set",
-            ".plugin",
-            in_sign="sssss",
-            out_sign="",
-            method=self._event_invitee_set,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "event_invitees_list",
-            ".plugin",
-            in_sign="sss",
-            out_sign="a{sa{ss}}",
-            method=self._event_invitees_list,
-            async_=True,
-        ),
-        host.bridge.add_method(
-            "event_invite",
-            ".plugin",
-            in_sign="sssss",
-            out_sign="",
-            method=self._invite,
-            async_=True,
-        )
-        host.bridge.add_method(
-            "event_invite_by_email",
-            ".plugin",
-            in_sign="ssssassssssss",
-            out_sign="",
-            method=self._invite_by_email,
-            async_=True,
-        )
-
-    def get_handler(self, client):
-        return EventsHandler(self)
-
-    def _parse_event_elt(self, event_elt):
-        """Helper method to parse event element
-
-        @param (domish.Element): event_elt
-        @return (tuple[int, dict[unicode, unicode]): timestamp, event_data
-        """
-        try:
-            timestamp = date_utils.date_parse(next(event_elt.elements(NS_EVENT, "date")))
-        except StopIteration:
-            timestamp = -1
-
-        data = {}
-
-        for key in ("name",):
-            try:
-                data[key] = event_elt[key]
-            except KeyError:
-                continue
-
-        for elt_name in ("description",):
-            try:
-                elt = next(event_elt.elements(NS_EVENT, elt_name))
-            except StopIteration:
-                continue
-            else:
-                data[elt_name] = str(elt)
-
-        for elt_name in ("image", "background-image"):
-            try:
-                image_elt = next(event_elt.elements(NS_EVENT, elt_name))
-                data[elt_name] = image_elt["src"]
-            except StopIteration:
-                continue
-            except KeyError:
-                log.warning(_("no src found for image"))
-
-        for uri_type in ("invitees", "blog"):
-            try:
-                elt = next(event_elt.elements(NS_EVENT, uri_type))
-                uri = data[uri_type + "_uri"] = elt["uri"]
-                uri_data = xmpp_uri.parse_xmpp_uri(uri)
-                if uri_data["type"] != "pubsub":
-                    raise ValueError
-            except StopIteration:
-                log.warning(_("no {uri_type} element found!").format(uri_type=uri_type))
-            except KeyError:
-                log.warning(_("incomplete {uri_type} element").format(uri_type=uri_type))
-            except ValueError:
-                log.warning(_("bad {uri_type} element").format(uri_type=uri_type))
-            else:
-                data[uri_type + "_service"] = uri_data["path"]
-                data[uri_type + "_node"] = uri_data["node"]
-
-        for meta_elt in event_elt.elements(NS_EVENT, "meta"):
-            key = meta_elt["name"]
-            if key in data:
-                log.warning(
-                    "Ignoring conflicting meta element: {xml}".format(
-                        xml=meta_elt.toXml()
-                    )
-                )
-                continue
-            data[key] = str(meta_elt)
-        if event_elt.link:
-            link_elt = event_elt.link
-            data["service"] = link_elt["service"]
-            data["node"] = link_elt["node"]
-            data["item"] = link_elt["item"]
-        if event_elt.getAttribute("creator") == "true":
-            data["creator"] = True
-        return timestamp, data
-
-    def event_elt_2_event_data(self, event_elt: domish.Element) -> Dict[str, Any]:
-        """Convert <event/> element to event data
-
-        @param event_elt: <event/> element
-            parent <item/> element can also be used
-        @raise exceptions.NotFound: can't find event payload
-        @raise ValueError: something is missing or badly formed
-        """
-        if event_elt.name == "item":
-            try:
-                event_elt = next(event_elt.elements(NS_EVENTS, "event"))
-            except StopIteration:
-                raise exceptions.NotFound("<event/> payload is missing")
-
-        event_data: Dict[str, Any] = {}
-
-        # id
-
-        parent_elt = event_elt.parent
-        if parent_elt is not None and parent_elt.hasAttribute("id"):
-            event_data["id"] = parent_elt["id"]
-
-        # name
-
-        name_data: Dict[str, str] = {}
-        event_data["name"] = name_data
-        for name_elt in event_elt.elements(NS_EVENTS, "name"):
-            lang = name_elt.getAttribute("xml:lang", "")
-            if lang in name_data:
-                raise ValueError("<name/> elements don't have distinct xml:lang")
-            name_data[lang] = str(name_elt)
-
-        if not name_data:
-            raise exceptions.NotFound("<name/> element is missing")
-
-        # start
-
-        try:
-            start_elt = next(event_elt.elements(NS_EVENTS, "start"))
-        except StopIteration:
-            raise exceptions.NotFound("<start/> element is missing")
-        event_data["start"] = utils.parse_xmpp_date(str(start_elt))
-
-        # end
-
-        try:
-            end_elt = next(event_elt.elements(NS_EVENTS, "end"))
-        except StopIteration:
-            raise exceptions.NotFound("<end/> element is missing")
-        event_data["end"] = utils.parse_xmpp_date(str(end_elt))
-
-        # head-picture
-
-        head_pic_elt = next(event_elt.elements(NS_EVENTS, "head-picture"), None)
-        if head_pic_elt is not None:
-            event_data["head-picture"] = self._sfs.parse_file_sharing_elt(head_pic_elt)
-
-        # description
-
-        seen_desc = set()
-        for description_elt in event_elt.elements(NS_EVENTS, "description"):
-            lang = description_elt.getAttribute("xml:lang", "")
-            desc_type = description_elt.getAttribute("type", "text")
-            lang_type = (lang, desc_type)
-            if lang_type in seen_desc:
-                raise ValueError(
-                    "<description/> elements don't have distinct xml:lang/type"
-                )
-            seen_desc.add(lang_type)
-            descriptions = event_data.setdefault("descriptions", [])
-            description_data = {"description": str(description_elt)}
-            if lang:
-                description_data["language"] = lang
-            if desc_type:
-                description_data["type"] = desc_type
-            descriptions.append(description_data)
-
-        # categories
-
-        for category_elt in event_elt.elements(NS_EVENTS, "category"):
-            try:
-                category_data = {
-                    "term": category_elt["term"]
-                }
-            except KeyError:
-                log.warning(
-                    "<category/> element is missing mandatory term: "
-                    f"{category_elt.toXml()}"
-                )
-                continue
-            wd = category_elt.getAttribute("wd")
-            if wd:
-                category_data["wikidata_id"] = wd
-            lang = category_elt.getAttribute("xml:lang")
-            if lang:
-                category_data["language"] = lang
-            event_data.setdefault("categories", []).append(category_data)
-
-        # locations
-
-        seen_location_ids = set()
-        for location_elt in event_elt.elements(NS_EVENTS, "location"):
-            location_id = location_elt.getAttribute("id", "")
-            if location_id in seen_location_ids:
-                raise ValueError("<location/> elements don't have distinct IDs")
-            seen_location_ids.add(location_id)
-            location_data = self._g.parse_geoloc_elt(location_elt)
-            if location_id:
-                location_data["id"] = location_id
-            lang = location_elt.getAttribute("xml:lang", "")
-            if lang:
-                location_data["language"] = lang
-            event_data.setdefault("locations", []).append(location_data)
-
-        # RSVPs
-
-        seen_rsvp_lang = set()
-        for rsvp_elt in event_elt.elements(NS_EVENTS, "rsvp"):
-            rsvp_lang = rsvp_elt.getAttribute("xml:lang", "")
-            if rsvp_lang in seen_rsvp_lang:
-                raise ValueError("<rsvp/> elements don't have distinct xml:lang")
-            seen_rsvp_lang.add(rsvp_lang)
-            rsvp_form = data_form.findForm(rsvp_elt, NS_RSVP)
-            if rsvp_form is None:
-                log.warning(f"RSVP form is missing: {rsvp_elt.toXml()}")
-                continue
-            rsvp_data = xml_tools.data_form_2_data_dict(rsvp_form)
-            if rsvp_lang:
-                rsvp_data["language"] = rsvp_lang
-            event_data.setdefault("rsvp", []).append(rsvp_data)
-
-        # linked pubsub nodes
-
-        for name in ("invitees", "comments", "blog", "schedule"):
-            elt = next(event_elt.elements(NS_EVENTS, name), None)
-            if elt is not None:
-                try:
-                    event_data[name] = {
-                        "service": elt["jid"],
-                        "node": elt["node"]
-                    }
-                except KeyError:
-                    log.warning(f"invalid {name} element: {elt.toXml()}")
-
-        # attachments
-
-        attachments_elt = next(event_elt.elements(NS_EVENTS, "attachments"), None)
-        if attachments_elt:
-            attachments = event_data["attachments"] = []
-            for file_sharing_elt in attachments_elt.elements(
-                    self._sfs.namespace, "file-sharing"):
-                try:
-                    file_sharing_data = self._sfs.parse_file_sharing_elt(file_sharing_elt)
-                except Exception as e:
-                    log.warning(f"invalid attachment: {e}\n{file_sharing_elt.toXml()}")
-                    continue
-                attachments.append(file_sharing_data)
-
-        # extra
-
-        extra_elt = next(event_elt.elements(NS_EVENTS, "extra"), None)
-        if extra_elt is not None:
-            extra_form = data_form.findForm(extra_elt, NS_EXTRA)
-            if extra_form is None:
-                log.warning(f"extra form is missing: {extra_elt.toXml()}")
-            else:
-                extra_data = event_data["extra"] = {}
-                for name, value in extra_form.items():
-                    if name.startswith("accessibility:"):
-                        extra_data.setdefault("accessibility", {})[name[14:]] = value
-                    elif name == "accessibility":
-                        log.warning(
-                            'ignoring "accessibility" key which is not standard: '
-                            f"{extra_form.toElement().toXml()}"
-                        )
-                    else:
-                        extra_data[name] = value
-
-        # external
-
-        external_elt = next(event_elt.elements(NS_EVENTS, "external"), None)
-        if external_elt:
-            try:
-                event_data["external"] = {
-                    "jid": external_elt["jid"],
-                    "node": external_elt["node"],
-                    "item": external_elt["item"]
-                }
-            except KeyError:
-                log.warning(f"invalid <external/> element: {external_elt.toXml()}")
-
-        return event_data
-
-    def _events_get(
-            self, service: str, node: str, event_ids: List[str], extra: str, profile_key: str
-    ):
-        client = self.host.get_client(profile_key)
-        d = defer.ensureDeferred(
-            self.events_get(
-                client,
-                jid.JID(service) if service else None,
-                node if node else NS_EVENTS,
-                event_ids,
-                data_format.deserialise(extra)
-            )
-        )
-        d.addCallback(data_format.serialise)
-        return d
-
-    async def events_get(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: str = NS_EVENTS,
-        events_ids: Optional[List[str]] = None,
-        extra: Optional[dict] = None,
-    ) -> List[Dict[str, Any]]:
-        """Retrieve event data
-
-        @param service: pubsub service
-        @param node: pubsub node
-        @param event_id: pubsub item ID
-        @return: event data:
-        """
-        if service is None:
-            service = client.jid.userhostJID()
-        items, __ = await self._p.get_items(
-            client, service, node, item_ids=events_ids, extra=extra
-        )
-        events = []
-        for item in items:
-            try:
-                events.append(self.event_elt_2_event_data((item)))
-            except (ValueError, exceptions.NotFound):
-                log.warning(
-                    f"Can't parse event for item {item['id']}: {item.toXml()}"
-                )
-
-        return events
-
-    def _event_create(
-        self,
-        data_s: str,
-        service: str,
-        node: str,
-        event_id: str = "",
-        profile_key: str = C.PROF_KEY_NONE
-    ):
-        client = self.host.get_client(profile_key)
-        return defer.ensureDeferred(
-            self.event_create(
-                client,
-                data_format.deserialise(data_s),
-                jid.JID(service) if service else None,
-                node or None,
-                event_id or None
-            )
-        )
-
-    def event_data_2_event_elt(self, event_data: Dict[str, Any]) -> domish.Element:
-        """Convert Event Data to corresponding Element
-
-        @param event_data: data of the event with keys as follow:
-            name (dict)
-                map of language to name
-                empty string can be used as key if no language is specified
-                this key is mandatory
-            start (int|float)
-                starting time of the event
-                this key is mandatory
-            end (int|float)
-                ending time of the event
-                this key is mandatory
-            head-picture(dict)
-                file sharing data for the main picture to use to represent the event
-            description(list[dict])
-                list of descriptions. If there are several descriptions, they must have
-                distinct (language, type).
-                Description data is dict which following keys:
-                    description(str)
-                        the description itself, either in plain text or xhtml
-                        this key is mandatory
-                    language(str)
-                        ISO-639 language code
-                    type(str)
-                        type of the description, either "text" (default) or "xhtml"
-            categories(list[dict])
-                each category is a dict with following keys:
-                    term(str)
-                        human readable short text of the category
-                        this key is mandatory
-                    wikidata_id(str)
-                        Entity ID from WikiData
-                    language(str)
-                        ISO-639 language code
-            locations(list[dict])
-                list of location dict as used in plugin XEP-0080 [get_geoloc_elt].
-                If several locations are used, they must have distinct IDs
-            rsvp(list[dict])
-                RSVP data. The dict is a data dict as used in
-                sat.tools.xml_tools.data_dict_2_data_form with some extra keys.
-                The "attending" key is automatically added if it's not already present,
-                except if the "no_default" key is present. Thus, an empty dict can be used
-                to use default RSVP.
-                If several dict are present in the list, they must have different "lang"
-                keys.
-                Following extra key can be used:
-                    language(str)
-                        ISO-639 code for language used in the form
-                    no_default(bool)
-                        if True, the "attending" field won't be automatically added
-            invitees(dict)
-                link to pubsub node holding invitees list.
-                Following keys are mandatory:
-                    service(str)
-                        pubsub service where the node is
-                    node (str)
-                        pubsub node to use
-            comments(dict)
-                link to pubsub node holding XEP-0277 comments on the event itself.
-                Following keys are mandatory:
-                    service(str)
-                        pubsub service where the node is
-                    node (str)
-                        pubsub node to use
-            blog(dict)
-                link to pubsub node holding a blog about the event.
-                Following keys are mandatory:
-                    service(str)
-                        pubsub service where the node is
-                    node (str)
-                        pubsub node to use
-            schedule(dict)
-                link to pubsub node holding an events node describing the schedule of this
-                event.
-                Following keys are mandatory:
-                    service(str)
-                        pubsub service where the node is
-                    node (str)
-                        pubsub node to use
-            attachments[list[dict]]
-                list of file sharing data about all kind of attachments of interest for
-                the event.
-            extra(dict)
-                extra information about the event.
-                Keys can be:
-                    website(str)
-                        main website about the event
-                    status(str)
-                        status of the event.
-                        Can be one of "confirmed", "tentative" or "cancelled"
-                    languages(list[str])
-                        ISO-639 codes for languages which will be mainly spoken at the
-                        event
-                    accessibility(dict)
-                        accessibility informations.
-                        Keys can be:
-                            wheelchair
-                                tell if the event is accessible to wheelchair.
-                                Value can be "full", "partial" or "no"
-            external(dict):
-                if present, this event is a link to an external one.
-                Keys (all mandatory) are:
-                    jid: pubsub service
-                    node: pubsub node
-                    item: event id
-        @return: Event element
-        @raise ValueError: some expected data were missing or incorrect
-        """
-        event_elt = domish.Element((NS_EVENTS, "event"))
-        try:
-            for lang, name in event_data["name"].items():
-                name_elt = event_elt.addElement("name", content=name)
-                if lang:
-                    name_elt["xml:lang"] = lang
-        except (KeyError, TypeError):
-            raise ValueError('"name" field is not a dict mapping language to event name')
-        try:
-            event_elt.addElement("start", content=utils.xmpp_date(event_data["start"]))
-            event_elt.addElement("end", content=utils.xmpp_date(event_data["end"]))
-        except (KeyError, TypeError, ValueError):
-            raise ValueError('"start" and "end" fields are mandatory')
-
-        if "head-picture" in event_data:
-            head_pic_data = event_data["head-picture"]
-            head_picture_elt = event_elt.addElement("head-picture")
-            head_picture_elt.addChild(self._sfs.get_file_sharing_elt(**head_pic_data))
-
-        seen_desc = set()
-        if "descriptions" in event_data:
-            for desc_data in event_data["descriptions"]:
-                desc_type = desc_data.get("type", "text")
-                lang = desc_data.get("language") or ""
-                lang_type = (lang, desc_type)
-                if lang_type in seen_desc:
-                    raise ValueError(
-                        '"xml:lang" and "type" is not unique among descriptions: '
-                        f"{desc_data}"
-                    )
-                seen_desc.add(lang_type)
-                try:
-                    description = desc_data["description"]
-                except KeyError:
-                    log.warning(f"description is missing in {desc_data!r}")
-                    continue
-
-                if desc_type == "text":
-                    description_elt = event_elt.addElement(
-                        "description", content=description
-                    )
-                elif desc_type == "xhtml":
-                    description_elt = event_elt.addElement("description")
-                    div_elt = xml_tools.parse(description, namespace=C.NS_XHTML)
-                    description_elt.addChild(div_elt)
-                else:
-                    log.warning(f"unknown description type {desc_type!r}")
-                    continue
-                if lang:
-                    description_elt["xml:lang"] = lang
-        for category_data in event_data.get("categories", []):
-            try:
-                category_term = category_data["term"]
-            except KeyError:
-                log.warning(f'"term" is missing categories data: {category_data}')
-                continue
-            category_elt = event_elt.addElement("category")
-            category_elt["term"] = category_term
-            category_wd = category_data.get("wikidata_id")
-            if category_wd:
-                category_elt["wd"] = category_wd
-            category_lang = category_data.get("language")
-            if category_lang:
-                category_elt["xml:lang"] = category_lang
-
-        seen_location_ids = set()
-        for location_data in event_data.get("locations", []):
-            location_id = location_data.get("id", "")
-            if location_id in seen_location_ids:
-                raise ValueError("locations must have distinct IDs")
-            seen_location_ids.add(location_id)
-            location_elt = event_elt.addElement("location")
-            location_elt.addChild(self._g.get_geoloc_elt(location_data))
-            if location_id:
-                location_elt["id"] = location_id
-
-        rsvp_data_list: Optional[List[dict]] = event_data.get("rsvp")
-        if rsvp_data_list is not None:
-            seen_lang = set()
-            for rsvp_data in rsvp_data_list:
-                if not rsvp_data:
-                    # we use a minimum data if an empty dict is received. It will be later
-                    # filled with defaut "attending" field.
-                    rsvp_data = {"fields": []}
-                rsvp_elt = event_elt.addElement("rsvp")
-                lang = rsvp_data.get("language", "")
-                if lang in seen_lang:
-                    raise ValueError(
-                        "If several RSVP are specified, they must have distinct "
-                        f"languages. {lang!r} language has been used several times."
-                    )
-                seen_lang.add(lang)
-                if lang:
-                    rsvp_elt["xml:lang"] = lang
-                if not rsvp_data.get("no_default", False):
-                    try:
-                        next(f for f in rsvp_data["fields"] if f["name"] == "attending")
-                    except StopIteration:
-                        rsvp_data["fields"].append({
-                            "type": "list-single",
-                            "name": "attending",
-                            "label": "Attending",
-                            "options": [
-                                {"label": "maybe", "value": "maybe"},
-                                {"label": "yes", "value": "yes"},
-                                {"label": "no", "value": "no"}
-                            ],
-                            "required": True
-                        })
-                rsvp_data["namespace"] = NS_RSVP
-                rsvp_form = xml_tools.data_dict_2_data_form(rsvp_data)
-                rsvp_elt.addChild(rsvp_form.toElement())
-
-        for node_type in ("invitees", "comments", "blog", "schedule"):
-            node_data = event_data.get(node_type)
-            if not node_data:
-                continue
-            try:
-                service, node = node_data["service"], node_data["node"]
-            except KeyError:
-                log.warning(f"invalid node data for {node_type}: {node_data}")
-            else:
-                pub_node_elt = event_elt.addElement(node_type)
-                pub_node_elt["jid"] = service
-                pub_node_elt["node"] = node
-
-        attachments = event_data.get("attachments")
-        if attachments:
-            attachments_elt = event_elt.addElement("attachments")
-            for attachment_data in attachments:
-                attachments_elt.addChild(
-                    self._sfs.get_file_sharing_elt(**attachment_data)
-                )
-
-        extra = event_data.get("extra")
-        if extra:
-            extra_form = data_form.Form(
-                "result",
-                formNamespace=NS_EXTRA
-            )
-            for node_type in ("website", "status"):
-                if node_type in extra:
-                    extra_form.addField(
-                        data_form.Field(var=node_type, value=extra[node_type])
-                    )
-            if "languages" in extra:
-                extra_form.addField(
-                    data_form.Field(
-                        "list-multi", var="languages", values=extra["languages"]
-                    )
-                )
-            for node_type, value in extra.get("accessibility", {}).items():
-                extra_form.addField(
-                    data_form.Field(var=f"accessibility:{node_type}", value=value)
-                )
-
-            extra_elt = event_elt.addElement("extra")
-            extra_elt.addChild(extra_form.toElement())
-
-        if "external" in event_data:
-            external_data = event_data["external"]
-            external_elt = event_elt.addElement("external")
-            for node_type in ("jid", "node", "item"):
-                try:
-                    value = external_data[node_type]
-                except KeyError:
-                    raise ValueError(f"Invalid external data: {external_data}")
-                external_elt[node_type] = value
-
-        return event_elt
-
-    async def event_create(
-        self,
-        client: SatXMPPEntity,
-        event_data: Dict[str, Any],
-        service: Optional[jid.JID] = None,
-        node: Optional[str] = None,
-        event_id: Optional[str] = None,
-    ) -> None:
-        """Create or replace an event
-
-        @param event_data: data of the event (cf. [event_data_2_event_elt])
-        @param node: PubSub node of the event
-            None to use default node (default namespace for personal agenda)
-        @param service: PubSub service
-            None to use profile's PEP
-        @param event_id: ID of the item to create.
-        """
-        if not service:
-            service = client.jid.userhostJID()
-        if not node:
-            node = NS_EVENTS
-        if event_id is None:
-            event_id = shortuuid.uuid()
-        event_elt = self.event_data_2_event_elt(event_data)
-
-        item_elt = pubsub.Item(id=event_id, payload=event_elt)
-        options = {self._p.OPT_ACCESS_MODEL: self._p.ACCESS_WHITELIST}
-        await self._p.create_if_new_node(
-            client, service, nodeIdentifier=node, options=options
-        )
-        await self._p.publish(client, service, node, items=[item_elt])
-        if event_data.get("rsvp"):
-            await self._a.create_attachments_node(client, service, node, event_id)
-
-    def _event_modify(
-        self,
-        data_s: str,
-        event_id: str,
-        service: str,
-        node: str,
-        profile_key: str = C.PROF_KEY_NONE
-    ) -> None:
-        client = self.host.get_client(profile_key)
-        defer.ensureDeferred(
-            self.event_modify(
-                client,
-                data_format.deserialise(data_s),
-                event_id,
-                jid.JID(service) if service else None,
-                node or None,
-            )
-        )
-
-    async def event_modify(
-        self,
-        client: SatXMPPEntity,
-        event_data: Dict[str, Any],
-        event_id: str,
-        service: Optional[jid.JID] = None,
-        node: Optional[str] = None,
-    ) -> None:
-        """Update an event
-
-        Similar as create instead that it update existing item instead of
-        creating or replacing it. Params are the same as for [event_create].
-        """
-        if not service:
-            service = client.jid.userhostJID()
-        if not node:
-            node = NS_EVENTS
-        old_event = (await self.events_get(client, service, node, [event_id]))[0]
-        old_event.update(event_data)
-        event_data = old_event
-        await self.event_create(client, event_data, service, node, event_id)
-
-    def rsvp_get(
-        self,
-        client: SatXMPPEntity,
-        attachments_elt: domish.Element,
-        data: Dict[str, Any],
-    ) -> None:
-        """Get RSVP answers from attachments"""
-        try:
-            rsvp_elt = next(
-                attachments_elt.elements(NS_EVENTS, "rsvp")
-            )
-        except StopIteration:
-            pass
-        else:
-            rsvp_form = data_form.findForm(rsvp_elt, NS_RSVP)
-            if rsvp_form is not None:
-                data["rsvp"] = rsvp_data = dict(rsvp_form)
-                self._a.set_timestamp(rsvp_elt, rsvp_data)
-
-    def rsvp_set(
-        self,
-        client: SatXMPPEntity,
-        data: Dict[str, Any],
-        former_elt: Optional[domish.Element]
-    ) -> Optional[domish.Element]:
-        """update the <reaction> attachment"""
-        rsvp_data = data["extra"].get("rsvp")
-        if rsvp_data is None:
-            return former_elt
-        elif rsvp_data:
-            rsvp_elt = domish.Element(
-                (NS_EVENTS, "rsvp"),
-                attribs = {
-                    "timestamp": utils.xmpp_date()
-                }
-            )
-            rsvp_form = data_form.Form("submit", formNamespace=NS_RSVP)
-            rsvp_form.makeFields(rsvp_data)
-            rsvp_elt.addChild(rsvp_form.toElement())
-            return rsvp_elt
-        else:
-            return None
-
-    def _event_invitee_get(
-        self,
-        service: str,
-        node: str,
-        item: str,
-        invitees_s: List[str],
-        extra: str,
-        profile_key: str
-    ) -> defer.Deferred:
-        client = self.host.get_client(profile_key)
-        if invitees_s:
-            invitees = [jid.JID(i) for i in invitees_s]
-        else:
-            invitees = None
-        d = defer.ensureDeferred(
-            self.event_invitee_get(
-                client,
-                jid.JID(service) if service else None,
-                node or None,
-                item,
-                invitees,
-                data_format.deserialise(extra)
-            )
-        )
-        d.addCallback(lambda ret: data_format.serialise(ret))
-        return d
-
-    async def event_invitee_get(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: Optional[str],
-        item: str,
-        invitees: Optional[List[jid.JID]] = None,
-        extra: Optional[Dict[str, Any]] = None,
-    ) -> Dict[str, Dict[str, Any]]:
-        """Retrieve attendance from event node
-
-        @param service: PubSub service
-        @param node: PubSub node of the event
-        @param item: PubSub item of the event
-        @param invitees: if set, only retrieve RSVPs from those guests
-        @param extra: extra data used to retrieve items as for [get_attachments]
-        @return: mapping of invitee bare JID to their RSVP
-            an empty dict is returned if nothing has been answered yed
-        """
-        if service is None:
-            service = client.jid.userhostJID()
-        if node is None:
-            node = NS_EVENTS
-        attachments, metadata = await self._a.get_attachments(
-            client, service, node, item, invitees, extra
-        )
-        ret = {}
-        for attachment in attachments:
-            try:
-                rsvp = attachment["rsvp"]
-            except KeyError:
-                continue
-            ret[attachment["from"]] = rsvp
-
-        return ret
-
-    def _event_invitee_set(
-        self,
-        service: str,
-        node: str,
-        item: str,
-        rsvp_s: str,
-        profile_key: str
-    ):
-        client = self.host.get_client(profile_key)
-        return defer.ensureDeferred(
-            self.event_invitee_set(
-                client,
-                jid.JID(service) if service else None,
-                node or None,
-                item,
-                data_format.deserialise(rsvp_s)
-            )
-        )
-
-    async def event_invitee_set(
-        self,
-        client: SatXMPPEntity,
-        service: Optional[jid.JID],
-        node: Optional[str],
-        item: str,
-        rsvp: Dict[str, Any],
-    ) -> None:
-        """Set or update attendance data in event node
-
-        @param service: PubSub service
-        @param node: PubSub node of the event
-        @param item: PubSub item of the event
-        @param rsvp: RSVP data (values to submit to the form)
-        """
-        if service is None:
-            service = client.jid.userhostJID()
-        if node is None:
-            node = NS_EVENTS
-        await self._a.set_attachements(client, {
-            "service": service.full(),
-            "node": node,
-            "id": item,
-            "extra": {"rsvp": rsvp}
-        })
-
-    def _event_invitees_list(self, service, node, profile_key):
-        service = jid.JID(service) if service else None
-        node = node if node else NS_EVENT
-        client = self.host.get_client(profile_key)
-        return defer.ensureDeferred(
-            self.event_invitees_list(client, service, node)
-        )
-
-    async def event_invitees_list(self, client, service, node):
-        """Retrieve attendance from event node
-
-        @param service(unicode, None): PubSub service
-        @param node(unicode): PubSub node of the event
-        @return (dict): a dict with current attendance status,
-            an empty dict is returned if nothing has been answered yed
-        """
-        items, metadata = await self._p.get_items(client, service, node)
-        invitees = {}
-        for item in items:
-            try:
-                event_elt = next(item.elements(NS_EVENT, "invitee"))
-            except StopIteration:
-                # no item found, event data are not set yet
-                log.warning(_(
-                    "no data found for {item_id} (service: {service}, node: {node})"
-                    .format(item_id=item["id"], service=service, node=node)))
-            else:
-                data = {}
-                for key in ("attend", "guests"):
-                    try:
-                        data[key] = event_elt[key]
-                    except KeyError:
-                        continue
-                invitees[item["id"]] = data
-        return invitees
-
-    async def invite_preflight(
-        self,
-        client: SatXMPPEntity,
-        invitee_jid: jid.JID,
-        service: jid.JID,
-        node: str,
-        item_id: Optional[str] = None,
-        name: str = '',
-        extra: Optional[dict] = None,
-    ) -> None:
-        if self._b is None:
-            raise exceptions.FeatureNotFound(
-                _('"XEP-0277" (blog) plugin is needed for this feature')
-            )
-        if item_id is None:
-            item_id = extra["default_item_id"] = NS_EVENT
-
-        __, event_data = await self.events_get(client, service, node, item_id)
-        log.debug(_("got event data"))
-        invitees_service = jid.JID(event_data["invitees_service"])
-        invitees_node = event_data["invitees_node"]
-        blog_service = jid.JID(event_data["blog_service"])
-        blog_node = event_data["blog_node"]
-        await self._p.set_node_affiliations(
-            client, invitees_service, invitees_node, {invitee_jid: "publisher"}
-        )
-        log.debug(
-            f"affiliation set on invitee node (jid: {invitees_service}, "
-            f"node: {invitees_node!r})"
-        )
-        await self._p.set_node_affiliations(
-            client, blog_service, blog_node, {invitee_jid: "member"}
-        )
-        blog_items, __ = await self._b.mb_get(client, blog_service, blog_node, None)
-
-        for item in blog_items:
-            try:
-                comments_service = jid.JID(item["comments_service"])
-                comments_node = item["comments_node"]
-            except KeyError:
-                log.debug(
-                    "no comment service set for item {item_id}".format(
-                        item_id=item["id"]
-                    )
-                )
-            else:
-                await self._p.set_node_affiliations(
-                    client, comments_service, comments_node, {invitee_jid: "publisher"}
-                )
-        log.debug(_("affiliation set on blog and comments nodes"))
-
-    def _invite(self, invitee_jid, service, node, item_id, profile):
-        return self.host.plugins["PUBSUB_INVITATION"]._send_pubsub_invitation(
-            invitee_jid, service, node, item_id or NS_EVENT, profile_key=profile
-        )
-
-    def _invite_by_email(self, service, node, id_=NS_EVENT, email="", emails_extra=None,
-                       name="", host_name="", language="", url_template="",
-                       message_subject="", message_body="",
-                       profile_key=C.PROF_KEY_NONE):
-        client = self.host.get_client(profile_key)
-        kwargs = {
-            "profile": client.profile,
-            "emails_extra": [str(e) for e in emails_extra],
-        }
-        for key in (
-            "email",
-            "name",
-            "host_name",
-            "language",
-            "url_template",
-            "message_subject",
-            "message_body",
-        ):
-            value = locals()[key]
-            kwargs[key] = str(value)
-        return defer.ensureDeferred(self.invite_by_email(
-            client, jid.JID(service) if service else None, node, id_ or NS_EVENT, **kwargs
-        ))
-
-    async def invite_by_email(self, client, service, node, id_=NS_EVENT, **kwargs):
-        """High level method to create an email invitation to an event
-
-        @param service(unicode, None): PubSub service
-        @param node(unicode): PubSub node of the event
-        @param id_(unicode): id_ with even data
-        """
-        if self._i is None:
-            raise exceptions.FeatureNotFound(
-                _('"Invitations" plugin is needed for this feature')
-            )
-        if self._b is None:
-            raise exceptions.FeatureNotFound(
-                _('"XEP-0277" (blog) plugin is needed for this feature')
-            )
-        service = service or client.jid.userhostJID()
-        event_uri = xmpp_uri.build_xmpp_uri(
-            "pubsub", path=service.full(), node=node, item=id_
-        )
-        kwargs["extra"] = {"event_uri": event_uri}
-        invitation_data = await self._i.create(**kwargs)
-        invitee_jid = invitation_data["jid"]
-        log.debug(_("invitation created"))
-        # now that we have a jid, we can send normal invitation
-        await self.invite(client, invitee_jid, service, node, id_)
-
-    def on_invitation_preflight(
-        self,
-        client: SatXMPPEntity,
-        name: str,
-        extra: dict,
-        service: jid.JID,
-        node: str,
-        item_id: Optional[str],
-        item_elt: domish.Element
-    ) -> None:
-        event_elt = item_elt.event
-        link_elt = event_elt.addElement("link")
-        link_elt["service"] = service.full()
-        link_elt["node"] = node
-        link_elt["item"] = item_id
-        __, event_data = self._parse_event_elt(event_elt)
-        try:
-            name = event_data["name"]
-        except KeyError:
-            pass
-        else:
-            extra["name"] = name
-        if 'image' in event_data:
-            extra["thumb_url"] = event_data['image']
-        extra["element"] = event_elt
-
-
-@implementer(iwokkel.IDisco)
-class EventsHandler(XMPPHandler):
-
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-
-    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
-        return [
-            disco.DiscoFeature(NS_EVENTS),
-        ]
-
-    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
-        return []
--- a/sat/stdui/ui_contact_list.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,308 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT standard user interface for managing contacts
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from sat.core.constants import Const as C
-from sat.tools import xml_tools
-from twisted.words.protocols.jabber import jid
-from xml.dom.minidom import Element
-
-
-class ContactList(object):
-    """Add, update and remove contacts."""
-
-    def __init__(self, host):
-        self.host = host
-        self.__add_id = host.register_callback(self._add_contact, with_data=True)
-        self.__update_id = host.register_callback(self._update_contact, with_data=True)
-        self.__confirm_delete_id = host.register_callback(
-            self._get_confirm_remove_xmlui, with_data=True
-        )
-
-        host.import_menu(
-            (D_("Contacts"), D_("Add contact")),
-            self._get_add_dialog_xmlui,
-            security_limit=2,
-            help_string=D_("Add contact"),
-        )
-        host.import_menu(
-            (D_("Contacts"), D_("Update contact")),
-            self._get_update_dialog_xmlui,
-            security_limit=2,
-            help_string=D_("Update contact"),
-        )
-        host.import_menu(
-            (D_("Contacts"), D_("Remove contact")),
-            self._get_remove_dialog_xmlui,
-            security_limit=2,
-            help_string=D_("Remove contact"),
-        )
-
-        # FIXME: a plugin should not be used here, and current profile's jid host would be better than installation wise host
-        if "MISC-ACCOUNT" in self.host.plugins:
-            self.default_host = self.host.plugins["MISC-ACCOUNT"].account_domain_new_get()
-        else:
-            self.default_host = "example.net"
-
-    def contacts_get(self, profile):
-        """Return a sorted list of the contacts for that profile
-
-        @param profile: %(doc_profile)s
-        @return: list[string]
-        """
-        client = self.host.get_client(profile)
-        ret = [contact.full() for contact in client.roster.get_jids()]
-        ret.sort()
-        return ret
-
-    def get_groups(self, new_groups=None, profile=C.PROF_KEY_NONE):
-        """Return a sorted list of the groups for that profile
-
-        @param new_group (list): add these groups to the existing ones
-        @param profile: %(doc_profile)s
-        @return: list[string]
-        """
-        client = self.host.get_client(profile)
-        ret = client.roster.get_groups()
-        ret.sort()
-        ret.extend([group for group in new_groups if group not in ret])
-        return ret
-
-    def get_groups_of_contact(self, user_jid_s, profile):
-        """Return all the groups of the given contact
-
-        @param user_jid_s (string)
-        @param profile: %(doc_profile)s
-        @return: list[string]
-        """
-        client = self.host.get_client(profile)
-        return client.roster.get_item(jid.JID(user_jid_s)).groups
-
-    def get_groups_of_all_contacts(self, profile):
-        """Return a mapping between the contacts and their groups
-
-        @param profile: %(doc_profile)s
-        @return: dict (key: string, value: list[string]):
-            - key: the JID userhost
-            - value: list of groups
-        """
-        client = self.host.get_client(profile)
-        return {item.jid.userhost(): item.groups for item in client.roster.get_items()}
-
-    def _data2elts(self, data):
-        """Convert a contacts data dict to minidom Elements
-
-        @param data (dict)
-        @return list[Element]
-        """
-        elts = []
-        for key in data:
-            key_elt = Element("jid")
-            key_elt.setAttribute("name", key)
-            for value in data[key]:
-                value_elt = Element("group")
-                value_elt.setAttribute("name", value)
-                key_elt.childNodes.append(value_elt)
-            elts.append(key_elt)
-        return elts
-
-    def get_dialog_xmlui(self, options, data, profile):
-        """Generic method to return the XMLUI dialog for adding or updating a contact
-
-        @param options (dict): parameters for the dialog, with the keys:
-                               - 'id': the menu callback id
-                               - 'title': deferred localized string
-                               - 'contact_text': deferred localized string
-        @param data (dict)
-        @param profile: %(doc_profile)s
-        @return dict
-        """
-        form_ui = xml_tools.XMLUI("form", title=options["title"], submit_id=options["id"])
-        if "message" in data:
-            form_ui.addText(data["message"])
-            form_ui.addDivider("dash")
-
-        form_ui.addText(options["contact_text"])
-        if options["id"] == self.__add_id:
-            contact = data.get(
-                xml_tools.form_escape("contact_jid"), "@%s" % self.default_host
-            )
-            form_ui.addString("contact_jid", value=contact)
-        elif options["id"] == self.__update_id:
-            contacts = self.contacts_get(profile)
-            list_ = form_ui.addList("contact_jid", options=contacts, selected=contacts[0])
-            elts = self._data2elts(self.get_groups_of_all_contacts(profile))
-            list_.set_internal_callback(
-                "groups_of_contact", fields=["contact_jid", "groups_list"], data_elts=elts
-            )
-
-        form_ui.addDivider("blank")
-
-        form_ui.addText(_("Select in which groups your contact is:"))
-        selected_groups = []
-        if "selected_groups" in data:
-            selected_groups = data["selected_groups"]
-        elif options["id"] == self.__update_id:
-            try:
-                selected_groups = self.get_groups_of_contact(contacts[0], profile)
-            except IndexError:
-                pass
-        groups = self.get_groups(selected_groups, profile)
-        form_ui.addList(
-            "groups_list", options=groups, selected=selected_groups, styles=["multi"]
-        )
-
-        adv_list = form_ui.change_container("advanced_list", columns=3, selectable="no")
-        form_ui.addLabel(D_("Add group"))
-        form_ui.addString("add_group")
-        button = form_ui.addButton("", value=D_("Add"))
-        button.set_internal_callback("move", fields=["add_group", "groups_list"])
-        adv_list.end()
-
-        form_ui.addDivider("blank")
-        return {"xmlui": form_ui.toXml()}
-
-    def _get_add_dialog_xmlui(self, data, profile):
-        """Get the dialog for adding contact
-
-        @param data (dict)
-        @param profile: %(doc_profile)s
-        @return dict
-        """
-        options = {
-            "id": self.__add_id,
-            "title": D_("Add contact"),
-            "contact_text": D_("New contact identifier (JID):"),
-        }
-        return self.get_dialog_xmlui(options, {}, profile)
-
-    def _get_update_dialog_xmlui(self, data, profile):
-        """Get the dialog for updating contact
-
-        @param data (dict)
-        @param profile: %(doc_profile)s
-        @return dict
-        """
-        if not self.contacts_get(profile):
-            _dialog = xml_tools.XMLUI("popup", title=D_("Nothing to update"))
-            _dialog.addText(_("Your contact list is empty."))
-            return {"xmlui": _dialog.toXml()}
-
-        options = {
-            "id": self.__update_id,
-            "title": D_("Update contact"),
-            "contact_text": D_("Which contact do you want to update?"),
-        }
-        return self.get_dialog_xmlui(options, {}, profile)
-
-    def _get_remove_dialog_xmlui(self, data, profile):
-        """Get the dialog for removing contact
-
-        @param data (dict)
-        @param profile: %(doc_profile)s
-        @return dict
-        """
-        if not self.contacts_get(profile):
-            _dialog = xml_tools.XMLUI("popup", title=D_("Nothing to delete"))
-            _dialog.addText(_("Your contact list is empty."))
-            return {"xmlui": _dialog.toXml()}
-
-        form_ui = xml_tools.XMLUI(
-            "form",
-            title=D_("Who do you want to remove from your contacts?"),
-            submit_id=self.__confirm_delete_id,
-        )
-        form_ui.addList("contact_jid", options=self.contacts_get(profile))
-        return {"xmlui": form_ui.toXml()}
-
-    def _get_confirm_remove_xmlui(self, data, profile):
-        """Get the confirmation dialog for removing contact
-
-        @param data (dict)
-        @param profile: %(doc_profile)s
-        @return dict
-        """
-        if C.bool(data.get("cancelled", "false")):
-            return {}
-        contact = data[xml_tools.form_escape("contact_jid")]
-
-        def delete_cb(data, profile):
-            if not C.bool(data.get("cancelled", "false")):
-                self._delete_contact(jid.JID(contact), profile)
-            return {}
-
-        delete_id = self.host.register_callback(delete_cb, with_data=True, one_shot=True)
-        form_ui = xml_tools.XMLUI("form", title=D_("Delete contact"), submit_id=delete_id)
-        form_ui.addText(
-            D_("Are you sure you want to remove %s from your contact list?") % contact
-        )
-        return {"xmlui": form_ui.toXml()}
-
-    def _add_contact(self, data, profile):
-        """Add the selected contact
-
-        @param data (dict)
-        @param profile: %(doc_profile)s
-        @return dict
-        """
-        if C.bool(data.get("cancelled", "false")):
-            return {}
-        contact_jid_s = data[xml_tools.form_escape("contact_jid")]
-        try:
-            contact_jid = jid.JID(contact_jid_s)
-        except (RuntimeError, jid.InvalidFormat, AttributeError):
-            # TODO: replace '\t' by a constant (see tools.xmlui.XMLUI.on_form_submitted)
-            data["selected_groups"] = data[xml_tools.form_escape("groups_list")].split(
-                "\t"
-            )
-            options = {
-                "id": self.__add_id,
-                "title": D_("Add contact"),
-                "contact_text": D_('Please enter a valid JID (like "contact@%s"):')
-                % self.default_host,
-            }
-            return self.get_dialog_xmlui(options, data, profile)
-        self.host.contact_add(contact_jid, profile_key=profile)
-        return self._update_contact(data, profile)  # after adding, updating
-
-    def _update_contact(self, data, profile):
-        """Update the selected contact
-
-        @param data (dict)
-        @param profile: %(doc_profile)s
-        @return dict
-        """
-        client = self.host.get_client(profile)
-        if C.bool(data.get("cancelled", "false")):
-            return {}
-        contact_jid = jid.JID(data[xml_tools.form_escape("contact_jid")])
-        # TODO: replace '\t' by a constant (see tools.xmlui.XMLUI.on_form_submitted)
-        groups = data[xml_tools.form_escape("groups_list")].split("\t")
-        self.host.contact_update(client, contact_jid, name="", groups=groups)
-        return {}
-
-    def _delete_contact(self, contact_jid, profile):
-        """Delete the selected contact
-
-        @param contact_jid (JID)
-        @param profile: %(doc_profile)s
-        @return dict
-        """
-        self.host.contact_del(contact_jid, profile_key=profile)
-        return {}
--- a/sat/stdui/ui_profile_manager.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,151 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT standard user interface for managing contacts
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import D_
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.tools import xml_tools
-from sat.memory.memory import ProfileSessions
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-
-
-log = getLogger(__name__)
-
-
-class ProfileManager(object):
-    """Manage profiles."""
-
-    def __init__(self, host):
-        self.host = host
-        self.profile_ciphers = {}
-        self._sessions = ProfileSessions()
-        host.register_callback(
-            self._authenticate_profile, force_id=C.AUTHENTICATE_PROFILE_ID, with_data=True
-        )
-        host.register_callback(
-            self._change_xmpp_password, force_id=C.CHANGE_XMPP_PASSWD_ID, with_data=True
-        )
-        self.__new_xmpp_passwd_id = host.register_callback(
-            self._change_xmpp_password_cb, with_data=True
-        )
-
-    def _start_session_eb(self, fail, first, profile):
-        """Errback method for start_session during profile authentication
-
-        @param first(bool): if True, this is the first try and we have tryied empty password
-            in this case we ask for a password to the user.
-        @param profile(unicode, None): %(doc_profile)s
-            must only be used if first is True
-        """
-        if first:
-            # first call, we ask for the password
-            form_ui = xml_tools.XMLUI(
-                "form", title=D_("Profile password for {}").format(profile), submit_id=""
-            )
-            form_ui.addPassword("profile_password", value="")
-            d = xml_tools.deferred_ui(self.host, form_ui, chained=True)
-            d.addCallback(self._authenticate_profile, profile)
-            return {"xmlui": form_ui.toXml()}
-
-        assert profile is None
-
-        if fail.check(exceptions.PasswordError):
-            dialog = xml_tools.XMLUI("popup", title=D_("Connection error"))
-            dialog.addText(D_("The provided profile password doesn't match."))
-        else:
-            log.error("Unexpected exceptions: {}".format(fail))
-            dialog = xml_tools.XMLUI("popup", title=D_("Internal error"))
-            dialog.addText(D_("Internal error: {}".format(fail)))
-        return {"xmlui": dialog.toXml(), "validated": C.BOOL_FALSE}
-
-    def _authenticate_profile(self, data, profile):
-        if C.bool(data.get("cancelled", "false")):
-            return {}
-        if self.host.memory.is_session_started(profile):
-            return {"validated": C.BOOL_TRUE}
-        try:
-            password = data[xml_tools.form_escape("profile_password")]
-        except KeyError:
-            # first request, we try empty password
-            password = ""
-            first = True
-            eb_profile = profile
-        else:
-            first = False
-            eb_profile = None
-        d = self.host.memory.start_session(password, profile)
-        d.addCallback(lambda __: {"validated": C.BOOL_TRUE})
-        d.addErrback(self._start_session_eb, first, eb_profile)
-        return d
-
-    def _change_xmpp_password(self, data, profile):
-        session_data = self._sessions.profile_get_unique(profile)
-        if not session_data:
-            server = self.host.memory.param_get_a(
-                C.FORCE_SERVER_PARAM, "Connection", profile_key=profile
-            )
-            if not server:
-                server = jid.parse(
-                    self.host.memory.param_get_a(
-                        "JabberID", "Connection", profile_key=profile
-                    )
-                )[1]
-            session_id, session_data = self._sessions.new_session(
-                {"count": 0, "server": server}, profile=profile
-            )
-        if (
-            session_data["count"] > 2
-        ):  # 3 attempts with a new password after the initial try
-            self._sessions.profile_del_unique(profile)
-            _dialog = xml_tools.XMLUI("popup", title=D_("Connection error"))
-            _dialog.addText(
-                D_("Can't connect to %s. Please check your connection details.")
-                % session_data["server"]
-            )
-            return {"xmlui": _dialog.toXml()}
-        session_data["count"] += 1
-        counter = " (%d)" % session_data["count"] if session_data["count"] > 1 else ""
-        title = D_("XMPP password for %(profile)s%(counter)s") % {
-            "profile": profile,
-            "counter": counter,
-        }
-        form_ui = xml_tools.XMLUI(
-            "form", title=title, submit_id=self.__new_xmpp_passwd_id
-        )
-        form_ui.addText(
-            D_(
-                "Can't connect to %s. Please check your connection details or try with another password."
-            )
-            % session_data["server"]
-        )
-        form_ui.addPassword("xmpp_password", value="")
-        return {"xmlui": form_ui.toXml()}
-
-    def _change_xmpp_password_cb(self, data, profile):
-        xmpp_password = data[xml_tools.form_escape("xmpp_password")]
-        d = self.host.memory.param_set(
-            "Password", xmpp_password, "Connection", profile_key=profile
-        )
-        d.addCallback(lambda __: defer.ensureDeferred(self.host.connect(profile)))
-        d.addCallback(lambda __: {})
-        d.addErrback(lambda __: self._change_xmpp_password({}, profile))
-        return d
--- a/sat/test/constants.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,58 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Primitivus: a SAT frontend
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _, D_
-from twisted.words.protocols.jabber import jid
-
-
-class Const(object):
-
-    PROF_KEY_NONE = "@NONE@"
-
-    PROFILE = [
-        "test_profile",
-        "test_profile2",
-        "test_profile3",
-        "test_profile4",
-        "test_profile5",
-    ]
-    JID_STR = [
-        "test@example.org/SàT",
-        "sender@example.net/house",
-        "sender@example.net/work",
-        "sender@server.net/res",
-        "xxx@server.net/res",
-    ]
-    JID = [jid.JID(jid_s) for jid_s in JID_STR]
-
-    PROFILE_DICT = {}
-    for i in range(0, len(PROFILE)):
-        PROFILE_DICT[PROFILE[i]] = JID[i]
-
-    MUC_STR = ["room@chat.server.domain", "sat_game@chat.server.domain"]
-    MUC = [jid.JID(jid_s) for jid_s in MUC_STR]
-
-    NO_SECURITY_LIMIT = -1
-    SECURITY_LIMIT = 0
-
-    # To test frontend parameters
-    APP_NAME = "dummy_frontend"
-    COMPOSITION_KEY = D_("Composition")
-    ENABLE_UNIBOX_PARAM = D_("Enable unibox")
-    PARAM_IN_QUOTES = D_("'Wysiwyg' edition")
--- a/sat/test/helpers.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,482 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-## logging configuration for tests ##
-from sat.core import log_config
-log_config.sat_configure()
-
-import logging
-from sat.core.log import getLogger
-getLogger().setLevel(logging.WARNING)  # put this to DEBUG when needed
-
-from sat.core import exceptions
-from sat.tools import config as tools_config
-from .constants import Const as C
-from wokkel.xmppim import RosterItem
-from wokkel.generic import parseXml
-from sat.core.xmpp import SatRosterProtocol
-from sat.memory.memory import Params, Memory
-from twisted.trial.unittest import FailTest
-from twisted.trial import unittest
-from twisted.internet import defer
-from twisted.words.protocols.jabber.jid import JID
-from twisted.words.xish import domish
-from xml.etree import cElementTree as etree
-from collections import Counter
-import re
-
-
-def b2s(value):
-    """Convert a bool to a unicode string used in bridge
-    @param value: boolean value
-    @return: unicode conversion, according to bridge convention
-
-    """
-    return  "True" if value else "False"
-
-
-def mute_logging():
-    """Temporarily set the logging level to CRITICAL to not pollute the output with expected errors."""
-    logger = getLogger()
-    logger.original_level = logger.getEffectiveLevel()
-    logger.setLevel(logging.CRITICAL)
-
-
-def unmute_logging():
-    """Restore the logging level after it has been temporarily disabled."""
-    logger = getLogger()
-    logger.setLevel(logger.original_level)
-
-
-class DifferentArgsException(FailTest):
-    pass
-
-
-class DifferentXMLException(FailTest):
-    pass
-
-
-class DifferentListException(FailTest):
-    pass
-
-
-class FakeSAT(object):
-    """Class to simulate a SAT instance"""
-
-    def __init__(self):
-        self.bridge = FakeBridge()
-        self.memory = FakeMemory(self)
-        self.trigger = FakeTriggerManager()
-        self.profiles = {}
-        self.reinit()
-
-    def reinit(self):
-        """This can be called by tests that check for sent and stored messages,
-        uses FakeClient or get/set some other data that need to be cleaned"""
-        for profile in self.profiles:
-            self.profiles[profile].reinit()
-        self.memory.reinit()
-        self.stored_messages = []
-        self.plugins = {}
-        self.profiles = {}
-
-    def contact_del(self, to, profile_key):
-        #TODO
-        pass
-
-    def register_callback(self, callback, *args, **kwargs):
-        pass
-
-    def message_send(self, to_s, msg, subject=None, mess_type='auto', extra={}, profile_key='@NONE@'):
-        self.send_and_store_message({"to": JID(to_s)})
-
-    def _send_message_to_stream(self, mess_data, client):
-        """Save the information to check later to whom messages have been sent.
-
-        @param mess_data: message data dictionnary
-        @param client: profile's client
-        """
-        client.xmlstream.send(mess_data['xml'])
-        return mess_data
-
-    def _store_message(self, mess_data, client):
-        """Save the information to check later if entries have been added to the history.
-
-        @param mess_data: message data dictionnary
-        @param client: profile's client
-        """
-        self.stored_messages.append(mess_data["to"])
-        return mess_data
-
-    def send_message_to_bridge(self, mess_data, client):
-        """Simulate the message being sent to the frontends.
-
-        @param mess_data: message data dictionnary
-        @param client: profile's client
-        """
-        return mess_data  # TODO
-
-    def get_profile_name(self, profile_key):
-        """Get the profile name from the profile_key"""
-        return profile_key
-
-    def get_client(self, profile_key):
-        """Convenient method to get client from profile key
-        @return: client or None if it doesn't exist"""
-        profile = self.memory.get_profile_name(profile_key)
-        if not profile:
-            raise exceptions.ProfileKeyUnknown
-        if profile not in self.profiles:
-            self.profiles[profile] = FakeClient(self, profile)
-        return self.profiles[profile]
-
-    def get_jid_n_stream(self, profile_key):
-        """Convenient method to get jid and stream from profile key
-        @return: tuple (jid, xmlstream) from profile, can be None"""
-        return (C.PROFILE_DICT[profile_key], None)
-
-    def is_connected(self, profile):
-        return True
-
-    def get_sent_messages(self, profile_index):
-        """Return all the sent messages (in the order they have been sent) and
-        empty the list. Called by tests. FakeClient instances associated to each
-        profile must have been previously initialized with the method
-        FakeSAT.get_client.
-
-        @param profile_index: index of the profile to consider (cf. C.PROFILE)
-        @return: the sent messages for given profile, or None"""
-        try:
-            tmp = self.profiles[C.PROFILE[profile_index]].xmlstream.sent
-            self.profiles[C.PROFILE[profile_index]].xmlstream.sent = []
-            return tmp
-        except IndexError:
-            return None
-
-    def get_sent_message(self, profile_index):
-        """Pop and return the sent message in first position (works like a FIFO).
-        Called by tests. FakeClient instances associated to each profile must have
-        been previously initialized with the method FakeSAT.get_client.
-
-        @param profile_index: index of the profile to consider (cf. C.PROFILE)
-        @return: the sent message for given profile, or None"""
-        try:
-            return self.profiles[C.PROFILE[profile_index]].xmlstream.sent.pop(0)
-        except IndexError:
-            return None
-
-    def get_sent_message_xml(self, profile_index):
-        """Pop and return the sent message in first position (works like a FIFO).
-        Called by tests. FakeClient instances associated to each profile must have
-        been previously initialized with the method FakeSAT.get_client.
-        @return: XML representation of the sent message for given profile, or None"""
-        entry = self.get_sent_message(profile_index)
-        return entry.toXml() if entry else None
-
-    def find_features_set(self, features, identity=None, jid_=None, profile=C.PROF_KEY_NONE):
-        """Call self.add_feature from your tests to change the return value.
-
-        @return: a set of entities
-        """
-        client = self.get_client(profile)
-        if jid_ is None:
-            jid_ = JID(client.jid.host)
-        try:
-            if set(features).issubset(client.features[jid_]):
-                return defer.succeed(set([jid_]))
-        except (TypeError, AttributeError, KeyError):
-            pass
-        return defer.succeed(set())
-
-    def add_feature(self, jid_, feature, profile_key):
-        """Add a feature to an entity.
-
-        To be called from your tests when needed.
-        """
-        client = self.get_client(profile_key)
-        if not hasattr(client, 'features'):
-            client.features = {}
-        if jid_ not in client.features:
-            client.features[jid_] = set()
-        client.features[jid_].add(feature)
-
-
-class FakeBridge(object):
-    """Class to simulate and test bridge calls"""
-
-    def __init__(self):
-        self.expected_calls = {}
-
-    def expect_call(self, name, *check_args, **check_kwargs):
-        if hasattr(self, name):  # queue this new call as one already exists
-            self.expected_calls.setdefault(name, [])
-            self.expected_calls[name].append((check_args, check_kwargs))
-            return
-
-        def check_call(*args, **kwargs):
-            if args != check_args or kwargs != check_kwargs:
-                print("\n\n--------------------")
-                print("Args are not equals:")
-                print("args\n----\n%s (sent)\n%s (wanted)" % (args, check_args))
-                print("kwargs\n------\n%s (sent)\n%s (wanted)" % (kwargs, check_kwargs))
-                print("--------------------\n\n")
-                raise DifferentArgsException
-            delattr(self, name)
-
-            if name in self.expected_calls:  # register the next call
-                args, kwargs = self.expected_calls[name].pop(0)
-                if len(self.expected_calls[name]) == 0:
-                    del self.expected_calls[name]
-                self.expect_call(name, *args, **kwargs)
-
-        setattr(self, name, check_call)
-
-    def add_method(self, name, int_suffix, in_sign, out_sign, method, async_=False, doc=None):
-        pass
-
-    def add_signal(self, name, int_suffix, signature):
-        pass
-
-    def add_test_callback(self, name, method):
-        """This can be used to register callbacks for bridge methods AND signals.
-        Contrary to expect_call, this will not check if the method or signal is
-        called/sent with the correct arguments, it will instead run the callback
-        of your choice."""
-        setattr(self, name, method)
-
-
-class FakeParams(Params):
-    """Class to simulate and test params object. The methods of Params that could
-    not be run (for example those using the storage attribute must be overwritten
-    by a naive simulation of what they should do."""
-
-    def __init__(self, host, storage):
-        Params.__init__(self, host, storage)
-        self.params = {}  # naive simulation of values storage
-
-    def param_set(self, name, value, category, security_limit=-1, profile_key='@NONE@'):
-        profile = self.get_profile_name(profile_key)
-        self.params.setdefault(profile, {})
-        self.params[profile_key][(category, name)] = value
-
-    def param_get_a(self, name, category, attr="value", profile_key='@NONE@'):
-        profile = self.get_profile_name(profile_key)
-        return self.params[profile][(category, name)]
-
-    def get_profile_name(self, profile_key, return_profile_keys=False):
-        if profile_key == '@DEFAULT@':
-            return C.PROFILE[0]
-        elif profile_key == '@NONE@':
-            raise exceptions.ProfileNotSetError
-        else:
-            return profile_key
-
-    def load_ind_params(self, profile, cache=None):
-        self.params[profile] = {}
-        return defer.succeed(None)
-
-
-class FakeMemory(Memory):
-    """Class to simulate and test memory object"""
-
-    def __init__(self, host):
-        # do not call Memory.__init__, we just want to call the methods that are
-        # manipulating basic stuff, the others should be overwritten when needed
-        self.host = host
-        self.params = FakeParams(host, None)
-        self.config = tools_config.parse_main_conf()
-        self.reinit()
-
-    def reinit(self):
-        """Tests that manipulate params, entities, features should
-        re-initialise the memory first to not fake the result."""
-        self.params.load_default_params()
-        self.params.params.clear()
-        self.params.frontends_cache = []
-        self.entities_data = {}
-
-    def get_profile_name(self, profile_key, return_profile_keys=False):
-        return self.params.get_profile_name(profile_key, return_profile_keys)
-
-    def add_to_history(self, from_jid, to_jid, message, _type='chat', extra=None, timestamp=None, profile="@NONE@"):
-        pass
-
-    def contact_add(self, contact_jid, attributes, groups, profile_key='@DEFAULT@'):
-        pass
-
-    def set_presence_status(self, contact_jid, show, priority, statuses, profile_key='@DEFAULT@'):
-        pass
-
-    def add_waiting_sub(self, type_, contact_jid, profile_key):
-        pass
-
-    def del_waiting_sub(self, contact_jid, profile_key):
-        pass
-
-    def update_entity_data(self, entity_jid, key, value, silent=False, profile_key="@NONE@"):
-        self.entities_data.setdefault(entity_jid, {})
-        self.entities_data[entity_jid][key] = value
-
-    def entity_data_get(self, entity_jid, keys, profile_key):
-        result = {}
-        for key in keys:
-            result[key] = self.entities_data[entity_jid][key]
-        return result
-
-
-class FakeTriggerManager(object):
-
-    def add(self, point_name, callback, priority=0):
-        pass
-
-    def point(self, point_name, *args, **kwargs):
-        """We always return true to continue the action"""
-        return True
-
-
-class FakeRosterProtocol(SatRosterProtocol):
-    """This class is used by FakeClient (one instance per profile)"""
-
-    def __init__(self, host, parent):
-        SatRosterProtocol.__init__(self, host)
-        self.parent = parent
-        self._jids = {}
-        self.add_item(parent.jid.userhostJID())
-
-    def add_item(self, jid, *args, **kwargs):
-        if not args and not kwargs:
-            # defaults values setted for the tests only
-            kwargs["subscriptionTo"] = True
-            kwargs["subscriptionFrom"] = True
-        roster_item = RosterItem(jid, *args, **kwargs)
-        attrs = {'to': b2s(roster_item.subscriptionTo), 'from': b2s(roster_item.subscriptionFrom), 'ask': b2s(roster_item.pendingOut)}
-        if roster_item.name:
-            attrs['name'] = roster_item.name
-        self.host.bridge.expect_call("contact_new", jid.full(), attrs, roster_item.groups, self.parent.profile)
-        self._jids[jid] = roster_item
-        self._register_item(roster_item)
-
-
-class FakeXmlStream(object):
-    """This class is used by FakeClient (one instance per profile)"""
-
-    def __init__(self):
-        self.sent = []
-
-    def send(self, obj):
-        """Save the sent messages to compare them later.
-
-        @param obj (domish.Element, str or unicode): message to send
-        """
-        if not isinstance(obj, domish.Element):
-            assert(isinstance(obj, str) or isinstance(obj, str))
-            obj = parseXml(obj)
-
-        if obj.name == 'iq':
-            # IQ request expects an answer, return the request itself so
-            # you can check if it has been well built by your plugin.
-            self.iqDeferreds[obj['id']].callback(obj)
-
-        self.sent.append(obj)
-        return defer.succeed(None)
-
-    def addObserver(self, *argv):
-        pass
-
-
-class FakeClient(object):
-    """Tests involving more than one profile need one instance of this class per profile"""
-
-    def __init__(self, host, profile=None):
-        self.host = host
-        self.profile = profile if profile else C.PROFILE[0]
-        self.jid = C.PROFILE_DICT[self.profile]
-        self.roster = FakeRosterProtocol(host, self)
-        self.xmlstream = FakeXmlStream()
-
-    def reinit(self):
-        self.xmlstream = FakeXmlStream()
-
-    def send(self, obj):
-        return self.xmlstream.send(obj)
-
-
-class SatTestCase(unittest.TestCase):
-
-    def assert_equal_xml(self, xml, expected, ignore_blank=False):
-        def equal_elt(got_elt, exp_elt):
-            if ignore_blank:
-                for elt in got_elt, exp_elt:
-                    for attr in ('text', 'tail'):
-                        value = getattr(elt, attr)
-                        try:
-                            value = value.strip() or None
-                        except AttributeError:
-                            value = None
-                        setattr(elt, attr, value)
-            if (got_elt.tag != exp_elt.tag):
-                print("XML are not equals (elt %s/%s):" % (got_elt, exp_elt))
-                print("tag: got [%s] expected: [%s]" % (got_elt.tag, exp_elt.tag))
-                return False
-            if (got_elt.attrib != exp_elt.attrib):
-                print("XML are not equals (elt %s/%s):" % (got_elt, exp_elt))
-                print("attribs: got %s expected %s" % (got_elt.attrib, exp_elt.attrib))
-                return False
-            if (got_elt.tail != exp_elt.tail or got_elt.text != exp_elt.text):
-                print("XML are not equals (elt %s/%s):" % (got_elt, exp_elt))
-                print("text: got [%s] expected: [%s]" % (got_elt.text, exp_elt.text))
-                print("tail: got [%s] expected: [%s]" % (got_elt.tail, exp_elt.tail))
-                return False
-            if (len(got_elt) != len(exp_elt)):
-                print("XML are not equals (elt %s/%s):" % (got_elt, exp_elt))
-                print("children len: got %d expected: %d" % (len(got_elt), len(exp_elt)))
-                return False
-            for idx, child in enumerate(got_elt):
-                if not equal_elt(child, exp_elt[idx]):
-                    return False
-            return True
-
-        def remove_blank(xml):
-            lines = [line.strip() for line in re.sub(r'[ \t\r\f\v]+', ' ', xml).split('\n')]
-            return '\n'.join([line for line in lines if line])
-
-        xml_elt = etree.fromstring(remove_blank(xml) if ignore_blank else xml)
-        expected_elt = etree.fromstring(remove_blank(expected) if ignore_blank else expected)
-
-        if not equal_elt(xml_elt, expected_elt):
-            print("---")
-            print("XML are not equals:")
-            print("got:\n-\n%s\n-\n\n" % etree.tostring(xml_elt, encoding='utf-8'))
-            print("was expecting:\n-\n%s\n-\n\n" % etree.tostring(expected_elt, encoding='utf-8'))
-            print("---")
-            raise DifferentXMLException
-
-    def assert_equal_unsorted_list(self, a, b, msg):
-        counter_a = Counter(a)
-        counter_b = Counter(b)
-        if counter_a != counter_b:
-            print("---")
-            print("Unsorted lists are not equals:")
-            print("got          : %s" % counter_a)
-            print("was expecting: %s" % counter_b)
-            if msg:
-                print(msg)
-            print("---")
-            raise DifferentListException
--- a/sat/test/helpers_plugins.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,301 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Helpers class for plugin dependencies """
-
-from twisted.internet import defer
-
-from wokkel.muc import Room, User
-from wokkel.generic import parseXml
-from wokkel.disco import DiscoItem, DiscoItems
-
-# temporary until the changes are integrated to Wokkel
-from sat_tmp.wokkel.rsm import RSMResponse
-
-from .constants import Const as C
-from sat.plugins import plugin_xep_0045
-from collections import OrderedDict
-
-
-class FakeMUCClient(object):
-    def __init__(self, plugin_parent):
-        self.plugin_parent = plugin_parent
-        self.host = plugin_parent.host
-        self.joined_rooms = {}
-
-    def join(self, room_jid, nick, options=None, profile_key=C.PROF_KEY_NONE):
-        """
-        @param room_jid: the room JID
-        @param nick: nick to be used in the room
-        @param options: joining options
-        @param profile_key: the profile key of the user joining the room
-        @return: the deferred joined wokkel.muc.Room instance
-        """
-        profile = self.host.memory.get_profile_name(profile_key)
-        roster = {}
-
-        # ask the other profiles to fill our roster
-        for i in range(0, len(C.PROFILE)):
-            other_profile = C.PROFILE[i]
-            if other_profile == profile:
-                continue
-            try:
-                other_room = self.plugin_parent.clients[other_profile].joined_rooms[
-                    room_jid
-                ]
-                roster.setdefault(
-                    other_room.nick, User(other_room.nick, C.PROFILE_DICT[other_profile])
-                )
-                for other_nick in other_room.roster:
-                    roster.setdefault(other_nick, other_room.roster[other_nick])
-            except (AttributeError, KeyError):
-                pass
-
-        # rename our nick if it already exists
-        while nick in list(roster.keys()):
-            if C.PROFILE_DICT[profile].userhost() == roster[nick].entity.userhost():
-                break  # same user with different resource --> same nickname
-            nick = nick + "_"
-
-        room = Room(room_jid, nick)
-        room.roster = roster
-        self.joined_rooms[room_jid] = room
-
-        # fill the other rosters with the new entry
-        for i in range(0, len(C.PROFILE)):
-            other_profile = C.PROFILE[i]
-            if other_profile == profile:
-                continue
-            try:
-                other_room = self.plugin_parent.clients[other_profile].joined_rooms[
-                    room_jid
-                ]
-                other_room.roster.setdefault(
-                    room.nick, User(room.nick, C.PROFILE_DICT[profile])
-                )
-            except (AttributeError, KeyError):
-                pass
-
-        return defer.succeed(room)
-
-    def leave(self, roomJID, profile_key=C.PROF_KEY_NONE):
-        """
-        @param roomJID: the room JID
-        @param profile_key: the profile key of the user joining the room
-        @return: a dummy deferred
-        """
-        profile = self.host.memory.get_profile_name(profile_key)
-        room = self.joined_rooms[roomJID]
-        # remove ourself from the other rosters
-        for i in range(0, len(C.PROFILE)):
-            other_profile = C.PROFILE[i]
-            if other_profile == profile:
-                continue
-            try:
-                other_room = self.plugin_parent.clients[other_profile].joined_rooms[
-                    roomJID
-                ]
-                del other_room.roster[room.nick]
-            except (AttributeError, KeyError):
-                pass
-        del self.joined_rooms[roomJID]
-        return defer.Deferred()
-
-
-class FakeXEP_0045(plugin_xep_0045.XEP_0045):
-    def __init__(self, host):
-        self.host = host
-        self.clients = {}
-        for profile in C.PROFILE:
-            self.clients[profile] = FakeMUCClient(self)
-
-    def join(self, room_jid, nick, options={}, profile_key="@DEFAULT@"):
-        """
-        @param roomJID: the room JID
-        @param nick: nick to be used in the room
-        @param options: ignore
-        @param profile_key: the profile of the user joining the room
-        @return: the deferred joined wokkel.muc.Room instance or None
-        """
-        profile = self.host.memory.get_profile_name(profile_key)
-        if room_jid in self.clients[profile].joined_rooms:
-            return defer.succeed(None)
-        room = self.clients[profile].join(room_jid, nick, profile_key=profile)
-        return room
-
-    def join_room(self, muc_index, user_index):
-        """Called by tests
-        @return: the nickname of the user who joined room"""
-        muc_jid = C.MUC[muc_index]
-        nick = C.JID[user_index].user
-        profile = C.PROFILE[user_index]
-        self.join(muc_jid, nick, profile_key=profile)
-        return self.get_nick(muc_index, user_index)
-
-    def leave(self, room_jid, profile_key="@DEFAULT@"):
-        """
-        @param roomJID: the room JID
-        @param profile_key: the profile of the user leaving the room
-        @return: a dummy deferred
-        """
-        profile = self.host.memory.get_profile_name(profile_key)
-        if room_jid not in self.clients[profile].joined_rooms:
-            raise plugin_xep_0045.UnknownRoom("This room has not been joined")
-        return self.clients[profile].leave(room_jid, profile)
-
-    def leave_room(self, muc_index, user_index):
-        """Called by tests
-        @return: the nickname of the user who left the room"""
-        muc_jid = C.MUC[muc_index]
-        nick = self.get_nick(muc_index, user_index)
-        profile = C.PROFILE[user_index]
-        self.leave(muc_jid, profile_key=profile)
-        return nick
-
-    def get_room(self, muc_index, user_index):
-        """Called by tests
-        @return: a wokkel.muc.Room instance"""
-        profile = C.PROFILE[user_index]
-        muc_jid = C.MUC[muc_index]
-        try:
-            return self.clients[profile].joined_rooms[muc_jid]
-        except (AttributeError, KeyError):
-            return None
-
-    def get_nick(self, muc_index, user_index):
-        try:
-            return self.get_room_nick(C.MUC[muc_index], C.PROFILE[user_index])
-        except (KeyError, AttributeError):
-            return ""
-
-    def get_nick_of_user(self, muc_index, user_index, profile_index, secure=True):
-        try:
-            room = self.clients[C.PROFILE[profile_index]].joined_rooms[C.MUC[muc_index]]
-            return self.getRoomNickOfUser(room, C.JID[user_index])
-        except (KeyError, AttributeError):
-            return None
-
-
-class FakeXEP_0249(object):
-    def __init__(self, host):
-        self.host = host
-
-    def invite(self, target, room, options={}, profile_key="@DEFAULT@"):
-        """
-        Invite a user to a room. To accept the invitation from a test,
-        just call FakeXEP_0045.join_room (no need to have a dedicated method).
-        @param target: jid of the user to invite
-        @param room: jid of the room where the user is invited
-        @options: attribute with extra info (reason, password) as in #XEP-0249
-        @profile_key: %(doc_profile_key)s
-        """
-        pass
-
-
-class FakeSatPubSubClient(object):
-    def __init__(self, host, parent_plugin):
-        self.host = host
-        self.parent_plugin = parent_plugin
-        self.__items = OrderedDict()
-        self.__rsm_responses = {}
-
-    def createNode(self, service, nodeIdentifier=None, options=None, sender=None):
-        return defer.succeed(None)
-
-    def deleteNode(self, service, nodeIdentifier, sender=None):
-        try:
-            del self.__items[nodeIdentifier]
-        except KeyError:
-            pass
-        return defer.succeed(None)
-
-    def subscribe(self, service, nodeIdentifier, subscriber, options=None, sender=None):
-        return defer.succeed(None)
-
-    def unsubscribe(
-        self,
-        service,
-        nodeIdentifier,
-        subscriber,
-        subscriptionIdentifier=None,
-        sender=None,
-    ):
-        return defer.succeed(None)
-
-    def publish(self, service, nodeIdentifier, items=None, sender=None):
-        node = self.__items.setdefault(nodeIdentifier, [])
-
-        def replace(item_obj):
-            index = 0
-            for current in node:
-                if current["id"] == item_obj["id"]:
-                    node[index] = item_obj
-                    return True
-                index += 1
-            return False
-
-        for item in items:
-            item_obj = parseXml(item) if isinstance(item, str) else item
-            if not replace(item_obj):
-                node.append(item_obj)
-        return defer.succeed(None)
-
-    def items(
-        self,
-        service,
-        nodeIdentifier,
-        maxItems=None,
-        itemIdentifiers=None,
-        subscriptionIdentifier=None,
-        sender=None,
-        ext_data=None,
-    ):
-        try:
-            items = self.__items[nodeIdentifier]
-        except KeyError:
-            items = []
-        if ext_data:
-            assert "id" in ext_data
-            if "rsm" in ext_data:
-                args = (0, items[0]["id"], items[-1]["id"]) if items else ()
-                self.__rsm_responses[ext_data["id"]] = RSMResponse(len(items), *args)
-        return defer.succeed(items)
-
-    def retract_items(self, service, nodeIdentifier, itemIdentifiers, sender=None):
-        node = self.__items[nodeIdentifier]
-        for item in [item for item in node if item["id"] in itemIdentifiers]:
-            node.remove(item)
-        return defer.succeed(None)
-
-    def get_rsm_response(self, id):
-        if id not in self.__rsm_responses:
-            return {}
-        result = self.__rsm_responses[id].toDict()
-        del self.__rsm_responses[id]
-        return result
-
-    def subscriptions(self, service, nodeIdentifier, sender=None):
-        return defer.succeed([])
-
-    def service_get_disco_items(self, service, nodeIdentifier, profile_key=C.PROF_KEY_NONE):
-        items = DiscoItems()
-        for item in list(self.__items.keys()):
-            items.append(DiscoItem(service, item))
-        return defer.succeed(items)
--- a/sat/test/test_core_xmpp.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,111 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.test import helpers
-from .constants import Const
-from twisted.trial import unittest
-from sat.core import xmpp
-from twisted.words.protocols.jabber.jid import JID
-from wokkel.generic import parseXml
-from wokkel.xmppim import RosterItem
-
-
-class SatXMPPClientTest(unittest.TestCase):
-
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-        self.client = xmpp.SatXMPPClient(self.host, Const.PROFILE[0], JID("test@example.org"), "test")
-
-    def test_init(self):
-        """Check that init values are correctly initialised"""
-        self.assertEqual(self.client.profile, Const.PROFILE[0])
-        print(self.client.jid.host)
-        self.assertEqual(self.client.host_app, self.host)
-
-
-class SatMessageProtocolTest(unittest.TestCase):
-
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-        self.message = xmpp.SatMessageProtocol(self.host)
-        self.message.parent = helpers.FakeClient(self.host)
-
-    def test_on_message(self):
-        xml = """
-        <message type="chat" from="sender@example.net/house" to="test@example.org/SàT" id="test_1">
-        <body>test</body>
-        </message>
-        """
-        stanza = parseXml(xml)
-        self.host.bridge.expect_call("message_new", "sender@example.net/house", "test", "chat", "test@example.org/SàT", {}, profile=Const.PROFILE[0])
-        self.message.onMessage(stanza)
-
-
-class SatRosterProtocolTest(unittest.TestCase):
-
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-        self.roster = xmpp.SatRosterProtocol(self.host)
-        self.roster.parent = helpers.FakeClient(self.host)
-
-    def test_register_item(self):
-        roster_item = RosterItem(Const.JID[0])
-        roster_item.name = "Test Man"
-        roster_item.subscriptionTo = True
-        roster_item.subscriptionFrom = True
-        roster_item.ask = False
-        roster_item.groups = set(["Test Group 1", "Test Group 2", "Test Group 3"])
-        self.host.bridge.expect_call("contact_new", Const.JID_STR[0], {'to': 'True', 'from': 'True', 'ask': 'False', 'name': 'Test Man'}, set(["Test Group 1", "Test Group 2", "Test Group 3"]), Const.PROFILE[0])
-        self.roster._register_item(roster_item)
-
-
-class SatPresenceProtocolTest(unittest.TestCase):
-
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-        self.presence = xmpp.SatPresenceProtocol(self.host)
-        self.presence.parent = helpers.FakeClient(self.host)
-
-    def test_availableReceived(self):
-        self.host.bridge.expect_call("presence_update", Const.JID_STR[0], "xa", 15, {'default': "test status", 'fr': 'statut de test'}, Const.PROFILE[0])
-        self.presence.availableReceived(Const.JID[0], 'xa', {None: "test status", 'fr': 'statut de test'}, 15)
-
-    def test_available_received_empty_statuses(self):
-        self.host.bridge.expect_call("presence_update", Const.JID_STR[0], "xa", 15, {}, Const.PROFILE[0])
-        self.presence.availableReceived(Const.JID[0], 'xa', None, 15)
-
-    def test_unavailableReceived(self):
-        self.host.bridge.expect_call("presence_update", Const.JID_STR[0], "unavailable", 0, {}, Const.PROFILE[0])
-        self.presence.unavailableReceived(Const.JID[0], None)
-
-    def test_subscribedReceived(self):
-        self.host.bridge.expect_call("subscribe", "subscribed", Const.JID[0].userhost(), Const.PROFILE[0])
-        self.presence.subscribedReceived(Const.JID[0])
-
-    def test_unsubscribedReceived(self):
-        self.host.bridge.expect_call("subscribe", "unsubscribed", Const.JID[0].userhost(), Const.PROFILE[0])
-        self.presence.unsubscribedReceived(Const.JID[0])
-
-    def test_subscribeReceived(self):
-        self.host.bridge.expect_call("subscribe", "subscribe", Const.JID[0].userhost(), Const.PROFILE[0])
-        self.presence.subscribeReceived(Const.JID[0])
-
-    def test_unsubscribeReceived(self):
-        self.host.bridge.expect_call("subscribe", "unsubscribe", Const.JID[0].userhost(), Const.PROFILE[0])
-        self.presence.unsubscribeReceived(Const.JID[0])
--- a/sat/test/test_helpers_plugins.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,124 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Test the helper classes to see if they behave well"""
-
-from sat.test import helpers
-from sat.test import helpers_plugins
-
-
-class FakeXEP_0045Test(helpers.SatTestCase):
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-        self.plugin = helpers_plugins.FakeXEP_0045(self.host)
-
-    def test_join_room(self):
-        self.plugin.join_room(0, 0)
-        self.assertEqual("test", self.plugin.get_nick(0, 0))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 0))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 0))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 0))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 0))
-        self.assertEqual("", self.plugin.get_nick(0, 1))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 1))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 1))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 1))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 1))
-        self.assertEqual("", self.plugin.get_nick(0, 2))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 2))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 2))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 2))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 2))
-        self.assertEqual("", self.plugin.get_nick(0, 3))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 3))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 3))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 3))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 3))
-        self.plugin.join_room(0, 1)
-        self.assertEqual("test", self.plugin.get_nick(0, 0))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 0))
-        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 1, 0))
-        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 2, 0))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 0))
-        self.assertEqual("sender", self.plugin.get_nick(0, 1))
-        self.assertEqual("test", self.plugin.get_nick_of_user(0, 0, 1))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 1))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 1))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 1))
-        self.assertEqual("", self.plugin.get_nick(0, 2))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 2))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 2))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 2))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 2))
-        self.assertEqual("", self.plugin.get_nick(0, 3))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 3))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 3))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 3))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 3))
-        self.plugin.join_room(0, 2)
-        self.assertEqual("test", self.plugin.get_nick(0, 0))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 0))
-        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 1, 0))
-        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 2, 0))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 0))
-        self.assertEqual("sender", self.plugin.get_nick(0, 1))
-        self.assertEqual("test", self.plugin.get_nick_of_user(0, 0, 1))
-        self.assertEqual(
-            "sender", self.plugin.get_nick_of_user(0, 1, 1)
-        )  # Const.JID[2] is in the roster for Const.PROFILE[1]
-        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 2, 1))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 1))
-        self.assertEqual("sender", self.plugin.get_nick(0, 2))
-        self.assertEqual("test", self.plugin.get_nick_of_user(0, 0, 2))
-        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 1, 2))
-        self.assertEqual(
-            "sender", self.plugin.get_nick_of_user(0, 2, 2)
-        )  # Const.JID[1] is in the roster for Const.PROFILE[2]
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 2))
-        self.assertEqual("", self.plugin.get_nick(0, 3))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 3))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 1, 3))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 2, 3))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 3))
-        self.plugin.join_room(0, 3)
-        self.assertEqual("test", self.plugin.get_nick(0, 0))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 0, 0))
-        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 1, 0))
-        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 2, 0))
-        self.assertEqual("sender_", self.plugin.get_nick_of_user(0, 3, 0))
-        self.assertEqual("sender", self.plugin.get_nick(0, 1))
-        self.assertEqual("test", self.plugin.get_nick_of_user(0, 0, 1))
-        self.assertEqual(
-            "sender", self.plugin.get_nick_of_user(0, 1, 1)
-        )  # Const.JID[2] is in the roster for Const.PROFILE[1]
-        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 2, 1))
-        self.assertEqual("sender_", self.plugin.get_nick_of_user(0, 3, 1))
-        self.assertEqual("sender", self.plugin.get_nick(0, 2))
-        self.assertEqual("test", self.plugin.get_nick_of_user(0, 0, 2))
-        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 1, 2))
-        self.assertEqual(
-            "sender", self.plugin.get_nick_of_user(0, 2, 2)
-        )  # Const.JID[1] is in the roster for Const.PROFILE[2]
-        self.assertEqual("sender_", self.plugin.get_nick_of_user(0, 3, 2))
-        self.assertEqual("sender_", self.plugin.get_nick(0, 3))
-        self.assertEqual("test", self.plugin.get_nick_of_user(0, 0, 3))
-        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 1, 3))
-        self.assertEqual("sender", self.plugin.get_nick_of_user(0, 2, 3))
-        self.assertEqual(None, self.plugin.get_nick_of_user(0, 3, 3))
--- a/sat/test/test_memory.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,313 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.core.i18n import _
-from sat.test import helpers
-from twisted.trial import unittest
-import traceback
-from .constants import Const
-from xml.dom import minidom
-
-
-class MemoryTest(unittest.TestCase):
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-
-    def _get_param_xml(self, param="1", security_level=None):
-        """Generate XML for testing parameters
-
-        @param param (str): a subset of "123"
-        @param security_level: security level of the parameters
-        @return (str)
-        """
-
-        def get_param(name):
-            return """
-            <param name="%(param_name)s" label="%(param_label)s" value="true" type="bool" %(security)s/>
-            """ % {
-                "param_name": name,
-                "param_label": _(name),
-                "security": ""
-                if security_level is None
-                else ('security="%d"' % security_level),
-            }
-
-        params = ""
-        if "1" in param:
-            params += get_param(Const.ENABLE_UNIBOX_PARAM)
-        if "2" in param:
-            params += get_param(Const.PARAM_IN_QUOTES)
-        if "3" in param:
-            params += get_param("Dummy param")
-        return """
-        <params>
-        <individual>
-        <category name="%(category_name)s" label="%(category_label)s">
-            %(params)s
-         </category>
-        </individual>
-        </params>
-        """ % {
-            "category_name": Const.COMPOSITION_KEY,
-            "category_label": _(Const.COMPOSITION_KEY),
-            "params": params,
-        }
-
-    def _param_exists(self, param="1", src=None):
-        """
-
-        @param param (str): a character in "12"
-        @param src (DOM element): the top-level element to look in
-        @return: True is the param exists
-        """
-        if param == "1":
-            name = Const.ENABLE_UNIBOX_PARAM
-        else:
-            name = Const.PARAM_IN_QUOTES
-        category = Const.COMPOSITION_KEY
-        if src is None:
-            src = self.host.memory.params.dom.documentElement
-        for type_node in src.childNodes:
-            # when src comes self.host.memory.params.dom, we have here
-            # some "individual" or "general" elements, when it comes
-            # from Memory.get_params we have here a "params" elements
-            if type_node.nodeName not in ("individual", "general", "params"):
-                continue
-            for cat_node in type_node.childNodes:
-                if (
-                    cat_node.nodeName != "category"
-                    or cat_node.getAttribute("name") != category
-                ):
-                    continue
-                for param in cat_node.childNodes:
-                    if param.nodeName == "param" and param.getAttribute("name") == name:
-                        return True
-        return False
-
-    def assert_param_generic(self, param="1", src=None, exists=True, deferred=False):
-        """
-        @param param (str): a character in "12"
-        @param src (DOM element): the top-level element to look in
-        @param exists (boolean): True to assert the param exists, False to assert it doesn't
-        @param deferred (boolean): True if this method is called from a Deferred callback
-        """
-        msg = (
-            "Expected parameter not found!\n"
-            if exists
-            else "Unexpected parameter found!\n"
-        )
-        if deferred:
-            # in this stack we can see the line where the error came from,
-            # if limit=5, 6 is not enough you can increase the value
-            msg += "\n".join(traceback.format_stack(limit=5 if exists else 6))
-        assertion = self._param_exists(param, src)
-        getattr(self, "assert%s" % exists)(assertion, msg)
-
-    def assert_param_exists(self, param="1", src=None):
-        self.assert_param_generic(param, src, True)
-
-    def assert_param_not_exists(self, param="1", src=None):
-        self.assert_param_generic(param, src, False)
-
-    def assert_param_exists_async(self, src, param="1"):
-        """@param src: a deferred result from Memory.get_params"""
-        self.assert_param_generic(
-            param, minidom.parseString(src.encode("utf-8")), True, True
-        )
-
-    def assert_param_not_exists_async(self, src, param="1"):
-        """@param src: a deferred result from Memory.get_params"""
-        self.assert_param_generic(
-            param, minidom.parseString(src.encode("utf-8")), False, True
-        )
-
-    def _get_params(self, security_limit, app="", profile_key="@NONE@"):
-        """Get the parameters accessible with the given security limit and application name.
-
-        @param security_limit (int): the security limit
-        @param app (str): empty string or "libervia"
-        @param profile_key
-        """
-        if profile_key == "@NONE@":
-            profile_key = "@DEFAULT@"
-        return self.host.memory.params.get_params(security_limit, app, profile_key)
-
-    def test_update_params(self):
-        self.host.memory.reinit()
-        # check if the update works
-        self.host.memory.update_params(self._get_param_xml())
-        self.assert_param_exists()
-        previous = self.host.memory.params.dom.cloneNode(True)
-        # now check if it is really updated and not duplicated
-        self.host.memory.update_params(self._get_param_xml())
-        self.assertEqual(
-            previous.toxml().encode("utf-8"),
-            self.host.memory.params.dom.toxml().encode("utf-8"),
-        )
-
-        self.host.memory.reinit()
-        # check successive updates (without intersection)
-        self.host.memory.update_params(self._get_param_xml("1"))
-        self.assert_param_exists("1")
-        self.assert_param_not_exists("2")
-        self.host.memory.update_params(self._get_param_xml("2"))
-        self.assert_param_exists("1")
-        self.assert_param_exists("2")
-
-        previous = self.host.memory.params.dom.cloneNode(True)  # save for later
-
-        self.host.memory.reinit()
-        # check successive updates (with intersection)
-        self.host.memory.update_params(self._get_param_xml("1"))
-        self.assert_param_exists("1")
-        self.assert_param_not_exists("2")
-        self.host.memory.update_params(self._get_param_xml("12"))
-        self.assert_param_exists("1")
-        self.assert_param_exists("2")
-
-        # successive updates with or without intersection should have the same result
-        self.assertEqual(
-            previous.toxml().encode("utf-8"),
-            self.host.memory.params.dom.toxml().encode("utf-8"),
-        )
-
-        self.host.memory.reinit()
-        # one update with two params in a new category
-        self.host.memory.update_params(self._get_param_xml("12"))
-        self.assert_param_exists("1")
-        self.assert_param_exists("2")
-
-    def test_get_params(self):
-        # tests with no security level on the parameter (most secure)
-        params = self._get_param_xml()
-        self.host.memory.reinit()
-        self.host.memory.update_params(params)
-        self._get_params(Const.NO_SECURITY_LIMIT).addCallback(self.assert_param_exists_async)
-        self._get_params(0).addCallback(self.assert_param_not_exists_async)
-        self._get_params(1).addCallback(self.assert_param_not_exists_async)
-        # tests with security level 0 on the parameter (not secure)
-        params = self._get_param_xml(security_level=0)
-        self.host.memory.reinit()
-        self.host.memory.update_params(params)
-        self._get_params(Const.NO_SECURITY_LIMIT).addCallback(self.assert_param_exists_async)
-        self._get_params(0).addCallback(self.assert_param_exists_async)
-        self._get_params(1).addCallback(self.assert_param_exists_async)
-        # tests with security level 1 on the parameter (more secure)
-        params = self._get_param_xml(security_level=1)
-        self.host.memory.reinit()
-        self.host.memory.update_params(params)
-        self._get_params(Const.NO_SECURITY_LIMIT).addCallback(self.assert_param_exists_async)
-        self._get_params(0).addCallback(self.assert_param_not_exists_async)
-        return self._get_params(1).addCallback(self.assert_param_exists_async)
-
-    def test_params_register_app(self):
-        def register(xml, security_limit, app):
-            """
-            @param xml: XML definition of the parameters to be added
-            @param security_limit: -1 means no security, 0 is the maximum security then the higher the less secure
-            @param app: name of the frontend registering the parameters
-            """
-            helpers.mute_logging()
-            self.host.memory.params_register_app(xml, security_limit, app)
-            helpers.unmute_logging()
-
-        # tests with no security level on the parameter (most secure)
-        params = self._get_param_xml()
-        self.host.memory.reinit()
-        register(params, Const.NO_SECURITY_LIMIT, Const.APP_NAME)
-        self.assert_param_exists()
-        self.host.memory.reinit()
-        register(params, 0, Const.APP_NAME)
-        self.assert_param_not_exists()
-        self.host.memory.reinit()
-        register(params, 1, Const.APP_NAME)
-        self.assert_param_not_exists()
-
-        # tests with security level 0 on the parameter (not secure)
-        params = self._get_param_xml(security_level=0)
-        self.host.memory.reinit()
-        register(params, Const.NO_SECURITY_LIMIT, Const.APP_NAME)
-        self.assert_param_exists()
-        self.host.memory.reinit()
-        register(params, 0, Const.APP_NAME)
-        self.assert_param_exists()
-        self.host.memory.reinit()
-        register(params, 1, Const.APP_NAME)
-        self.assert_param_exists()
-
-        # tests with security level 1 on the parameter (more secure)
-        params = self._get_param_xml(security_level=1)
-        self.host.memory.reinit()
-        register(params, Const.NO_SECURITY_LIMIT, Const.APP_NAME)
-        self.assert_param_exists()
-        self.host.memory.reinit()
-        register(params, 0, Const.APP_NAME)
-        self.assert_param_not_exists()
-        self.host.memory.reinit()
-        register(params, 1, Const.APP_NAME)
-        self.assert_param_exists()
-
-        # tests with security level 1 and several parameters being registered
-        params = self._get_param_xml("12", security_level=1)
-        self.host.memory.reinit()
-        register(params, Const.NO_SECURITY_LIMIT, Const.APP_NAME)
-        self.assert_param_exists()
-        self.assert_param_exists("2")
-        self.host.memory.reinit()
-        register(params, 0, Const.APP_NAME)
-        self.assert_param_not_exists()
-        self.assert_param_not_exists("2")
-        self.host.memory.reinit()
-        register(params, 1, Const.APP_NAME)
-        self.assert_param_exists()
-        self.assert_param_exists("2")
-
-        # tests with several parameters being registered in an existing category
-        self.host.memory.reinit()
-        self.host.memory.update_params(self._get_param_xml("3"))
-        register(self._get_param_xml("12"), Const.NO_SECURITY_LIMIT, Const.APP_NAME)
-        self.assert_param_exists()
-        self.assert_param_exists("2")
-        self.host.memory.reinit()
-
-    def test_params_register_app_get_params(self):
-        # test retrieving the parameter for a specific frontend
-        self.host.memory.reinit()
-        params = self._get_param_xml(security_level=1)
-        self.host.memory.params_register_app(params, 1, Const.APP_NAME)
-        self._get_params(1, "").addCallback(self.assert_param_exists_async)
-        self._get_params(1, Const.APP_NAME).addCallback(self.assert_param_exists_async)
-        self._get_params(1, "another_dummy_frontend").addCallback(
-            self.assert_param_not_exists_async
-        )
-
-        # the same with several parameters registered at the same time
-        self.host.memory.reinit()
-        params = self._get_param_xml("12", security_level=0)
-        self.host.memory.params_register_app(params, 5, Const.APP_NAME)
-        self._get_params(5, "").addCallback(self.assert_param_exists_async)
-        self._get_params(5, "").addCallback(self.assert_param_exists_async, "2")
-        self._get_params(5, Const.APP_NAME).addCallback(self.assert_param_exists_async)
-        self._get_params(5, Const.APP_NAME).addCallback(self.assert_param_exists_async, "2")
-        self._get_params(5, "another_dummy_frontend").addCallback(
-            self.assert_param_not_exists_async
-        )
-        return self._get_params(5, "another_dummy_frontend").addCallback(
-            self.assert_param_not_exists_async, "2"
-        )
--- a/sat/test/test_memory_crypto.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,71 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2016  Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016  Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-""" Tests for the plugin radiocol """
-
-from sat.test import helpers
-from sat.memory.crypto import BlockCipher, PasswordHasher
-import random
-import string
-from twisted.internet import defer
-
-
-def get_random_unicode(len):
-    """Return a random unicode string"""
-    return "".join(random.choice(string.letters + "éáúóâêûôßüöä") for i in range(len))
-
-
-class CryptoTest(helpers.SatTestCase):
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-
-    def test_encrypt_decrypt(self):
-        d_list = []
-
-        def test(key, message):
-            d = BlockCipher.encrypt(key, message)
-            d.addCallback(lambda ciphertext: BlockCipher.decrypt(key, ciphertext))
-            d.addCallback(lambda decrypted: self.assertEqual(message, decrypted))
-            d_list.append(d)
-
-        for key_len in (0, 2, 8, 10, 16, 24, 30, 32, 40):
-            key = get_random_unicode(key_len)
-            for message_len in (0, 2, 16, 24, 32, 100):
-                message = get_random_unicode(message_len)
-                test(key, message)
-        return defer.DeferredList(d_list)
-
-    def test_hash_verify(self):
-        d_list = []
-        for password in (0, 2, 8, 10, 16, 24, 30, 32, 40):
-            d = PasswordHasher.hash(password)
-
-            def cb(hashed):
-                d1 = PasswordHasher.verify(password, hashed)
-                d1.addCallback(lambda result: self.assertTrue(result))
-                d_list.append(d1)
-                attempt = get_random_unicode(10)
-                d2 = PasswordHasher.verify(attempt, hashed)
-                d2.addCallback(lambda result: self.assertFalse(result))
-                d_list.append(d2)
-
-            d.addCallback(cb)
-        return defer.DeferredList(d_list)
--- a/sat/test/test_plugin_misc_groupblog.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,615 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Plugin groupblogs """
-
-from .constants import Const as C
-from sat.test import helpers, helpers_plugins
-from sat.plugins import plugin_misc_groupblog
-from sat.plugins import plugin_xep_0060
-from sat.plugins import plugin_xep_0277
-from sat.plugins import plugin_xep_0163
-from sat.plugins import plugin_misc_text_syntaxes
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-import importlib
-
-
-NS_PUBSUB = "http://jabber.org/protocol/pubsub"
-
-DO_NOT_COUNT_COMMENTS = -1
-
-SERVICE = "pubsub.example.com"
-PUBLISHER = "test@example.org"
-OTHER_PUBLISHER = "other@xmpp.net"
-NODE_ID = "urn:xmpp:groupblog:{publisher}".format(publisher=PUBLISHER)
-OTHER_NODE_ID = "urn:xmpp:groupblog:{publisher}".format(publisher=OTHER_PUBLISHER)
-ITEM_ID_1 = "c745a688-9b02-11e3-a1a3-c0143dd4fe51"
-COMMENT_ID_1 = "d745a688-9b02-11e3-a1a3-c0143dd4fe52"
-COMMENT_ID_2 = "e745a688-9b02-11e3-a1a3-c0143dd4fe53"
-
-
-def COMMENTS_NODE_ID(publisher=PUBLISHER):
-    return "urn:xmpp:comments:_{id}__urn:xmpp:groupblog:{publisher}".format(
-        id=ITEM_ID_1, publisher=publisher
-    )
-
-
-def COMMENTS_NODE_URL(publisher=PUBLISHER):
-    return "xmpp:{service}?node={node}".format(
-        service=SERVICE,
-        id=ITEM_ID_1,
-        node=COMMENTS_NODE_ID(publisher).replace(":", "%3A").replace("@", "%40"),
-    )
-
-
-def ITEM(publisher=PUBLISHER):
-    return """
-          <item id='{id}' xmlns='{ns}'>
-            <entry>
-              <title type='text'>The Uses of This World</title>
-              <id>{id}</id>
-              <updated>2003-12-12T17:47:23Z</updated>
-              <published>2003-12-12T17:47:23Z</published>
-              <link href='{comments_node_url}' rel='replies' title='comments'/>
-              <author>
-                <name>{publisher}</name>
-              </author>
-            </entry>
-          </item>
-        """.format(
-        ns=NS_PUBSUB,
-        id=ITEM_ID_1,
-        publisher=publisher,
-        comments_node_url=COMMENTS_NODE_URL(publisher),
-    )
-
-
-def COMMENT(id_=COMMENT_ID_1):
-    return """
-          <item id='{id}' xmlns='{ns}'>
-            <entry>
-              <title type='text'>The Uses of This World</title>
-              <id>{id}</id>
-              <updated>2003-12-12T17:47:23Z</updated>
-              <published>2003-12-12T17:47:23Z</published>
-              <author>
-                <name>{publisher}</name>
-              </author>
-            </entry>
-          </item>
-        """.format(
-        ns=NS_PUBSUB, id=id_, publisher=PUBLISHER
-    )
-
-
-def ITEM_DATA(id_=ITEM_ID_1, count=0):
-    res = {
-        "id": ITEM_ID_1,
-        "type": "main_item",
-        "content": "The Uses of This World",
-        "author": PUBLISHER,
-        "updated": "1071251243.0",
-        "published": "1071251243.0",
-        "service": SERVICE,
-        "comments": COMMENTS_NODE_URL_1,
-        "comments_service": SERVICE,
-        "comments_node": COMMENTS_NODE_ID_1,
-    }
-    if count != DO_NOT_COUNT_COMMENTS:
-        res.update({"comments_count": str(count)})
-    return res
-
-
-def COMMENT_DATA(id_=COMMENT_ID_1):
-    return {
-        "id": id_,
-        "type": "comment",
-        "content": "The Uses of This World",
-        "author": PUBLISHER,
-        "updated": "1071251243.0",
-        "published": "1071251243.0",
-        "service": SERVICE,
-        "node": COMMENTS_NODE_ID_1,
-        "verified_publisher": "false",
-    }
-
-
-COMMENTS_NODE_ID_1 = COMMENTS_NODE_ID()
-COMMENTS_NODE_ID_2 = COMMENTS_NODE_ID(OTHER_PUBLISHER)
-COMMENTS_NODE_URL_1 = COMMENTS_NODE_URL()
-COMMENTS_NODE_URL_2 = COMMENTS_NODE_URL(OTHER_PUBLISHER)
-ITEM_1 = ITEM()
-ITEM_2 = ITEM(OTHER_PUBLISHER)
-COMMENT_1 = COMMENT(COMMENT_ID_1)
-COMMENT_2 = COMMENT(COMMENT_ID_2)
-
-
-def ITEM_DATA_1(count=0):
-    return ITEM_DATA(count=count)
-
-
-COMMENT_DATA_1 = COMMENT_DATA()
-COMMENT_DATA_2 = COMMENT_DATA(COMMENT_ID_2)
-
-
-class XEP_groupblogTest(helpers.SatTestCase):
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-        self.host.plugins["XEP-0060"] = plugin_xep_0060.XEP_0060(self.host)
-        self.host.plugins["XEP-0163"] = plugin_xep_0163.XEP_0163(self.host)
-        importlib.reload(plugin_misc_text_syntaxes)  # reload the plugin to avoid conflict error
-        self.host.plugins["TEXT_SYNTAXES"] = plugin_misc_text_syntaxes.TextSyntaxes(
-            self.host
-        )
-        self.host.plugins["XEP-0277"] = plugin_xep_0277.XEP_0277(self.host)
-        self.plugin = plugin_misc_groupblog.GroupBlog(self.host)
-        self.plugin._initialise = self._initialise
-        self.__initialised = False
-        self._initialise(C.PROFILE[0])
-
-    def _initialise(self, profile_key):
-        profile = profile_key
-        client = self.host.get_client(profile)
-        if not self.__initialised:
-            client.item_access_pubsub = jid.JID(SERVICE)
-            xep_0060 = self.host.plugins["XEP-0060"]
-            client.pubsub_client = helpers_plugins.FakeSatPubSubClient(
-                self.host, xep_0060
-            )
-            client.pubsub_client.parent = client
-            self.psclient = client.pubsub_client
-            helpers.FakeSAT.getDiscoItems = self.psclient.service_get_disco_items
-            self.__initialised = True
-        return defer.succeed((profile, client))
-
-    def _add_item(self, profile, item, parent_node=None):
-        client = self.host.get_client(profile)
-        client.pubsub_client._add_item(item, parent_node)
-
-    def test_send_group_blog(self):
-        self._initialise(C.PROFILE[0])
-        d = self.psclient.items(SERVICE, NODE_ID)
-        d.addCallback(lambda items: self.assertEqual(len(items), 0))
-        d.addCallback(
-            lambda __: self.plugin.sendGroupBlog(
-                "PUBLIC", [], "test", {}, C.PROFILE[0]
-            )
-        )
-        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
-        return d.addCallback(lambda items: self.assertEqual(len(items), 1))
-
-    def test_delete_group_blog(self):
-        pub_data = (SERVICE, NODE_ID, ITEM_ID_1)
-        self.host.bridge.expect_call(
-            "personalEvent",
-            C.JID_STR[0],
-            "MICROBLOG_DELETE",
-            {"type": "main_item", "id": ITEM_ID_1},
-            C.PROFILE[0],
-        )
-
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.plugin.deleteGroupBlog(
-                pub_data, COMMENTS_NODE_URL_1, profile_key=C.PROFILE[0]
-            )
-        )
-        return d.addCallback(self.assertEqual, None)
-
-    def test_update_group_blog(self):
-        pub_data = (SERVICE, NODE_ID, ITEM_ID_1)
-        new_text = "silfu23RFWUP)IWNOEIOEFÖ"
-
-        self._initialise(C.PROFILE[0])
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.plugin.updateGroupBlog(
-                pub_data, COMMENTS_NODE_URL_1, new_text, {}, profile_key=C.PROFILE[0]
-            )
-        )
-        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
-        return d.addCallback(
-            lambda items: self.assertEqual(
-                "".join(items[0].entry.title.children), new_text
-            )
-        )
-
-    def test_send_group_blog_comment(self):
-        self._initialise(C.PROFILE[0])
-        d = self.psclient.items(SERVICE, NODE_ID)
-        d.addCallback(lambda items: self.assertEqual(len(items), 0))
-        d.addCallback(
-            lambda __: self.plugin.sendGroupBlogComment(
-                COMMENTS_NODE_URL_1, "test", {}, profile_key=C.PROFILE[0]
-            )
-        )
-        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
-        return d.addCallback(lambda items: self.assertEqual(len(items), 1))
-
-    def test_get_group_blogs(self):
-        self._initialise(C.PROFILE[0])
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.plugin.getGroupBlogs(PUBLISHER, profile_key=C.PROFILE[0])
-        )
-        result = (
-            [ITEM_DATA_1()],
-            {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
-        )
-        return d.addCallback(self.assertEqual, result)
-
-    def test_get_group_blogs_no_count(self):
-        self._initialise(C.PROFILE[0])
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.plugin.getGroupBlogs(
-                PUBLISHER, count_comments=False, profile_key=C.PROFILE[0]
-            )
-        )
-        result = (
-            [ITEM_DATA_1(DO_NOT_COUNT_COMMENTS)],
-            {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
-        )
-        return d.addCallback(self.assertEqual, result)
-
-    def test_get_group_blogs_with_i_ds(self):
-        self._initialise(C.PROFILE[0])
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.plugin.getGroupBlogs(
-                PUBLISHER, [ITEM_ID_1], profile_key=C.PROFILE[0]
-            )
-        )
-        result = (
-            [ITEM_DATA_1()],
-            {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
-        )
-        return d.addCallback(self.assertEqual, result)
-
-    def test_get_group_blogs_with_rsm(self):
-        self._initialise(C.PROFILE[0])
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.plugin.getGroupBlogs(
-                PUBLISHER, rsm_data={"max_": 1}, profile_key=C.PROFILE[0]
-            )
-        )
-        result = (
-            [ITEM_DATA_1()],
-            {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
-        )
-        return d.addCallback(self.assertEqual, result)
-
-    def test_get_group_blogs_with_comments(self):
-        self._initialise(C.PROFILE[0])
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1])
-        )
-        d.addCallback(
-            lambda __: self.plugin.getGroupBlogsWithComments(
-                PUBLISHER, [], profile_key=C.PROFILE[0]
-            )
-        )
-        result = (
-            [
-                (
-                    ITEM_DATA_1(1),
-                    (
-                        [COMMENT_DATA_1],
-                        {
-                            "count": "1",
-                            "index": "0",
-                            "first": COMMENT_ID_1,
-                            "last": COMMENT_ID_1,
-                        },
-                    ),
-                )
-            ],
-            {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
-        )
-        return d.addCallback(self.assertEqual, result)
-
-    def test_get_group_blogs_with_comments_2(self):
-        self._initialise(C.PROFILE[0])
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.psclient.publish(
-                SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2]
-            )
-        )
-        d.addCallback(
-            lambda __: self.plugin.getGroupBlogsWithComments(
-                PUBLISHER, [], profile_key=C.PROFILE[0]
-            )
-        )
-        result = (
-            [
-                (
-                    ITEM_DATA_1(2),
-                    (
-                        [COMMENT_DATA_1, COMMENT_DATA_2],
-                        {
-                            "count": "2",
-                            "index": "0",
-                            "first": COMMENT_ID_1,
-                            "last": COMMENT_ID_2,
-                        },
-                    ),
-                )
-            ],
-            {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
-        )
-
-        return d.addCallback(self.assertEqual, result)
-
-    def test_get_group_blogs_atom(self):
-        self._initialise(C.PROFILE[0])
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.plugin.getGroupBlogsAtom(
-                PUBLISHER, {"max_": 1}, profile_key=C.PROFILE[0]
-            )
-        )
-
-        def cb(atom):
-            self.assertIsInstance(atom, str)
-            self.assertTrue(atom.startswith('<?xml version="1.0" encoding="utf-8"?>'))
-
-        return d.addCallback(cb)
-
-    def test_get_massive_group_blogs(self):
-        self._initialise(C.PROFILE[0])
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.plugin.getMassiveGroupBlogs(
-                "JID", [jid.JID(PUBLISHER)], {"max_": 1}, profile_key=C.PROFILE[0]
-            )
-        )
-        result = {
-            PUBLISHER: (
-                [ITEM_DATA_1()],
-                {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
-            )
-        }
-
-        def clean(res):
-            del self.host.plugins["XEP-0060"].node_cache[
-                C.PROFILE[0] + "@found@" + SERVICE
-            ]
-            return res
-
-        d.addCallback(clean)
-        d.addCallback(self.assertEqual, result)
-
-    def test_get_massive_group_blogs_with_comments(self):
-        self._initialise(C.PROFILE[0])
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.psclient.publish(
-                SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2]
-            )
-        )
-        d.addCallback(
-            lambda __: self.plugin.getMassiveGroupBlogs(
-                "JID", [jid.JID(PUBLISHER)], {"max_": 1}, profile_key=C.PROFILE[0]
-            )
-        )
-        result = {
-            PUBLISHER: (
-                [ITEM_DATA_1(2)],
-                {"count": "1", "index": "0", "first": ITEM_ID_1, "last": ITEM_ID_1},
-            )
-        }
-
-        def clean(res):
-            del self.host.plugins["XEP-0060"].node_cache[
-                C.PROFILE[0] + "@found@" + SERVICE
-            ]
-            return res
-
-        d.addCallback(clean)
-        d.addCallback(self.assertEqual, result)
-
-    def test_get_group_blog_comments(self):
-        self._initialise(C.PROFILE[0])
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.psclient.publish(SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1])
-        )
-        d.addCallback(
-            lambda __: self.plugin.getGroupBlogComments(
-                SERVICE, COMMENTS_NODE_ID_1, {"max_": 1}, profile_key=C.PROFILE[0]
-            )
-        )
-        result = (
-            [COMMENT_DATA_1],
-            {"count": "1", "index": "0", "first": COMMENT_ID_1, "last": COMMENT_ID_1},
-        )
-        return d.addCallback(self.assertEqual, result)
-
-    def test_subscribe_group_blog(self):
-        self._initialise(C.PROFILE[0])
-        d = self.plugin.subscribeGroupBlog(PUBLISHER, profile_key=C.PROFILE[0])
-        return d.addCallback(self.assertEqual, None)
-
-    def test_massive_subscribe_group_blogs(self):
-        self._initialise(C.PROFILE[0])
-        d = self.plugin.massiveSubscribeGroupBlogs(
-            "JID", [jid.JID(PUBLISHER)], profile_key=C.PROFILE[0]
-        )
-
-        def clean(res):
-            del self.host.plugins["XEP-0060"].node_cache[
-                C.PROFILE[0] + "@found@" + SERVICE
-            ]
-            del self.host.plugins["XEP-0060"].node_cache[
-                C.PROFILE[0] + "@subscriptions@" + SERVICE
-            ]
-            return res
-
-        d.addCallback(clean)
-        return d.addCallback(self.assertEqual, None)
-
-    def test_delete_all_group_blogs(self):
-        """Delete our main node and associated comments node"""
-        self._initialise(C.PROFILE[0])
-        self.host.profiles[C.PROFILE[0]].roster.add_item(jid.JID(OTHER_PUBLISHER))
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.psclient.publish(
-                SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2]
-            )
-        )
-        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
-        d.addCallback(lambda items: self.assertEqual(len(items), 1))
-        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
-        d.addCallback(lambda items: self.assertEqual(len(items), 2))
-
-        d.addCallback(
-            lambda __: self.psclient.publish(SERVICE, OTHER_NODE_ID, [ITEM_2])
-        )
-        d.addCallback(
-            lambda __: self.psclient.publish(
-                SERVICE, COMMENTS_NODE_ID_2, [COMMENT_1, COMMENT_2]
-            )
-        )
-        d.addCallback(lambda __: self.psclient.items(SERVICE, OTHER_NODE_ID))
-        d.addCallback(lambda items: self.assertEqual(len(items), 1))
-        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2))
-        d.addCallback(lambda items: self.assertEqual(len(items), 2))
-
-        def clean(res):
-            del self.host.plugins["XEP-0060"].node_cache[
-                C.PROFILE[0] + "@found@" + SERVICE
-            ]
-            return res
-
-        d.addCallback(lambda __: self.plugin.deleteAllGroupBlogs(C.PROFILE[0]))
-        d.addCallback(clean)
-
-        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
-        d.addCallback(lambda items: self.assertEqual(len(items), 0))
-        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
-        d.addCallback(lambda items: self.assertEqual(len(items), 0))
-
-        d.addCallback(lambda __: self.psclient.items(SERVICE, OTHER_NODE_ID))
-        d.addCallback(lambda items: self.assertEqual(len(items), 1))
-        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2))
-        d.addCallback(lambda items: self.assertEqual(len(items), 2))
-        return d
-
-    def test_delete_all_group_blogs_comments(self):
-        """Delete the comments we posted on other node's"""
-        self._initialise(C.PROFILE[0])
-        self.host.profiles[C.PROFILE[0]].roster.add_item(jid.JID(OTHER_PUBLISHER))
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.psclient.publish(
-                SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2]
-            )
-        )
-        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
-        d.addCallback(lambda items: self.assertEqual(len(items), 1))
-        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
-        d.addCallback(lambda items: self.assertEqual(len(items), 2))
-
-        d.addCallback(
-            lambda __: self.psclient.publish(SERVICE, OTHER_NODE_ID, [ITEM_2])
-        )
-        d.addCallback(
-            lambda __: self.psclient.publish(
-                SERVICE, COMMENTS_NODE_ID_2, [COMMENT_1, COMMENT_2]
-            )
-        )
-        d.addCallback(lambda __: self.psclient.items(SERVICE, OTHER_NODE_ID))
-        d.addCallback(lambda items: self.assertEqual(len(items), 1))
-        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2))
-        d.addCallback(lambda items: self.assertEqual(len(items), 2))
-
-        def clean(res):
-            del self.host.plugins["XEP-0060"].node_cache[
-                C.PROFILE[0] + "@found@" + SERVICE
-            ]
-            return res
-
-        d.addCallback(lambda __: self.plugin.deleteAllGroupBlogsComments(C.PROFILE[0]))
-        d.addCallback(clean)
-
-        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
-        d.addCallback(lambda items: self.assertEqual(len(items), 1))
-        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
-        d.addCallback(lambda items: self.assertEqual(len(items), 2))
-
-        d.addCallback(lambda __: self.psclient.items(SERVICE, OTHER_NODE_ID))
-        d.addCallback(lambda items: self.assertEqual(len(items), 1))
-        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2))
-        d.addCallback(lambda items: self.assertEqual(len(items), 0))
-        return d
-
-    def test_delete_all_group_blogs_and_comments(self):
-        self._initialise(C.PROFILE[0])
-        self.host.profiles[C.PROFILE[0]].roster.add_item(jid.JID(OTHER_PUBLISHER))
-        d = self.psclient.publish(SERVICE, NODE_ID, [ITEM_1])
-        d.addCallback(
-            lambda __: self.psclient.publish(
-                SERVICE, COMMENTS_NODE_ID_1, [COMMENT_1, COMMENT_2]
-            )
-        )
-        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
-        d.addCallback(lambda items: self.assertEqual(len(items), 1))
-        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
-        d.addCallback(lambda items: self.assertEqual(len(items), 2))
-
-        d.addCallback(
-            lambda __: self.psclient.publish(SERVICE, OTHER_NODE_ID, [ITEM_2])
-        )
-        d.addCallback(
-            lambda __: self.psclient.publish(
-                SERVICE, COMMENTS_NODE_ID_2, [COMMENT_1, COMMENT_2]
-            )
-        )
-        d.addCallback(lambda __: self.psclient.items(SERVICE, OTHER_NODE_ID))
-        d.addCallback(lambda items: self.assertEqual(len(items), 1))
-        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2))
-        d.addCallback(lambda items: self.assertEqual(len(items), 2))
-
-        def clean(res):
-            del self.host.plugins["XEP-0060"].node_cache[
-                C.PROFILE[0] + "@found@" + SERVICE
-            ]
-            return res
-
-        d.addCallback(
-            lambda __: self.plugin.deleteAllGroupBlogsAndComments(C.PROFILE[0])
-        )
-        d.addCallback(clean)
-
-        d.addCallback(lambda __: self.psclient.items(SERVICE, NODE_ID))
-        d.addCallback(lambda items: self.assertEqual(len(items), 0))
-        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_1))
-        d.addCallback(lambda items: self.assertEqual(len(items), 0))
-
-        d.addCallback(lambda __: self.psclient.items(SERVICE, OTHER_NODE_ID))
-        d.addCallback(lambda items: self.assertEqual(len(items), 1))
-        d.addCallback(lambda __: self.psclient.items(SERVICE, COMMENTS_NODE_ID_2))
-        d.addCallback(lambda items: self.assertEqual(len(items), 0))
-        return d
--- a/sat/test/test_plugin_misc_radiocol.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,518 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009, 2010, 2011, 2012, 2013  Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013  Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Tests for the plugin radiocol """
-
-from sat.core import exceptions
-from sat.test import helpers, helpers_plugins
-from sat.plugins import plugin_misc_radiocol as plugin
-from sat.plugins import plugin_misc_room_game as plugin_room_game
-from .constants import Const
-
-from twisted.words.protocols.jabber.jid import JID
-from twisted.words.xish import domish
-from twisted.internet import reactor
-from twisted.internet import defer
-from twisted.python.failure import Failure
-from twisted.trial.unittest import SkipTest
-
-try:
-    from mutagen.oggvorbis import OggVorbis
-    from mutagen.mp3 import MP3
-    from mutagen.easyid3 import EasyID3
-    from mutagen.id3 import ID3NoHeaderError
-except ImportError:
-    raise exceptions.MissingModule(
-        "Missing module Mutagen, please download/install from https://bitbucket.org/lazka/mutagen"
-    )
-
-import uuid
-import os
-import copy
-import shutil
-
-
-ROOM_JID = JID(Const.MUC_STR[0])
-PROFILE = Const.PROFILE[0]
-REFEREE_FULL = JID(ROOM_JID.userhost() + "/" + Const.JID[0].user)
-PLAYERS_INDICES = [0, 1, 3]  # referee included
-OTHER_PROFILES = [Const.PROFILE[1], Const.PROFILE[3]]
-OTHER_PLAYERS = [Const.JID[1], Const.JID[3]]
-
-
-class RadiocolTest(helpers.SatTestCase):
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-
-    def reinit(self):
-        self.host.reinit()
-        self.host.plugins["ROOM-GAME"] = plugin_room_game.RoomGame(self.host)
-        self.plugin = plugin.Radiocol(self.host)  # must be init after ROOM-GAME
-        self.plugin.testing = True
-        self.plugin_0045 = self.host.plugins["XEP-0045"] = helpers_plugins.FakeXEP_0045(
-            self.host
-        )
-        self.plugin_0249 = self.host.plugins["XEP-0249"] = helpers_plugins.FakeXEP_0249(
-            self.host
-        )
-        for profile in Const.PROFILE:
-            self.host.get_client(profile)  # init self.host.profiles[profile]
-        self.songs = []
-        self.playlist = []
-        self.sound_dir = self.host.memory.config_get("", "media_dir") + "/test/sound/"
-        try:
-            for filename in os.listdir(self.sound_dir):
-                if filename.endswith(".ogg") or filename.endswith(".mp3"):
-                    self.songs.append(filename)
-        except OSError:
-            raise SkipTest("The sound samples in sat_media/test/sound were not found")
-
-    def _build_players(self, players=[]):
-        """@return: the "started" content built with the given players"""
-        content = "<started"
-        if not players:
-            content += "/>"
-        else:
-            content += ">"
-            for i in range(0, len(players)):
-                content += "<player index='%s'>%s</player>" % (i, players[i])
-            content += "</started>"
-        return content
-
-    def _expected_message(self, to_jid, type_, content):
-        """
-        @param to_jid: recipient full jid
-        @param type_: message type ('normal' or 'groupchat')
-        @param content: content as unicode or list of domish elements
-        @return: the message XML built from the given recipient, message type and content
-        """
-        if isinstance(content, list):
-            new_content = copy.deepcopy(content)
-            for element in new_content:
-                if not element.hasAttribute("xmlns"):
-                    element["xmlns"] = ""
-            content = "".join([element.toXml() for element in new_content])
-        return "<message to='%s' type='%s'><%s xmlns='%s'>%s</%s></message>" % (
-            to_jid.full(),
-            type_,
-            plugin.RADIOC_TAG,
-            plugin.NC_RADIOCOL,
-            content,
-            plugin.RADIOC_TAG,
-        )
-
-    def _reject_song_cb(self, profile_index):
-        """Check if the message "song_rejected" has been sent by the referee
-        and process the command with the profile of the uploader
-        @param profile_index: uploader's profile"""
-        sent = self.host.get_sent_message(0)
-        content = "<song_rejected xmlns='' reason='Too many songs in queue'/>"
-        self.assert_equal_xml(
-            sent.toXml(),
-            self._expected_message(
-                JID(
-                    ROOM_JID.userhost()
-                    + "/"
-                    + self.plugin_0045.get_nick(0, profile_index),
-                    "normal",
-                    content,
-                )
-            ),
-        )
-        self._room_game_cmd(
-            sent, ["radiocol_song_rejected", ROOM_JID.full(), "Too many songs in queue"]
-        )
-
-    def _no_upload_cb(self):
-        """Check if the message "no_upload" has been sent by the referee
-        and process the command with the profiles of each room users"""
-        sent = self.host.get_sent_message(0)
-        content = "<no_upload xmlns=''/>"
-        self.assert_equal_xml(
-            sent.toXml(), self._expected_message(ROOM_JID, "groupchat", content)
-        )
-        self._room_game_cmd(sent, ["radiocol_no_upload", ROOM_JID.full()])
-
-    def _upload_ok_cb(self):
-        """Check if the message "upload_ok" has been sent by the referee
-        and process the command with the profiles of each room users"""
-        sent = self.host.get_sent_message(0)
-        content = "<upload_ok xmlns=''/>"
-        self.assert_equal_xml(
-            sent.toXml(), self._expected_message(ROOM_JID, "groupchat", content)
-        )
-        self._room_game_cmd(sent, ["radiocol_upload_ok", ROOM_JID.full()])
-
-    def _preload_cb(self, attrs, profile_index):
-        """Check if the message "preload" has been sent by the referee
-        and process the command with the profiles of each room users
-        @param attrs: information dict about the song
-        @param profile_index: profile index of the uploader
-        """
-        sent = self.host.get_sent_message(0)
-        attrs["sender"] = self.plugin_0045.get_nick(0, profile_index)
-        radiocol_elt = next(domish.generateElementsNamed(sent.elements(), "radiocol"))
-        preload_elt = next(domish.generateElementsNamed(
-            radiocol_elt.elements(), "preload"
-        ))
-        attrs["timestamp"] = preload_elt["timestamp"]  # we could not guess it...
-        content = "<preload xmlns='' %s/>" % " ".join(
-            ["%s='%s'" % (attr, attrs[attr]) for attr in attrs]
-        )
-        if sent.hasAttribute("from"):
-            del sent["from"]
-        self.assert_equal_xml(
-            sent.toXml(), self._expected_message(ROOM_JID, "groupchat", content)
-        )
-        self._room_game_cmd(
-            sent,
-            [
-                "radiocol_preload",
-                ROOM_JID.full(),
-                attrs["timestamp"],
-                attrs["filename"],
-                attrs["title"],
-                attrs["artist"],
-                attrs["album"],
-                attrs["sender"],
-            ],
-        )
-
-    def _play_next_song_cb(self):
-        """Check if the message "play" has been sent by the referee
-        and process the command with the profiles of each room users"""
-        sent = self.host.get_sent_message(0)
-        filename = self.playlist.pop(0)
-        content = "<play xmlns='' filename='%s' />" % filename
-        self.assert_equal_xml(
-            sent.toXml(), self._expected_message(ROOM_JID, "groupchat", content)
-        )
-        self._room_game_cmd(sent, ["radiocol_play", ROOM_JID.full(), filename])
-
-        game_data = self.plugin.games[ROOM_JID]
-        if len(game_data["queue"]) == plugin.QUEUE_LIMIT - 1:
-            self._upload_ok_cb()
-
-    def _add_song_cb(self, d, filepath, profile_index):
-        """Check if the message "song_added" has been sent by the uploader
-        and process the command with the profile of the referee
-        @param d: deferred value or failure got from self.plugin.radiocol_song_added
-        @param filepath: full path to the sound file
-        @param profile_index: the profile index of the uploader
-        """
-        if isinstance(d, Failure):
-            self.fail("OGG or MP3 song could not be added!")
-
-        game_data = self.plugin.games[ROOM_JID]
-
-        # this is copied from the plugin
-        if filepath.lower().endswith(".mp3"):
-            actual_song = MP3(filepath)
-            try:
-                song = EasyID3(filepath)
-
-                class Info(object):
-                    def __init__(self, length):
-                        self.length = length
-
-                song.info = Info(actual_song.info.length)
-            except ID3NoHeaderError:
-                song = actual_song
-        else:
-            song = OggVorbis(filepath)
-
-        attrs = {
-            "filename": os.path.basename(filepath),
-            "title": song.get("title", ["Unknown"])[0],
-            "artist": song.get("artist", ["Unknown"])[0],
-            "album": song.get("album", ["Unknown"])[0],
-            "length": str(song.info.length),
-        }
-        self.assertEqual(game_data["to_delete"][attrs["filename"]], filepath)
-
-        content = "<song_added xmlns='' %s/>" % " ".join(
-            ["%s='%s'" % (attr, attrs[attr]) for attr in attrs]
-        )
-        sent = self.host.get_sent_message(profile_index)
-        self.assert_equal_xml(
-            sent.toXml(), self._expected_message(REFEREE_FULL, "normal", content)
-        )
-
-        reject_song = len(game_data["queue"]) >= plugin.QUEUE_LIMIT
-        no_upload = len(game_data["queue"]) + 1 >= plugin.QUEUE_LIMIT
-        play_next = (
-            not game_data["playing"]
-            and len(game_data["queue"]) + 1 == plugin.QUEUE_TO_START
-        )
-
-        self._room_game_cmd(sent, profile_index)  # queue unchanged or +1
-        if reject_song:
-            self._reject_song_cb(profile_index)
-            return
-        if no_upload:
-            self._no_upload_cb()
-        self._preload_cb(attrs, profile_index)
-        self.playlist.append(attrs["filename"])
-        if play_next:
-            self._play_next_song_cb()  # queue -1
-
-    def _room_game_cmd(self, sent, from_index=0, call=[]):
-        """Process a command. It is also possible to call this method as
-        _room_game_cmd(sent, call) instead of _room_game_cmd(sent, from_index, call).
-        If from index is a list, it is assumed that it is containing the value
-        for call and from_index will take its default value.
-        @param sent: the sent message that we need to process
-        @param from_index: index of the message sender
-        @param call: list containing the name of the expected bridge call
-        followed by its arguments, or empty list if no call is expected
-        """
-        if isinstance(from_index, list):
-            call = from_index
-            from_index = 0
-
-        sent["from"] = ROOM_JID.full() + "/" + self.plugin_0045.get_nick(0, from_index)
-        recipient = JID(sent["to"]).resource
-
-        # The message could have been sent to a room user (room_jid + '/' + nick),
-        # but when it is received, the 'to' attribute of the message has been
-        # changed to the recipient own JID. We need to simulate that here.
-        if recipient:
-            room = self.plugin_0045.get_room(0, 0)
-            sent["to"] = (
-                Const.JID_STR[0]
-                if recipient == room.nick
-                else room.roster[recipient].entity.full()
-            )
-
-        for index in range(0, len(Const.PROFILE)):
-            nick = self.plugin_0045.get_nick(0, index)
-            if nick:
-                if not recipient or nick == recipient:
-                    if call and (
-                        self.plugin.is_player(ROOM_JID, nick)
-                        or call[0] == "radiocol_started"
-                    ):
-                        args = copy.deepcopy(call)
-                        args.append(Const.PROFILE[index])
-                        self.host.bridge.expect_call(*args)
-                    self.plugin.room_game_cmd(sent, Const.PROFILE[index])
-
-    def _sync_cb(self, sync_data, profile_index):
-        """Synchronize one player when he joins a running game.
-        @param sync_data: result from self.plugin.getSyncData
-        @param profile_index: index of the profile to be synchronized
-        """
-        for nick in sync_data:
-            expected = self._expected_message(
-                JID(ROOM_JID.userhost() + "/" + nick), "normal", sync_data[nick]
-            )
-            sent = self.host.get_sent_message(0)
-            self.assert_equal_xml(sent.toXml(), expected)
-            for elt in sync_data[nick]:
-                if elt.name == "preload":
-                    self.host.bridge.expect_call(
-                        "radiocol_preload",
-                        ROOM_JID.full(),
-                        elt["timestamp"],
-                        elt["filename"],
-                        elt["title"],
-                        elt["artist"],
-                        elt["album"],
-                        elt["sender"],
-                        Const.PROFILE[profile_index],
-                    )
-                elif elt.name == "play":
-                    self.host.bridge.expect_call(
-                        "radiocol_play",
-                        ROOM_JID.full(),
-                        elt["filename"],
-                        Const.PROFILE[profile_index],
-                    )
-                elif elt.name == "no_upload":
-                    self.host.bridge.expect_call(
-                        "radiocol_no_upload", ROOM_JID.full(), Const.PROFILE[profile_index]
-                    )
-            sync_data[nick]
-            self._room_game_cmd(sent, [])
-
-    def _join_room(self, room, nicks, player_index, sync=True):
-        """Make a player join a room and update the list of nicks
-        @param room: wokkel.muc.Room instance from the referee perspective
-        @param nicks: list of the players which will be updated
-        @param player_index: profile index of the new player
-        @param sync: set to True to synchronize data
-        """
-        user_nick = self.plugin_0045.join_room(0, player_index)
-        self.plugin.user_joined_trigger(room, room.roster[user_nick], PROFILE)
-        if player_index not in PLAYERS_INDICES:
-            # this user is actually not a player
-            self.assertFalse(self.plugin.is_player(ROOM_JID, user_nick))
-            to_jid, type_ = (JID(ROOM_JID.userhost() + "/" + user_nick), "normal")
-        else:
-            # this user is a player
-            self.assertTrue(self.plugin.is_player(ROOM_JID, user_nick))
-            nicks.append(user_nick)
-            to_jid, type_ = (ROOM_JID, "groupchat")
-
-        # Check that the message "players" has been sent by the referee
-        expected = self._expected_message(to_jid, type_, self._build_players(nicks))
-        sent = self.host.get_sent_message(0)
-        self.assert_equal_xml(sent.toXml(), expected)
-
-        # Process the command with the profiles of each room users
-        self._room_game_cmd(
-            sent,
-            [
-                "radiocol_started",
-                ROOM_JID.full(),
-                REFEREE_FULL.full(),
-                nicks,
-                [plugin.QUEUE_TO_START, plugin.QUEUE_LIMIT],
-            ],
-        )
-
-        if sync:
-            self._sync_cb(self.plugin._get_sync_data(ROOM_JID, [user_nick]), player_index)
-
-    def _leave_room(self, room, nicks, player_index):
-        """Make a player leave a room and update the list of nicks
-        @param room: wokkel.muc.Room instance from the referee perspective
-        @param nicks: list of the players which will be updated
-        @param player_index: profile index of the new player
-        """
-        user_nick = self.plugin_0045.get_nick(0, player_index)
-        user = room.roster[user_nick]
-        self.plugin_0045.leave_room(0, player_index)
-        self.plugin.user_left_trigger(room, user, PROFILE)
-        nicks.remove(user_nick)
-
-    def _upload_song(self, song_index, profile_index):
-        """Upload the song of index song_index (modulo self.songs size) from the profile of index profile_index.
-
-        @param song_index: index of the song or None to test with non existing file
-        @param profile_index: index of the uploader's profile
-        """
-        if song_index is None:
-            dst_filepath = str(uuid.uuid1())
-            expect_io_error = True
-        else:
-            song_index = song_index % len(self.songs)
-            src_filename = self.songs[song_index]
-            dst_filepath = "/tmp/%s%s" % (uuid.uuid1(), os.path.splitext(src_filename)[1])
-            shutil.copy(self.sound_dir + src_filename, dst_filepath)
-            expect_io_error = False
-
-        try:
-            d = self.plugin.radiocol_song_added(
-                REFEREE_FULL, dst_filepath, Const.PROFILE[profile_index]
-            )
-        except IOError:
-            self.assertTrue(expect_io_error)
-            return
-
-        self.assertFalse(expect_io_error)
-        cb = lambda defer: self._add_song_cb(defer, dst_filepath, profile_index)
-
-        def eb(failure):
-            if not isinstance(failure, Failure):
-                self.fail("Adding a song which is not OGG nor MP3 should fail!")
-            self.assertEqual(failure.value.__class__, exceptions.DataError)
-
-        if src_filename.endswith(".ogg") or src_filename.endswith(".mp3"):
-            d.addCallbacks(cb, cb)
-        else:
-            d.addCallbacks(eb, eb)
-
-    def test_init(self):
-        self.reinit()
-        self.assertEqual(self.plugin.invite_mode, self.plugin.FROM_PLAYERS)
-        self.assertEqual(self.plugin.wait_mode, self.plugin.FOR_NONE)
-        self.assertEqual(self.plugin.join_mode, self.plugin.INVITED)
-        self.assertEqual(self.plugin.ready_mode, self.plugin.FORCE)
-
-    def test_game(self):
-        self.reinit()
-
-        # create game
-        self.plugin.prepare_room(OTHER_PLAYERS, ROOM_JID, PROFILE)
-        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
-        room = self.plugin_0045.get_room(0, 0)
-        nicks = [self.plugin_0045.get_nick(0, 0)]
-
-        sent = self.host.get_sent_message(0)
-        self.assert_equal_xml(
-            sent.toXml(),
-            self._expected_message(ROOM_JID, "groupchat", self._build_players(nicks)),
-        )
-        self._room_game_cmd(
-            sent,
-            [
-                "radiocol_started",
-                ROOM_JID.full(),
-                REFEREE_FULL.full(),
-                nicks,
-                [plugin.QUEUE_TO_START, plugin.QUEUE_LIMIT],
-            ],
-        )
-
-        self._join_room(room, nicks, 1)  # player joins
-        self._join_room(room, nicks, 4)  # user not playing joins
-
-        song_index = 0
-        self._upload_song(
-            song_index, 0
-        )  # ogg or mp3 file should exist in sat_media/test/song
-        self._upload_song(None, 0)  # non existing file
-
-        # another songs are added by Const.JID[1] until the radio starts + 1 to fill the queue
-        # when the first song starts + 1 to be rejected because the queue is full
-        for song_index in range(1, plugin.QUEUE_TO_START + 1):
-            self._upload_song(song_index, 1)
-
-        self.plugin.play_next(Const.MUC[0], PROFILE)  # simulate the end of the first song
-        self._play_next_song_cb()
-        self._upload_song(
-            song_index, 1
-        )  # now the song is accepted and the queue is full again
-
-        self._join_room(room, nicks, 3)  # new player joins
-
-        self.plugin.play_next(Const.MUC[0], PROFILE)  # the second song finishes
-        self._play_next_song_cb()
-        self._upload_song(0, 3)  # the player who recently joined re-upload the first file
-
-        self._leave_room(room, nicks, 1)  # one player leaves
-        self._join_room(room, nicks, 1)  # and join again
-
-        self.plugin.play_next(Const.MUC[0], PROFILE)  # empty the queue
-        self._play_next_song_cb()
-        self.plugin.play_next(Const.MUC[0], PROFILE)
-        self._play_next_song_cb()
-
-        for filename in self.playlist:
-            self.plugin.delete_file("/tmp/" + filename)
-
-        return defer.succeed(None)
-
-    def tearDown(self, *args, **kwargs):
-        """Clean the reactor"""
-        helpers.SatTestCase.tearDown(self, *args, **kwargs)
-        for delayed_call in reactor.getDelayedCalls():
-            delayed_call.cancel()
--- a/sat/test/test_plugin_misc_room_game.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,654 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Tests for the plugin room game (base class for MUC games) """
-
-from sat.core.i18n import _
-from .constants import Const
-from sat.test import helpers, helpers_plugins
-from sat.plugins import plugin_misc_room_game as plugin
-from twisted.words.protocols.jabber.jid import JID
-from wokkel.muc import User
-
-from logging import WARNING
-
-# Data used for test initialization
-NAMESERVICE = "http://www.goffi.org/protocol/dummy"
-TAG = "dummy"
-PLUGIN_INFO = {
-    "name": "Dummy plugin",
-    "import_name": "DUMMY",
-    "type": "MISC",
-    "protocols": [],
-    "dependencies": [],
-    "main": "Dummy",
-    "handler": "no",  # handler MUST be "no" (dynamic inheritance)
-    "description": _("""Dummy plugin to test room game"""),
-}
-
-ROOM_JID = JID(Const.MUC_STR[0])
-PROFILE = Const.PROFILE[0]
-OTHER_PROFILE = Const.PROFILE[1]
-
-
-class RoomGameTest(helpers.SatTestCase):
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-
-    def reinit(self, game_init={}, player_init={}):
-        self.host.reinit()
-        self.plugin = plugin.RoomGame(self.host)
-        self.plugin._init_(
-            self.host, PLUGIN_INFO, (NAMESERVICE, TAG), game_init, player_init
-        )
-        self.plugin_0045 = self.host.plugins["XEP-0045"] = helpers_plugins.FakeXEP_0045(
-            self.host
-        )
-        self.plugin_0249 = self.host.plugins["XEP-0249"] = helpers_plugins.FakeXEP_0249(
-            self.host
-        )
-        for profile in Const.PROFILE:
-            self.host.get_client(profile)  # init self.host.profiles[profile]
-
-    def init_game(self, muc_index, user_index):
-        self.plugin_0045.join_room(user_index, muc_index)
-        self.plugin._init_game(JID(Const.MUC_STR[muc_index]), Const.JID[user_index].user)
-
-    def _expected_message(self, to, type_, tag, players=[]):
-        content = "<%s" % tag
-        if not players:
-            content += "/>"
-        else:
-            content += ">"
-            for i in range(0, len(players)):
-                content += "<player index='%s'>%s</player>" % (i, players[i])
-            content += "</%s>" % tag
-        return "<message to='%s' type='%s'><%s xmlns='%s'>%s</dummy></message>" % (
-            to.full(),
-            type_,
-            TAG,
-            NAMESERVICE,
-            content,
-        )
-
-    def test_create_or_invite_solo(self):
-        self.reinit()
-        self.plugin_0045.join_room(0, 0)
-        self.plugin._create_or_invite(self.plugin_0045.get_room(0, 0), [], Const.PROFILE[0])
-        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
-
-    def test_create_or_invite_multi_not_waiting(self):
-        self.reinit()
-        self.plugin_0045.join_room(0, 0)
-        other_players = [Const.JID[1], Const.JID[2]]
-        self.plugin._create_or_invite(
-            self.plugin_0045.get_room(0, 0), other_players, Const.PROFILE[0]
-        )
-        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
-
-    def test_create_or_invite_multi_waiting(self):
-        self.reinit(player_init={"score": 0})
-        self.plugin_0045.join_room(0, 0)
-        other_players = [Const.JID[1], Const.JID[2]]
-        self.plugin._create_or_invite(
-            self.plugin_0045.get_room(0, 0), other_players, Const.PROFILE[0]
-        )
-        self.assertTrue(self.plugin._game_exists(ROOM_JID, False))
-        self.assertFalse(self.plugin._game_exists(ROOM_JID, True))
-
-    def test_init_game(self):
-        self.reinit()
-        self.init_game(0, 0)
-        self.assertTrue(self.plugin.is_referee(ROOM_JID, Const.JID[0].user))
-        self.assertEqual([], self.plugin.games[ROOM_JID]["players"])
-
-    def test_check_join_auth(self):
-        self.reinit()
-        check = lambda value: getattr(self, "assert%s" % value)(
-            self.plugin._check_join_auth(ROOM_JID, Const.JID[0], Const.JID[0].user)
-        )
-        check(False)
-        # to test the "invited" mode, the referee must be different than the user to test
-        self.init_game(0, 1)
-        self.plugin.join_mode = self.plugin.ALL
-        check(True)
-        self.plugin.join_mode = self.plugin.INVITED
-        check(False)
-        self.plugin.invitations[ROOM_JID] = [(None, [Const.JID[0].userhostJID()])]
-        check(True)
-        self.plugin.join_mode = self.plugin.NONE
-        check(False)
-        self.plugin.games[ROOM_JID]["players"].append(Const.JID[0].user)
-        check(True)
-
-    def test_update_players(self):
-        self.reinit()
-        self.init_game(0, 0)
-        self.assertEqual(self.plugin.games[ROOM_JID]["players"], [])
-        self.plugin._update_players(ROOM_JID, [], True, Const.PROFILE[0])
-        self.assertEqual(self.plugin.games[ROOM_JID]["players"], [])
-        self.plugin._update_players(ROOM_JID, ["user1"], True, Const.PROFILE[0])
-        self.assertEqual(self.plugin.games[ROOM_JID]["players"], ["user1"])
-        self.plugin._update_players(ROOM_JID, ["user2", "user3"], True, Const.PROFILE[0])
-        self.assertEqual(
-            self.plugin.games[ROOM_JID]["players"], ["user1", "user2", "user3"]
-        )
-        self.plugin._update_players(
-            ROOM_JID, ["user2", "user3"], True, Const.PROFILE[0]
-        )  # should not be stored twice
-        self.assertEqual(
-            self.plugin.games[ROOM_JID]["players"], ["user1", "user2", "user3"]
-        )
-
-    def test_synchronize_room(self):
-        self.reinit()
-        self.init_game(0, 0)
-        self.plugin._synchronize_room(ROOM_JID, [Const.MUC[0]], Const.PROFILE[0])
-        self.assertEqual(
-            self.host.get_sent_message_xml(0),
-            self._expected_message(ROOM_JID, "groupchat", "players", []),
-        )
-        self.plugin.games[ROOM_JID]["players"].append("test1")
-        self.plugin._synchronize_room(ROOM_JID, [Const.MUC[0]], Const.PROFILE[0])
-        self.assertEqual(
-            self.host.get_sent_message_xml(0),
-            self._expected_message(ROOM_JID, "groupchat", "players", ["test1"]),
-        )
-        self.plugin.games[ROOM_JID]["started"] = True
-        self.plugin.games[ROOM_JID]["players"].append("test2")
-        self.plugin._synchronize_room(ROOM_JID, [Const.MUC[0]], Const.PROFILE[0])
-        self.assertEqual(
-            self.host.get_sent_message_xml(0),
-            self._expected_message(ROOM_JID, "groupchat", "started", ["test1", "test2"]),
-        )
-        self.plugin.games[ROOM_JID]["players"].append("test3")
-        self.plugin.games[ROOM_JID]["players"].append("test4")
-        user1 = JID(ROOM_JID.userhost() + "/" + Const.JID[0].user)
-        user2 = JID(ROOM_JID.userhost() + "/" + Const.JID[1].user)
-        self.plugin._synchronize_room(ROOM_JID, [user1, user2], Const.PROFILE[0])
-        self.assert_equal_xml(
-            self.host.get_sent_message_xml(0),
-            self._expected_message(
-                user1, "normal", "started", ["test1", "test2", "test3", "test4"]
-            ),
-        )
-        self.assert_equal_xml(
-            self.host.get_sent_message_xml(0),
-            self._expected_message(
-                user2, "normal", "started", ["test1", "test2", "test3", "test4"]
-            ),
-        )
-
-    def test_invite_players(self):
-        self.reinit()
-        self.init_game(0, 0)
-        self.plugin_0045.join_room(0, 1)
-        self.assertEqual(self.plugin.invitations[ROOM_JID], [])
-        room = self.plugin_0045.get_room(0, 0)
-        nicks = self.plugin._invite_players(
-            room, [Const.JID[1], Const.JID[2]], Const.JID[0].user, Const.PROFILE[0]
-        )
-        self.assertEqual(
-            self.plugin.invitations[ROOM_JID][0][1],
-            [Const.JID[1].userhostJID(), Const.JID[2].userhostJID()],
-        )
-        # the following assertion is True because Const.JID[1] and Const.JID[2] have the same userhost
-        self.assertEqual(nicks, [Const.JID[1].user, Const.JID[2].user])
-
-        nicks = self.plugin._invite_players(
-            room, [Const.JID[1], Const.JID[3]], Const.JID[0].user, Const.PROFILE[0]
-        )
-        self.assertEqual(
-            self.plugin.invitations[ROOM_JID][1][1],
-            [Const.JID[1].userhostJID(), Const.JID[3].userhostJID()],
-        )
-        # this time Const.JID[1] and Const.JID[3] have the same user but the host differs
-        self.assertEqual(nicks, [Const.JID[1].user])
-
-    def test_check_invite_auth(self):
-        def check(value, index):
-            nick = self.plugin_0045.get_nick(0, index)
-            getattr(self, "assert%s" % value)(
-                self.plugin._check_invite_auth(ROOM_JID, nick)
-            )
-
-        self.reinit()
-
-        for mode in [
-            self.plugin.FROM_ALL,
-            self.plugin.FROM_NONE,
-            self.plugin.FROM_REFEREE,
-            self.plugin.FROM_PLAYERS,
-        ]:
-            self.plugin.invite_mode = mode
-            check(True, 0)
-
-        self.init_game(0, 0)
-        self.plugin.invite_mode = self.plugin.FROM_ALL
-        check(True, 0)
-        check(True, 1)
-        self.plugin.invite_mode = self.plugin.FROM_NONE
-        check(True, 0)  # game initialized but not started yet, referee can invite
-        check(False, 1)
-        self.plugin.invite_mode = self.plugin.FROM_REFEREE
-        check(True, 0)
-        check(False, 1)
-        user_nick = self.plugin_0045.join_room(0, 1)
-        self.plugin.games[ROOM_JID]["players"].append(user_nick)
-        self.plugin.invite_mode = self.plugin.FROM_PLAYERS
-        check(True, 0)
-        check(True, 1)
-        check(False, 2)
-
-    def test_is_referee(self):
-        self.reinit()
-        self.init_game(0, 0)
-        self.assertTrue(self.plugin.is_referee(ROOM_JID, self.plugin_0045.get_nick(0, 0)))
-        self.assertFalse(self.plugin.is_referee(ROOM_JID, self.plugin_0045.get_nick(0, 1)))
-
-    def test_is_player(self):
-        self.reinit()
-        self.init_game(0, 0)
-        self.assertTrue(self.plugin.is_player(ROOM_JID, self.plugin_0045.get_nick(0, 0)))
-        user_nick = self.plugin_0045.join_room(0, 1)
-        self.plugin.games[ROOM_JID]["players"].append(user_nick)
-        self.assertTrue(self.plugin.is_player(ROOM_JID, user_nick))
-        self.assertFalse(self.plugin.is_player(ROOM_JID, self.plugin_0045.get_nick(0, 2)))
-
-    def test_check_wait_auth(self):
-        def check(value, other_players, confirmed, rest):
-            room = self.plugin_0045.get_room(0, 0)
-            self.assertEqual(
-                (value, confirmed, rest), self.plugin._check_wait_auth(room, other_players)
-            )
-
-        self.reinit()
-        self.init_game(0, 0)
-        other_players = [Const.JID[1], Const.JID[3]]
-        self.plugin.wait_mode = self.plugin.FOR_NONE
-        check(True, [], [], [])
-        check(
-            True, [Const.JID[0]], [], [Const.JID[0]]
-        )  # getRoomNickOfUser checks for the other users only
-        check(True, other_players, [], other_players)
-        self.plugin.wait_mode = self.plugin.FOR_ALL
-        check(True, [], [], [])
-        check(False, [Const.JID[0]], [], [Const.JID[0]])
-        check(False, other_players, [], other_players)
-        self.plugin_0045.join_room(0, 1)
-        check(False, other_players, [], other_players)
-        self.plugin_0045.join_room(0, 4)
-        check(
-            False,
-            other_players,
-            [self.plugin_0045.get_nick_of_user(0, 1, 0)],
-            [Const.JID[3]],
-        )
-        self.plugin_0045.join_room(0, 3)
-        check(
-            True,
-            other_players,
-            [
-                self.plugin_0045.get_nick_of_user(0, 1, 0),
-                self.plugin_0045.get_nick_of_user(0, 3, 0),
-            ],
-            [],
-        )
-
-        other_players = [Const.JID[1], Const.JID[3], Const.JID[2]]
-        # the following assertion is True because Const.JID[1] and Const.JID[2] have the same userhost
-        check(
-            True,
-            other_players,
-            [
-                self.plugin_0045.get_nick_of_user(0, 1, 0),
-                self.plugin_0045.get_nick_of_user(0, 3, 0),
-                self.plugin_0045.get_nick_of_user(0, 2, 0),
-            ],
-            [],
-        )
-
-    def test_prepare_room_trivial(self):
-        self.reinit()
-        other_players = []
-        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
-        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
-        self.assertTrue(
-            self.plugin._check_join_auth(ROOM_JID, Const.JID[0], Const.JID[0].user)
-        )
-        self.assertTrue(self.plugin._check_invite_auth(ROOM_JID, Const.JID[0].user))
-        self.assertEqual((True, [], []), self.plugin._check_wait_auth(ROOM_JID, []))
-        self.assertTrue(self.plugin.is_referee(ROOM_JID, Const.JID[0].user))
-        self.assertTrue(self.plugin.is_player(ROOM_JID, Const.JID[0].user))
-        self.assertEqual(
-            (False, True), self.plugin._check_create_game_and_init(ROOM_JID, PROFILE)
-        )
-
-    def test_prepare_room_invite(self):
-        self.reinit()
-        other_players = [Const.JID[1], Const.JID[2]]
-        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
-        room = self.plugin_0045.get_room(0, 0)
-
-        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
-        self.assertTrue(
-            self.plugin._check_join_auth(ROOM_JID, Const.JID[1], Const.JID[1].user)
-        )
-        self.assertFalse(
-            self.plugin._check_join_auth(ROOM_JID, Const.JID[3], Const.JID[3].user)
-        )
-        self.assertFalse(self.plugin._check_invite_auth(ROOM_JID, Const.JID[1].user))
-        self.assertEqual(
-            (True, [], other_players), self.plugin._check_wait_auth(room, other_players)
-        )
-
-        player2_nick = self.plugin_0045.join_room(0, 1)
-        self.plugin.user_joined_trigger(room, room.roster[player2_nick], PROFILE)
-        self.assertTrue(self.plugin.is_player(ROOM_JID, player2_nick))
-        self.assertTrue(self.plugin._check_invite_auth(ROOM_JID, player2_nick))
-        self.assertFalse(self.plugin.is_referee(ROOM_JID, player2_nick))
-        self.assertTrue(self.plugin.is_player(ROOM_JID, player2_nick))
-        self.assertTrue(
-            self.plugin.is_player(ROOM_JID, self.plugin_0045.get_nick_of_user(0, 2, 0))
-        )
-        self.assertFalse(self.plugin.is_player(ROOM_JID, "xxx"))
-        self.assertEqual(
-            (False, False),
-            self.plugin._check_create_game_and_init(ROOM_JID, Const.PROFILE[1]),
-        )
-
-    def test_prepare_room_score_1(self):
-        self.reinit(player_init={"score": 0})
-        other_players = [Const.JID[1], Const.JID[2]]
-        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
-        room = self.plugin_0045.get_room(0, 0)
-
-        self.assertFalse(self.plugin._game_exists(ROOM_JID, True))
-        self.assertTrue(
-            self.plugin._check_join_auth(ROOM_JID, Const.JID[1], Const.JID[1].user)
-        )
-        self.assertFalse(
-            self.plugin._check_join_auth(ROOM_JID, Const.JID[3], Const.JID[3].user)
-        )
-        self.assertFalse(self.plugin._check_invite_auth(ROOM_JID, Const.JID[1].user))
-        self.assertEqual(
-            (False, [], other_players), self.plugin._check_wait_auth(room, other_players)
-        )
-
-        user_nick = self.plugin_0045.join_room(0, 1)
-        self.plugin.user_joined_trigger(room, room.roster[user_nick], PROFILE)
-        self.assertTrue(self.plugin.is_player(ROOM_JID, user_nick))
-        self.assertFalse(self.plugin._check_invite_auth(ROOM_JID, user_nick))
-        self.assertFalse(self.plugin.is_referee(ROOM_JID, user_nick))
-        self.assertTrue(self.plugin.is_player(ROOM_JID, user_nick))
-        # the following assertion is True because Const.JID[1] and Const.JID[2] have the same userhost
-        self.assertTrue(
-            self.plugin.is_player(ROOM_JID, self.plugin_0045.get_nick_of_user(0, 2, 0))
-        )
-        # the following assertion is True because Const.JID[1] nick in the room is equal to Const.JID[3].user
-        self.assertTrue(self.plugin.is_player(ROOM_JID, Const.JID[3].user))
-        # but Const.JID[3] is actually not in the room
-        self.assertEqual(self.plugin_0045.get_nick_of_user(0, 3, 0), None)
-        self.assertEqual(
-            (True, False), self.plugin._check_create_game_and_init(ROOM_JID, Const.PROFILE[0])
-        )
-
-    def test_prepare_room_score_2(self):
-        self.reinit(player_init={"score": 0})
-        other_players = [Const.JID[1], Const.JID[4]]
-        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
-        room = self.plugin_0045.get_room(0, 0)
-
-        user_nick = self.plugin_0045.join_room(0, 1)
-        self.plugin.user_joined_trigger(room, room.roster[user_nick], PROFILE)
-        self.assertEqual(
-            (True, False), self.plugin._check_create_game_and_init(ROOM_JID, PROFILE)
-        )
-        user_nick = self.plugin_0045.join_room(0, 4)
-        self.plugin.user_joined_trigger(room, room.roster[user_nick], PROFILE)
-        self.assertEqual(
-            (False, True), self.plugin._check_create_game_and_init(ROOM_JID, PROFILE)
-        )
-
-    def test_user_joined_trigger(self):
-        self.reinit(player_init={"xxx": "xyz"})
-        other_players = [Const.JID[1], Const.JID[3]]
-        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
-        nicks = [self.plugin_0045.get_nick(0, 0)]
-
-        self.assertEqual(
-            self.host.get_sent_message_xml(0),
-            self._expected_message(ROOM_JID, "groupchat", "players", nicks),
-        )
-        self.assertTrue(len(self.plugin.invitations[ROOM_JID]) == 1)
-
-        # wrong profile
-        user_nick = self.plugin_0045.join_room(0, 1)
-        room = self.plugin_0045.get_room(0, 1)
-        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[1]), OTHER_PROFILE)
-        self.assertEqual(
-            self.host.get_sent_message(0), None
-        )  # no new message has been sent
-        self.assertFalse(self.plugin._game_exists(ROOM_JID, True))  # game not started
-
-        # referee profile, user is allowed, wait for one more
-        room = self.plugin_0045.get_room(0, 0)
-        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[1]), PROFILE)
-        nicks.append(user_nick)
-        self.assertEqual(
-            self.host.get_sent_message_xml(0),
-            self._expected_message(ROOM_JID, "groupchat", "players", nicks),
-        )
-        self.assertFalse(self.plugin._game_exists(ROOM_JID, True))  # game not started
-
-        # referee profile, user is not allowed
-        user_nick = self.plugin_0045.join_room(0, 4)
-        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[4]), PROFILE)
-        self.assertEqual(
-            self.host.get_sent_message_xml(0),
-            self._expected_message(
-                JID(ROOM_JID.userhost() + "/" + user_nick), "normal", "players", nicks
-            ),
-        )
-        self.assertFalse(self.plugin._game_exists(ROOM_JID, True))  # game not started
-
-        # referee profile, user is allowed, everybody here
-        user_nick = self.plugin_0045.join_room(0, 3)
-        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[3]), PROFILE)
-        nicks.append(user_nick)
-        self.assertEqual(
-            self.host.get_sent_message_xml(0),
-            self._expected_message(ROOM_JID, "groupchat", "started", nicks),
-        )
-        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))  # game started
-        self.assertTrue(len(self.plugin.invitations[ROOM_JID]) == 0)
-
-        # wait for none
-        self.reinit()
-        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
-        self.assertNotEqual(self.host.get_sent_message(0), None)  # init messages
-        room = self.plugin_0045.get_room(0, 0)
-        nicks = [self.plugin_0045.get_nick(0, 0)]
-        user_nick = self.plugin_0045.join_room(0, 3)
-        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[3]), PROFILE)
-        nicks.append(user_nick)
-        self.assertEqual(
-            self.host.get_sent_message_xml(0),
-            self._expected_message(ROOM_JID, "groupchat", "started", nicks),
-        )
-        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
-
-    def test_user_left_trigger(self):
-        self.reinit(player_init={"xxx": "xyz"})
-        other_players = [Const.JID[1], Const.JID[3], Const.JID[4]]
-        self.plugin.prepare_room(other_players, ROOM_JID, PROFILE)
-        room = self.plugin_0045.get_room(0, 0)
-        nicks = [self.plugin_0045.get_nick(0, 0)]
-        self.assertEqual(
-            self.plugin.invitations[ROOM_JID][0][1],
-            [
-                Const.JID[1].userhostJID(),
-                Const.JID[3].userhostJID(),
-                Const.JID[4].userhostJID(),
-            ],
-        )
-
-        # one user joins
-        user_nick = self.plugin_0045.join_room(0, 1)
-        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[1]), PROFILE)
-        nicks.append(user_nick)
-
-        # the user leaves
-        self.assertEqual(self.plugin.games[ROOM_JID]["players"], nicks)
-        room = self.plugin_0045.get_room(0, 1)
-        # to not call self.plugin_0045.leave_room(0, 1) here, we are testing the trigger with a wrong profile
-        self.plugin.user_left_trigger(
-            room, User(user_nick, Const.JID[1]), Const.PROFILE[1]
-        )  # not the referee
-        self.assertEqual(self.plugin.games[ROOM_JID]["players"], nicks)
-        room = self.plugin_0045.get_room(0, 0)
-        user_nick = self.plugin_0045.leave_room(0, 1)
-        self.plugin.user_left_trigger(
-            room, User(user_nick, Const.JID[1]), PROFILE
-        )  # referee
-        nicks.pop()
-        self.assertEqual(self.plugin.games[ROOM_JID]["players"], nicks)
-
-        # all the users join
-        user_nick = self.plugin_0045.join_room(0, 1)
-        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[1]), PROFILE)
-        nicks.append(user_nick)
-        user_nick = self.plugin_0045.join_room(0, 3)
-        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[3]), PROFILE)
-        nicks.append(user_nick)
-        user_nick = self.plugin_0045.join_room(0, 4)
-        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[4]), PROFILE)
-        nicks.append(user_nick)
-        self.assertEqual(self.plugin.games[ROOM_JID]["players"], nicks)
-        self.assertTrue(len(self.plugin.invitations[ROOM_JID]) == 0)
-
-        # one user leaves
-        user_nick = self.plugin_0045.leave_room(0, 4)
-        self.plugin.user_left_trigger(room, User(user_nick, Const.JID[4]), PROFILE)
-        nicks.pop()
-        self.assertEqual(
-            self.plugin.invitations[ROOM_JID][0][1], [Const.JID[4].userhostJID()]
-        )
-
-        # another leaves
-        user_nick = self.plugin_0045.leave_room(0, 3)
-        self.plugin.user_left_trigger(room, User(user_nick, Const.JID[3]), PROFILE)
-        nicks.pop()
-        self.assertEqual(
-            self.plugin.invitations[ROOM_JID][0][1],
-            [Const.JID[4].userhostJID(), Const.JID[3].userhostJID()],
-        )
-
-        # they can join again
-        user_nick = self.plugin_0045.join_room(0, 3)
-        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[3]), PROFILE)
-        nicks.append(user_nick)
-        user_nick = self.plugin_0045.join_room(0, 4)
-        self.plugin.user_joined_trigger(room, User(user_nick, Const.JID[4]), PROFILE)
-        nicks.append(user_nick)
-        self.assertEqual(self.plugin.games[ROOM_JID]["players"], nicks)
-        self.assertTrue(len(self.plugin.invitations[ROOM_JID]) == 0)
-
-    def test_check_create_game_and_init(self):
-        self.reinit()
-        helpers.mute_logging()
-        self.assertEqual(
-            (False, False), self.plugin._check_create_game_and_init(ROOM_JID, PROFILE)
-        )
-        helpers.unmute_logging()
-
-        nick = self.plugin_0045.join_room(0, 0)
-        self.assertEqual(
-            (True, False), self.plugin._check_create_game_and_init(ROOM_JID, PROFILE)
-        )
-        self.assertTrue(self.plugin._game_exists(ROOM_JID, False))
-        self.assertFalse(self.plugin._game_exists(ROOM_JID, True))
-        self.assertTrue(self.plugin.is_referee(ROOM_JID, nick))
-
-        helpers.mute_logging()
-        self.assertEqual(
-            (False, False), self.plugin._check_create_game_and_init(ROOM_JID, OTHER_PROFILE)
-        )
-        helpers.unmute_logging()
-
-        self.plugin_0045.join_room(0, 1)
-        self.assertEqual(
-            (False, False), self.plugin._check_create_game_and_init(ROOM_JID, OTHER_PROFILE)
-        )
-
-        self.plugin.create_game(ROOM_JID, [Const.JID[1]], PROFILE)
-        self.assertEqual(
-            (False, True), self.plugin._check_create_game_and_init(ROOM_JID, PROFILE)
-        )
-        self.assertEqual(
-            (False, False), self.plugin._check_create_game_and_init(ROOM_JID, OTHER_PROFILE)
-        )
-
-    def test_create_game(self):
-
-        self.reinit(player_init={"xxx": "xyz"})
-        nicks = []
-        for i in [0, 1, 3, 4]:
-            nicks.append(self.plugin_0045.join_room(0, i))
-
-        # game not exists
-        self.plugin.create_game(ROOM_JID, nicks, PROFILE)
-        self.assertTrue(self.plugin._game_exists(ROOM_JID, True))
-        self.assertEqual(self.plugin.games[ROOM_JID]["players"], nicks)
-        self.assertEqual(
-            self.host.get_sent_message_xml(0),
-            self._expected_message(ROOM_JID, "groupchat", "started", nicks),
-        )
-        for nick in nicks:
-            self.assertEqual("init", self.plugin.games[ROOM_JID]["status"][nick])
-            self.assertEqual(
-                self.plugin.player_init, self.plugin.games[ROOM_JID]["players_data"][nick]
-            )
-            self.plugin.games[ROOM_JID]["players_data"][nick]["xxx"] = nick
-        for nick in nicks:
-            # checks that a copy of self.player_init has been done and not a reference
-            self.assertEqual(
-                nick, self.plugin.games[ROOM_JID]["players_data"][nick]["xxx"]
-            )
-
-        # game exists, current profile is referee
-        self.reinit(player_init={"xxx": "xyz"})
-        self.init_game(0, 0)
-        self.plugin.games[ROOM_JID]["started"] = True
-        self.plugin.create_game(ROOM_JID, nicks, PROFILE)
-        self.assertEqual(
-            self.host.get_sent_message_xml(0),
-            self._expected_message(ROOM_JID, "groupchat", "started", nicks),
-        )
-
-        # game exists, current profile is not referee
-        self.reinit(player_init={"xxx": "xyz"})
-        self.init_game(0, 0)
-        self.plugin.games[ROOM_JID]["started"] = True
-        self.plugin_0045.join_room(0, 1)
-        self.plugin.create_game(ROOM_JID, nicks, OTHER_PROFILE)
-        self.assertEqual(
-            self.host.get_sent_message(0), None
-        )  # no sync message has been sent by other_profile
--- a/sat/test/test_plugin_misc_text_syntaxes.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,115 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Plugin text syntaxes tests """
-
-from sat.test import helpers
-from sat.plugins import plugin_misc_text_syntaxes
-from twisted.trial.unittest import SkipTest
-import re
-import importlib
-
-
-class SanitisationTest(helpers.SatTestCase):
-
-    EVIL_HTML1 = """
-   <html>
-    <head>
-      <script type="text/javascript" src="evil-site"></script>
-      <link rel="alternate" type="text/rss" src="evil-rss">
-      <style>
-        body {background-image: url(javascript:do_evil)};
-        div {color: expression(evil)};
-      </style>
-    </head>
-    <body onload="evil_function()">
-      <!-- I am interpreted for EVIL! -->
-      <a href="javascript:evil_function()">a link</a>
-      <a href="#" onclick="evil_function()">another link</a>
-      <p onclick="evil_function()">a paragraph</p>
-      <div style="display: none">secret EVIL!</div>
-      <object> of EVIL! </object>
-      <iframe src="evil-site"></iframe>
-      <form action="evil-site">
-        Password: <input type="password" name="password">
-      </form>
-      <blink>annoying EVIL!</blink>
-      <a href="evil-site">spam spam SPAM!</a>
-      <image src="evil!">
-    </body>
-   </html>"""  # example from lxml: /usr/share/doc/python-lxml-doc/html/lxmlhtml.html#cleaning-up-html
-
-    EVIL_HTML2 = """<p style='display: None; test: blah; background: url(: alert()); color: blue;'>test <strong>retest</strong><br><span style="background-color: (alert('bouh')); titi; color: #cf2828; font-size: 3px; direction: !important; color: red; color: red !important; font-size: 100px       !important; font-size: 100px  ! important; font-size: 100%; font-size: 100ox; font-size: 100px; font-size: 100;;;; font-size: 100 %; color: 100 px 1.7em; color: rgba(0, 0, 0, 0.1); color: rgb(35,79,255); background-color: no-repeat; background-color: :alert(1); color: (alert('XSS')); color: (window.location='http://example.org/'); color: url(:window.location='http://example.org/'); "> toto </span></p>"""
-
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-        importlib.reload(plugin_misc_text_syntaxes)  # reload the plugin to avoid conflict error
-        self.text_syntaxes = plugin_misc_text_syntaxes.TextSyntaxes(self.host)
-
-    def test_xhtml_sanitise(self):
-        expected = """<div>
-      <style>/* deleted */</style>
-    <body>
-      <a href="">a link</a>
-      <a href="#">another link</a>
-      <p>a paragraph</p>
-      <div style="">secret EVIL!</div>
-       of EVIL!
-        Password:
-      annoying EVIL!
-      <a href="evil-site">spam spam SPAM!</a>
-      <img src="evil!">
-    </img></body>
-   </div>"""
-
-        d = self.text_syntaxes.clean_xhtml(self.EVIL_HTML1)
-        d.addCallback(self.assert_equal_xml, expected, ignore_blank=True)
-        return d
-
-    def test_styles_sanitise(self):
-        expected = """<p style="color: blue">test <strong>retest</strong><br/><span style="color: #cf2828; font-size: 3px; color: red; color: red !important; font-size: 100px       !important; font-size: 100%; font-size: 100px; font-size: 100; font-size: 100 %; color: rgba(0, 0, 0, 0.1); color: rgb(35,79,255); background-color: no-repeat"> toto </span></p>"""
-
-        d = self.text_syntaxes.clean_xhtml(self.EVIL_HTML2)
-        d.addCallback(self.assert_equal_xml, expected)
-        return d
-
-    def test_html2text(self):
-        """Check that html2text is not inserting \n in the middle of that link.
-        By default lines are truncated after the 79th characters."""
-        source = '<img src="http://sat.goffi.org/static/images/screenshots/libervia/libervia_discussions.png" alt="sat"/>'
-        expected = "![sat](http://sat.goffi.org/static/images/screenshots/libervia/libervia_discussions.png)"
-        try:
-            d = self.text_syntaxes.convert(
-                source,
-                self.text_syntaxes.SYNTAX_XHTML,
-                self.text_syntaxes.SYNTAX_MARKDOWN,
-            )
-        except plugin_misc_text_syntaxes.UnknownSyntax:
-            raise SkipTest("Markdown syntax is not available.")
-        d.addCallback(self.assertEqual, expected)
-        return d
-
-    def test_remove_xhtml_markups(self):
-        expected = """ a link another link a paragraph secret EVIL! of EVIL! Password: annoying EVIL! spam spam SPAM! """
-        result = self.text_syntaxes._remove_markups(self.EVIL_HTML1)
-        self.assertEqual(re.sub(r"\s+", " ", result).rstrip(), expected.rstrip())
-
-        expected = """test retest toto"""
-        result = self.text_syntaxes._remove_markups(self.EVIL_HTML2)
-        self.assertEqual(re.sub(r"\s+", " ", result).rstrip(), expected.rstrip())
--- a/sat/test/test_plugin_xep_0033.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,211 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Plugin extended addressing stanzas """
-
-from .constants import Const
-from sat.test import helpers
-from sat.plugins import plugin_xep_0033 as plugin
-from sat.core.exceptions import CancelError
-from twisted.internet import defer
-from wokkel.generic import parseXml
-from twisted.words.protocols.jabber.jid import JID
-
-PROFILE_INDEX = 0
-PROFILE = Const.PROFILE[PROFILE_INDEX]
-JID_STR_FROM = Const.JID_STR[1]
-JID_STR_TO = Const.PROFILE_DICT[PROFILE].host
-JID_STR_X_TO = Const.JID_STR[0]
-JID_STR_X_CC = Const.JID_STR[1]
-JID_STR_X_BCC = Const.JID_STR[2]
-
-ADDRS = ("to", JID_STR_X_TO, "cc", JID_STR_X_CC, "bcc", JID_STR_X_BCC)
-
-
-class XEP_0033Test(helpers.SatTestCase):
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-        self.plugin = plugin.XEP_0033(self.host)
-
-    def test_message_received(self):
-        self.host.memory.reinit()
-        xml = """
-        <message type="chat" from="%s" to="%s" id="test_1">
-            <body>test</body>
-            <addresses xmlns='http://jabber.org/protocol/address'>
-                <address type='to' jid='%s'/>
-                <address type='cc' jid='%s'/>
-                <address type='bcc' jid='%s'/>
-            </addresses>
-        </message>
-        """ % (
-            JID_STR_FROM,
-            JID_STR_TO,
-            JID_STR_X_TO,
-            JID_STR_X_CC,
-            JID_STR_X_BCC,
-        )
-        stanza = parseXml(xml.encode("utf-8"))
-        treatments = defer.Deferred()
-        self.plugin.message_received_trigger(
-            self.host.get_client(PROFILE), stanza, treatments
-        )
-        data = {"extra": {}}
-
-        def cb(data):
-            expected = ("to", JID_STR_X_TO, "cc", JID_STR_X_CC, "bcc", JID_STR_X_BCC)
-            msg = "Expected: %s\nGot:      %s" % (expected, data["extra"]["addresses"])
-            self.assertEqual(
-                data["extra"]["addresses"], "%s:%s\n%s:%s\n%s:%s\n" % expected, msg
-            )
-
-        treatments.addCallback(cb)
-        return treatments.callback(data)
-
-    def _get_mess_data(self):
-        mess_data = {
-            "to": JID(JID_STR_TO),
-            "type": "chat",
-            "message": "content",
-            "extra": {},
-        }
-        mess_data["extra"]["address"] = "%s:%s\n%s:%s\n%s:%s\n" % ADDRS
-        original_stanza = """
-        <message type="chat" from="%s" to="%s" id="test_1">
-            <body>content</body>
-        </message>
-        """ % (
-            JID_STR_FROM,
-            JID_STR_TO,
-        )
-        mess_data["xml"] = parseXml(original_stanza.encode("utf-8"))
-        return mess_data
-
-    def _assert_addresses(self, mess_data):
-        """The mess_data that we got here has been modified by self.plugin.messageSendTrigger,
-        check that the addresses element has been added to the stanza."""
-        expected = self._get_mess_data()["xml"]
-        addresses_extra = (
-            """
-        <addresses xmlns='http://jabber.org/protocol/address'>
-            <address type='%s' jid='%s'/>
-            <address type='%s' jid='%s'/>
-            <address type='%s' jid='%s'/>
-        </addresses>"""
-            % ADDRS
-        )
-        addresses_element = parseXml(addresses_extra.encode("utf-8"))
-        expected.addChild(addresses_element)
-        self.assert_equal_xml(
-            mess_data["xml"].toXml().encode("utf-8"), expected.toXml().encode("utf-8")
-        )
-
-    def _check_sent_and_stored(self):
-        """Check that all the recipients got their messages and that the history has been filled.
-        /!\ see the comments in XEP_0033.send_and_store_message"""
-        sent = []
-        stored = []
-        d_list = []
-
-        def cb(entities, to_jid):
-            if host in entities:
-                if (
-                    host not in sent
-                ):  # send the message to the entity offering the feature
-                    sent.append(host)
-                    stored.append(host)
-                stored.append(to_jid)  # store in history for each recipient
-            else:  # feature not supported, use normal behavior
-                sent.append(to_jid)
-                stored.append(to_jid)
-            helpers.unmute_logging()
-
-        for to_s in (JID_STR_X_TO, JID_STR_X_CC, JID_STR_X_BCC):
-            to_jid = JID(to_s)
-            host = JID(to_jid.host)
-            helpers.mute_logging()
-            d = self.host.find_features_set([plugin.NS_ADDRESS], jid_=host, profile=PROFILE)
-            d.addCallback(cb, to_jid)
-            d_list.append(d)
-
-        def cb_list(__):
-            msg = "/!\ see the comments in XEP_0033.send_and_store_message"
-            sent_recipients = [
-                JID(elt["to"]) for elt in self.host.get_sent_messages(PROFILE_INDEX)
-            ]
-            self.assert_equal_unsorted_list(sent_recipients, sent, msg)
-            self.assert_equal_unsorted_list(self.host.stored_messages, stored, msg)
-
-        return defer.DeferredList(d_list).addCallback(cb_list)
-
-    def _trigger(self, data):
-        """Execute self.plugin.messageSendTrigger with a different logging
-        level to not pollute the output, then check that the plugin did its
-        job. It should abort sending the message or add the extended
-        addressing information to the stanza.
-        @param data: the data to be processed by self.plugin.messageSendTrigger
-        """
-        pre_treatments = defer.Deferred()
-        post_treatments = defer.Deferred()
-        helpers.mute_logging()
-        self.plugin.messageSendTrigger(
-            self.host.get_client[PROFILE], data, pre_treatments, post_treatments
-        )
-        post_treatments.callback(data)
-        helpers.unmute_logging()
-        post_treatments.addCallbacks(
-            self._assert_addresses, lambda failure: failure.trap(CancelError)
-        )
-        return post_treatments
-
-    def test_message_send_trigger_feature_not_supported(self):
-        # feature is not supported, abort the message
-        self.host.memory.reinit()
-        data = self._get_mess_data()
-        return self._trigger(data)
-
-    def test_message_send_trigger_feature_supported(self):
-        # feature is supported by the main target server
-        self.host.reinit()
-        self.host.add_feature(JID(JID_STR_TO), plugin.NS_ADDRESS, PROFILE)
-        data = self._get_mess_data()
-        d = self._trigger(data)
-        return d.addCallback(lambda __: self._check_sent_and_stored())
-
-    def test_message_send_trigger_feature_fully_supported(self):
-        # feature is supported by all target servers
-        self.host.reinit()
-        self.host.add_feature(JID(JID_STR_TO), plugin.NS_ADDRESS, PROFILE)
-        for dest in (JID_STR_X_TO, JID_STR_X_CC, JID_STR_X_BCC):
-            self.host.add_feature(JID(JID(dest).host), plugin.NS_ADDRESS, PROFILE)
-        data = self._get_mess_data()
-        d = self._trigger(data)
-        return d.addCallback(lambda __: self._check_sent_and_stored())
-
-    def test_message_send_trigger_fix_wrong_entity(self):
-        # check that a wrong recipient entity is fixed by the backend
-        self.host.reinit()
-        self.host.add_feature(JID(JID_STR_TO), plugin.NS_ADDRESS, PROFILE)
-        for dest in (JID_STR_X_TO, JID_STR_X_CC, JID_STR_X_BCC):
-            self.host.add_feature(JID(JID(dest).host), plugin.NS_ADDRESS, PROFILE)
-        data = self._get_mess_data()
-        data["to"] = JID(JID_STR_X_TO)
-        d = self._trigger(data)
-        return d.addCallback(lambda __: self._check_sent_and_stored())
--- a/sat/test/test_plugin_xep_0085.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,104 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Plugin chat states notification tests """
-
-from .constants import Const
-from sat.test import helpers
-from sat.core.constants import Const as C
-from sat.plugins import plugin_xep_0085 as plugin
-from copy import deepcopy
-from twisted.internet import defer
-from wokkel.generic import parseXml
-
-
-class XEP_0085Test(helpers.SatTestCase):
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-        self.plugin = plugin.XEP_0085(self.host)
-        self.host.memory.param_set(
-            plugin.PARAM_NAME,
-            True,
-            plugin.PARAM_KEY,
-            C.NO_SECURITY_LIMIT,
-            Const.PROFILE[0],
-        )
-
-    def test_message_received(self):
-        for state in plugin.CHAT_STATES:
-            xml = """
-            <message type="chat" from="%s" to="%s" id="test_1">
-            %s
-            <%s xmlns='%s'/>
-            </message>
-            """ % (
-                Const.JID_STR[1],
-                Const.JID_STR[0],
-                "<body>test</body>" if state == "active" else "",
-                state,
-                plugin.NS_CHAT_STATES,
-            )
-            stanza = parseXml(xml.encode("utf-8"))
-            self.host.bridge.expect_call(
-                "chat_state_received", Const.JID_STR[1], state, Const.PROFILE[0]
-            )
-            self.plugin.message_received_trigger(
-                self.host.get_client(Const.PROFILE[0]), stanza, None
-            )
-
-    def test_message_send_trigger(self):
-        def cb(data):
-            xml = data["xml"].toXml().encode("utf-8")
-            self.assert_equal_xml(xml, expected.toXml().encode("utf-8"))
-
-        d_list = []
-
-        for state in plugin.CHAT_STATES:
-            mess_data = {
-                "to": Const.JID[0],
-                "type": "chat",
-                "message": "content",
-                "extra": {} if state == "active" else {"chat_state": state},
-            }
-            stanza = """
-            <message type="chat" from="%s" to="%s" id="test_1">
-            %s
-            </message>
-            """ % (
-                Const.JID_STR[1],
-                Const.JID_STR[0],
-                ("<body>%s</body>" % mess_data["message"]) if state == "active" else "",
-            )
-            mess_data["xml"] = parseXml(stanza.encode("utf-8"))
-            expected = deepcopy(mess_data["xml"])
-            expected.addElement(state, plugin.NS_CHAT_STATES)
-            post_treatments = defer.Deferred()
-            self.plugin.messageSendTrigger(
-                self.host.get_client(Const.PROFILE[0]), mess_data, None, post_treatments
-            )
-
-            post_treatments.addCallback(cb)
-            post_treatments.callback(mess_data)
-            d_list.append(post_treatments)
-
-        def cb_list(__):  # cancel the timer to not block the process
-            self.plugin.map[Const.PROFILE[0]][Const.JID[0]].timer.cancel()
-
-        return defer.DeferredList(d_list).addCallback(cb_list)
--- a/sat/test/test_plugin_xep_0203.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,67 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Plugin XEP-0203 """
-
-from sat.test import helpers
-from sat.plugins.plugin_xep_0203 import XEP_0203
-from twisted.words.xish import domish
-from twisted.words.protocols.jabber.jid import JID
-from dateutil.tz import tzutc
-import datetime
-
-NS_PUBSUB = "http://jabber.org/protocol/pubsub"
-
-
-class XEP_0203Test(helpers.SatTestCase):
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-        self.plugin = XEP_0203(self.host)
-
-    def test_delay(self):
-        delay_xml = """
-          <delay xmlns='urn:xmpp:delay'
-             from='capulet.com'
-             stamp='2002-09-10T23:08:25Z'>
-            Offline Storage
-          </delay>
-        """
-        message_xml = (
-            """
-        <message
-            from='romeo@montague.net/orchard'
-            to='juliet@capulet.com'
-            type='chat'>
-          <body>text</body>
-          %s
-        </message>
-        """
-            % delay_xml
-        )
-
-        parent = domish.Element((None, "message"))
-        parent["from"] = "romeo@montague.net/orchard"
-        parent["to"] = "juliet@capulet.com"
-        parent["type"] = "chat"
-        parent.addElement("body", None, "text")
-        stamp = datetime.datetime(2002, 9, 10, 23, 8, 25, tzinfo=tzutc())
-        elt = self.plugin.delay(stamp, JID("capulet.com"), "Offline Storage", parent)
-        self.assert_equal_xml(elt.toXml(), delay_xml, True)
-        self.assert_equal_xml(parent.toXml(), message_xml, True)
--- a/sat/test/test_plugin_xep_0277.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,126 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Plugin XEP-0277 tests """
-
-from sat.test import helpers
-from sat.plugins import plugin_xep_0277
-from sat.plugins import plugin_xep_0060
-from sat.plugins import plugin_misc_text_syntaxes
-from sat.tools.xml_tools import ElementParser
-from wokkel.pubsub import NS_PUBSUB
-import importlib
-
-
-class XEP_0277Test(helpers.SatTestCase):
-
-    PUBSUB_ENTRY_1 = (
-        """
-    <item id="c745a688-9b02-11e3-a1a3-c0143dd4fe51">
-        <entry xmlns="%s">
-            <title type="text">&lt;span&gt;titre&lt;/span&gt;</title>
-            <id>c745a688-9b02-11e3-a1a3-c0143dd4fe51</id>
-            <updated>2014-02-21T16:16:39+02:00</updated>
-            <published>2014-02-21T16:16:38+02:00</published>
-            <content type="text">&lt;p&gt;contenu&lt;/p&gt;texte sans balise&lt;p&gt;autre contenu&lt;/p&gt;</content>
-            <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>contenu</p>texte sans balise<p>autre contenu</p></div></content>
-        <author>
-            <name>test1@souliane.org</name>
-        </author>
-    </entry>
-    </item>
-    """
-        % plugin_xep_0277.NS_ATOM
-    )
-
-    PUBSUB_ENTRY_2 = (
-        """
-    <item id="c745a688-9b02-11e3-a1a3-c0143dd4fe51">
-        <entry xmlns='%s'>
-            <title type="text">&lt;div&gt;titre&lt;/div&gt;</title>
-            <title type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><div style="background-image: url('xxx');">titre</div></div></title>
-            <id>c745a688-9b02-11e3-a1a3-c0143dd4fe51</id>
-            <updated>2014-02-21T16:16:39+02:00</updated>
-            <published>2014-02-21T16:16:38+02:00</published>
-            <content type="text">&lt;div&gt;&lt;p&gt;contenu&lt;/p&gt;texte dans balise&lt;p&gt;autre contenu&lt;/p&gt;&lt;/div&gt;</content>
-            <content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><p>contenu</p>texte dans balise<p>autre contenu</p></div></content>
-        <author>
-            <name>test1@souliane.org</name>
-            <nick>test1</nick>
-        </author>
-    </entry>
-    </item>
-    """
-        % plugin_xep_0277.NS_ATOM
-    )
-
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-
-        class XEP_0163(object):
-            def __init__(self, host):
-                pass
-
-            def add_pep_event(self, *args):
-                pass
-
-        self.host.plugins["XEP-0060"] = plugin_xep_0060.XEP_0060(self.host)
-        self.host.plugins["XEP-0163"] = XEP_0163(self.host)
-        importlib.reload(plugin_misc_text_syntaxes)  # reload the plugin to avoid conflict error
-        self.host.plugins["TEXT_SYNTAXES"] = plugin_misc_text_syntaxes.TextSyntaxes(
-            self.host
-        )
-        self.plugin = plugin_xep_0277.XEP_0277(self.host)
-
-    def test_item2mbdata_1(self):
-        expected = {
-            "id": "c745a688-9b02-11e3-a1a3-c0143dd4fe51",
-            "atom_id": "c745a688-9b02-11e3-a1a3-c0143dd4fe51",
-            "title": "<span>titre</span>",
-            "updated": "1392992199.0",
-            "published": "1392992198.0",
-            "content": "<p>contenu</p>texte sans balise<p>autre contenu</p>",
-            "content_xhtml": "<div><p>contenu</p>texte sans balise<p>autre contenu</p></div>",
-            "author": "test1@souliane.org",
-        }
-        item_elt = (
-            next(ElementParser()(self.PUBSUB_ENTRY_1, namespace=NS_PUBSUB).elements())
-        )
-        d = self.plugin.item2mbdata(item_elt)
-        d.addCallback(self.assertEqual, expected)
-        return d
-
-    def test_item2mbdata_2(self):
-        expected = {
-            "id": "c745a688-9b02-11e3-a1a3-c0143dd4fe51",
-            "atom_id": "c745a688-9b02-11e3-a1a3-c0143dd4fe51",
-            "title": "<div>titre</div>",
-            "title_xhtml": '<div><div style="">titre</div></div>',
-            "updated": "1392992199.0",
-            "published": "1392992198.0",
-            "content": "<div><p>contenu</p>texte dans balise<p>autre contenu</p></div>",
-            "content_xhtml": "<div><p>contenu</p>texte dans balise<p>autre contenu</p></div>",
-            "author": "test1@souliane.org",
-        }
-        item_elt = (
-            next(ElementParser()(self.PUBSUB_ENTRY_2, namespace=NS_PUBSUB).elements())
-        )
-        d = self.plugin.item2mbdata(item_elt)
-        d.addCallback(self.assertEqual, expected)
-        return d
--- a/sat/test/test_plugin_xep_0297.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,89 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Plugin XEP-0297 """
-
-from .constants import Const as C
-from sat.test import helpers
-from sat.plugins.plugin_xep_0203 import XEP_0203
-from sat.plugins.plugin_xep_0297 import XEP_0297
-from twisted.words.protocols.jabber.jid import JID
-from dateutil.tz import tzutc
-import datetime
-from wokkel.generic import parseXml
-
-
-NS_PUBSUB = "http://jabber.org/protocol/pubsub"
-
-
-class XEP_0297Test(helpers.SatTestCase):
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-        self.plugin = XEP_0297(self.host)
-        self.host.plugins["XEP-0203"] = XEP_0203(self.host)
-
-    def test_delay(self):
-        stanza = parseXml(
-            """
-          <message from='juliet@capulet.lit/orchard'
-                   id='0202197'
-                   to='romeo@montague.lit'
-                   type='chat'>
-            <body>Yet I should kill thee with much cherishing.</body>
-            <mood xmlns='http://jabber.org/protocol/mood'>
-                <amorous/>
-            </mood>
-          </message>
-        """.encode(
-                "utf-8"
-            )
-        )
-        output = """
-          <message to='mercutio@verona.lit' type='chat'>
-            <body>A most courteous exposition!</body>
-            <forwarded xmlns='urn:xmpp:forward:0'>
-              <delay xmlns='urn:xmpp:delay' stamp='2010-07-10T23:08:25Z'/>
-              <message from='juliet@capulet.lit/orchard'
-                       id='0202197'
-                       to='romeo@montague.lit'
-                       type='chat'
-                       xmlns='jabber:client'>
-                  <body>Yet I should kill thee with much cherishing.</body>
-                  <mood xmlns='http://jabber.org/protocol/mood'>
-                      <amorous/>
-                  </mood>
-              </message>
-            </forwarded>
-          </message>
-        """
-        stamp = datetime.datetime(2010, 7, 10, 23, 8, 25, tzinfo=tzutc())
-        d = self.plugin.forward(
-            stanza,
-            JID("mercutio@verona.lit"),
-            stamp,
-            body="A most courteous exposition!",
-            profile_key=C.PROFILE[0],
-        )
-        d.addCallback(
-            lambda __: self.assert_equal_xml(
-                self.host.get_sent_message_xml(0), output, True
-            )
-        )
-        return d
--- a/sat/test/test_plugin_xep_0313.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,314 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Plugin XEP-0313 """
-
-from .constants import Const as C
-from sat.test import helpers
-from sat.plugins.plugin_xep_0313 import XEP_0313
-from twisted.words.protocols.jabber.jid import JID
-from twisted.words.xish import domish
-from wokkel.data_form import Field
-from dateutil.tz import tzutc
-import datetime
-
-# TODO: change this when RSM and MAM are in wokkel
-from sat_tmp.wokkel.rsm import RSMRequest
-from sat_tmp.wokkel.mam import buildForm, MAMRequest
-
-NS_PUBSUB = "http://jabber.org/protocol/pubsub"
-SERVICE = "sat-pubsub.tazar.int"
-SERVICE_JID = JID(SERVICE)
-
-
-class XEP_0313Test(helpers.SatTestCase):
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-        self.plugin = XEP_0313(self.host)
-        self.client = self.host.get_client(C.PROFILE[0])
-        mam_client = self.plugin.get_handler(C.PROFILE[0])
-        mam_client.makeConnection(self.host.get_client(C.PROFILE[0]).xmlstream)
-
-    def test_query_archive(self):
-        xml = """
-        <iq type='set' id='%s' to='%s'>
-          <query xmlns='urn:xmpp:mam:1'/>
-        </iq>
-        """ % (
-            ("H_%d" % domish.Element._idCounter),
-            SERVICE,
-        )
-        d = self.plugin.queryArchive(self.client, MAMRequest(), SERVICE_JID)
-        d.addCallback(
-            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
-        )
-        return d
-
-    def test_query_archive_pubsub(self):
-        xml = """
-        <iq type='set' id='%s' to='%s'>
-          <query xmlns='urn:xmpp:mam:1' node='fdp/submitted/capulet.lit/sonnets' />
-        </iq>
-        """ % (
-            ("H_%d" % domish.Element._idCounter),
-            SERVICE,
-        )
-        d = self.plugin.queryArchive(
-            self.client, MAMRequest(node="fdp/submitted/capulet.lit/sonnets"), SERVICE_JID
-        )
-        d.addCallback(
-            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
-        )
-        return d
-
-    def test_query_archive_with(self):
-        xml = """
-        <iq type='set' id='%s' to='%s'>
-          <query xmlns='urn:xmpp:mam:1'>
-            <x xmlns='jabber:x:data' type='submit'>
-              <field var='FORM_TYPE' type='hidden'>
-                <value>urn:xmpp:mam:1</value>
-              </field>
-              <field var='with' type='jid-single'>
-                <value>juliet@capulet.lit</value>
-              </field>
-            </x>
-          </query>
-        </iq>
-        """ % (
-            ("H_%d" % domish.Element._idCounter),
-            SERVICE,
-        )
-        form = buildForm(with_jid=JID("juliet@capulet.lit"))
-        d = self.plugin.queryArchive(self.client, MAMRequest(form), SERVICE_JID)
-        d.addCallback(
-            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
-        )
-        return d
-
-    def test_query_archive_start_end(self):
-        xml = """
-        <iq type='set' id='%s' to='%s'>
-          <query xmlns='urn:xmpp:mam:1'>
-            <x xmlns='jabber:x:data' type='submit'>
-              <field var='FORM_TYPE' type='hidden'>
-                <value>urn:xmpp:mam:1</value>
-              </field>
-              <field var='start' type='text-single'>
-                <value>2010-06-07T00:00:00Z</value>
-              </field>
-              <field var='end' type='text-single'>
-                <value>2010-07-07T13:23:54Z</value>
-              </field>
-            </x>
-          </query>
-        </iq>
-        """ % (
-            ("H_%d" % domish.Element._idCounter),
-            SERVICE,
-        )
-        start = datetime.datetime(2010, 6, 7, 0, 0, 0, tzinfo=tzutc())
-        end = datetime.datetime(2010, 7, 7, 13, 23, 54, tzinfo=tzutc())
-        form = buildForm(start=start, end=end)
-        d = self.plugin.queryArchive(self.client, MAMRequest(form), SERVICE_JID)
-        d.addCallback(
-            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
-        )
-        return d
-
-    def test_query_archive_start(self):
-        xml = """
-        <iq type='set' id='%s' to='%s'>
-          <query xmlns='urn:xmpp:mam:1'>
-            <x xmlns='jabber:x:data' type='submit'>
-              <field var='FORM_TYPE' type='hidden'>
-                <value>urn:xmpp:mam:1</value>
-              </field>
-              <field var='start' type='text-single'>
-                <value>2010-08-07T00:00:00Z</value>
-              </field>
-            </x>
-          </query>
-        </iq>
-        """ % (
-            ("H_%d" % domish.Element._idCounter),
-            SERVICE,
-        )
-        start = datetime.datetime(2010, 8, 7, 0, 0, 0, tzinfo=tzutc())
-        form = buildForm(start=start)
-        d = self.plugin.queryArchive(self.client, MAMRequest(form), SERVICE_JID)
-        d.addCallback(
-            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
-        )
-        return d
-
-    def test_query_archive_rsm(self):
-        xml = """
-        <iq type='set' id='%s' to='%s'>
-          <query xmlns='urn:xmpp:mam:1'>
-            <x xmlns='jabber:x:data' type='submit'>
-              <field var='FORM_TYPE' type='hidden'>
-                <value>urn:xmpp:mam:1</value>
-              </field>
-              <field var='start' type='text-single'>
-                <value>2010-08-07T00:00:00Z</value>
-              </field>
-            </x>
-            <set xmlns='http://jabber.org/protocol/rsm'>
-              <max>10</max>
-            </set>
-          </query>
-        </iq>
-        """ % (
-            ("H_%d" % domish.Element._idCounter),
-            SERVICE,
-        )
-        start = datetime.datetime(2010, 8, 7, 0, 0, 0, tzinfo=tzutc())
-        form = buildForm(start=start)
-        rsm = RSMRequest(max_=10)
-        d = self.plugin.queryArchive(self.client, MAMRequest(form, rsm), SERVICE_JID)
-        d.addCallback(
-            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
-        )
-        return d
-
-    def test_query_archive_rsm_paging(self):
-        xml = """
-        <iq type='set' id='%s' to='%s'>
-          <query xmlns='urn:xmpp:mam:1'>
-              <x xmlns='jabber:x:data' type='submit'>
-                <field var='FORM_TYPE' type='hidden'><value>urn:xmpp:mam:1</value></field>
-                <field var='start' type='text-single'><value>2010-08-07T00:00:00Z</value></field>
-              </x>
-              <set xmlns='http://jabber.org/protocol/rsm'>
-                 <max>10</max>
-                 <after>09af3-cc343-b409f</after>
-              </set>
-          </query>
-        </iq>
-        """ % (
-            ("H_%d" % domish.Element._idCounter),
-            SERVICE,
-        )
-        start = datetime.datetime(2010, 8, 7, 0, 0, 0, tzinfo=tzutc())
-        form = buildForm(start=start)
-        rsm = RSMRequest(max_=10, after="09af3-cc343-b409f")
-        d = self.plugin.queryArchive(self.client, MAMRequest(form, rsm), SERVICE_JID)
-        d.addCallback(
-            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
-        )
-        return d
-
-    def test_query_fields(self):
-        xml = """
-        <iq type='get' id="%s" to='%s'>
-          <query xmlns='urn:xmpp:mam:1'/>
-        </iq>
-        """ % (
-            ("H_%d" % domish.Element._idCounter),
-            SERVICE,
-        )
-        d = self.plugin.queryFields(self.client, SERVICE_JID)
-        d.addCallback(
-            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
-        )
-        return d
-
-    def test_query_archive_fields(self):
-        xml = """
-        <iq type='set' id='%s' to='%s'>
-          <query xmlns='urn:xmpp:mam:1'>
-            <x xmlns='jabber:x:data' type='submit'>
-              <field type='hidden' var='FORM_TYPE'>
-                <value>urn:xmpp:mam:1</value>
-              </field>
-              <field type='text-single' var='urn:example:xmpp:free-text-search'>
-                <value>Where arth thou, my Juliet?</value>
-              </field>
-              <field type='text-single' var='urn:example:xmpp:stanza-content'>
-                <value>{http://jabber.org/protocol/mood}mood/lonely</value>
-              </field>
-            </x>
-          </query>
-        </iq>
-        """ % (
-            ("H_%d" % domish.Element._idCounter),
-            SERVICE,
-        )
-        extra_fields = [
-            Field(
-                "text-single",
-                "urn:example:xmpp:free-text-search",
-                "Where arth thou, my Juliet?",
-            ),
-            Field(
-                "text-single",
-                "urn:example:xmpp:stanza-content",
-                "{http://jabber.org/protocol/mood}mood/lonely",
-            ),
-        ]
-        form = buildForm(extra_fields=extra_fields)
-        d = self.plugin.queryArchive(self.client, MAMRequest(form), SERVICE_JID)
-        d.addCallback(
-            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
-        )
-        return d
-
-    def test_query_prefs(self):
-        xml = """
-        <iq type='get' id='%s' to='%s'>
-          <prefs xmlns='urn:xmpp:mam:1'>
-            <always/>
-            <never/>
-          </prefs>
-        </iq>
-        """ % (
-            ("H_%d" % domish.Element._idCounter),
-            SERVICE,
-        )
-        d = self.plugin.get_prefs(self.client, SERVICE_JID)
-        d.addCallback(
-            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
-        )
-        return d
-
-    def test_set_prefs(self):
-        xml = """
-        <iq type='set' id='%s' to='%s'>
-          <prefs xmlns='urn:xmpp:mam:1' default='roster'>
-            <always>
-              <jid>romeo@montague.lit</jid>
-            </always>
-            <never>
-              <jid>montague@montague.lit</jid>
-            </never>
-          </prefs>
-        </iq>
-        """ % (
-            ("H_%d" % domish.Element._idCounter),
-            SERVICE,
-        )
-        always = [JID("romeo@montague.lit")]
-        never = [JID("montague@montague.lit")]
-        d = self.plugin.setPrefs(self.client, SERVICE_JID, always=always, never=never)
-        d.addCallback(
-            lambda __: self.assert_equal_xml(self.host.get_sent_message_xml(0), xml, True)
-        )
-        return d
--- a/sat/test/test_plugin_xep_0334.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,106 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Plugin XEP-0334 """
-
-from .constants import Const as C
-from sat.test import helpers
-from sat.plugins.plugin_xep_0334 import XEP_0334
-from twisted.internet import defer
-from wokkel.generic import parseXml
-from sat.core import exceptions
-
-HINTS = ("no-permanent-storage", "no-storage", "no-copy")
-
-
-class XEP_0334Test(helpers.SatTestCase):
-    def setUp(self):
-        self.host = helpers.FakeSAT()
-        self.plugin = XEP_0334(self.host)
-
-    def test_message_send_trigger(self):
-        template_xml = """
-        <message
-            from='romeo@montague.net/orchard'
-            to='juliet@capulet.com'
-            type='chat'>
-          <body>text</body>
-          %s
-        </message>
-        """
-        original_xml = template_xml % ""
-
-        d_list = []
-
-        def cb(data, expected_xml):
-            result_xml = data["xml"].toXml().encode("utf-8")
-            self.assert_equal_xml(result_xml, expected_xml, True)
-
-        for key in HINTS + ("", "dummy_hint"):
-            mess_data = {
-                "xml": parseXml(original_xml.encode("utf-8")),
-                "extra": {key: True},
-            }
-            treatments = defer.Deferred()
-            self.plugin.messageSendTrigger(
-                self.host.get_client(C.PROFILE[0]), mess_data, defer.Deferred(), treatments
-            )
-            if treatments.callbacks:  # the trigger added a callback
-                expected_xml = template_xml % ('<%s xmlns="urn:xmpp:hints"/>' % key)
-                treatments.addCallback(cb, expected_xml)
-                treatments.callback(mess_data)
-                d_list.append(treatments)
-
-        return defer.DeferredList(d_list)
-
-    def test_message_received_trigger(self):
-        template_xml = """
-        <message
-            from='romeo@montague.net/orchard'
-            to='juliet@capulet.com'
-            type='chat'>
-          <body>text</body>
-          %s
-        </message>
-        """
-
-        def cb(__):
-            raise Exception("Errback should not be ran instead of callback!")
-
-        def eb(failure):
-            failure.trap(exceptions.SkipHistory)
-
-        d_list = []
-
-        for key in HINTS + ("dummy_hint",):
-            message = parseXml(template_xml % ('<%s xmlns="urn:xmpp:hints"/>' % key))
-            post_treat = defer.Deferred()
-            self.plugin.message_received_trigger(
-                self.host.get_client(C.PROFILE[0]), message, post_treat
-            )
-            if post_treat.callbacks:
-                assert key in ("no-permanent-storage", "no-storage")
-                post_treat.addCallbacks(cb, eb)
-                post_treat.callback(None)
-                d_list.append(post_treat)
-            else:
-                assert key not in ("no-permanent-storage", "no-storage")
-
-        return defer.DeferredList(d_list)
--- a/sat/tools/async_trigger.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,74 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""Misc usefull classes"""
-
-from typing import Tuple, Any
-from . import trigger as sync_trigger
-from . import utils
-from twisted.internet import defer
-
-class TriggerManager(sync_trigger.TriggerManager):
-    """This is a TriggerManager with an new async_point method"""
-
-    @defer.inlineCallbacks
-    def async_point(self, point_name, *args, **kwargs):
-        """This put a trigger point with potentially async Deferred
-
-        All the triggers for that point will be run
-        @param point_name: name of the trigger point
-        @param *args: args to transmit to trigger
-        @param *kwargs: kwargs to transmit to trigger
-            if "triggers_no_cancel" is present, it will be popped out
-                when set to True, this argument don't let triggers stop
-                the workflow
-        @return D(bool): True if the action must be continued, False else
-        """
-        if point_name not in self.__triggers:
-            defer.returnValue(True)
-
-        can_cancel = not kwargs.pop('triggers_no_cancel', False)
-
-        for priority, trigger in self.__triggers[point_name]:
-            try:
-                cont = yield utils.as_deferred(trigger, *args, **kwargs)
-                if can_cancel and not cont:
-                    defer.returnValue(False)
-            except sync_trigger.SkipOtherTriggers:
-                break
-        defer.returnValue(True)
-
-    async def async_return_point(
-        self,
-        point_name: str,
-        *args, **kwargs
-    ) -> Tuple[bool, Any]:
-        """Async version of return_point"""
-        if point_name not in self.__triggers:
-            return True, None
-
-        for priority, trigger in self.__triggers[point_name]:
-            try:
-                cont, ret_value = await utils.as_deferred(trigger, *args, **kwargs)
-                if not cont:
-                    return False, ret_value
-            except sync_trigger.SkipOtherTriggers:
-                break
-        return True, None
-
--- a/sat/tools/common/ansi.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,60 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-import sys
-
-
-class ANSI(object):
-
-    ## ANSI escape sequences ##
-    RESET = "\033[0m"
-    NORMAL_WEIGHT = "\033[22m"
-    FG_BLACK, FG_RED, FG_GREEN, FG_YELLOW, FG_BLUE, FG_MAGENTA, FG_CYAN, FG_WHITE = (
-        "\033[3%dm" % nb for nb in range(8)
-    )
-    BOLD = "\033[1m"
-    BLINK = "\033[5m"
-    BLINK_OFF = "\033[25m"
-
-    @classmethod
-    def color(cls, *args):
-        """output text using ANSI codes
-
-        this method simply merge arguments, and add RESET if is not the last arguments
-        """
-        # XXX: we expect to have at least one argument
-        if args[-1] != cls.RESET:
-            args = list(args)
-            args.append(cls.RESET)
-        return "".join(args)
-
-
-try:
-    tty = sys.stdout.isatty()
-except (
-    AttributeError,
-    TypeError,
-):  # FIXME: TypeError is here for Pyjamas, need to be removed
-    tty = False
-if not tty:
-    #  we don't want ANSI escape codes if we are not outputing to a tty!
-    for attr in dir(ANSI):
-        if isinstance(getattr(ANSI, attr), str):
-            setattr(ANSI, attr, "")
-del tty
--- a/sat/tools/common/async_process.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,144 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""tools to launch process in a async way (using Twisted)"""
-
-import os.path
-from twisted.internet import defer, reactor, protocol
-from twisted.python.failure import Failure
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.core.log import getLogger
-log = getLogger(__name__)
-
-
-class CommandProtocol(protocol.ProcessProtocol):
-    """handle an external command"""
-    # name of the command (unicode)
-    name = None
-    # full path to the command (bytes)
-    command = None
-    # True to activate logging of command outputs (bool)
-    log = False
-
-    def __init__(self, deferred, stdin=None):
-        """
-        @param deferred(defer.Deferred): will be called when command is completed
-        @param stdin(str, None): if not None, will be push to standard input
-        """
-        self._stdin = stdin
-        self._deferred = deferred
-        self.data = []
-        self.err_data = []
-
-    @property
-    def command_name(self):
-        """returns command name or empty string if it can't be guessed"""
-        if self.name is not None:
-            return self.name
-        elif self.command is not None:
-            return os.path.splitext(os.path.basename(self.command))[0].decode('utf-8',
-                                                                              'ignore')
-        else:
-            return ''
-
-    def connectionMade(self):
-        if self._stdin is not None:
-            self.transport.write(self._stdin)
-            self.transport.closeStdin()
-
-    def outReceived(self, data):
-        if self.log:
-            log.info(data.decode('utf-8', 'replace'))
-        self.data.append(data)
-
-    def errReceived(self, data):
-        if self.log:
-            log.warning(data.decode('utf-8', 'replace'))
-        self.err_data.append(data)
-
-    def processEnded(self, reason):
-        data = b''.join(self.data)
-        if (reason.value.exitCode == 0):
-            log.debug(f'{self.command_name!r} command succeed')
-            # we don't use "replace" on purpose, we want an exception if decoding
-            # is not working properly
-            self._deferred.callback(data)
-        else:
-            err_data = b''.join(self.err_data)
-
-            msg = (_("Can't complete {name} command (error code: {code}):\n"
-                     "stderr:\n{stderr}\n{stdout}\n")
-                   .format(name = self.command_name,
-                           code = reason.value.exitCode,
-                           stderr= err_data.decode(errors='replace'),
-                           stdout = "stdout: " + data.decode(errors='replace')
-                                    if data else '',
-                           ))
-            self._deferred.errback(Failure(exceptions.CommandException(
-                msg, data, err_data)))
-
-    @classmethod
-    def run(cls, *args, **kwargs):
-        """Create a new CommandProtocol and execute the given command.
-
-        @param *args(unicode): command arguments
-            if cls.command is specified, it will be the path to the command to execute
-            otherwise, first argument must be the path
-        @param **kwargs: can be:
-            - stdin(unicode, None): data to push to standard input
-            - verbose(bool): if True stdout and stderr will be logged
-            other keyword arguments will be used in reactor.spawnProcess
-        @return ((D)bytes): stdout in case of success
-        @raise RuntimeError: command returned a non zero status
-            stdin and stdout will be given as arguments
-
-        """
-        stdin = kwargs.pop('stdin', None)
-        if stdin is not None:
-            stdin = stdin.encode('utf-8')
-        verbose = kwargs.pop('verbose', False)
-        args = list(args)
-        d = defer.Deferred()
-        prot = cls(d, stdin=stdin)
-        if verbose:
-            prot.log = True
-        if cls.command is None:
-            if not args:
-                raise ValueError(
-                    "You must either specify cls.command or use a full path to command "
-                    "to execute as first argument")
-            command = args.pop(0)
-            if prot.name is None:
-                name = os.path.splitext(os.path.basename(command))[0]
-                prot.name = name
-        else:
-            command = cls.command
-        cmd_args = [command] + args
-        if "env" not in kwargs:
-            # we pass parent environment by default
-            kwargs["env"] = None
-        reactor.spawnProcess(prot,
-                             command,
-                             cmd_args,
-                             **kwargs)
-        return d
-
-
-run = CommandProtocol.run
--- a/sat/tools/common/async_utils.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,53 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""tools to launch process in a async way (using Twisted)"""
-
-from collections import OrderedDict
-from typing import Optional, Callable, Awaitable
-from sat.core.log import getLogger
-
-
-log = getLogger(__name__)
-
-
-def async_lru(maxsize: Optional[int] = 50) -> Callable:
-    """Decorator to cache async function results using LRU algorithm
-
-        @param maxsize: maximum number of items to keep in cache.
-            None to have no limit
-
-    """
-    def decorator(func: Callable) -> Callable:
-        cache = OrderedDict()
-        async def wrapper(*args) -> Awaitable:
-            if args in cache:
-                log.debug(f"using result in cache for {args}")
-                cache.move_to_end(args)
-                result = cache[args]
-                return result
-            log.debug(f"caching result for {args}")
-            result = await func(*args)
-            cache[args] = result
-            if maxsize is not None and len(cache) > maxsize:
-                value = cache.popitem(False)
-                log.debug(f"Removing LRU value: {value}")
-            return result
-        return wrapper
-    return decorator
--- a/sat/tools/common/data_format.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,153 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" tools common to backend and frontends """
-#  FIXME: json may be more appropriate than manual serialising like done here
-
-from typing import Any
-
-from sat.core import exceptions
-import json
-
-
-def dict2iter(name, dict_, pop=False):
-    """iterate into a list serialised in a dict
-
-    name is the name of the key.
-    Serialisation is done with [name] [name#1] [name#2] and so on
-    e.g.: if name is 'group', keys are group, group#1, group#2, ...
-    iteration stop at first missing increment
-    Empty values are possible
-    @param name(unicode): name of the key
-    @param dict_(dict): dictionary with the serialised list
-    @param pop(bool): if True, remove the value from dict
-    @return iter: iterate through the deserialised list
-    """
-    if pop:
-        get = lambda d, k: d.pop(k)
-    else:
-        get = lambda d, k: d[k]
-
-    try:
-        yield get(dict_, name)
-    except KeyError:
-        return
-    else:
-        idx = 1
-        while True:
-            try:
-                yield get(dict_, "{}#{}".format(name, idx))
-            except KeyError:
-                return
-            else:
-                idx += 1
-
-
-def dict2iterdict(name, dict_, extra_keys, pop=False):
-    """like dict2iter but yield dictionaries
-
-    params are like in [dict2iter], extra_keys is used for extra dict keys.
-    e.g. dict2iterdict(comments, mb_data, ('node', 'service')) will yield dicts like:
-        {u'comments': u'value1', u'node': u'value2', u'service': u'value3'}
-    """
-    #  FIXME: this format seem overcomplicated, it may be more appropriate to use json here
-    if pop:
-        get = lambda d, k: d.pop(k)
-    else:
-        get = lambda d, k: d[k]
-    for idx, main_value in enumerate(dict2iter(name, dict_, pop=pop)):
-        ret = {name: main_value}
-        for k in extra_keys:
-            ret[k] = get(
-                dict_, "{}{}_{}".format(name, ("#" + str(idx)) if idx else "", k)
-            )
-        yield ret
-
-
-def iter2dict(name, iter_, dict_=None, check_conflict=True):
-    """Fill a dict with values from an iterable
-
-    name is used to serialise iter_, in the same way as in [dict2iter]
-    Build from the tags a dict using the microblog data format.
-
-    @param name(unicode): key to use for serialisation
-        e.g. "group" to have keys "group", "group#1", "group#2", ...
-    @param iter_(iterable): values to store
-    @param dict_(None, dict): dictionary to fill, or None to create one
-    @param check_conflict(bool): if True, raise an exception in case of existing key
-    @return (dict): filled dict, or newly created one
-    @raise exceptions.ConflictError: a needed key already exists
-    """
-    if dict_ is None:
-        dict_ = {}
-    for idx, value in enumerate(iter_):
-        if idx == 0:
-            key = name
-        else:
-            key = "{}#{}".format(name, idx)
-        if check_conflict and key in dict_:
-            raise exceptions.ConflictError
-        dict_[key] = value
-    return dict
-
-
-def get_sub_dict(name, dict_, sep="_"):
-    """get a sub dictionary from a serialised dictionary
-
-    look for keys starting with name, and create a dict with it
-    eg.: if "key" is looked for, {'html': 1, 'key_toto': 2, 'key_titi': 3} will return:
-        {None: 1, toto: 2, titi: 3}
-    @param name(unicode): name of the key
-    @param dict_(dict): dictionary with the serialised list
-    @param sep(unicode): separator used between name and subkey
-    @return iter: iterate through the deserialised items
-    """
-    for k, v in dict_.items():
-        if k.startswith(name):
-            if k == name:
-                yield None, v
-            else:
-                if k[len(name)] != sep:
-                    continue
-                else:
-                    yield k[len(name) + 1 :], v
-
-def serialise(data):
-    """Serialise data so it can be sent to bridge
-
-    @return(unicode): serialised data, can be transmitted as string to the bridge
-    """
-    return json.dumps(data, ensure_ascii=False, default=str)
-
-def deserialise(serialised_data: str, default: Any = None, type_check: type = dict):
-    """Deserialize data from bridge
-
-    @param serialised_data(unicode): data to deserialise
-    @default (object): value to use when serialised data is empty string
-    @param type_check(type): if not None, the deserialised data must be of this type
-    @return(object): deserialised data
-    @raise ValueError: serialised_data is of wrong type
-    """
-    if serialised_data == "":
-        return default
-    ret = json.loads(serialised_data)
-    if type_check is not None and not isinstance(ret, type_check):
-        raise ValueError("Bad data type, was expecting {type_check}, got {real_type}"
-            .format(type_check=type_check, real_type=type(ret)))
-    return ret
--- a/sat/tools/common/data_objects.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,213 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""Objects handling bridge data, with jinja2 safe markup handling"""
-
-from sat.core.constants import Const as C
-from sat.tools.common import data_format
-from os.path import basename
-
-try:
-    from jinja2 import Markup as safe
-except ImportError:
-    safe = str
-
-from sat.tools.common import uri as xmpp_uri
-import urllib.request, urllib.parse, urllib.error
-
-q = lambda value: urllib.parse.quote(value.encode("utf-8"), safe="@")
-
-
-class Message(object):
-    def __init__(self, msg_data):
-        self._uid = msg_data[0]
-        self._timestamp = msg_data[1]
-        self._from_jid = msg_data[2]
-        self._to_jid = msg_data[3]
-        self._message_data = msg_data[4]
-        self._subject_data = msg_data[5]
-        self._type = msg_data[6]
-        self._extra = data_format.deserialise(msg_data[7])
-        self._html = dict(data_format.get_sub_dict("xhtml", self._extra))
-
-    @property
-    def id(self):
-        return self._uid
-
-    @property
-    def timestamp(self):
-        return self._timestamp
-
-    @property
-    def from_(self):
-        return self._from_jid
-
-    @property
-    def text(self):
-        try:
-            return self._message_data[""]
-        except KeyError:
-            return next(iter(self._message_data.values()))
-
-    @property
-    def subject(self):
-        try:
-            return self._subject_data[""]
-        except KeyError:
-            return next(iter(self._subject_data.values()))
-
-    @property
-    def type(self):
-        return self._type
-
-    @property
-    def thread(self):
-        return self._extra.get("thread")
-
-    @property
-    def thread_parent(self):
-        return self._extra.get("thread_parent")
-
-    @property
-    def received(self):
-        return self._extra.get("received_timestamp", self._timestamp)
-
-    @property
-    def delay_sender(self):
-        return self._extra.get("delay_sender")
-
-    @property
-    def info_type(self):
-        return self._extra.get("info_type")
-
-    @property
-    def html(self):
-        if not self._html:
-            return None
-        try:
-            return safe(self._html[""])
-        except KeyError:
-            return safe(next(iter(self._html.values())))
-
-
-class Messages(object):
-    def __init__(self, msgs_data):
-        self.messages = [Message(m) for m in msgs_data]
-
-    def __len__(self):
-        return self.messages.__len__()
-
-    def __missing__(self, key):
-        return self.messages.__missing__(key)
-
-    def __getitem__(self, key):
-        return self.messages.__getitem__(key)
-
-    def __iter__(self):
-        return self.messages.__iter__()
-
-    def __reversed__(self):
-        return self.messages.__reversed__()
-
-    def __contains__(self, item):
-        return self.messages.__contains__(item)
-
-
-class Room(object):
-    def __init__(self, jid, name=None, url=None):
-        self.jid = jid
-        self.name = name or jid
-        if url is not None:
-            self.url = url
-
-
-class Identity(object):
-    def __init__(self, jid_str, data=None):
-        self.jid_str = jid_str
-        self.data = data if data is not None else {}
-
-    @property
-    def avatar_basename(self):
-        try:
-            return basename(self.data['avatar']['path'])
-        except (TypeError, KeyError):
-            return None
-
-    def __getitem__(self, key):
-        return self.data[key]
-
-    def __getattr__(self, key):
-        try:
-            return self.data[key]
-        except KeyError:
-            raise AttributeError(key)
-
-
-class Identities:
-    def __init__(self):
-        self.identities = {}
-
-    def __iter__(self):
-        return iter(self.identities)
-
-    def __getitem__(self, jid_str):
-        try:
-            return self.identities[jid_str]
-        except KeyError:
-            return None
-
-    def __setitem__(self, jid_str, data):
-        self.identities[jid_str] = Identity(jid_str, data)
-
-    def __contains__(self, jid_str):
-        return jid_str in self.identities
-
-
-class ObjectQuoter(object):
-    """object wrapper which quote attribues (to be used in templates)"""
-
-    def __init__(self, obj):
-        self.obj = obj
-
-    def __unicode__(self):
-        return q(self.obj.__unicode__())
-
-    def __str__(self):
-        return self.__unicode__()
-
-    def __getattr__(self, name):
-        return q(self.obj.__getattr__(name))
-
-    def __getitem__(self, key):
-        return q(self.obj.__getitem__(key))
-
-
-class OnClick(object):
-    """Class to handle clickable elements targets"""
-
-    def __init__(self, url=None):
-        self.url = url
-
-    def format_url(self, *args, **kwargs):
-        """format URL using Python formatting
-
-        values will be quoted before being used
-        """
-        return self.url.format(
-            *[q(a) for a in args], **{k: ObjectQuoter(v) for k, v in kwargs.items()}
-        )
--- a/sat/tools/common/date_utils.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,267 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""tools to help manipulating time and dates"""
-
-from typing import Union
-import calendar
-import datetime
-import re
-import time
-
-from babel import dates
-from dateutil import parser, tz
-from dateutil.parser import ParserError
-from dateutil.relativedelta import relativedelta
-from dateutil.utils import default_tzinfo
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-
-RELATIVE_RE = re.compile(
-    r"\s*(?P<in>\bin\b)?"
-    r"(?P<date>[^+-].+[^\s+-])?\s*(?P<direction>[-+])?\s*"
-    r"\s*(?P<quantity>\d+)\s*"
-    r"(?P<unit>(second|sec|s|minute|min|month|mo|m|hour|hr|h|day|d|week|w|year|yr|y))s?"
-    r"(?P<ago>\s+ago)?\s*",
-    re.I
-)
-TIME_SYMBOL_MAP = {
-    "s": "second",
-    "sec": "second",
-    "m": "minute",
-    "min": "minute",
-    "h": "hour",
-    "hr": "hour",
-    "d": "day",
-    "w": "week",
-    "mo": "month",
-    "y": "year",
-    "yr": "year",
-}
-YEAR_FIRST_RE = re.compile(r"\d{4}[^\d]+")
-TZ_UTC = tz.tzutc()
-TZ_LOCAL = tz.gettz()
-
-
-def date_parse(value, default_tz=TZ_UTC):
-    """Parse a date and return corresponding unix timestamp
-
-    @param value(unicode): date to parse, in any format supported by parser
-    @param default_tz(datetime.tzinfo): default timezone
-    @return (int): timestamp
-    """
-    value = str(value).strip()
-    dayfirst = False if YEAR_FIRST_RE.match(value) else True
-
-    try:
-        dt = default_tzinfo(
-            parser.parse(value, dayfirst=dayfirst),
-            default_tz)
-    except ParserError as e:
-        if value == "now":
-            dt = datetime.datetime.now(tz.tzutc())
-        else:
-            try:
-                # the date may already be a timestamp
-                return int(value)
-            except ValueError:
-                raise e
-    return calendar.timegm(dt.utctimetuple())
-
-def date_parse_ext(value, default_tz=TZ_UTC):
-    """Extended date parse which accept relative date
-
-    @param value(unicode): date to parse, in any format supported by parser
-        and with the hability to specify X days/weeks/months/years in the past or future.
-        Relative date are specified either with something like `[main_date] +1 week`
-        or with something like `3 days ago`, and it is case insensitive. [main_date] is
-        a date parsable by parser, or empty to specify current date/time.
-        "now" can also be used to specify current date/time.
-    @param default_tz(datetime.tzinfo): same as for date_parse
-    @return (int): timestamp
-    """
-    m = RELATIVE_RE.match(value)
-    if m is None:
-        return date_parse(value, default_tz=default_tz)
-
-    if sum(1 for g in ("direction", "in", "ago") if m.group(g)) > 1:
-        raise ValueError(
-            _('You can use only one of direction (+ or -), "in" and "ago"'))
-
-    if m.group("direction") == '-' or m.group("ago"):
-        direction = -1
-    else:
-        direction = 1
-
-    date = m.group("date")
-    if date is not None:
-        date = date.strip()
-    if not date or date == "now":
-        dt = datetime.datetime.now(tz.tzutc())
-    else:
-        try:
-            dt = default_tzinfo(parser.parse(date, dayfirst=True), default_tz)
-        except ParserError as e:
-            try:
-                timestamp = int(date)
-            except ValueError:
-                raise e
-            else:
-                dt = datetime.datetime.fromtimestamp(timestamp, tz.tzutc())
-
-    quantity = int(m.group("quantity"))
-    unit = m.group("unit").lower()
-    try:
-        unit = TIME_SYMBOL_MAP[unit]
-    except KeyError:
-        pass
-    delta_kw = {f"{unit}s": direction * quantity}
-    dt = dt + relativedelta(**delta_kw)
-    return calendar.timegm(dt.utctimetuple())
-
-
-def date_fmt(
-    timestamp: Union[float, int, str],
-    fmt: str = "short",
-    date_only: bool = False,
-    auto_limit: int = 7,
-    auto_old_fmt: str = "short",
-    auto_new_fmt: str = "relative",
-    locale_str: str = C.DEFAULT_LOCALE,
-    tz_info: Union[datetime.tzinfo, str] = TZ_UTC
-) -> str:
-    """Format date according to locale
-
-    @param timestamp: unix time
-    @param fmt: one of:
-        - short: e.g. u'31/12/17'
-        - medium: e.g. u'Apr 1, 2007'
-        - long: e.g. u'April 1, 2007'
-        - full: e.g. u'Sunday, April 1, 2007'
-        - relative: format in relative time
-            e.g.: 3 hours
-            note that this format is not precise
-        - iso: ISO 8601 format
-            e.g.: u'2007-04-01T19:53:23Z'
-        - auto: use auto_old_fmt if date is older than auto_limit
-            else use auto_new_fmt
-        - auto_day: shorcut to set auto format with change on day
-            old format will be short, and new format will be time only
-        or a free value which is passed to babel.dates.format_datetime
-        (see http://babel.pocoo.org/en/latest/dates.html?highlight=pattern#pattern-syntax)
-    @param date_only: if True, only display date (not datetime)
-    @param auto_limit: limit in days before using auto_old_fmt
-        use 0 to have a limit at last midnight (day change)
-    @param auto_old_fmt: format to use when date is older than limit
-    @param auto_new_fmt: format to use when date is equal to or more recent
-        than limit
-    @param locale_str: locale to use (as understood by babel)
-    @param tz_info: time zone to use
-
-    """
-    timestamp = float(timestamp)
-    if isinstance(tz_info, str):
-        tz_info = tz.gettz(tz_info)
-    if fmt == "auto_day":
-        fmt, auto_limit, auto_old_fmt, auto_new_fmt = "auto", 0, "short", "HH:mm"
-    if fmt == "auto":
-        if auto_limit == 0:
-            now = datetime.datetime.now(tz_info)
-            # we want to use given tz_info, so we don't use date() or today()
-            today = datetime.datetime(year=now.year, month=now.month, day=now.day,
-                                      tzinfo=now.tzinfo)
-            today = calendar.timegm(today.utctimetuple())
-            if timestamp < today:
-                fmt = auto_old_fmt
-            else:
-                fmt = auto_new_fmt
-        else:
-            days_delta = (time.time() - timestamp) / 3600
-            if days_delta > (auto_limit or 7):
-                fmt = auto_old_fmt
-            else:
-                fmt = auto_new_fmt
-
-    if fmt == "relative":
-        delta = timestamp - time.time()
-        return dates.format_timedelta(
-            delta, granularity="minute", add_direction=True, locale=locale_str
-        )
-    elif fmt in ("short", "long", "full"):
-        if date_only:
-            dt = datetime.datetime.fromtimestamp(timestamp, tz_info)
-            return dates.format_date(dt, format=fmt, locale=locale_str)
-        else:
-            return dates.format_datetime(timestamp, format=fmt, locale=locale_str,
-                                        tzinfo=tz_info)
-    elif fmt == "iso":
-        if date_only:
-            fmt = "yyyy-MM-dd"
-        else:
-            fmt = "yyyy-MM-ddTHH:mm:ss'Z'"
-        return dates.format_datetime(timestamp, format=fmt)
-    else:
-        return dates.format_datetime(timestamp, format=fmt, locale=locale_str,
-                                     tzinfo=tz_info)
-
-
-def delta2human(start_ts: Union[float, int], end_ts: Union[float, int]) -> str:
-    """Convert delta of 2 unix times to human readable text
-
-    @param start_ts: timestamp of starting time
-    @param end_ts: timestamp of ending time
-    """
-    if end_ts < start_ts:
-        raise exceptions.InternalError(
-            "end timestamp must be bigger or equal to start timestamp !"
-        )
-    rd = relativedelta(
-        datetime.datetime.fromtimestamp(end_ts),
-        datetime.datetime.fromtimestamp(start_ts)
-    )
-    text_elems = []
-    for unit in ("years", "months", "days", "hours", "minutes"):
-        value = getattr(rd, unit)
-        if value == 1:
-            # we remove final "s" when there is only 1
-            text_elems.append(f"1 {unit[:-1]}")
-        elif value > 1:
-            text_elems.append(f"{value} {unit}")
-
-    return ", ".join(text_elems)
-
-
-def get_timezone_name(tzinfo, timestamp: Union[float, int]) -> str:
-    """
-    Get the DST-aware timezone name for a given timezone and timestamp.
-
-    @param tzinfo: The timezone to get the name for
-    @param timestamp: The timestamp to use, as a Unix timestamp (number of seconds since
-        the Unix epoch).
-    @return: The DST-aware timezone name.
-    """
-
-    dt = datetime.datetime.fromtimestamp(timestamp)
-    dt_tz = dt.replace(tzinfo=tzinfo)
-    tz_name = dt_tz.tzname()
-    if tz_name is None:
-        raise exceptions.InternalError("tz_name should not be None")
-    return tz_name
--- a/sat/tools/common/dynamic_import.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,43 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" tools dynamic import """
-
-from importlib import import_module
-from sat.core.log import getLogger
-
-
-log = getLogger(__name__)
-
-
-def bridge(name, module_path="sat.bridge"):
-    """import bridge module
-
-    @param module_path(str): path of the module to import
-    @param name(str): name of the bridge to import (e.g.: dbus)
-    @return (module, None): imported module or None if nothing is found
-    """
-    try:
-        bridge_module = import_module(module_path + "." + name)
-    except ImportError:
-        try:
-            bridge_module = import_module(module_path + "." + name + "_bridge")
-        except ImportError as e:
-            log.warning(f"Can't import bridge {name!r}: {e}")
-            bridge_module = None
-    return bridge_module
--- a/sat/tools/common/email.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,84 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SàT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""email sending facilities"""
-
-
-from email.mime.text import MIMEText
-from twisted.mail import smtp
-from sat.core.constants import Const as C
-from sat.tools import config as tools_config
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-def send_email(config, to_emails, subject="", body="", from_email=None):
-    """Send an email using SàT configuration
-
-    @param config (SafeConfigParser): the configuration instance
-    @param to_emails(list[unicode], unicode): list of recipients
-        if unicode, it will be split to get emails
-    @param subject(unicode): subject of the message
-    @param body(unicode): body of the message
-    @param from_email(unicode): address of the sender
-    @return (D): same as smtp.sendmail
-    """
-    if isinstance(to_emails, str):
-        to_emails = to_emails.split()
-    email_host = tools_config.config_get(config, None, "email_server") or "localhost"
-    email_from = from_email or tools_config.config_get(config, None, "email_from")
-
-    # we suppose that email domain and XMPP domain are identical
-    domain = tools_config.config_get(config, None, "xmpp_domain")
-    if domain is None:
-        if email_from is not None:
-            domain = email_from.split("@", 1)[-1]
-        else:
-            domain = "example.net"
-
-    if email_from is None:
-        email_from = "no_reply@" + domain
-    email_sender_domain = tools_config.config_get(
-        config, None, "email_sender_domain", domain
-    )
-    email_port = int(tools_config.config_get(config, None, "email_port", 25))
-    email_username = tools_config.config_get(config, None, "email_username")
-    email_password = tools_config.config_get(config, None, "email_password")
-    email_auth = C.bool(tools_config.config_get(config, None, "email_auth", C.BOOL_FALSE))
-    email_starttls = C.bool(tools_config.config_get(config, None, "email_starttls",
-                            C.BOOL_FALSE))
-
-    msg = MIMEText(body, "plain", "UTF-8")
-    msg["Subject"] = subject
-    msg["From"] = email_from
-    msg["To"] = ", ".join(to_emails)
-
-    return smtp.sendmail(
-        email_host.encode("utf-8"),
-        email_from.encode("utf-8"),
-        [email.encode("utf-8") for email in to_emails],
-        msg.as_bytes(),
-        senderDomainName=email_sender_domain if email_sender_domain else None,
-        port=email_port,
-        username=email_username.encode("utf-8") if email_username else None,
-        password=email_password.encode("utf-8") if email_password else None,
-        requireAuthentication=email_auth,
-        requireTransportSecurity=email_starttls,
-    )
--- a/sat/tools/common/files_utils.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,34 +0,0 @@
-#!/usr/bin/env python3
-
-# SaT: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""tools to help manipulating files"""
-from pathlib import Path
-
-
-def get_unique_name(path):
-    """Generate a path with a name not conflicting with existing file
-
-    @param path(str, Path): path to the file to create
-    @return (Path): unique path (can be the same as path if there is no conflict)
-    """
-    ori_path = path = Path(path)
-    idx = 1
-    while path.exists():
-        path = ori_path.with_name(f"{ori_path.stem}_{idx}{ori_path.suffix}")
-        idx += 1
-    return path
--- a/sat/tools/common/regex.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,95 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Salut à Toi: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" regex tools common to backend and frontends """
-
-import re
-import unicodedata
-
-path_escape = {"%": "%25", "/": "%2F", "\\": "%5c"}
-path_escape_rev = {re.escape(v): k for k, v in path_escape.items()}
-path_escape = {re.escape(k): v for k, v in path_escape.items()}
-#  thanks to Martijn Pieters (https://stackoverflow.com/a/14693789)
-RE_ANSI_REMOVE = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
-RE_TEXT_URL = re.compile(r'[^a-zA-Z0-9,_]+')
-TEXT_MAX_LEN = 60
-# min lenght is currently deactivated
-TEXT_WORD_MIN_LENGHT = 0
-
-
-def re_join(exps):
-    """Join (OR) various regexes"""
-    return re.compile("|".join(exps))
-
-
-def re_sub_dict(pattern, repl_dict, string):
-    """Replace key, value found in dict according to pattern
-
-    @param pattern(basestr): pattern using keys found in repl_dict
-    @repl_dict(dict): keys found in this dict will be replaced by
-        corresponding values
-    @param string(basestr): string to use for the replacement
-    """
-    return pattern.sub(lambda m: repl_dict[re.escape(m.group(0))], string)
-
-
-path_escape_re = re_join(list(path_escape.keys()))
-path_escape_rev_re = re_join(list(path_escape_rev.keys()))
-
-
-def path_escape(string):
-    """Escape string so it can be use in a file path
-
-    @param string(basestr): string to escape
-    @return (str, unicode): escaped string, usable in a file path
-    """
-    return re_sub_dict(path_escape_re, path_escape, string)
-
-
-def path_unescape(string):
-    """Unescape string from value found in file path
-
-    @param string(basestr): string found in file path
-    @return (str, unicode): unescaped string
-    """
-    return re_sub_dict(path_escape_rev_re, path_escape_rev, string)
-
-
-def ansi_remove(string):
-    """Remove ANSI escape codes from string
-
-    @param string(basestr): string to filter
-    @return (str, unicode): string without ANSI escape codes
-    """
-    return RE_ANSI_REMOVE.sub("", string)
-
-
-def url_friendly_text(text):
-    """Convert text to url-friendly one"""
-    # we change special chars to ascii one,
-    # trick found at https://stackoverflow.com/a/3194567
-    text = unicodedata.normalize('NFD', text).encode('ascii', 'ignore').decode('utf-8')
-    text = RE_TEXT_URL.sub(' ', text).lower()
-    text = '-'.join([t for t in text.split() if t and len(t)>=TEXT_WORD_MIN_LENGHT])
-    while len(text) > TEXT_MAX_LEN:
-        if '-' in text:
-            text = text.rsplit('-', 1)[0]
-        else:
-            text = text[:TEXT_MAX_LEN]
-    return text
--- a/sat/tools/common/template.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1064 +0,0 @@
-#!/usr/bin/env python3
-
-# SAT: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""Template generation"""
-
-import os.path
-import time
-import re
-import json
-from datetime import datetime
-from pathlib import Path
-from collections import namedtuple
-from typing import Optional, List, Tuple, Union
-from xml.sax.saxutils import quoteattr
-from babel import support
-from babel import Locale
-from babel.core import UnknownLocaleError
-import pygments
-from pygments import lexers
-from pygments import formatters
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.tools import config
-from sat.tools.common import date_utils
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-try:
-    import sat_templates
-except ImportError:
-    raise exceptions.MissingModule(
-        "sat_templates module is not available, please install it or check your path to "
-        "use template engine"
-    )
-else:
-    sat_templates  # to avoid pyflakes warning
-
-try:
-    import jinja2
-except:
-    raise exceptions.MissingModule(
-        "Missing module jinja2, please install it from http://jinja.pocoo.org or with "
-        "pip install jinja2"
-    )
-
-from lxml import etree
-from jinja2 import Markup as safe
-from jinja2 import is_undefined
-from jinja2 import utils
-from jinja2 import TemplateNotFound
-from jinja2 import contextfilter
-from jinja2.loaders import split_template_path
-
-HTML_EXT = ("html", "xhtml")
-RE_ATTR_ESCAPE = re.compile(r"[^a-z_-]")
-SITE_RESERVED_NAMES = ("sat",)
-TPL_RESERVED_CHARS = r"()/."
-RE_TPL_RESERVED_CHARS = re.compile("[" + TPL_RESERVED_CHARS + "]")
-BROWSER_DIR = "_browser"
-BROWSER_META_FILE = "browser_meta.json"
-
-TemplateData = namedtuple("TemplateData", ['site', 'theme', 'path'])
-
-
-class TemplateLoader(jinja2.BaseLoader):
-    """A template loader which handle site, theme and absolute paths"""
-    # TODO: list_templates should be implemented
-
-    def __init__(self, sites_paths, sites_themes, trusted=False):
-        """
-        @param trusted(bool): if True, absolue template paths will be allowed
-            be careful when using this option and sure that you can trust the template,
-            as this allow the template to open any file on the system that the
-            launching user can access.
-        """
-        if not sites_paths or not "" in sites_paths:
-            raise exceptions.InternalError("Invalid sites_paths")
-        super(jinja2.BaseLoader, self).__init__()
-        self.sites_paths = sites_paths
-        self.sites_themes = sites_themes
-        self.trusted = trusted
-
-    @staticmethod
-    def parse_template(template):
-        """Parse template path and return site, theme and path
-
-        @param template_path(unicode): path to template with parenthesis syntax
-            The site and/or theme can be specified in parenthesis just before the path
-            e.g.: (some_theme)path/to/template.html
-                  (/some_theme)path/to/template.html (equivalent to previous one)
-                  (other_site/other_theme)path/to/template.html
-                  (other_site/)path/to/template.html (defaut theme for other_site)
-                  /absolute/path/to/template.html (in trusted environment only)
-        @return (TemplateData):
-            site, theme and template_path.
-            if site is empty, SàT Templates are used
-            site and theme can be both None if absolute path is used
-            Relative path is the path from theme root dir e.g. blog/articles.html
-        """
-        if template.startswith("("):
-            # site and/or theme are specified
-            try:
-                theme_end = template.index(")")
-            except IndexError:
-                raise ValueError("incorrect site/theme in template")
-            theme_data = template[1:theme_end]
-            theme_splitted = theme_data.split('/')
-            if len(theme_splitted) == 1:
-                site, theme = "", theme_splitted[0]
-            elif len(theme_splitted) == 2:
-                site, theme = theme_splitted
-            else:
-                raise ValueError("incorrect site/theme in template")
-            template_path = template[theme_end+1:]
-            if not template_path or template_path.startswith("/"):
-                raise ValueError("incorrect template path")
-        elif template.startswith("/"):
-            # this is an absolute path, so we have no site and no theme
-            site = None
-            theme = None
-            template_path = template
-        else:
-            # a default template
-            site = ""
-            theme = C.TEMPLATE_THEME_DEFAULT
-            template_path = template
-
-        if site is not None:
-            site = site.strip()
-            if not site:
-                site = ""
-            elif site in SITE_RESERVED_NAMES:
-                raise ValueError(_("{site} can't be used as site name, "
-                                   "it's reserved.").format(site=site))
-
-        if theme is not None:
-            theme = theme.strip()
-            if not theme:
-                theme = C.TEMPLATE_THEME_DEFAULT
-            if RE_TPL_RESERVED_CHARS.search(theme):
-                raise ValueError(_("{theme} contain forbidden char. Following chars "
-                                   "are forbidden: {reserved}").format(
-                                   theme=theme, reserved=TPL_RESERVED_CHARS))
-
-        return TemplateData(site, theme, template_path)
-
-    @staticmethod
-    def get_sites_and_themes(
-            site: str,
-            theme: str,
-            settings: Optional[dict] = None,
-        ) -> List[Tuple[str, str]]:
-        """Get sites and themes to check for template/file
-
-        Will add default theme and default site in search list when suitable. Settings'
-        `fallback` can be used to modify behaviour: themes in this list will then be used
-        instead of default (it can also be empty list or None, in which case no fallback
-        is used).
-
-        @param site: site requested
-        @param theme: theme requested
-        @return: site and theme couples to check
-        """
-        if settings is None:
-            settings = {}
-        sites_and_themes = [[site, theme]]
-        fallback = settings.get("fallback", [C.TEMPLATE_THEME_DEFAULT])
-        for fb_theme in fallback:
-            if theme != fb_theme:
-                sites_and_themes.append([site, fb_theme])
-        if site:
-            for fb_theme in fallback:
-                sites_and_themes.append(["", fb_theme])
-        return sites_and_themes
-
-    def _get_template_f(self, site, theme, path_elts):
-        """Look for template and return opened file if found
-
-        @param site(unicode): names of site to check
-            (default site will also checked)
-        @param theme(unicode): theme to check (default theme will also be checked)
-        @param path_elts(iterable[str]): elements of template path
-        @return (tuple[(File, None), (str, None)]): a tuple with:
-            - opened template, or None if not found
-            - absolute file path, or None if not found
-        """
-        if site is None:
-            raise exceptions.InternalError(
-                "_get_template_f must not be used with absolute path")
-        settings = self.sites_themes[site][theme]['settings']
-        for site_to_check, theme_to_check in self.get_sites_and_themes(
-                site, theme, settings):
-            try:
-                base_path = self.sites_paths[site_to_check]
-            except KeyError:
-                log.warning(_("Unregistered site requested: {site_to_check}").format(
-                    site_to_check=site_to_check))
-            filepath = os.path.join(
-                base_path,
-                C.TEMPLATE_TPL_DIR,
-                theme_to_check,
-                *path_elts
-            )
-            f = utils.open_if_exists(filepath, 'r')
-            if f is not None:
-                return f, filepath
-        return None, None
-
-    def get_source(self, environment, template):
-        """Retrieve source handling site and themes
-
-        If the path is absolute it is used directly if in trusted environment
-        else and exception is raised.
-        if the path is just relative, "default" theme is used.
-        @raise PermissionError: absolute path used in untrusted environment
-        """
-        site, theme, template_path = self.parse_template(template)
-
-        if site is None:
-            # we have an abolute template
-            if theme is not None:
-                raise exceptions.InternalError("We can't have a theme with absolute "
-                                               "template.")
-            if not self.trusted:
-                log.error(_("Absolute template used while unsecure is disabled, hack "
-                            "attempt? Template: {template}").format(template=template))
-                raise exceptions.PermissionError("absolute template is not allowed")
-            filepath = template_path
-            f = utils.open_if_exists(filepath, 'r')
-        else:
-            # relative path, we have to deal with site and theme
-            assert theme and template_path
-            path_elts = split_template_path(template_path)
-            # if we have non default site, we check it first, else we only check default
-            f, filepath = self._get_template_f(site, theme, path_elts)
-
-        if f is None:
-            if (site is not None and path_elts[0] == "error"
-                and os.path.splitext(template_path)[1][1:] in HTML_EXT):
-                # if an HTML error is requested but doesn't exist, we try again
-                # with base error.
-                f, filepath = self._get_template_f(
-                    site, theme, ("error", "base.html"))
-                if f is None:
-                    raise exceptions.InternalError("error/base.html should exist")
-            else:
-                raise TemplateNotFound(template)
-
-        try:
-            contents = f.read()
-        finally:
-            f.close()
-
-        mtime = os.path.getmtime(filepath)
-
-        def uptodate():
-            try:
-                return os.path.getmtime(filepath) == mtime
-            except OSError:
-                return False
-
-        return contents, filepath, uptodate
-
-
-class Indexer(object):
-    """Index global to a page"""
-
-    def __init__(self):
-        self._indexes = {}
-
-    def next(self, value):
-        if value not in self._indexes:
-            self._indexes[value] = 0
-            return 0
-        self._indexes[value] += 1
-        return self._indexes[value]
-
-    def current(self, value):
-        return self._indexes.get(value)
-
-
-class ScriptsHandler(object):
-    def __init__(self, renderer, template_data):
-        self.renderer = renderer
-        self.template_data = template_data
-        self.scripts = []  #  we don't use a set because order may be important
-
-    def include(self, library_name, attribute="defer"):
-        """Mark that a script need to be imported.
-
-        Must be used before base.html is extended, as <script> are generated there.
-        If called several time with the same library, it will be imported once.
-        @param library_name(unicode): name of the library to import
-        @param loading:
-        """
-        if attribute not in ("defer", "async", ""):
-            raise exceptions.DataError(
-                _('Invalid attribute, please use one of "defer", "async" or ""')
-            )
-        if not library_name.endswith(".js"):
-            library_name = library_name + ".js"
-        if (library_name, attribute) not in self.scripts:
-            self.scripts.append((library_name, attribute))
-        return ""
-
-    def generate_scripts(self):
-        """Generate the <script> elements
-
-        @return (unicode): <scripts> HTML tags
-        """
-        scripts = []
-        tpl = "<script src={src} {attribute}></script>"
-        for library, attribute in self.scripts:
-            library_path = self.renderer.get_static_path(self.template_data, library)
-            if library_path is None:
-                log.warning(_("Can't find {libary} javascript library").format(
-                    library=library))
-                continue
-            path = self.renderer.get_front_url(library_path)
-            scripts.append(tpl.format(src=quoteattr(path), attribute=attribute))
-        return safe("\n".join(scripts))
-
-
-class Environment(jinja2.Environment):
-
-    def get_template(self, name, parent=None, globals=None):
-        if name[0] not in ('/', '('):
-            # if name is not an absolute path or a full template name (this happen on
-            # extend or import during rendering), we convert it to a full template name.
-            # This is needed to handle cache correctly when a base template is overriden.
-            # Without that, we could not distinguish something like base/base.html if
-            # it's launched from some_site/some_theme or from [default]/default
-            name = "({site}/{theme}){template}".format(
-                site=self._template_data.site,
-                theme=self._template_data.theme,
-                template=name)
-
-        return super(Environment, self).get_template(name, parent, globals)
-
-
-class Renderer(object):
-
-    def __init__(self, host, front_url_filter=None, trusted=False, private=False):
-        """
-        @param front_url_filter(callable): filter to retrieve real url of a directory/file
-            The callable will get a two arguments:
-                - a dict with a "template_data" key containing TemplateData instance of
-                  current template. Only site and theme should be necessary.
-                - the relative URL of the file to retrieve, relative from theme root
-            None to use default filter which return real path on file
-            Need to be specified for web rendering, to reflect URL seen by end user
-        @param trusted(bool): if True, allow to access absolute path
-            Only set to True if environment is safe (e.g. command line tool)
-        @param private(bool): if True, also load sites from sites_path_private_dict
-        """
-        self.host = host
-        self.trusted = trusted
-        self.sites_paths = {
-            "": os.path.dirname(sat_templates.__file__),
-        }
-        self.sites_themes = {
-        }
-        conf = config.parse_main_conf()
-        public_sites = config.config_get(conf, None, "sites_path_public_dict", {})
-        sites_data = [public_sites]
-        if private:
-            private_sites = config.config_get(conf, None, "sites_path_private_dict", {})
-            sites_data.append(private_sites)
-        for sites in sites_data:
-            normalised = {}
-            for name, path in sites.items():
-                if RE_TPL_RESERVED_CHARS.search(name):
-                    log.warning(_("Can't add \"{name}\" site, it contains forbidden "
-                                  "characters. Forbidden characters are {forbidden}.")
-                                .format(name=name, forbidden=TPL_RESERVED_CHARS))
-                    continue
-                path = os.path.expanduser(os.path.normpath(path))
-                if not path or not path.startswith("/"):
-                    log.warning(_("Can't add \"{name}\" site, it should map to an "
-                                  "absolute path").format(name=name))
-                    continue
-                normalised[name] = path
-            self.sites_paths.update(normalised)
-
-        for site, site_path in self.sites_paths.items():
-            tpl_path = Path(site_path) / C.TEMPLATE_TPL_DIR
-            for p in tpl_path.iterdir():
-                if not p.is_dir():
-                    continue
-                log.debug(f"theme found for {site or 'default site'}: {p.name}")
-                theme_data = self.sites_themes.setdefault(site, {})[p.name] = {
-                    'path': p,
-                    'settings': {}}
-                theme_settings = p / "settings.json"
-                if theme_settings.is_file:
-                    try:
-                        with theme_settings.open() as f:
-                            settings = json.load(f)
-                    except Exception as e:
-                        log.warning(_(
-                            "Can't load theme settings at {path}: {e}").format(
-                            path=theme_settings, e=e))
-                    else:
-                        log.debug(
-                            f"found settings for theme {p.name!r} at {theme_settings}")
-                        fallback = settings.get("fallback")
-                        if fallback is None:
-                            settings["fallback"] = []
-                        elif isinstance(fallback, str):
-                            settings["fallback"] = [fallback]
-                        elif not isinstance(fallback, list):
-                            raise ValueError(
-                                'incorrect type for "fallback" in settings '
-                                f'({type(fallback)}) at {theme_settings}: {fallback}'
-                            )
-                        theme_data['settings'] = settings
-                browser_path = p / BROWSER_DIR
-                if browser_path.is_dir():
-                    theme_data['browser_path'] = browser_path
-                browser_meta_path = browser_path / BROWSER_META_FILE
-                if browser_meta_path.is_file():
-                    try:
-                        with browser_meta_path.open() as f:
-                            theme_data['browser_meta'] = json.load(f)
-                    except Exception as e:
-                        log.error(
-                            f"Can't parse browser metadata at {browser_meta_path}: {e}"
-                        )
-                        continue
-
-        self.env = Environment(
-            loader=TemplateLoader(
-                sites_paths=self.sites_paths,
-                sites_themes=self.sites_themes,
-                trusted=trusted
-            ),
-            autoescape=jinja2.select_autoescape(["html", "xhtml", "xml"]),
-            trim_blocks=True,
-            lstrip_blocks=True,
-            extensions=["jinja2.ext.i18n"],
-        )
-        self.env._template_data = None
-        self._locale_str = C.DEFAULT_LOCALE
-        self._locale = Locale.parse(self._locale_str)
-        self.install_translations()
-
-        # we want to have access to SàT constants in templates
-        self.env.globals["C"] = C
-
-        # custom filters
-        self.env.filters["next_gidx"] = self._next_gidx
-        self.env.filters["cur_gidx"] = self._cur_gidx
-        self.env.filters["date_fmt"] = self._date_fmt
-        self.env.filters["timestamp_to_hour"] = self._timestamp_to_hour
-        self.env.filters["delta_to_human"] = date_utils.delta2human
-        self.env.filters["xmlui_class"] = self._xmlui_class
-        self.env.filters["attr_escape"] = self.attr_escape
-        self.env.filters["item_filter"] = self._item_filter
-        self.env.filters["adv_format"] = self._adv_format
-        self.env.filters["dict_ext"] = self._dict_ext
-        self.env.filters["highlight"] = self.highlight
-        self.env.filters["front_url"] = (self._front_url if front_url_filter is None
-                                         else front_url_filter)
-        # custom tests
-        self.env.tests["in_the_past"] = self._in_the_past
-        self.icons_path = os.path.join(host.media_dir, "fonts/fontello/svg")
-
-        # policies
-        self.env.policies["ext.i18n.trimmed"] = True
-        self.env.policies["json.dumps_kwargs"] = {
-            "sort_keys": True,
-            # if object can't be serialised, we use None
-            "default": lambda o: o.to_json() if hasattr(o, "to_json") else None
-        }
-
-    def get_front_url(self, template_data, path=None):
-        """Give front URL (i.e. URL seen by end-user) of a path
-
-        @param template_data[TemplateData]: data of current template
-        @param path(unicode, None): relative path of file to get,
-            if set, will remplate template_data.path
-        """
-        return self.env.filters["front_url"]({"template_data": template_data},
-                                path or template_data.path)
-
-    def install_translations(self):
-        # TODO: support multi translation
-        #       for now, only translations in sat_templates are handled
-        self.translations = {}
-        for site_key, site_path in self.sites_paths.items():
-            site_prefix = "[{}] ".format(site_key) if site_key else ''
-            i18n_dir = os.path.join(site_path, "i18n")
-            for lang_dir in os.listdir(i18n_dir):
-                lang_path = os.path.join(i18n_dir, lang_dir)
-                if not os.path.isdir(lang_path):
-                    continue
-                po_path = os.path.join(lang_path, "LC_MESSAGES/sat.mo")
-                try:
-                    locale = Locale.parse(lang_dir)
-                    with open(po_path, "rb") as f:
-                        try:
-                            translations = self.translations[locale]
-                        except KeyError:
-                            self.translations[locale] = support.Translations(f, "sat")
-                        else:
-                            translations.merge(support.Translations(f, "sat"))
-                except EnvironmentError:
-                    log.error(
-                        _("Can't find template translation at {path}").format(
-                            path=po_path))
-                except UnknownLocaleError as e:
-                    log.error(_("{site}Invalid locale name: {msg}").format(
-                        site=site_prefix, msg=e))
-                else:
-                    log.info(_("{site}loaded {lang} templates translations").format(
-                        site = site_prefix,
-                        lang=lang_dir))
-
-        default_locale = Locale.parse(self._locale_str)
-        if default_locale not in self.translations:
-            # default locale disable gettext,
-            # so we can use None instead of a Translations instance
-            self.translations[default_locale] = None
-
-        self.env.install_null_translations(True)
-        # we generate a tuple of locales ordered by display name that templates can access
-        # through the "locales" variable
-        self.locales = tuple(sorted(list(self.translations.keys()),
-                                    key=lambda l: l.language_name.lower()))
-
-
-    def set_locale(self, locale_str):
-        """set current locale
-
-        change current translation locale and self self._locale and self._locale_str
-        """
-        if locale_str == self._locale_str:
-            return
-        if locale_str == "en":
-            # we default to GB English when it's not specified
-            # one of the main reason is to avoid the nonsense U.S. short date format
-            locale_str = "en_GB"
-        try:
-            locale = Locale.parse(locale_str)
-        except ValueError as e:
-            log.warning(_("invalid locale value: {msg}").format(msg=e))
-            locale_str = self._locale_str = C.DEFAULT_LOCALE
-            locale = Locale.parse(locale_str)
-
-        locale_str = str(locale)
-        if locale_str != C.DEFAULT_LOCALE:
-            try:
-                translations = self.translations[locale]
-            except KeyError:
-                log.warning(_("Can't find locale {locale}".format(locale=locale)))
-                locale_str = C.DEFAULT_LOCALE
-                locale = Locale.parse(self._locale_str)
-            else:
-                self.env.install_gettext_translations(translations, True)
-                log.debug(_("Switched to {lang}").format(lang=locale.english_name))
-
-        if locale_str == C.DEFAULT_LOCALE:
-            self.env.install_null_translations(True)
-
-        self._locale = locale
-        self._locale_str = locale_str
-
-    def get_theme_and_root(self, template):
-        """retrieve theme and root dir of a given template
-
-        @param template(unicode): template to parse
-        @return (tuple[unicode, unicode]): theme and absolute path to theme's root dir
-        @raise NotFound: requested site has not been found
-        """
-        # FIXME: check use in jp, and include site
-        site, theme, __ = self.env.loader.parse_template(template)
-        if site is None:
-            # absolute template
-            return  "", os.path.dirname(template)
-        try:
-            site_root_dir = self.sites_paths[site]
-        except KeyError:
-            raise exceptions.NotFound
-        return theme, os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR, theme)
-
-    def get_themes_data(self, site_name):
-        try:
-            return self.sites_themes[site_name]
-        except KeyError:
-            raise exceptions.NotFound(f"no theme found for {site_name}")
-
-    def get_static_path(
-            self,
-            template_data: TemplateData,
-            filename: str,
-            settings: Optional[dict]=None
-        ) -> Optional[TemplateData]:
-        """Retrieve path of a static file if it exists with current theme or default
-
-        File will be looked at <site_root_dir>/<theme_dir>/<static_dir>/filename,
-        then <site_root_dir>/<default_theme_dir>/<static_dir>/filename anf finally
-        <default_site>/<default_theme_dir>/<static_dir> (i.e. sat_templates).
-        In case of absolute URL, base dir of template is used as base. For instance if
-        template is an absolute template to "/some/path/template.html", file will be
-        checked at "/some/path/<filename>"
-        @param template_data: data of current template
-        @param filename: name of the file to retrieve
-        @param settings: theme settings, can be used to modify behaviour
-        @return: built template data instance where .path is
-            the relative path to the file, from theme root dir.
-            None if not found.
-        """
-        if template_data.site is None:
-            # we have an absolue path
-            if (not template_data.theme is None
-                or not template_data.path.startswith('/')):
-                raise exceptions.InternalError(
-                    "invalid template data, was expecting absolute URL")
-            static_dir = os.path.dirname(template_data.path)
-            file_path = os.path.join(static_dir, filename)
-            if os.path.exists(file_path):
-                return TemplateData(site=None, theme=None, path=file_path)
-            else:
-                return None
-
-        sites_and_themes = TemplateLoader.get_sites_and_themes(template_data.site,
-                                                            template_data.theme,
-                                                            settings)
-        for site, theme in sites_and_themes:
-            site_root_dir = self.sites_paths[site]
-            relative_path = os.path.join(C.TEMPLATE_STATIC_DIR, filename)
-            absolute_path = os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR,
-                                         theme, relative_path)
-            if os.path.exists(absolute_path):
-                return TemplateData(site=site, theme=theme, path=relative_path)
-
-        return None
-
-    def _append_css_paths(
-            self,
-            template_data: TemplateData,
-            css_files: list,
-            css_files_noscript: list,
-            name_root: str,
-            settings: dict
-
-        ) -> None:
-        """Append found css to css_files and css_files_noscript
-
-        @param css_files: list to fill of relative path to found css file
-        @param css_files_noscript: list to fill of relative path to found css file
-            with "_noscript" suffix
-        """
-        name = name_root + ".css"
-        css_path = self.get_static_path(template_data, name, settings)
-        if css_path is not None:
-            css_files.append(self.get_front_url(css_path))
-            noscript_name = name_root + "_noscript.css"
-            noscript_path = self.get_static_path(
-                template_data, noscript_name, settings)
-            if noscript_path is not None:
-                css_files_noscript.append(self.get_front_url(noscript_path))
-
-    def get_css_files(self, template_data):
-        """Retrieve CSS files to use according template_data
-
-        For each element of the path, a .css file is looked for in /static, and returned
-        if it exists.
-        Previous element are kept by replacing '/' with '_'.
-        styles_extra.css, styles.css, highlight.css and fonts.css are always used if they
-            exist.
-        For each found file, if a file with the same name and "_noscript" suffix exists,
-        it will be returned is second part of resulting tuple.
-        For instance, if template_data is (some_site, some_theme, blog/articles.html),
-        following files are returned, each time trying [some_site root] first,
-        then default site (i.e. sat_templates) root:
-            - some_theme/static/styles.css is returned if it exists
-              else default/static/styles.css
-            - some_theme/static/blog.css is returned if it exists
-              else default/static/blog.css (if it exists too)
-            - some_theme/static/blog_articles.css is returned if it exists
-              else default/static/blog_articles.css (if it exists too)
-        and for each found files, if same file with _noscript suffix exists, it is put
-        in noscript list (for instance (some_theme/static/styles_noscript.css)).
-        The behaviour may be changed with theme settings: if "fallback" is set, specified
-        themes will be checked instead of default. The theme will be checked in given
-        order, and "fallback" may be None or empty list to not check anything.
-        @param template_data(TemplateData): data of the current template
-        @return (tuple[list[unicode], list[unicode]]): a tuple with:
-            - front URLs of CSS files to use
-            - front URLs of CSS files to use when scripts are not enabled
-        """
-        # TODO: some caching would be nice
-        css_files = []
-        css_files_noscript = []
-        path_elems = template_data.path.split('/')
-        path_elems[-1] = os.path.splitext(path_elems[-1])[0]
-        site = template_data.site
-        if site is None:
-            # absolute path
-            settings = {}
-        else:
-            settings = self.sites_themes[site][template_data.theme]['settings']
-
-        css_path = self.get_static_path(template_data, 'fonts.css', settings)
-        if css_path is not None:
-            css_files.append(self.get_front_url(css_path))
-
-        for name_root in ('styles', 'styles_extra', 'highlight'):
-            self._append_css_paths(
-                template_data, css_files, css_files_noscript, name_root, settings)
-
-        for idx in range(len(path_elems)):
-            name_root = "_".join(path_elems[:idx+1])
-            self._append_css_paths(
-                template_data, css_files, css_files_noscript, name_root, settings)
-
-        return css_files, css_files_noscript
-
-    ## custom filters ##
-
-    @contextfilter
-    def _front_url(self, ctx, relative_url):
-        """Get front URL (URL seen by end-user) from a relative URL
-
-        This default method return absolute full path
-        """
-        template_data = ctx['template_data']
-        if template_data.site is None:
-            assert template_data.theme is None
-            assert template_data.path.startswith("/")
-            return os.path.join(os.path.dirname(template_data.path, relative_url))
-
-        site_root_dir = self.sites_paths[template_data.site]
-        return os.path.join(site_root_dir, C.TEMPLATE_TPL_DIR, template_data.theme,
-                            relative_url)
-
-    @contextfilter
-    def _next_gidx(self, ctx, value):
-        """Use next current global index as suffix"""
-        next_ = ctx["gidx"].next(value)
-        return value if next_ == 0 else "{}_{}".format(value, next_)
-
-    @contextfilter
-    def _cur_gidx(self, ctx, value):
-        """Use current current global index as suffix"""
-        current = ctx["gidx"].current(value)
-        return value if not current else "{}_{}".format(value, current)
-
-    def _date_fmt(
-        self,
-        timestamp: Union[int, float],
-        fmt: str = "short",
-        date_only: bool = False,
-        auto_limit: int = 7,
-        auto_old_fmt: str = "short",
-        auto_new_fmt: str = "relative",
-        tz_name: Optional[str] = None
-    ) -> str:
-        if is_undefined(fmt):
-            fmt = "short"
-
-        try:
-            return date_utils.date_fmt(
-                timestamp, fmt, date_only, auto_limit, auto_old_fmt,
-                auto_new_fmt, locale_str = self._locale_str,
-                tz_info=tz_name or date_utils.TZ_UTC
-            )
-        except Exception as e:
-            log.warning(_("Can't parse date: {msg}").format(msg=e))
-            return str(timestamp)
-
-    def _timestamp_to_hour(self, timestamp: float) -> int:
-        """Get hour of day corresponding to a timestamp"""
-        dt = datetime.fromtimestamp(timestamp)
-        return dt.hour
-
-    def attr_escape(self, text):
-        """escape a text to a value usable as an attribute
-
-        remove spaces, and put in lower case
-        """
-        return RE_ATTR_ESCAPE.sub("_", text.strip().lower())[:50]
-
-    def _xmlui_class(self, xmlui_item, fields):
-        """return classes computed from XMLUI fields name
-
-        will return a string with a series of escaped {name}_{value} separated by spaces.
-        @param xmlui_item(xmlui.XMLUIPanel): XMLUI containing the widgets to use
-        @param fields(iterable(unicode)): names of the widgets to use
-        @return (unicode, None): computer string to use as class attribute value
-            None if no field was specified
-        """
-        classes = []
-        for name in fields:
-            escaped_name = self.attr_escape(name)
-            try:
-                for value in xmlui_item.widgets[name].values:
-                    classes.append(escaped_name + "_" + self.attr_escape(value))
-            except KeyError:
-                log.debug(
-                    _('ignoring field "{name}": it doesn\'t exists').format(name=name)
-                )
-                continue
-        return " ".join(classes) or None
-
-    @contextfilter
-    def _item_filter(self, ctx, item, filters):
-        """return item's value, filtered if suitable
-
-        @param item(object): item to filter
-            value must have name and value attributes,
-            mostly used for XMLUI items
-        @param filters(dict[unicode, (callable, dict, None)]): map of name => filter
-            if filter is None, return the value unchanged
-            if filter is a callable, apply it
-            if filter is a dict, it can have following keys:
-                - filters: iterable of filters to apply
-                - filters_args: kwargs of filters in the same order as filters (use empty
-                                dict if needed)
-                - template: template to format where {value} is the filtered value
-        """
-        value = item.value
-        filter_ = filters.get(item.name, None)
-        if filter_ is None:
-            return value
-        elif isinstance(filter_, dict):
-            filters_args = filter_.get("filters_args")
-            for idx, f_name in enumerate(filter_.get("filters", [])):
-                kwargs = filters_args[idx] if filters_args is not None else {}
-                filter_func = self.env.filters[f_name]
-                try:
-                    eval_context_filter = filter_func.evalcontextfilter
-                except AttributeError:
-                    eval_context_filter = False
-
-                if eval_context_filter:
-                    value = filter_func(ctx.eval_ctx, value, **kwargs)
-                else:
-                    value = filter_func(value, **kwargs)
-            template = filter_.get("template")
-            if template:
-                # format will return a string, so we need to check first
-                # if the value is safe or not, and re-mark it after formatting
-                is_safe = isinstance(value, safe)
-                value = template.format(value=value)
-                if is_safe:
-                    value = safe(value)
-            return value
-
-    def _adv_format(self, value, template, **kwargs):
-        """Advancer formatter
-
-        like format() method, but take care or special values like None
-        @param value(unicode): value to format
-        @param template(None, unicode): template to use with format() method.
-            It will be formatted using value=value and **kwargs
-            None to return value unchanged
-        @return (unicode): formatted value
-        """
-        if template is None:
-            return value
-        #  jinja use string when no special char is used, so we have to convert to unicode
-        return str(template).format(value=value, **kwargs)
-
-    def _dict_ext(self, source_dict, extra_dict, key=None):
-        """extend source_dict with extra dict and return the result
-
-        @param source_dict(dict): dictionary to extend
-        @param extra_dict(dict, None): dictionary to use to extend first one
-            None to return source_dict unmodified
-        @param key(unicode, None): if specified extra_dict[key] will be used
-            if it doesn't exists, a copy of unmodified source_dict is returned
-        @return (dict): resulting dictionary
-        """
-        if extra_dict is None:
-            return source_dict
-        if key is not None:
-            extra_dict = extra_dict.get(key, {})
-        ret = source_dict.copy()
-        ret.update(extra_dict)
-        return ret
-
-    def highlight(self, code, lexer_name=None, lexer_opts=None, html_fmt_opts=None):
-        """Do syntax highlighting on code
-
-        Under the hood, pygments is used, check its documentation for options possible
-        values.
-        @param code(unicode): code or markup to highlight
-        @param lexer_name(unicode, None): name of the lexer to use
-            None to autodetect it
-        @param html_fmt_opts(dict, None): kword arguments to use for HtmlFormatter
-        @return (unicode): HTML markup with highlight classes
-        """
-        if lexer_opts is None:
-            lexer_opts = {}
-        if html_fmt_opts is None:
-            html_fmt_opts = {}
-        if lexer_name is None:
-            lexer = lexers.guess_lexer(code, **lexer_opts)
-        else:
-            lexer = lexers.get_lexer_by_name(lexer_name, **lexer_opts)
-        formatter = formatters.HtmlFormatter(**html_fmt_opts)
-        return safe(pygments.highlight(code, lexer, formatter))
-
-    ## custom tests ##
-
-    def _in_the_past(self, timestamp):
-        """check if a date is in the past
-
-        @param timestamp(unicode, int): unix time
-        @return (bool): True if date is in the past
-        """
-        return time.time() > int(timestamp)
-
-    ## template methods ##
-
-    def _icon_defs(self, *names):
-        """Define svg icons which will be used in the template.
-
-        Their name is used as id
-        """
-        svg_elt = etree.Element(
-            "svg",
-            nsmap={None: "http://www.w3.org/2000/svg"},
-            width="0",
-            height="0",
-            style="display: block",
-        )
-        defs_elt = etree.SubElement(svg_elt, "defs")
-        for name in names:
-            path = os.path.join(self.icons_path, name + ".svg")
-            icon_svg_elt = etree.parse(path).getroot()
-            # we use icon name as id, so we can retrieve them easily
-            icon_svg_elt.set("id", name)
-            if not icon_svg_elt.tag == "{http://www.w3.org/2000/svg}svg":
-                raise exceptions.DataError("invalid SVG element")
-            defs_elt.append(icon_svg_elt)
-        return safe(etree.tostring(svg_elt, encoding="unicode"))
-
-    def _icon_use(self, name, cls=""):
-        return safe('<svg class="svg-icon{cls}" xmlns="http://www.w3.org/2000/svg" '
-                    'viewBox="0 0 100 100">\n'
-                    '    <use href="#{name}"/>'
-                    '</svg>\n'.format(name=name, cls=(" " + cls) if cls else ""))
-
-    def _icon_from_client(self, client):
-        """Get icon name to represent a disco client"""
-        if client is None:
-            return 'desktop'
-        elif 'pc' in client:
-            return 'desktop'
-        elif 'phone' in client:
-            return 'mobile'
-        elif 'web' in client:
-            return 'globe'
-        elif 'console' in client:
-            return 'terminal'
-        else:
-            return 'desktop'
-
-    def render(self, template, site=None, theme=None, locale=C.DEFAULT_LOCALE,
-               media_path="", css_files=None, css_inline=False, **kwargs):
-        """Render a template
-
-        @param template(unicode): template to render (e.g. blog/articles.html)
-        @param site(unicode): site name
-            None or empty string for defaut site (i.e. SàT templates)
-        @param theme(unicode): template theme
-        @param media_path(unicode): prefix of the SàT media path/URL to use for
-            template root. Must end with a u'/'
-        @param css_files(list[unicode],None): CSS files to use
-            CSS files must be in static dir of the template
-            use None for automatic selection of CSS files based on template category
-            None is recommended. General static/style.css and theme file name will be
-            used.
-        @param css_inline(bool): if True, CSS will be embedded in the HTML page
-        @param **kwargs: variable to transmit to the template
-        """
-        if not template:
-            raise ValueError("template can't be empty")
-        if site is not None or theme is not None:
-            # user wants to set site and/or theme, so we add it to the template path
-            if site is None:
-                site = ''
-            if theme is None:
-                theme = C.TEMPLATE_THEME_DEFAULT
-            if template[0] == "(":
-                raise ValueError(
-                    "you can't specify site or theme in template path and in argument "
-                    "at the same time"
-                )
-
-            template_data = TemplateData(site, theme, template)
-            template = "({site}/{theme}){template}".format(
-                site=site, theme=theme, template=template)
-        else:
-            template_data = self.env.loader.parse_template(template)
-
-        # we need to save template_data in environment, to load right templates when they
-        # are referenced from other templates (e.g. import)
-        # FIXME: this trick will not work anymore if we use async templates (it works
-        #        here because we know that the rendering will be blocking until we unset
-        #        _template_data)
-        self.env._template_data = template_data
-
-        template_source = self.env.get_template(template)
-
-        if css_files is None:
-            css_files, css_files_noscript = self.get_css_files(template_data)
-        else:
-            css_files_noscript = []
-
-        kwargs["icon_defs"] = self._icon_defs
-        kwargs["icon"] = self._icon_use
-        kwargs["icon_from_client"] = self._icon_from_client
-
-        if css_inline:
-            css_contents = []
-            for files, suffix in ((css_files, ""),
-                                  (css_files_noscript, "_noscript")):
-                site_root_dir = self.sites_paths[template_data.site]
-                for css_file in files:
-                    css_file_path = os.path.join(site_root_dir, css_file)
-                    with open(css_file_path) as f:
-                        css_contents.append(f.read())
-                if css_contents:
-                    kwargs["css_content" + suffix] = "\n".join(css_contents)
-
-        scripts_handler = ScriptsHandler(self, template_data)
-        self.set_locale(locale)
-
-        # XXX: theme used in template arguments is the requested theme, which may differ
-        #      from actual theme if the template doesn't exist in the requested theme.
-        rendered = template_source.render(
-            template_data=template_data,
-            media_path=media_path,
-            css_files=css_files,
-            css_files_noscript=css_files_noscript,
-            locale=self._locale,
-            locales=self.locales,
-            gidx=Indexer(),
-            script=scripts_handler,
-            **kwargs
-        )
-        self.env._template_data = None
-        return rendered
--- a/sat/tools/common/template_xmlui.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,243 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" template XMLUI parsing
-
-XMLUI classes from this modules can then be iterated to create the template
-"""
-
-from functools import partial
-from sat.core.log import getLogger
-from sat_frontends.tools import xmlui
-from sat_frontends.tools import jid
-try:
-    from jinja2 import Markup as safe
-except ImportError:
-    # Safe marks XHTML values as usable as it.
-    # If jinja2 is not there, we can use a simple lamba
-    safe = lambda x: x
-
-
-log = getLogger(__name__)
-
-
-## Widgets ##
-
-
-class Widget(object):
-    category = "widget"
-    type = None
-    enabled = True
-    read_only = True
-
-    def __init__(self, xmlui_parent):
-        self.xmlui_parent = xmlui_parent
-
-    @property
-    def name(self):
-        return self._xmlui_name
-
-
-class ValueWidget(Widget):
-    def __init__(self, xmlui_parent, value):
-        super(ValueWidget, self).__init__(xmlui_parent)
-        self.value = value
-
-    @property
-    def values(self):
-        return [self.value]
-
-    @property
-    def labels(self):
-        #  helper property, there is not label for ValueWidget
-        # but using labels make rendering more easy (one single method to call)
-        #  values are actually returned
-        return [self.value]
-
-
-class InputWidget(ValueWidget):
-    def __init__(self, xmlui_parent, value, read_only=False):
-        super(InputWidget, self).__init__(xmlui_parent, value)
-        self.read_only = read_only
-
-
-class OptionsWidget(Widget):
-    def __init__(self, xmlui_parent, options, selected, style):
-        super(OptionsWidget, self).__init__(xmlui_parent)
-        self.options = options
-        self.selected = selected
-        self.style = style
-
-    @property
-    def values(self):
-        for value, label in self.items:
-            yield value
-
-    @property
-    def value(self):
-        if self.multi or self.no_select or len(self.selected) != 1:
-            raise ValueError(
-                "Can't get value for list with multiple selections or nothing selected"
-            )
-        return self.selected[0]
-
-    @property
-    def labels(self):
-        """return only labels from self.items"""
-        for value, label in self.items:
-            yield label
-
-    @property
-    def items(self):
-        """return suitable items, according to style"""
-        no_select = self.no_select
-        for value, label in self.options:
-            if no_select or value in self.selected:
-                yield value, label
-
-    @property
-    def inline(self):
-        return "inline" in self.style
-
-    @property
-    def no_select(self):
-        return "noselect" in self.style
-
-    @property
-    def multi(self):
-        return "multi" in self.style
-
-
-class EmptyWidget(xmlui.EmptyWidget, Widget):
-    def __init__(self, _xmlui_parent):
-        Widget.__init__(self)
-
-
-class TextWidget(xmlui.TextWidget, ValueWidget):
-    type = "text"
-
-
-class JidWidget(xmlui.JidWidget, ValueWidget):
-    type = "jid"
-
-    def __init__(self, xmlui_parent, value):
-        self.value = jid.JID(value)
-
-
-class LabelWidget(xmlui.LabelWidget, ValueWidget):
-    type = "label"
-
-    @property
-    def for_name(self):
-        try:
-            return self._xmlui_for_name
-        except AttributeError:
-            return None
-
-
-class StringWidget(xmlui.StringWidget, InputWidget):
-    type = "string"
-
-
-class JidInputWidget(xmlui.JidInputWidget, StringWidget):
-    type = "jid"
-
-class TextBoxWidget(xmlui.TextWidget, InputWidget):
-    type = "textbox"
-
-
-class XHTMLBoxWidget(xmlui.XHTMLBoxWidget, InputWidget):
-    type = "xhtmlbox"
-
-    def __init__(self, xmlui_parent, value, read_only=False):
-        # XXX: XHTMLBoxWidget value must be cleaned (harmful XHTML must be removed)
-        #      This is normally done in the backend, the frontends should not need to
-        #      worry about it.
-        super(XHTMLBoxWidget, self).__init__(
-            xmlui_parent=xmlui_parent, value=safe(value), read_only=read_only)
-
-
-class ListWidget(xmlui.ListWidget, OptionsWidget):
-    type = "list"
-
-
-## Containers ##
-
-
-class Container(object):
-    category = "container"
-    type = None
-
-    def __init__(self, xmlui_parent):
-        self.xmlui_parent = xmlui_parent
-        self.children = []
-
-    def __iter__(self):
-        return iter(self.children)
-
-    def _xmlui_append(self, widget):
-        self.children.append(widget)
-
-    def _xmlui_remove(self, widget):
-        self.children.remove(widget)
-
-
-class VerticalContainer(xmlui.VerticalContainer, Container):
-    type = "vertical"
-
-
-class PairsContainer(xmlui.PairsContainer, Container):
-    type = "pairs"
-
-
-class LabelContainer(xmlui.PairsContainer, Container):
-    type = "label"
-
-
-## Factory ##
-
-
-class WidgetFactory(object):
-
-    def __getattr__(self, attr):
-        if attr.startswith("create"):
-            cls = globals()[attr[6:]]
-            return cls
-
-
-## Core ##
-
-
-class XMLUIPanel(xmlui.XMLUIPanel):
-    widget_factory = WidgetFactory()
-
-    def show(self, *args, **kwargs):
-        raise NotImplementedError
-
-
-class XMLUIDialog(xmlui.XMLUIDialog):
-    dialog_factory = WidgetFactory()
-
-    def __init__(*args, **kwargs):
-        raise NotImplementedError
-
-
-create = partial(xmlui.create, class_map={
-    xmlui.CLASS_PANEL: XMLUIPanel,
-    xmlui.CLASS_DIALOG: XMLUIDialog})
--- a/sat/tools/common/tls.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,140 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""TLS handling with twisted"""
-
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.tools import config as tools_config
-
-
-try:
-    import OpenSSL
-    from twisted.internet import ssl
-except ImportError:
-    ssl = None
-
-
-log = getLogger(__name__)
-
-
-def get_options_from_config(config, section=""):
-    options = {}
-    for option in ('tls_certificate', 'tls_private_key', 'tls_chain'):
-        options[option] = tools_config.config_get(config, section, option)
-    return options
-
-
-def tls_options_check(options):
-    """Check options coherence if TLS is activated, and update missing values
-
-    Must be called only if TLS is activated
-    """
-    if not options["tls_certificate"]:
-        raise exceptions.ConfigError(
-            "a TLS certificate is needed to activate HTTPS connection")
-    if not options["tls_private_key"]:
-        options["tls_private_key"] = options["tls_certificate"]
-
-
-def load_certificates(f):
-    """Read a .pem file with a list of certificates
-
-    @param f (file): file obj (opened .pem file)
-    @return (list[OpenSSL.crypto.X509]): list of certificates
-    @raise OpenSSL.crypto.Error: error while parsing the file
-    """
-    # XXX: didn't found any method to load a .pem file with several certificates
-    #      so the certificates split is done here
-    certificates = []
-    buf = []
-    while True:
-        line = f.readline()
-        buf.append(line)
-        if "-----END CERTIFICATE-----" in line:
-            certificates.append(
-                OpenSSL.crypto.load_certificate(
-                    OpenSSL.crypto.FILETYPE_PEM, "".join(buf)
-                )
-            )
-            buf = []
-        elif not line:
-            log.debug(f"{len(certificates)} certificate(s) found")
-            return certificates
-
-
-def load_p_key(f):
-    """Read a private key from a .pem file
-
-    @param f (file): file obj (opened .pem file)
-    @return (list[OpenSSL.crypto.PKey]): private key object
-    @raise OpenSSL.crypto.Error: error while parsing the file
-    """
-    return OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, f.read())
-
-
-def load_certificate(f):
-    """Read a public certificate from a .pem file
-
-    @param f (file): file obj (opened .pem file)
-    @return (list[OpenSSL.crypto.X509]): public certificate
-    @raise OpenSSL.crypto.Error: error while parsing the file
-    """
-    return OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, f.read())
-
-
-def get_tls_context_factory(options):
-    """Load TLS certificate and build the context factory needed for listenSSL"""
-    if ssl is None:
-        raise ImportError("Python module pyOpenSSL is not installed!")
-
-    cert_options = {}
-
-    for name, option, method in [
-        ("privateKey", "tls_private_key", load_p_key),
-        ("certificate", "tls_certificate", load_certificate),
-        ("extraCertChain", "tls_chain", load_certificates),
-    ]:
-        path = options[option]
-        if not path:
-            assert option == "tls_chain"
-            continue
-        log.debug(f"loading {option} from {path}")
-        try:
-            with open(path) as f:
-                cert_options[name] = method(f)
-        except IOError as e:
-            raise exceptions.DataError(
-                f"Error while reading file {path} for option {option}: {e}"
-            )
-        except OpenSSL.crypto.Error:
-            raise exceptions.DataError(
-                f"Error while parsing file {path} for option {option}, are you sure "
-                f"it is a valid .pem file?"
-            )
-            if (
-                option == "tls_private_key"
-                and options["tls_certificate"] == path
-            ):
-                raise exceptions.ConfigError(
-                    f"You are using the same file for private key and public "
-                    f"certificate, make sure that both a in {path} or use "
-                    f"--tls_private_key option"
-                )
-
-    return ssl.CertificateOptions(**cert_options)
--- a/sat/tools/common/uri.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,121 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" XMPP uri parsing tools """
-
-from typing import Optional
-import sys
-import urllib.parse
-import urllib.request, urllib.parse, urllib.error
-
-# FIXME: basic implementation, need to follow RFC 5122
-
-
-def parse_xmpp_uri(uri):
-    """Parse an XMPP uri and return a dict with various information
-
-    @param uri(unicode): uri to parse
-    @return dict(unicode, unicode): data depending of the URI where key can be:
-        type: one of ("pubsub", TODO)
-            type is always present
-        sub_type: can be:
-            - microblog
-            only used for pubsub for now
-        path: XMPP path (jid of the service or entity)
-        node: node used
-        id: id of the element (item for pubsub)
-    @raise ValueError: the scheme is not xmpp
-    """
-    uri_split = urllib.parse.urlsplit(uri)
-    if uri_split.scheme != "xmpp":
-        raise ValueError("this is not a XMPP URI")
-
-    # XXX: we don't use jid.JID for path as it can be used both in backend and frontend
-    # which may use different JID classes
-    data = {"path": urllib.parse.unquote(uri_split.path)}
-
-    query_end = uri_split.query.find(";")
-    if query_end == -1:
-        # we just have a JID
-        query_type = None
-    else:
-        query_type = uri_split.query[:query_end]
-        if "=" in query_type:
-            raise ValueError("no query type, invalid XMPP URI")
-
-    if sys.version_info >= (3, 9):
-        # parse_qs behaviour has been modified in Python 3.9, ";" is not understood as a
-        # parameter separator anymore but the "separator" argument has been added to
-        # change it.
-        pairs = urllib.parse.parse_qs(uri_split.geturl(), separator=";")
-    else:
-        pairs = urllib.parse.parse_qs(uri_split.geturl())
-    for k, v in list(pairs.items()):
-        if len(v) != 1:
-            raise NotImplementedError("multiple values not managed")
-        if k in ("path", "type", "sub_type"):
-            raise NotImplementedError("reserved key used in URI, this is not supported")
-        data[k] = urllib.parse.unquote(v[0])
-
-    if query_type:
-        data["type"] = query_type
-    elif "node" in data:
-        data["type"] = "pubsub"
-    else:
-        data["type"] = ""
-
-    if "node" in data:
-        if data["node"].startswith("urn:xmpp:microblog:"):
-            data["sub_type"] = "microblog"
-
-    return data
-
-
-def add_pairs(uri, pairs):
-    for k, v in pairs.items():
-        uri.append(
-            ";"
-            + urllib.parse.quote_plus(k.encode("utf-8"))
-            + "="
-            + urllib.parse.quote_plus(v.encode("utf-8"))
-        )
-
-
-def build_xmpp_uri(type_: Optional[str] = None, **kwargs: str) -> str:
-    uri = ["xmpp:"]
-    subtype = kwargs.pop("subtype", None)
-    path = kwargs.pop("path")
-    uri.append(urllib.parse.quote_plus(path.encode("utf-8")).replace("%40", "@"))
-
-    if type_ is None:
-        # we have an URI to a JID
-        if kwargs:
-            raise NotImplementedError(
-                "keyword arguments are not supported for URI without type"
-            )
-    elif type_ == "pubsub":
-        if subtype == "microblog" and not kwargs.get("node"):
-            kwargs["node"] = "urn:xmpp:microblog:0"
-        if kwargs:
-            uri.append("?")
-            add_pairs(uri, kwargs)
-    else:
-        raise NotImplementedError("{type_} URI are not handled yet".format(type_=type_))
-
-    return "".join(uri)
--- a/sat/tools/common/utils.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,159 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""Misc utils for both backend and frontends"""
-
-import collections.abc
-size_units = {
-    "b": 1,
-    "kb": 1000,
-    "mb": 1000**2,
-    "gb": 1000**3,
-    "tb": 1000**4,
-    "pb": 1000**5,
-    "eb": 1000**6,
-    "zb": 1000**7,
-    "yb": 1000**8,
-    "o": 1,
-    "ko": 1000,
-    "mo": 1000**2,
-    "go": 1000**3,
-    "to": 1000**4,
-    "po": 1000**5,
-    "eo": 1000**6,
-    "zo": 1000**7,
-    "yo": 1000**8,
-    "kib": 1024,
-    "mib": 1024**2,
-    "gib": 1024**3,
-    "tib": 1024**4,
-    "pib": 1024**5,
-    "eib": 1024**6,
-    "zib": 1024**7,
-    "yib": 1024**8,
-    "kio": 1024,
-    "mio": 1024**2,
-    "gio": 1024**3,
-    "tio": 1024**4,
-    "pio": 1024**5,
-    "eio": 1024**6,
-    "zio": 1024**7,
-    "yio": 1024**8,
-}
-
-
-def per_luminance(red, green, blue):
-    """Caculate the perceived luminance of a RGB color
-
-    @param red(int): 0-1 normalized value of red
-    @param green(int): 0-1 normalized value of green
-    @param blue(int): 0-1 normalized value of blue
-    @return (float): 0-1 value of luminance (<0.5 is dark, else it's light)
-    """
-    # cf. https://stackoverflow.com/a/1855903, thanks Gacek
-
-    return 0.299 * red + 0.587 * green + 0.114 * blue
-
-
-def recursive_update(ori: dict, update: dict):
-    """Recursively update a dictionary"""
-    # cf. https://stackoverflow.com/a/3233356, thanks Alex Martelli
-    for k, v in update.items():
-        if isinstance(v, collections.abc.Mapping):
-            ori[k] = recursive_update(ori.get(k, {}), v)
-        else:
-            ori[k] = v
-    return ori
-
-class OrderedSet(collections.abc.MutableSet):
-    """A mutable sequence which doesn't keep duplicates"""
-    # TODO: complete missing set methods
-
-    def __init__(self, values=None):
-        self._dict = {}
-        if values is not None:
-            self.update(values)
-
-    def __len__(self):
-        return len(self._dict)
-
-    def __iter__(self):
-        return iter(self._dict.keys())
-
-    def __contains__(self, item):
-        return item in self._dict
-
-    def add(self, item):
-        self._dict[item] = None
-
-    def discard(self, item):
-        try:
-            del self._dict[item]
-        except KeyError:
-            pass
-
-    def update(self, items):
-        self._dict.update({i: None for i in items})
-
-
-def parse_size(size):
-    """Parse a file size with optional multiple symbole"""
-    try:
-        return int(size)
-    except ValueError:
-        number = []
-        symbol = []
-        try:
-            for c in size:
-                if c == " ":
-                    continue
-                if c.isdigit():
-                    number.append(c)
-                elif c.isalpha():
-                    symbol.append(c)
-                else:
-                    raise ValueError("unexpected char in size: {c!r} (size: {size!r})")
-            number = int("".join(number))
-            symbol = "".join(symbol)
-            if symbol:
-                try:
-                    multiplier = size_units[symbol.lower()]
-                except KeyError:
-                    raise ValueError(
-                        "unknown size multiplier symbole: {symbol!r} (size: {size!r})")
-                else:
-                    return number * multiplier
-            return number
-        except Exception as e:
-            raise ValueError(f"invalid size: {e}")
-
-
-def get_size_multiplier(size, suffix="o"):
-    """Get multiplier of a file size"""
-    size = int(size)
-    #  cf. https://stackoverflow.com/a/1094933 (thanks)
-    for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]:
-        if abs(size) < 1024.0:
-            return size, f"{unit}{suffix}"
-        size /= 1024.0
-    return size, f"Yi{suffix}"
-
-
-def get_human_size(size, suffix="o", sep=" "):
-    size, symbol = get_size_multiplier(size, suffix)
-    return f"{size:.2f}{sep}{symbol}"
--- a/sat/tools/config.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,171 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" Configuration related useful methods """
-
-import os
-import csv
-import json
-from typing import Any
-from configparser import ConfigParser, DEFAULTSECT, NoOptionError, NoSectionError
-from xdg import BaseDirectory
-from sat.core.log import getLogger
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core import exceptions
-
-log = getLogger(__name__)
-
-
-def fix_config_option(section, option, value, silent=True):
-    """Force a configuration option value
-
-    the option will be written in the first found user config file, a new user
-    config will be created if none is found.
-
-    @param section (str): the config section
-    @param option (str): the config option
-    @param value (str): the new value
-    @param silent (boolean): toggle logging output (must be True when called from sat.sh)
-    """
-    config = ConfigParser()
-    target_file = None
-    for file_ in C.CONFIG_FILES[::-1]:
-        # we will eventually update the existing file with the highest priority,
-        # if it's a user personal file...
-        if not silent:
-            log.debug(_("Testing file %s") % file_)
-        if os.path.isfile(file_):
-            if file_.startswith(os.path.expanduser("~")):
-                config.read([file_])
-                target_file = file_
-            break
-    if not target_file:
-        # ... otherwise we create a new config file for that user
-        target_file = (
-            f"{BaseDirectory.save_config_path(C.APP_NAME_FILE)}/{C.APP_NAME_FILE}.conf"
-        )
-    if section and section.upper() != DEFAULTSECT and not config.has_section(section):
-        config.add_section(section)
-    config.set(section, option, value)
-    with open(target_file, "wb") as configfile:
-        config.write(configfile)  # for the next time that user launches sat
-    if not silent:
-        if option in ("passphrase",):  # list here the options storing a password
-            value = "******"
-        log.warning(_("Config auto-update: {option} set to {value} in the file "
-                      "{config_file}.").format(option=option, value=value,
-                                                config_file=target_file))
-
-
-def parse_main_conf(log_filenames=False):
-    """Look for main .ini configuration file, and parse it
-
-    @param log_filenames(bool): if True, log filenames of read config files
-    """
-    config = ConfigParser(defaults=C.DEFAULT_CONFIG)
-    try:
-        filenames = config.read(C.CONFIG_FILES)
-    except Exception as e:
-        log.error(_("Can't read main config: {msg}").format(msg=e), exc_info=True)
-    else:
-        if log_filenames:
-            if filenames:
-                log.info(
-                    _("Configuration was read from: {filenames}").format(
-                        filenames=', '.join(filenames)))
-            else:
-                log.warning(
-                    _("No configuration file found, using default settings")
-                )
-
-    return config
-
-
-def config_get(config, section, name, default=None):
-    """Get a configuration option
-
-    @param config (ConfigParser): the configuration instance
-    @param section (str): section of the config file (None or '' for DEFAULT)
-    @param name (str): name of the option
-    @param default: value to use if not found, or Exception to raise an exception
-    @return (unicode, list, dict): parsed value
-    @raise: NoOptionError if option is not present and default is Exception
-            NoSectionError if section doesn't exists and default is Exception
-            exceptions.ParsingError error while parsing value
-    """
-    if not section:
-        section = DEFAULTSECT
-
-    try:
-        value = config.get(section, name)
-    except (NoOptionError, NoSectionError) as e:
-        if default is Exception:
-            raise e
-        return default
-
-    if name.endswith("_path") or name.endswith("_dir"):
-        value = os.path.expanduser(value)
-    # thx to Brian (http://stackoverflow.com/questions/186857/splitting-a-semicolon-separated-string-to-a-dictionary-in-python/186873#186873)
-    elif name.endswith("_list"):
-        value = next(csv.reader(
-            [value], delimiter=",", quotechar='"', skipinitialspace=True
-        ))
-    elif name.endswith("_dict"):
-        try:
-            value = json.loads(value)
-        except ValueError as e:
-            raise exceptions.ParsingError("Error while parsing data: {}".format(e))
-        if not isinstance(value, dict):
-            raise exceptions.ParsingError(
-                "{name} value is not a dict: {value}".format(name=name, value=value)
-            )
-    elif name.endswith("_json"):
-        try:
-            value = json.loads(value)
-        except ValueError as e:
-            raise exceptions.ParsingError("Error while parsing data: {}".format(e))
-    return value
-
-
-def get_conf(
-    conf: ConfigParser,
-    prefix: str,
-    section: str,
-    name: str,
-    default: Any
-) -> Any:
-    """Get configuration value from environment or config file
-
-    @param str: prefix to use for the varilable name (see `name` below)
-    @param section: config section to use
-    @param name: unsuffixed name.
-        For environment variable, `LIBERVIA_<prefix>_` will be prefixed (and name
-        will be set to uppercase).
-        For config file, `<prefix>_` will be prefixed (and DEFAULT section will be
-        used).
-        Environment variable has priority over config values. If Environment variable
-        is set but empty string, config value will be used.
-    @param default: default value to use if varilable is set neither in environment,
-    nor in config
-    """
-    # XXX: This is a temporary method until parameters are refactored
-    value = os.getenv(f"LIBERVIA_{prefix}_{name}".upper())
-    return value or config_get(conf, section, f"{prefix}_{name}", default)
--- a/sat/tools/image.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,226 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""Methods to manipulate images"""
-
-import tempfile
-import mimetypes
-from PIL import Image, ImageOps
-from pathlib import Path
-from twisted.internet import threads
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-try:
-    import cairosvg
-except Exception as e:
-    log.warning(_("SVG support not available, please install cairosvg: {e}").format(
-        e=e))
-    cairosvg = None
-
-
-def check(host, path, max_size=None):
-    """Analyze image and return a report
-
-    report will indicate if image is too large, and the recommended new size if this is
-    the case
-    @param host: SàT instance
-    @param path(str, pathlib.Path): image to open
-    @param max_size(tuple[int, int]): maximum accepted size of image
-        None to use value set in config
-    @return dict: report on image, with following keys:
-        - too_large: true if image is oversized
-        - recommended_size: if too_large is True, recommended size to use
-    """
-    report = {}
-    image = Image.open(path)
-    if max_size is None:
-        max_size = tuple(host.memory.config_get(None, "image_max", (1200, 720)))
-    if image.size > max_size:
-        report['too_large'] = True
-        if image.size[0] > max_size[0]:
-            factor = max_size[0] / image.size[0]
-            if image.size[1] * factor > max_size[1]:
-                factor = max_size[1] / image.size[1]
-        else:
-            factor = max_size[1] / image.size[1]
-        report['recommended_size'] = [int(image.width*factor), int(image.height*factor)]
-    else:
-        report['too_large'] = False
-
-    return report
-
-
-def _resize_blocking(image_path, new_size, dest, fix_orientation):
-    im_path = Path(image_path)
-    im = Image.open(im_path)
-    resized = im.resize(new_size, Image.LANCZOS)
-    if fix_orientation:
-        resized = ImageOps.exif_transpose(resized)
-
-    if dest is None:
-        dest = tempfile.NamedTemporaryFile(suffix=im_path.suffix, delete=False)
-    elif isinstance(dest, Path):
-        dest = dest.open('wb')
-
-    with dest as f:
-        resized.save(f, format=im.format)
-
-    return Path(f.name)
-
-
-def resize(image_path, new_size, dest=None, fix_orientation=True):
-    """Resize an image to a new file, and return its path
-
-    @param image_path(str, Path): path of the original image
-    @param new_size(tuple[int, int]): size to use for new image
-    @param dest(None, Path, file): where the resized image must be stored, can be:
-        - None: use a temporary file
-            file will be converted to PNG
-        - Path: path to the file to create/overwrite
-        - file: a file object which must be opened for writing in binary mode
-    @param fix_orientation: if True, use EXIF data to set orientation
-    @return (Path): path of the resized file.
-        The image at this path should be deleted after use
-    """
-    return threads.deferToThread(
-        _resize_blocking, image_path, new_size, dest, fix_orientation)
-
-
-def _convert_blocking(image_path, dest, extra):
-    media_type = mimetypes.guess_type(str(image_path), strict=False)[0]
-
-    if dest is None:
-        dest = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
-        filepath = Path(dest.name)
-    elif isinstance(dest, Path):
-        filepath = dest
-    else:
-        # we should have a file-like object
-        try:
-            name = dest.name
-        except AttributeError:
-            name = None
-        if name:
-            try:
-                filepath = Path(name)
-            except TypeError:
-                filepath = Path('noname.png')
-        else:
-            filepath = Path('noname.png')
-
-    if media_type == "image/svg+xml":
-        if cairosvg is None:
-            raise exceptions.MissingModule(
-                f"Can't convert SVG image at {image_path} due to missing CairoSVG module")
-        width, height = extra.get('width'), extra.get('height')
-        cairosvg.svg2png(
-            url=str(image_path), write_to=dest,
-            output_width=width, output_height=height
-        )
-    else:
-        suffix = filepath.suffix
-        if not suffix:
-            raise ValueError(
-                "A suffix is missing for destination, it is needed to determine file "
-                "format")
-        if not suffix in Image.EXTENSION:
-            Image.init()
-        try:
-            im_format = Image.EXTENSION[suffix]
-        except KeyError:
-            raise ValueError(
-                "Dest image format can't be determined, {suffix!r} suffix is unknown"
-            )
-        im = Image.open(image_path)
-        im.save(dest, format=im_format)
-
-    log.debug(f"image {image_path} has been converted to {filepath}")
-    return filepath
-
-
-def convert(image_path, dest=None, extra=None):
-    """Convert an image to a new file, and return its path
-
-    @param image_path(str, Path): path of the image to convert
-    @param dest(None, Path, file): where the converted image must be stored, can be:
-        - None: use a temporary file
-        - Path: path to the file to create/overwrite
-        - file: a file object which must be opened for writing in binary mode
-    @param extra(None, dict): conversion options
-        if image_path link to a SVG file, following options can be used:
-                - width: destination width
-                - height: destination height
-    @return (Path): path of the converted file.
-        a generic name is used if dest is an unnamed file like object
-    """
-    image_path = Path(image_path)
-    if not image_path.is_file():
-        raise ValueError(f"There is no file at {image_path}!")
-    if extra is None:
-        extra = {}
-    return threads.deferToThread(_convert_blocking, image_path, dest, extra)
-
-
-def __fix_orientation_blocking(image_path):
-    im = Image.open(image_path)
-    im_format = im.format
-    exif = im.getexif()
-    orientation = exif.get(0x0112)
-    if orientation is None or orientation<2:
-        # nothing to do
-        return False
-    im = ImageOps.exif_transpose(im)
-    im.save(image_path, im_format)
-    log.debug(f"image {image_path} orientation has been fixed")
-    return True
-
-
-def fix_orientation(image_path: Path) -> bool:
-    """Apply orientation found in EXIF data if any
-
-    @param image_path: image location, image will be modified in place
-    @return True if image has been modified
-    """
-    return threads.deferToThread(__fix_orientation_blocking, image_path)
-
-
-def guess_type(source):
-    """Guess image media type
-
-    @param source(str, Path, file): image to guess type
-    @return (str, None): media type, or None if we can't guess
-    """
-    if isinstance(source, str):
-        source = Path(source)
-
-    if isinstance(source, Path):
-        # we first try to guess from file name
-        media_type = mimetypes.guess_type(source, strict=False)[0]
-        if media_type is not None:
-            return media_type
-
-    # file name is not enough, we try to open it
-    img = Image.open(source)
-    try:
-        return Image.MIME[img.format]
-    except KeyError:
-        return None
--- a/sat/tools/sat_defer.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,269 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""tools related to deferred"""
-
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-from sat.core import exceptions
-from twisted.internet import defer
-from twisted.internet import error as internet_error
-from twisted.internet import reactor
-from twisted.words.protocols.jabber import error as jabber_error
-from twisted.python import failure
-from sat.core.constants import Const as C
-from sat.memory import memory
-
-KEY_DEFERREDS = "deferreds"
-KEY_NEXT = "next_defer"
-
-
-def stanza_2_not_found(failure_):
-    """Convert item-not-found StanzaError to exceptions.NotFound"""
-    failure_.trap(jabber_error.StanzaError)
-    if failure_.value.condition == 'item-not-found':
-        raise exceptions.NotFound(failure_.value.text or failure_.value.condition)
-    return failure_
-
-
-class DelayedDeferred(object):
-    """A Deferred-like which is launched after a delay"""
-
-    def __init__(self, delay, result):
-        """
-        @param delay(float): delay before launching the callback, in seconds
-        @param result: result used with the callback
-        """
-        self._deferred = defer.Deferred()
-        self._timer = reactor.callLater(delay, self._deferred.callback, result)
-
-    def cancel(self):
-        try:
-            self._timer.cancel()
-        except internet_error.AlreadyCalled:
-            pass
-        self._deferred.cancel()
-
-    def addCallbacks(self, *args, **kwargs):
-        self._deferred.addCallbacks(*args, **kwargs)
-
-    def addCallback(self, *args, **kwargs):
-        self._deferred.addCallback(*args, **kwargs)
-
-    def addErrback(self, *args, **kwargs):
-        self._deferred.addErrback(*args, **kwargs)
-
-    def addBoth(self, *args, **kwargs):
-        self._deferred.addBoth(*args, **kwargs)
-
-    def chainDeferred(self, *args, **kwargs):
-        self._deferred.chainDeferred(*args, **kwargs)
-
-    def pause(self):
-        self._deferred.pause()
-
-    def unpause(self):
-        self._deferred.unpause()
-
-
-class RTDeferredSessions(memory.Sessions):
-    """Real Time Deferred Sessions"""
-
-    def __init__(self, timeout=120):
-        """Manage list of Deferreds in real-time, allowing to get intermediate results
-
-        @param timeout (int): nb of seconds before deferreds cancellation
-        """
-        super(RTDeferredSessions, self).__init__(
-            timeout=timeout, resettable_timeout=False
-        )
-
-    def new_session(self, deferreds, profile):
-        """Launch a new session with a list of deferreds
-
-        @param deferreds(list[defer.Deferred]): list of deferred to call
-        @param profile: %(doc_profile)s
-        @param return (tupe[str, defer.Deferred]): tuple with session id and a deferred wich fire *WITHOUT RESULT* when all results are received
-        """
-        data = {KEY_NEXT: defer.Deferred()}
-        session_id, session_data = super(RTDeferredSessions, self).new_session(
-            data, profile=profile
-        )
-        if isinstance(deferreds, dict):
-            session_data[KEY_DEFERREDS] = list(deferreds.values())
-            iterator = iter(deferreds.items())
-        else:
-            session_data[KEY_DEFERREDS] = deferreds
-            iterator = enumerate(deferreds)
-
-        for idx, d in iterator:
-            d._RTDeferred_index = idx
-            d._RTDeferred_return = None
-            d.addCallback(self._callback, d, session_id, profile)
-            d.addErrback(self._errback, d, session_id, profile)
-        return session_id
-
-    def _purge_session(
-        self, session_id, reason="timeout", no_warning=False, got_result=False
-    ):
-        """Purge the session
-
-        @param session_id(str): id of the session to purge
-        @param reason (unicode): human readable reason why the session is purged
-        @param no_warning(bool): if True, no warning will be put in logs
-        @param got_result(bool): True if the session is purged after normal ending (i.e.: all the results have been gotten).
-            reason and no_warning are ignored if got_result is True.
-        @raise KeyError: session doesn't exists (anymore ?)
-        """
-        if not got_result:
-            try:
-                timer, session_data, profile = self._sessions[session_id]
-            except ValueError:
-                raise exceptions.InternalError(
-                    "was expecting timer, session_data and profile; is profile set ?"
-                )
-
-            # next_defer must be called before deferreds,
-            # else its callback will be called by _gotResult
-            next_defer = session_data[KEY_NEXT]
-            if not next_defer.called:
-                next_defer.errback(failure.Failure(defer.CancelledError(reason)))
-
-            deferreds = session_data[KEY_DEFERREDS]
-            for d in deferreds:
-                d.cancel()
-
-            if not no_warning:
-                log.warning(
-                    "RTDeferredList cancelled: {} (profile {})".format(reason, profile)
-                )
-
-        super(RTDeferredSessions, self)._purge_session(session_id)
-
-    def _gotResult(self, session_id, profile):
-        """Method called after each callback or errback
-
-        manage the next_defer deferred
-        """
-        session_data = self.profile_get(session_id, profile)
-        defer_next = session_data[KEY_NEXT]
-        if not defer_next.called:
-            defer_next.callback(None)
-
-    def _callback(self, result, deferred, session_id, profile):
-        deferred._RTDeferred_return = (True, result)
-        self._gotResult(session_id, profile)
-
-    def _errback(self, failure, deferred, session_id, profile):
-        deferred._RTDeferred_return = (False, failure)
-        self._gotResult(session_id, profile)
-
-    def cancel(self, session_id, reason="timeout", no_log=False):
-        """Stop this RTDeferredList
-
-        Cancel all remaining deferred, and call self.final_defer.errback
-        @param reason (unicode): reason of the cancellation
-        @param no_log(bool): if True, don't log the cancellation
-        """
-        self._purge_session(session_id, reason=reason, no_warning=no_log)
-
-    def get_results(
-        self, session_id, on_success=None, on_error=None, profile=C.PROF_KEY_NONE
-    ):
-        """Get current results of a real-time deferred session
-
-        result already gotten are deleted
-        @param session_id(str): session id
-        @param on_success: can be:
-            - None: add success normaly to results
-            - callable: replace result by the return value of on_success(result) (may be deferred)
-        @param on_error: can be:
-            - None: add error normaly to results
-            - C.IGNORE: don't put errors in results
-            - callable: replace failure by the return value of on_error(failure) (may be deferred)
-        @param profile=%(doc_profile)s
-        @param result(tuple): tuple(remaining, results) where:
-            - remaining[int] is the number of remaining deferred
-                (deferreds from which we don't have result yet)
-            - results is a dict where:
-                - key is the index of the deferred if deferred is a list, or its key if it's a dict
-                - value = (success, result) where:
-                    - success is True if the deferred was successful
-                    - result is the result in case of success, else the failure
-            If remaining == 0, the session is ended
-        @raise KeyError: the session is already finished or doesn't exists at all
-        """
-        if profile == C.PROF_KEY_NONE:
-            raise exceptions.ProfileNotSetError
-        session_data = self.profile_get(session_id, profile)
-
-        @defer.inlineCallbacks
-        def next_cb(__):
-            # we got one or several results
-            results = {}
-            filtered_data = []  # used to keep deferreds without results
-            deferreds = session_data[KEY_DEFERREDS]
-
-            for d in deferreds:
-                if (
-                    d._RTDeferred_return
-                ):  # we don't use d.called as called is True before the full callbacks chain has been called
-                    # we have a result
-                    idx = d._RTDeferred_index
-                    success, result = d._RTDeferred_return
-                    if success:
-                        if on_success is not None:
-                            if callable(on_success):
-                                result = yield on_success(result)
-                            else:
-                                raise exceptions.InternalError(
-                                    "Unknown value of on_success: {}".format(on_success)
-                                )
-
-                    else:
-                        if on_error is not None:
-                            if on_error == C.IGNORE:
-                                continue
-                            elif callable(on_error):
-                                result = yield on_error(result)
-                            else:
-                                raise exceptions.InternalError(
-                                    "Unknown value of on_error: {}".format(on_error)
-                                )
-                    results[idx] = (success, result)
-                else:
-                    filtered_data.append(d)
-
-            # we change the deferred with the filtered list
-            # in other terms, we don't want anymore deferred from which we have got the result
-            session_data[KEY_DEFERREDS] = filtered_data
-
-            if filtered_data:
-                # we create a new next_defer only if we are still waiting for results
-                session_data[KEY_NEXT] = defer.Deferred()
-            else:
-                # no more data to get, the result have been gotten,
-                # we can cleanly finish the session
-                self._purge_session(session_id, got_result=True)
-
-            defer.returnValue((len(filtered_data), results))
-
-        # we wait for a result
-        return session_data[KEY_NEXT].addCallback(next_cb)
--- a/sat/tools/stream.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,262 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" interfaces """
-
-from argparse import OPTIONAL
-from pathlib import Path
-from typing import Callable, Optional, Union
-import uuid
-import os
-from zope import interface
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.core_types import SatXMPPEntity
-from sat.core.log import getLogger
-from twisted.protocols import basic
-from twisted.internet import interfaces
-
-from sat.core.sat_main import SAT
-
-log = getLogger(__name__)
-
-
-class IStreamProducer(interface.Interface):
-    def start_stream(consumer):
-        """start producing the stream
-
-        @return (D): deferred fired when stream is finished
-        """
-        pass
-
-
-class SatFile:
-    """A file-like object to have high level files manipulation"""
-
-    # TODO: manage "with" statement
-
-    def __init__(
-        self,
-        host: SAT,
-        client: SatXMPPEntity,
-        path: Union[str, Path],
-        mode: str = "rb",
-        uid: Optional[str] = None,
-        size: Optional[int] = None,
-        data_cb: Optional[Callable] = None,
-        auto_end_signals: bool = True,
-        check_size_with_read: bool = False,
-        pre_close_cb: Optional[Callable]=None
-    ) -> None:
-        """
-        @param host: %(doc_host)s
-        @param path(Path, str): path to the file to get or write to
-        @param mode(str): same as for built-in "open" function
-        @param uid(unicode, None): unique id identifing this progressing element
-            This uid will be used with self.host.progress_get
-            will be automaticaly generated if None
-        @param size(None, int): size of the file (when known in advance)
-        @param data_cb(None, callable): method to call on each data read/write
-            can be used to do processing like calculating hash.
-            if data_cb return a non None value, it will be used instead of the
-            data read/to write
-        @param auto_end_signals(bool): if True, progress_finished and progress_error signals
-            are automatically sent.
-            if False, you'll have to call self.progress_finished and self.progress_error
-            yourself.
-            progress_started signal is always sent automatically
-        @param check_size_with_read(bool): if True, size will be checked using number of
-            bytes read or written. This is useful when data_cb modifiy len of file.
-        @param pre_close_cb:
-        """
-        self.host = host
-        self.profile = client.profile
-        self.uid = uid or str(uuid.uuid4())
-        self._file = open(path, mode)
-        self.size = size
-        self.data_cb = data_cb
-        self.auto_end_signals = auto_end_signals
-        self.pre_close_cb = pre_close_cb
-        metadata = self.get_progress_metadata()
-        self.host.register_progress_cb(
-            self.uid, self.get_progress, metadata, profile=client.profile
-        )
-        self.host.bridge.progress_started(self.uid, metadata, client.profile)
-
-        self._transfer_count = 0 if check_size_with_read else None
-
-    @property
-    def check_size_with_read(self):
-        return self._transfer_count is not None
-
-    @check_size_with_read.setter
-    def check_size_with_read(self, value):
-        if value and self._transfer_count is None:
-            self._transfer_count = 0
-        else:
-            self._transfer_count = None
-
-    def check_size(self):
-        """Check that current size correspond to given size
-
-        must be used when the transfer is supposed to be finished
-        @return (bool): True if the position is the same as given size
-        @raise exceptions.NotFound: size has not be specified
-        """
-        if self.check_size_with_read:
-            position = self._transfer_count
-        else:
-            position = self._file.tell()
-        if self.size is None:
-            raise exceptions.NotFound
-        return position == self.size
-
-    def close(self, progress_metadata=None, error=None):
-        """Close the current file
-
-        @param progress_metadata(None, dict): metadata to send with _onProgressFinished
-            message
-        @param error(None, unicode): set to an error message if progress was not
-            successful
-            mutually exclusive with progress_metadata
-            error can happen even if error is None, if current size differ from given size
-        """
-        if self._file.closed:
-            return  # avoid double close (which is allowed) error
-        if self.pre_close_cb is not None:
-            self.pre_close_cb()
-        if error is None:
-            try:
-                size_ok = self.check_size()
-            except exceptions.NotFound:
-                size_ok = True
-            if not size_ok:
-                error = "declared and actual size mismatch"
-                log.warning(error)
-                progress_metadata = None
-
-        self._file.close()
-
-        if self.auto_end_signals:
-            if error is None:
-                self.progress_finished(progress_metadata)
-            else:
-                assert progress_metadata is None
-                self.progress_error(error)
-
-        self.host.remove_progress_cb(self.uid, self.profile)
-        if error is not None:
-            log.error(f"file {self._file} closed with an error: {error}")
-
-    @property
-    def closed(self):
-        return self._file.closed
-
-    def progress_finished(self, metadata=None):
-        if metadata is None:
-            metadata = {}
-        self.host.bridge.progress_finished(self.uid, metadata, self.profile)
-
-    def progress_error(self, error):
-        self.host.bridge.progress_error(self.uid, error, self.profile)
-
-    def flush(self):
-        self._file.flush()
-
-    def write(self, buf):
-        if self.data_cb is not None:
-            ret = self.data_cb(buf)
-            if ret is not None:
-                buf = ret
-        if self._transfer_count is not None:
-            self._transfer_count += len(buf)
-        self._file.write(buf)
-
-    def read(self, size=-1):
-        read = self._file.read(size)
-        if self.data_cb is not None:
-            ret = self.data_cb(read)
-            if ret is not None:
-                read = ret
-        if self._transfer_count is not None:
-            self._transfer_count += len(read)
-        return read
-
-    def seek(self, offset, whence=os.SEEK_SET):
-        self._file.seek(offset, whence)
-
-    def tell(self):
-        return self._file.tell()
-
-    @property
-    def mode(self):
-        return self._file.mode
-
-    def get_progress_metadata(self):
-        """Return progression metadata as given to progress_started
-
-        @return (dict): metadata (check bridge for documentation)
-        """
-        metadata = {"type": C.META_TYPE_FILE}
-
-        mode = self._file.mode
-        if "+" in mode:
-            pass  # we have no direction in read/write modes
-        elif mode in ("r", "rb"):
-            metadata["direction"] = "out"
-        elif mode in ("w", "wb"):
-            metadata["direction"] = "in"
-        elif "U" in mode:
-            metadata["direction"] = "out"
-        else:
-            raise exceptions.InternalError
-
-        metadata["name"] = self._file.name
-
-        return metadata
-
-    def get_progress(self, progress_id, profile):
-        ret = {"position": self._file.tell()}
-        if self.size:
-            ret["size"] = self.size
-        return ret
-
-
-@interface.implementer(IStreamProducer)
-@interface.implementer(interfaces.IConsumer)
-class FileStreamObject(basic.FileSender):
-    def __init__(self, host, client, path, **kwargs):
-        """
-
-        A SatFile will be created and put in self.file_obj
-        @param path(unicode): path to the file
-        @param **kwargs: kw arguments to pass to SatFile
-        """
-        self.file_obj = SatFile(host, client, path, **kwargs)
-
-    def registerProducer(self, producer, streaming):
-        pass
-
-    def start_stream(self, consumer):
-        return self.beginFileTransfer(self.file_obj, consumer)
-
-    def write(self, data):
-        self.file_obj.write(data)
-
-    def close(self, *args, **kwargs):
-        self.file_obj.close(*args, **kwargs)
--- a/sat/tools/trigger.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,134 +0,0 @@
-#!/usr/bin/env python3
-
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""Misc usefull classes"""
-
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-class TriggerException(Exception):
-    pass
-
-
-class SkipOtherTriggers(Exception):
-    """ Exception to raise if normal behaviour must be followed instead of following triggers list """
-
-    pass
-
-
-class TriggerManager(object):
-    """This class manage triggers: code which interact to change the behaviour of SàT"""
-
-    try:  # FIXME: to be removed when a better solution is found
-        MIN_PRIORITY = float("-inf")
-        MAX_PRIORITY = float("+inf")
-    except:  # XXX: Pyjamas will bug if you specify ValueError here
-        # Pyjamas uses the JS Float class
-        MIN_PRIORITY = Number.NEGATIVE_INFINITY
-        MAX_PRIORITY = Number.POSITIVE_INFINITY
-
-    def __init__(self):
-        self.__triggers = {}
-
-    def add(self, point_name, callback, priority=0):
-        """Add a trigger to a point
-
-        @param point_name: name of the point when the trigger should be run
-        @param callback: method to call at the trigger point
-        @param priority: callback will be called in priority order, biggest
-        first
-        """
-        if point_name not in self.__triggers:
-            self.__triggers[point_name] = []
-        if priority != 0 and priority in [
-            trigger_tuple[0] for trigger_tuple in self.__triggers[point_name]
-        ]:
-            if priority in (self.MIN_PRIORITY, self.MAX_PRIORITY):
-                log.warning(_("There is already a bound priority [%s]") % point_name)
-            else:
-                log.debug(
-                    _("There is already a trigger with the same priority [%s]")
-                    % point_name
-                )
-        self.__triggers[point_name].append((priority, callback))
-        self.__triggers[point_name].sort(
-            key=lambda trigger_tuple: trigger_tuple[0], reverse=True
-        )
-
-    def remove(self, point_name, callback):
-        """Remove a trigger from a point
-
-        @param point_name: name of the point when the trigger should be run
-        @param callback: method to remove, must exists in the trigger point
-        """
-        for trigger_tuple in self.__triggers[point_name]:
-            if trigger_tuple[1] == callback:
-                self.__triggers[point_name].remove(trigger_tuple)
-                return
-        raise TriggerException("Trying to remove an unexisting trigger")
-
-    def point(self, point_name, *args, **kwargs):
-        """This put a trigger point
-
-        All the triggers for that point will be run
-        @param point_name: name of the trigger point
-        @param *args: args to transmit to trigger
-        @param *kwargs: kwargs to transmit to trigger
-            if "triggers_no_cancel" is present, it will be popup out
-                when set to True, this argument don't let triggers stop
-                the workflow
-        @return: True if the action must be continued, False else
-        """
-        if point_name not in self.__triggers:
-            return True
-
-        can_cancel = not kwargs.pop('triggers_no_cancel', False)
-
-        for priority, trigger in self.__triggers[point_name]:
-            try:
-                if not trigger(*args, **kwargs) and can_cancel:
-                    return False
-            except SkipOtherTriggers:
-                break
-        return True
-
-    def return_point(self, point_name, *args, **kwargs):
-        """Like point but trigger must return (continue, return_value)
-
-        All triggers for that point must return a tuple with 2 values:
-            - continue, same as for point, if False action must be finished
-            - return_value: value to return ONLY IF CONTINUE IS FALSE
-        @param point_name: name of the trigger point
-        @return: True if the action must be continued, False else
-        """
-
-        if point_name not in self.__triggers:
-            return True
-
-        for priority, trigger in self.__triggers[point_name]:
-            try:
-                cont, ret_value = trigger(*args, **kwargs)
-                if not cont:
-                    return False, ret_value
-            except SkipOtherTriggers:
-                break
-        return True, None
--- a/sat/tools/utils.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,362 +0,0 @@
-#!/usr/bin/env python3
-
-# SaT: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-""" various useful methods """
-
-from typing import Optional, Union
-import unicodedata
-import os.path
-import datetime
-import subprocess
-import time
-import sys
-import random
-import inspect
-import textwrap
-import functools
-import asyncio
-from twisted.python import procutils, failure
-from twisted.internet import defer
-from sat.core.constants import Const as C
-from sat.core.log import getLogger
-from sat.tools import xmpp_datetime
-
-log = getLogger(__name__)
-
-
-NO_REPOS_DATA = "repository data unknown"
-repos_cache_dict = None
-repos_cache = None
-
-
-def clean_ustr(ustr):
-    """Clean unicode string
-
-    remove special characters from unicode string
-    """
-
-    def valid_chars(unicode_source):
-        for char in unicode_source:
-            if unicodedata.category(char) == "Cc" and char != "\n":
-                continue
-            yield char
-
-    return "".join(valid_chars(ustr))
-
-
-def logError(failure_):
-    """Genertic errback which log the error as a warning, and re-raise it"""
-    log.warning(failure_.value)
-    raise failure_
-
-
-def partial(func, *fixed_args, **fixed_kwargs):
-    # FIXME: temporary hack to workaround the fact that inspect.getargspec is not working with functools.partial
-    #        making partial unusable with current D-bus module (in add_method).
-    #        Should not be needed anywore once moved to Python 3
-
-    ori_args = inspect.getargspec(func).args
-    func = functools.partial(func, *fixed_args, **fixed_kwargs)
-    if ori_args[0] == "self":
-        del ori_args[0]
-    ori_args = ori_args[len(fixed_args) :]
-    for kw in fixed_kwargs:
-        ori_args.remove(kw)
-
-    exec(
-        textwrap.dedent(
-            """\
-    def method({args}):
-        return func({kw_args})
-    """
-        ).format(
-            args=", ".join(ori_args), kw_args=", ".join([a + "=" + a for a in ori_args])
-        ),
-        locals(),
-    )
-
-    return method
-
-
-def as_deferred(func, *args, **kwargs):
-    """Call a method and return a Deferred
-
-    the method can be a simple callable, a Deferred or a coroutine.
-    It is similar to defer.maybeDeferred, but also handles coroutines
-    """
-    try:
-        ret = func(*args, **kwargs)
-    except Exception as e:
-        return defer.fail(failure.Failure(e))
-    else:
-        if asyncio.iscoroutine(ret):
-            return defer.ensureDeferred(ret)
-        elif isinstance(ret, defer.Deferred):
-            return ret
-        elif isinstance(ret, failure.Failure):
-            return defer.fail(ret)
-        else:
-            return defer.succeed(ret)
-
-
-def aio(func):
-    """Decorator to return a Deferred from asyncio coroutine
-
-    Functions with this decorator are run in asyncio context
-    """
-    def wrapper(*args, **kwargs):
-        return defer.Deferred.fromFuture(asyncio.ensure_future(func(*args, **kwargs)))
-    return wrapper
-
-
-def as_future(d):
-    return d.asFuture(asyncio.get_event_loop())
-
-
-def ensure_deferred(func):
-    """Decorator to apply ensureDeferred to a function
-
-    to be used when the function is called by third party library (e.g. wokkel)
-    Otherwise, it's better to use ensureDeferred as early as possible.
-    """
-    def wrapper(*args, **kwargs):
-        return defer.ensureDeferred(func(*args, **kwargs))
-    return wrapper
-
-
-def xmpp_date(
-    timestamp: Optional[Union[float, int]] = None,
-    with_time: bool = True
-) -> str:
-    """Return date according to XEP-0082 specification
-
-    to avoid reveling the timezone, we always return UTC dates
-    the string returned by this method is valid with RFC 3339
-    this function redirects to the functions in the :mod:`sat.tools.datetime` module
-    @param timestamp(None, float): posix timestamp. If None current time will be used
-    @param with_time(bool): if True include the time
-    @return(unicode): XEP-0082 formatted date and time
-    """
-    dtime = datetime.datetime.fromtimestamp(
-        time.time() if timestamp is None else timestamp,
-        datetime.timezone.utc
-    )
-
-    return (
-        xmpp_datetime.format_datetime(dtime) if with_time
-        else xmpp_datetime.format_date(dtime.date())
-    )
-
-
-def parse_xmpp_date(
-    xmpp_date_str: str,
-    with_time: bool = True
-) -> float:
-    """Get timestamp from XEP-0082 datetime
-
-    @param xmpp_date_str: XEP-0082 formatted datetime or time
-    @param with_time: if True, ``xmpp_date_str`` must be a datetime, otherwise if must be
-    a time profile.
-    @return: datetime converted to unix time
-    @raise ValueError: the format is invalid
-    """
-    if with_time:
-        dt = xmpp_datetime.parse_datetime(xmpp_date_str)
-    else:
-        d = xmpp_datetime.parse_date(xmpp_date_str)
-        dt = datetime.datetime.combine(d, datetime.datetime.min.time())
-
-    return dt.timestamp()
-
-
-def generate_password(vocabulary=None, size=20):
-    """Generate a password with random characters.
-
-    @param vocabulary(iterable): characters to use to create password
-    @param size(int): number of characters in the password to generate
-    @return (unicode): generated password
-    """
-    random.seed()
-    if vocabulary is None:
-        vocabulary = [
-            chr(i) for i in list(range(0x30, 0x3A)) + list(range(0x41, 0x5B)) + list(range(0x61, 0x7B))
-        ]
-    return "".join([random.choice(vocabulary) for i in range(15)])
-
-
-def get_repository_data(module, as_string=True, is_path=False):
-    """Retrieve info on current mecurial repository
-
-    Data is gotten by using the following methods, in order:
-        - using "hg" executable
-        - looking for a .hg/dirstate in parent directory of module (or in module/.hg if
-            is_path is True), and parse dirstate file to get revision
-        - checking package version, which should have repository data when we are on a dev version
-    @param module(unicode): module to look for (e.g. sat, libervia)
-        module can be a path if is_path is True (see below)
-    @param as_string(bool): if True return a string, else return a dictionary
-    @param is_path(bool): if True "module" is not handled as a module name, but as an
-        absolute path to the parent of a ".hg" directory
-    @return (unicode, dictionary): retrieved info in a nice string,
-        or a dictionary with retrieved data (key is not present if data is not found),
-        key can be:
-            - node: full revision number (40 bits)
-            - branch: branch name
-            - date: ISO 8601 format date
-            - tag: latest tag used in hierarchie
-            - distance: number of commits since the last tag
-    """
-    global repos_cache_dict
-    if as_string:
-        global repos_cache
-        if repos_cache is not None:
-            return repos_cache
-    else:
-        if repos_cache_dict is not None:
-            return repos_cache_dict
-
-    if sys.platform == "android":
-        #  FIXME: workaround to avoid trouble on android, need to be fixed properly
-        repos_cache = "Cagou android build"
-        return repos_cache
-
-    KEYS = ("node", "node_short", "branch", "date", "tag", "distance")
-    ori_cwd = os.getcwd()
-
-    if is_path:
-        repos_root = os.path.abspath(module)
-    else:
-        repos_root = os.path.abspath(os.path.dirname(module.__file__))
-
-    try:
-        hg_path = procutils.which("hg")[0]
-    except IndexError:
-        log.warning("Can't find hg executable")
-        hg_path = None
-        hg_data = {}
-
-    if hg_path is not None:
-        os.chdir(repos_root)
-        try:
-            hg_data_raw = subprocess.check_output(
-                [
-                    "python3",
-                    hg_path,
-                    "log",
-                    "-r",
-                    "-1",
-                    "--template",
-                    "{node}\n"
-                    "{node|short}\n"
-                    "{branch}\n"
-                    "{date|isodate}\n"
-                    "{latesttag}\n"
-                    "{latesttagdistance}",
-                ],
-                text=True
-            )
-        except subprocess.CalledProcessError as e:
-            log.error(f"Can't get repository data: {e}")
-            hg_data = {}
-        except Exception as e:
-            log.error(f"Unexpected error, can't get repository data : [{type(e)}] {e}")
-            hg_data = {}
-        else:
-            hg_data = dict(list(zip(KEYS, hg_data_raw.split("\n"))))
-            try:
-                hg_data["modified"] = "+" in subprocess.check_output(["python3", hg_path, "id", "-i"], text=True)
-            except subprocess.CalledProcessError:
-                pass
-    else:
-        hg_data = {}
-
-    if not hg_data:
-        # .hg/dirstate method
-        log.debug("trying dirstate method")
-        if is_path:
-            os.chdir(repos_root)
-        else:
-            os.chdir(os.path.abspath(os.path.dirname(repos_root)))
-        try:
-            with open(".hg/dirstate", 'rb') as hg_dirstate:
-                hg_data["node"] = hg_dirstate.read(20).hex()
-                hg_data["node_short"] = hg_data["node"][:12]
-        except IOError:
-            log.debug("Can't access repository data")
-
-    # we restore original working dir
-    os.chdir(ori_cwd)
-
-    if not hg_data:
-        log.debug("Mercurial not available or working, trying package version")
-        try:
-            import pkg_resources
-        except ImportError:
-            log.warning("pkg_resources not available, can't get package data")
-        else:
-            try:
-                pkg_version = pkg_resources.get_distribution(C.APP_NAME_FILE).version
-                version, local_id = pkg_version.split("+", 1)
-            except pkg_resources.DistributionNotFound:
-                log.warning("can't retrieve package data")
-            except ValueError:
-                log.info(
-                    "no local version id in package: {pkg_version}".format(
-                        pkg_version=pkg_version
-                    )
-                )
-            else:
-                version = version.replace(".dev0", "D")
-                if version != C.APP_VERSION:
-                    log.warning(
-                        "Incompatible version ({version}) and pkg_version ({pkg_version})"
-                        .format(
-                            version=C.APP_VERSION, pkg_version=pkg_version
-                        )
-                    )
-                else:
-                    try:
-                        hg_node, hg_distance = local_id.split(".")
-                    except ValueError:
-                        log.warning("Version doesn't specify repository data")
-                    hg_data = {"node_short": hg_node, "distance": hg_distance}
-
-    repos_cache_dict = hg_data
-
-    if as_string:
-        if not hg_data:
-            repos_cache = NO_REPOS_DATA
-        else:
-            strings = ["rev", hg_data["node_short"]]
-            try:
-                if hg_data["modified"]:
-                    strings.append("[M]")
-            except KeyError:
-                pass
-            try:
-                strings.extend(["({branch} {date})".format(**hg_data)])
-            except KeyError:
-                pass
-            try:
-                strings.extend(["+{distance}".format(**hg_data)])
-            except KeyError:
-                pass
-            repos_cache = " ".join(strings)
-        return repos_cache
-    else:
-        return hg_data
--- a/sat/tools/video.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,60 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""Methods to manipulate videos"""
-from typing import Union
-from pathlib import Path
-from twisted.python.procutils import which
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.core.log import getLogger
-from .common import async_process
-
-
-log = getLogger(__name__)
-
-
-
-
-
-try:
-    ffmpeg_path = which('ffmpeg')[0]
-except IndexError:
-    log.warning(_(
-        "ffmpeg executable not found, video thumbnails won't be available"))
-    ffmpeg_path = None
-
-
-async def get_thumbnail(video_path: Union[Path, str], dest_path: Path) -> Path:
-    """Extract thumbnail from video
-
-    @param video_path: source of the video
-    @param dest_path: path where the file must be saved
-    @return: path of the generated thumbnail
-        image is created in temporary directory but is not delete automatically
-        it should be deleted after use.
-        Image will be in JPEG format.
-    @raise exceptions.NotFound: ffmpeg is missing
-    """
-    if ffmpeg_path is None:
-        raise exceptions.NotFound(
-            _("ffmpeg executable is not available, can't generate video thumbnail"))
-
-    await async_process.run(
-        ffmpeg_path, "-i", str(video_path), "-ss", "10", "-frames:v", "1", str(dest_path)
-    )
--- a/sat/tools/web.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,126 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: an XMPP client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from typing import Optional, Union
-from pathlib import Path
-from io import BufferedIOBase
-
-from OpenSSL import SSL
-import treq
-from treq.client import HTTPClient
-from twisted.internet import reactor, ssl
-from twisted.internet.interfaces import IOpenSSLClientConnectionCreator
-from twisted.web import iweb
-from twisted.web import client as http_client
-from zope.interface import implementer
-
-from sat.core import exceptions
-from sat.core.log import getLogger
-
-
-log = getLogger(__name__)
-
-
-SSLError = SSL.Error
-
-
-@implementer(IOpenSSLClientConnectionCreator)
-class NoCheckConnectionCreator(object):
-    def __init__(self, hostname, ctx):
-        self._ctx = ctx
-
-    def clientConnectionForTLS(self, tlsProtocol):
-        context = self._ctx
-        connection = SSL.Connection(context, None)
-        connection.set_app_data(tlsProtocol)
-        return connection
-
-
-@implementer(iweb.IPolicyForHTTPS)
-class NoCheckContextFactory:
-    """Context factory which doesn't do TLS certificate check
-
-    /!\\ it's obvisously a security flaw to use this class,
-    and it should be used only with explicit agreement from the end used
-    """
-
-    def creatorForNetloc(self, hostname, port):
-        log.warning(
-            "TLS check disabled for {host} on port {port}".format(
-                host=hostname, port=port
-            )
-        )
-        certificateOptions = ssl.CertificateOptions(trustRoot=None)
-        return NoCheckConnectionCreator(hostname, certificateOptions.getContext())
-
-
-#: following treq doesn't check TLS, obviously it is unsecure and should not be used
-#: without explicit warning
-treq_client_no_ssl = HTTPClient(http_client.Agent(reactor, NoCheckContextFactory()))
-
-
-async def download_file(
-    url: str,
-    dest: Union[str, Path, BufferedIOBase],
-    max_size: Optional[int] = None
-) -> None:
-    """Helper method to download a file
-
-    This is for internal download, for high level download with progression, use
-    ``plugin_misc_download``.
-
-    Inspired from
-    https://treq.readthedocs.io/en/latest/howto.html#handling-streaming-responses
-
-    @param dest: destination filename or file-like object
-        of it's a file-like object, you'll have to close it yourself
-    @param max_size: if set, an exceptions.DataError will be raised if the downloaded file
-        is bigger that given value (in bytes).
-    """
-    if isinstance(dest, BufferedIOBase):
-        f = dest
-        must_close = False
-    else:
-        dest = Path(dest)
-        f = dest.open("wb")
-        must_close = True
-    d = treq.get(url, unbuffered=True)
-    written = 0
-
-    def write(data: bytes):
-        if max_size is not None:
-            nonlocal written
-            written += len(data)
-            if written > max_size:
-                raise exceptions.DataError(
-                    "downloaded file is bigger than expected ({max_size})"
-                )
-        f.write(data)
-
-    d.addCallback(treq.collect, f.write)
-    try:
-        await d
-    except exceptions.DataError as e:
-        log.warning("download cancelled due to file oversized")
-        raise e
-    except Exception as e:
-        log.error(f"Can't write file {dest}: {e}")
-        raise e
-    finally:
-        if must_close:
-            f.close()
--- a/sat/tools/xml_tools.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2093 +0,0 @@
-#!/usr/bin/env python3
-
-# SAT: a jabber client
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-from collections import OrderedDict
-import html.entities
-import re
-from typing import Dict, Optional, Tuple, Union, Literal, overload, Iterable
-from xml.dom import NotFoundErr, minidom
-import xml.etree.ElementTree as ET
-from lxml import etree
-
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from twisted.words.xish import domish
-from wokkel import data_form
-
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-
-log = getLogger(__name__)
-
-"""This library help manage XML used in SàT (parameters, registration, etc)"""
-
-SAT_FORM_PREFIX = "SAT_FORM_"
-SAT_PARAM_SEPARATOR = "_XMLUI_PARAM_"  # used to have unique elements names
-html_entity_re = re.compile(r"&([a-zA-Z]+?);")
-XML_ENTITIES = ("quot", "amp", "apos", "lt", "gt")
-
-# method to clean XHTML, receive raw unsecure XML or HTML, must return cleaned raw XHTML
-# this method must be set during runtime
-clean_xhtml = None
-
-# TODO: move XMLUI stuff in a separate module
-# TODO: rewrite this with lxml or ElementTree or domish.Element: it's complicated and difficult to maintain with current minidom implementation
-
-# Helper functions
-
-
-def _data_form_field_2_xmlui_data(field, read_only=False):
-    """Get data needed to create an XMLUI's Widget from Wokkel's data_form's Field.
-
-    The attribute field can be modified (if it's fixed and it has no value).
-    @param field (data_form.Field): a field with attributes "value", "fieldType",
-                                    "label" and "var"
-    @param read_only (bool): if True and it makes sense, create a read only input widget
-    @return: a tuple (widget_type, widget_args, widget_kwargs)
-    """
-    widget_args = field.values or [None]
-    widget_kwargs = {}
-    if field.fieldType is None and field.ext_type is not None:
-        # we have an extended field
-        if field.ext_type == "xml":
-            element = field.value
-            if element.uri == C.NS_XHTML:
-                widget_type = "xhtmlbox"
-                widget_args[0] = element.toXml()
-                widget_kwargs["read_only"] = read_only
-            else:
-                log.warning("unknown XML element, falling back to textbox")
-                widget_type = "textbox"
-                widget_args[0] = element.toXml()
-                widget_kwargs["read_only"] = read_only
-        else:
-
-            raise exceptions.DataError("unknown extended type {ext_type}".format(
-                ext_type = field.ext_type))
-
-    elif field.fieldType == "fixed" or field.fieldType is None:
-        widget_type = "text"
-        if field.value is None:
-            if field.label is None:
-                log.warning(_("Fixed field has neither value nor label, ignoring it"))
-                field.value = ""
-            else:
-                field.value = field.label
-                field.label = None
-            widget_args = [field.value]
-    elif field.fieldType == "text-single":
-        widget_type = "string"
-        widget_kwargs["read_only"] = read_only
-    elif field.fieldType == "jid-single":
-        widget_type = "jid_input"
-        widget_kwargs["read_only"] = read_only
-    elif field.fieldType == "text-multi":
-        widget_type = "textbox"
-        widget_args = ["\n".join(field.values)]
-        widget_kwargs["read_only"] = read_only
-    elif field.fieldType == "hidden":
-        widget_type = "hidden"
-    elif field.fieldType == "text-private":
-        widget_type = "password"
-        widget_kwargs["read_only"] = read_only
-    elif field.fieldType == "boolean":
-        widget_type = "bool"
-        if widget_args[0] is None:
-            widget_args = ["false"]
-        widget_kwargs["read_only"] = read_only
-    elif field.fieldType == "integer":
-        widget_type = "integer"
-        widget_kwargs["read_only"] = read_only
-    elif field.fieldType == "list-single":
-        widget_type = "list"
-        widget_kwargs["options"] = [
-            (option.value, option.label or option.value) for option in field.options
-        ]
-        widget_kwargs["selected"] = widget_args
-        widget_args = []
-    elif field.fieldType == "list-multi":
-        widget_type = "list"
-        widget_kwargs["options"] = [
-            (option.value, option.label or option.value) for option in field.options
-        ]
-        widget_kwargs["selected"] = widget_args
-        widget_kwargs["styles"] =  ["multi"]
-        widget_args = []
-    else:
-        log.error(
-            "FIXME FIXME FIXME: Type [%s] is not managed yet by SàT" % field.fieldType
-        )
-        widget_type = "string"
-        widget_kwargs["read_only"] = read_only
-
-    if field.var:
-        widget_kwargs["name"] = field.var
-
-    return widget_type, widget_args, widget_kwargs
-
-def data_form_2_widgets(form_ui, form, read_only=False, prepend=None, filters=None):
-    """Complete an existing XMLUI with widget converted from XEP-0004 data forms.
-
-    @param form_ui (XMLUI): XMLUI instance
-    @param form (data_form.Form): Wokkel's implementation of data form
-    @param read_only (bool): if True and it makes sense, create a read only input widget
-    @param prepend(iterable, None): widgets to prepend to main LabelContainer
-        if not None, must be an iterable of *args for add_widget. Those widgets will
-        be added first to the container.
-    @param filters(dict, None): if not None, a dictionary of callable:
-        key is the name of the widget to filter
-        the value is a callable, it will get form's XMLUI, widget's type, args and kwargs
-            and must return widget's type, args and kwargs (which can be modified)
-        This is especially useful to modify well known fields
-    @return: the completed XMLUI instance
-    """
-    if filters is None:
-        filters = {}
-    if form.instructions:
-        form_ui.addText("\n".join(form.instructions), "instructions")
-
-    form_ui.change_container("label")
-
-    if prepend is not None:
-        for widget_args in prepend:
-            form_ui.add_widget(*widget_args)
-
-    for field in form.fieldList:
-        widget_type, widget_args, widget_kwargs = _data_form_field_2_xmlui_data(
-            field, read_only
-        )
-        try:
-            widget_filter = filters[widget_kwargs["name"]]
-        except KeyError:
-            pass
-        else:
-            widget_type, widget_args, widget_kwargs = widget_filter(
-                form_ui, widget_type, widget_args, widget_kwargs
-            )
-        if widget_type != "hidden":
-            label = field.label or field.var
-            if label:
-                form_ui.addLabel(label)
-            else:
-                form_ui.addEmpty()
-
-        form_ui.add_widget(widget_type, *widget_args, **widget_kwargs)
-
-    return form_ui
-
-
-def data_form_2_xmlui(form, submit_id, session_id=None, read_only=False):
-    """Take a data form (Wokkel's XEP-0004 implementation) and convert it to a SàT XMLUI.
-
-    @param form (data_form.Form): a Form instance
-    @param submit_id (unicode): callback id to call when submitting form
-    @param session_id (unicode): session id to return with the data
-    @param read_only (bool): if True and it makes sense, create a read only input widget
-    @return: XMLUI instance
-    """
-    form_ui = XMLUI("form", "vertical", submit_id=submit_id, session_id=session_id)
-    return data_form_2_widgets(form_ui, form, read_only=read_only)
-
-
-def data_form_2_data_dict(form: data_form.Form) -> dict:
-    """Convert data form to a simple dict, easily serialisable
-
-    see data_dict_2_data_form for a description of the format
-    """
-    fields = []
-    data_dict = {
-        "fields": fields
-    }
-    if form.formNamespace:
-        data_dict["namespace"] = form.formNamespace
-    for form_field in form.fieldList:
-        field = {"type": form_field.fieldType}
-        fields.append(field)
-        for src_name, dest_name in (
-            ('var', 'name'),
-            ('label', 'label'),
-            ('value', 'value'),
-            # FIXME: we probably should have only "values"
-            ('values', 'values')
-        ):
-            value = getattr(form_field, src_name, None)
-            if value:
-                field[dest_name] = value
-        if form_field.options:
-            options = field["options"] = []
-            for form_opt in form_field.options:
-                opt = {"value": form_opt.value}
-                if form_opt.label:
-                    opt["label"] = form_opt.label
-                options.append(opt)
-
-        if form_field.fieldType is None and form_field.ext_type == "xml":
-            if isinstance(form_field.value, domish.Element):
-                if ((form_field.value.uri == C.NS_XHTML
-                     and form_field.value.name == "div")):
-                    field["type"] = "xhtml"
-                    if form_field.value.children:
-                        log.warning(
-                            "children are not managed for XHTML fields: "
-                            f"{form_field.value.toXml()}"
-                        )
-    return data_dict
-
-
-def data_dict_2_data_form(data_dict):
-    """Convert serialisable dict of data to a data form
-
-    The format of the dict is as follow:
-        - an optional "namespace" key with form namespace
-        - a mandatory "fields" key with list of fields as follow:
-            - "type" is mostly the same as data_form.Field.fieldType
-            - "name" is used to set the "var" attribute of data_form.Field
-            - "label", and "value" follow same attribude in data_form.Field
-            - "xhtml" is used for "xml" fields with child in the C.NS_XHTML namespace
-            - "options" are list of dict with optional "label" and mandatory "value"
-              following suitable attributes from data_form.Option
-            - "required" is the same as data_form.Field.required
-    """
-    # TODO: describe format
-    fields = []
-    for field_data in data_dict["fields"]:
-        field_type = field_data.get('type', 'text-single')
-        kwargs = {
-            "fieldType": field_type,
-            "var": field_data["name"],
-            "label": field_data.get('label'),
-            "value": field_data.get("value"),
-            "required": field_data.get("required")
-        }
-        if field_type == "xhtml":
-            kwargs.update({
-                "fieldType": None,
-                "ext_type": "xml",
-            })
-            if kwargs["value"] is None:
-                kwargs["value"] = domish.Element((C.NS_XHTML, "div"))
-        elif "options" in field_data:
-            kwargs["options"] = [
-                data_form.Option(o["value"], o.get("label"))
-                for o in field_data["options"]
-            ]
-        field = data_form.Field(**kwargs)
-        fields.append(field)
-    return data_form.Form(
-        "form",
-        formNamespace=data_dict.get("namespace"),
-        fields=fields
-    )
-
-
-def data_form_elt_result_2_xmlui_data(form_xml):
-    """Parse a data form result (not parsed by Wokkel's XEP-0004 implementation).
-
-    The raw data form is used because Wokkel doesn't manage result items parsing yet.
-    @param form_xml (domish.Element): element of the data form
-    @return: a couple (headers, result_list):
-        - headers (dict{unicode: unicode}): form headers (field labels and types)
-        - xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs)
-    """
-    headers = OrderedDict()
-    try:
-        reported_elt = next(form_xml.elements("jabber:x:data", "reported"))
-    except StopIteration:
-        raise exceptions.DataError(
-            "Couldn't find expected <reported> tag in %s" % form_xml.toXml()
-        )
-
-    for elt in reported_elt.elements():
-        if elt.name != "field":
-            raise exceptions.DataError("Unexpected tag")
-        name = elt["var"]
-        label = elt.attributes.get("label", "")
-        type_ = elt.attributes.get("type")
-        headers[name] = (label, type_)
-
-    if not headers:
-        raise exceptions.DataError("No reported fields (see XEP-0004 §3.4)")
-
-    xmlui_data = []
-    item_elts = form_xml.elements("jabber:x:data", "item")
-
-    for item_elt in item_elts:
-        for elt in item_elt.elements():
-            if elt.name != "field":
-                log.warning("Unexpected tag (%s)" % elt.name)
-                continue
-            field = data_form.Field.fromElement(elt)
-
-            xmlui_data.append(_data_form_field_2_xmlui_data(field))
-
-    return headers, xmlui_data
-
-
-def xmlui_data_2_advanced_list(xmlui, headers, xmlui_data):
-    """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list.
-
-    The raw data form is used because Wokkel doesn't manage result items parsing yet.
-    @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added
-    @param headers (dict{unicode: unicode}): form headers (field labels and types)
-    @param xmlui_data (list[tuple]): list of (widget_type, widget_args, widget_kwargs)
-    @return: the completed XMLUI instance
-    """
-    adv_list = AdvancedListContainer(
-        xmlui, headers=headers, columns=len(headers), parent=xmlui.current_container
-    )
-    xmlui.change_container(adv_list)
-
-    for widget_type, widget_args, widget_kwargs in xmlui_data:
-        xmlui.add_widget(widget_type, *widget_args, **widget_kwargs)
-
-    return xmlui
-
-
-def data_form_result_2_advanced_list(xmlui, form_xml):
-    """Take a raw data form result (not parsed by Wokkel's XEP-0004 implementation) and convert it to an advanced list.
-
-    The raw data form is used because Wokkel doesn't manage result items parsing yet.
-    @param xmlui (XMLUI): the XMLUI where the AdvancedList will be added
-    @param form_xml (domish.Element): element of the data form
-    @return: the completed XMLUI instance
-    """
-    headers, xmlui_data = data_form_elt_result_2_xmlui_data(form_xml)
-    xmlui_data_2_advanced_list(xmlui, headers, xmlui_data)
-
-
-def data_form_elt_result_2_xmlui(form_elt, session_id=None):
-    """Take a raw data form (not parsed by XEP-0004) and convert it to a SàT XMLUI.
-
-    The raw data form is used because Wokkel doesn't manage result items parsing yet.
-    @param form_elt (domish.Element): element of the data form
-    @param session_id (unicode): session id to return with the data
-    @return: XMLUI instance
-    """
-    xml_ui = XMLUI("window", "vertical", session_id=session_id)
-    try:
-        data_form_result_2_advanced_list(xml_ui, form_elt)
-    except exceptions.DataError:
-        parsed_form = data_form.Form.fromElement(form_elt)
-        data_form_2_widgets(xml_ui, parsed_form, read_only=True)
-    return xml_ui
-
-
-def data_form_result_2_xmlui(result_form, base_form, session_id=None, prepend=None,
-                         filters=None, read_only=True):
-    """Convert data form result to SàT XMLUI.
-
-    @param result_form (data_form.Form): result form to convert
-    @param base_form (data_form.Form): initial form (i.e. of form type "form")
-        this one is necessary to reconstruct options when needed (e.g. list elements)
-    @param session_id (unicode): session id to return with the data
-    @param prepend: same as for [data_form_2_widgets]
-    @param filters: same as for [data_form_2_widgets]
-    @param read_only: same as for [data_form_2_widgets]
-    @return: XMLUI instance
-    """
-    # we deepcopy the form because _data_form_field_2_xmlui_data can modify the value
-    # FIXME: check if it's really important, the only modified value seems to be
-    #        the replacement of None by "" on fixed fields
-    # form = deepcopy(result_form)
-    form = result_form
-    for name, field in form.fields.items():
-        try:
-            base_field = base_form.fields[name]
-        except KeyError:
-            continue
-        field.options = base_field.options[:]
-    xml_ui = XMLUI("window", "vertical", session_id=session_id)
-    data_form_2_widgets(xml_ui, form, read_only=read_only, prepend=prepend, filters=filters)
-    return xml_ui
-
-
-def _clean_value(value):
-    """Workaround method to avoid DBus types with D-Bus bridge.
-
-    @param value: value to clean
-    @return: value in a non DBus type (only clean string yet)
-    """
-    # XXX: must be removed when DBus types will no cause problems anymore
-    # FIXME: should be cleaned inside D-Bus bridge itself
-    if isinstance(value, str):
-        return str(value)
-    return value
-
-
-def xmlui_result_2_data_form_result(xmlui_data):
-    """ Extract form data from a XMLUI return.
-
-    @param xmlui_data (dict): data returned by frontends for XMLUI form
-    @return: dict of data usable by Wokkel's data form
-    """
-    ret = {}
-    for key, value in xmlui_data.items():
-        if not key.startswith(SAT_FORM_PREFIX):
-            continue
-        if isinstance(value, str):
-            if "\n" in value:
-                # data form expects multi-lines text to be in separated values
-                value = value.split('\n')
-            elif "\t" in value:
-                # FIXME: workaround to handle multiple values. Proper serialisation must
-                #   be done in XMLUI
-                value = value.split("\t")
-        ret[key[len(SAT_FORM_PREFIX) :]] = _clean_value(value)
-    return ret
-
-
-def form_escape(name):
-    """Return escaped name for forms.
-
-    @param name (unicode): form name
-    @return: unicode
-    """
-    return "%s%s" % (SAT_FORM_PREFIX, name)
-
-
-def is_xmlui_cancelled(raw_xmlui):
-    """Tell if an XMLUI has been cancelled by checking raw XML"""
-    return C.bool(raw_xmlui.get('cancelled', C.BOOL_FALSE))
-
-
-def xmlui_result_to_elt(xmlui_data):
-    """Construct result domish.Element from XMLUI result.
-
-    @param xmlui_data (dict): data returned by frontends for XMLUI form
-    @return: domish.Element
-    """
-    form = data_form.Form("submit")
-    form.makeFields(xmlui_result_2_data_form_result(xmlui_data))
-    return form.toElement()
-
-
-def tuple_list_2_data_form(values):
-    """Convert a list of tuples (name, value) to a wokkel submit data form.
-
-    @param values (list): list of tuples
-    @return: data_form.Form
-    """
-    form = data_form.Form("submit")
-    for value in values:
-        field = data_form.Field(var=value[0], value=value[1])
-        form.addField(field)
-
-    return form
-
-
-def params_xml_2_xmlui(xml):
-    """Convert the XML for parameter to a SàT XML User Interface.
-
-    @param xml (unicode)
-    @return: XMLUI
-    """
-    # TODO: refactor params and use Twisted directly to parse XML
-    params_doc = minidom.parseString(xml.encode("utf-8"))
-    top = params_doc.documentElement
-    if top.nodeName != "params":
-        raise exceptions.DataError(_("INTERNAL ERROR: parameters xml not valid"))
-
-    param_ui = XMLUI("param", "tabs")
-    tabs_cont = param_ui.current_container
-
-    for category in top.getElementsByTagName("category"):
-        category_name = category.getAttribute("name")
-        label = category.getAttribute("label")
-        if not category_name:
-            raise exceptions.DataError(
-                _("INTERNAL ERROR: params categories must have a name")
-            )
-        tabs_cont.add_tab(category_name, label=label, container=LabelContainer)
-        for param in category.getElementsByTagName("param"):
-            widget_kwargs = {}
-
-            param_name = param.getAttribute("name")
-            param_label = param.getAttribute("label")
-            type_ = param.getAttribute("type")
-            if not param_name and type_ != "text":
-                raise exceptions.DataError(_("INTERNAL ERROR: params must have a name"))
-
-            value = param.getAttribute("value") or None
-            callback_id = param.getAttribute("callback_id") or None
-
-            if type_ == "list":
-                options, selected = _params_get_list_options(param)
-                widget_kwargs["options"] = options
-                widget_kwargs["selected"] = selected
-                widget_kwargs["styles"] = ["extensible"]
-            elif type_ == "jids_list":
-                widget_kwargs["jids"] = _params_get_list_jids(param)
-
-            if type_ in ("button", "text"):
-                param_ui.addEmpty()
-                value = param_label
-            else:
-                param_ui.addLabel(param_label or param_name)
-
-            if value:
-                widget_kwargs["value"] = value
-
-            if callback_id:
-                widget_kwargs["callback_id"] = callback_id
-                others = [
-                    "%s%s%s"
-                    % (category_name, SAT_PARAM_SEPARATOR, other.getAttribute("name"))
-                    for other in category.getElementsByTagName("param")
-                    if other.getAttribute("type") != "button"
-                ]
-                widget_kwargs["fields_back"] = others
-
-            widget_kwargs["name"] = "%s%s%s" % (
-                category_name,
-                SAT_PARAM_SEPARATOR,
-                param_name,
-            )
-
-            param_ui.add_widget(type_, **widget_kwargs)
-
-    return param_ui.toXml()
-
-
-def _params_get_list_options(param):
-    """Retrieve the options for list element.
-
-    The <option/> tags must be direct children of <param/>.
-    @param param (domish.Element): element
-    @return: a tuple (options, selected_value)
-    """
-    if len(param.getElementsByTagName("options")) > 0:
-        raise exceptions.DataError(
-            _("The 'options' tag is not allowed in parameter of type 'list'!")
-        )
-    elems = param.getElementsByTagName("option")
-    if len(elems) == 0:
-        return []
-    options = []
-    for elem in elems:
-        value = elem.getAttribute("value")
-        if not value:
-            raise exceptions.InternalError("list option must have a value")
-        label = elem.getAttribute("label")
-        if label:
-            options.append((value, label))
-        else:
-            options.append(value)
-    selected = [
-        elem.getAttribute("value")
-        for elem in elems
-        if elem.getAttribute("selected") == "true"
-    ]
-    return (options, selected)
-
-
-def _params_get_list_jids(param):
-    """Retrive jids from a jids_list element.
-
-    the <jid/> tags must be direct children of <param/>
-    @param param (domish.Element): element
-    @return: a list of jids
-    """
-    elems = param.getElementsByTagName("jid")
-    jids = [
-        elem.firstChild.data
-        for elem in elems
-        if elem.firstChild is not None and elem.firstChild.nodeType == elem.TEXT_NODE
-    ]
-    return jids
-
-
-### XMLUI Elements ###
-
-
-class Element(object):
-    """ Base XMLUI element """
-
-    type = None
-
-    def __init__(self, xmlui, parent=None):
-        """Create a container element
-
-        @param xmlui: XMLUI instance
-        @parent: parent element
-        """
-        assert self.type is not None
-        self.children = []
-        if not hasattr(self, "elem"):
-            self.elem = parent.xmlui.doc.createElement(self.type)
-        self.xmlui = xmlui
-        if parent is not None:
-            parent.append(self)
-        self.parent = parent
-
-    def append(self, child):
-        """Append a child to this element.
-
-        @param child (Element): child element
-        @return: the added child Element
-        """
-        self.elem.appendChild(child.elem)
-        child.parent = self
-        self.children.append(child)
-        return child
-
-
-class TopElement(Element):
-    """ Main XML Element """
-
-    type = "top"
-
-    def __init__(self, xmlui):
-        self.elem = xmlui.doc.documentElement
-        super(TopElement, self).__init__(xmlui)
-
-
-class TabElement(Element):
-    """ Used by TabsContainer to give name and label to tabs."""
-
-    type = "tab"
-
-    def __init__(self, parent, name, label, selected=False):
-        """
-
-        @param parent (TabsContainer): parent container
-        @param name (unicode): tab name
-        @param label (unicode): tab label
-        @param selected (bool): set to True to select this tab
-        """
-        if not isinstance(parent, TabsContainer):
-            raise exceptions.DataError(_("TabElement must be a child of TabsContainer"))
-        super(TabElement, self).__init__(parent.xmlui, parent)
-        self.elem.setAttribute("name", name)
-        self.elem.setAttribute("label", label)
-        if selected:
-            self.set_selected(selected)
-
-    def set_selected(self, selected=False):
-        """Set the tab selected.
-
-        @param selected (bool): set to True to select this tab
-        """
-        self.elem.setAttribute("selected", "true" if selected else "false")
-
-
-class FieldBackElement(Element):
-    """ Used by ButtonWidget to indicate which field have to be sent back """
-
-    type = "field_back"
-
-    def __init__(self, parent, name):
-        assert isinstance(parent, ButtonWidget)
-        super(FieldBackElement, self).__init__(parent.xmlui, parent)
-        self.elem.setAttribute("name", name)
-
-
-class InternalFieldElement(Element):
-    """ Used by internal callbacks to indicate which fields are manipulated """
-
-    type = "internal_field"
-
-    def __init__(self, parent, name):
-        super(InternalFieldElement, self).__init__(parent.xmlui, parent)
-        self.elem.setAttribute("name", name)
-
-
-class InternalDataElement(Element):
-    """ Used by internal callbacks to retrieve extra data """
-
-    type = "internal_data"
-
-    def __init__(self, parent, children):
-        super(InternalDataElement, self).__init__(parent.xmlui, parent)
-        assert isinstance(children, list)
-        for child in children:
-            self.elem.childNodes.append(child)
-
-
-class OptionElement(Element):
-    """" Used by ListWidget to specify options """
-
-    type = "option"
-
-    def __init__(self, parent, option, selected=False):
-        """
-
-        @param parent
-        @param option (string, tuple)
-        @param selected (boolean)
-        """
-        assert isinstance(parent, ListWidget)
-        super(OptionElement, self).__init__(parent.xmlui, parent)
-        if isinstance(option, str):
-            value, label = option, option
-        elif isinstance(option, tuple):
-            value, label = option
-        else:
-            raise NotImplementedError
-        self.elem.setAttribute("value", value)
-        self.elem.setAttribute("label", label)
-        if selected:
-            self.elem.setAttribute("selected", "true")
-
-
-class JidElement(Element):
-    """" Used by JidsListWidget to specify jids"""
-
-    type = "jid"
-
-    def __init__(self, parent, jid_):
-        """
-        @param jid_(jid.JID, unicode): jid to append
-        """
-        assert isinstance(parent, JidsListWidget)
-        super(JidElement, self).__init__(parent.xmlui, parent)
-        if isinstance(jid_, jid.JID):
-            value = jid_.full()
-        elif isinstance(jid_, str):
-            value = str(jid_)
-        else:
-            raise NotImplementedError
-        jid_txt = self.xmlui.doc.createTextNode(value)
-        self.elem.appendChild(jid_txt)
-
-
-class RowElement(Element):
-    """" Used by AdvancedListContainer """
-
-    type = "row"
-
-    def __init__(self, parent):
-        assert isinstance(parent, AdvancedListContainer)
-        super(RowElement, self).__init__(parent.xmlui, parent)
-        if parent.next_row_idx is not None:
-            if parent.auto_index:
-                raise exceptions.DataError(_("Can't set row index if auto_index is True"))
-            self.elem.setAttribute("index", parent.next_row_idx)
-            parent.next_row_idx = None
-
-
-class HeaderElement(Element):
-    """" Used by AdvancedListContainer """
-
-    type = "header"
-
-    def __init__(self, parent, name=None, label=None, description=None):
-        """
-        @param parent: AdvancedListContainer instance
-        @param name: name of the container
-        @param label: label to be displayed in columns
-        @param description: long descriptive text
-        """
-        assert isinstance(parent, AdvancedListContainer)
-        super(HeaderElement, self).__init__(parent.xmlui, parent)
-        if name:
-            self.elem.setAttribute("name", name)
-        if label:
-            self.elem.setAttribute("label", label)
-        if description:
-            self.elem.setAttribute("description", description)
-
-
-## Containers ##
-
-
-class Container(Element):
-    """ And Element which contains other ones and has a layout """
-
-    type = None
-
-    def __init__(self, xmlui, parent=None):
-        """Create a container element
-
-        @param xmlui: XMLUI instance
-        @parent: parent element or None
-        """
-        self.elem = xmlui.doc.createElement("container")
-        super(Container, self).__init__(xmlui, parent)
-        self.elem.setAttribute("type", self.type)
-
-    def get_parent_container(self):
-        """ Return first parent container
-
-        @return: parent container or None
-        """
-        current = self.parent
-        while not isinstance(current, (Container)) and current is not None:
-            current = current.parent
-        return current
-
-
-class VerticalContainer(Container):
-    type = "vertical"
-
-
-class HorizontalContainer(Container):
-    type = "horizontal"
-
-
-class PairsContainer(Container):
-    """Container with series of 2 elements"""
-    type = "pairs"
-
-
-class LabelContainer(Container):
-    """Like PairsContainer, but first element can only be a label"""
-    type = "label"
-
-
-class TabsContainer(Container):
-    type = "tabs"
-
-    def add_tab(self, name, label=None, selected=None, container=VerticalContainer):
-        """Add a tab.
-
-        @param name (unicode): tab name
-        @param label (unicode): tab label
-        @param selected (bool): set to True to select this tab
-        @param container (class): container class, inheriting from Container
-        @return: the container for the new tab
-        """
-        if not label:
-            label = name
-        tab_elt = TabElement(self, name, label, selected)
-        new_container = container(self.xmlui, tab_elt)
-        return self.xmlui.change_container(new_container)
-
-    def end(self):
-        """ Called when we have finished tabs
-
-        change current container to first container parent
-        """
-        parent_container = self.get_parent_container()
-        self.xmlui.change_container(parent_container)
-
-
-class AdvancedListContainer(Container):
-    """A list which can contain other widgets, headers, etc"""
-
-    type = "advanced_list"
-
-    def __init__(
-        self,
-        xmlui,
-        callback_id=None,
-        name=None,
-        headers=None,
-        items=None,
-        columns=None,
-        selectable="no",
-        auto_index=False,
-        parent=None,
-    ):
-        """Create an advanced list
-
-        @param headers: optional headers information
-        @param callback_id: id of the method to call when selection is done
-        @param items: list of widgets to add (just the first row)
-        @param columns: number of columns in this table, or None to autodetect
-        @param selectable: one of:
-            'no': nothing is done
-            'single': one row can be selected
-        @param auto_index: if True, indexes will be generated by frontends,
-                           starting from 0
-        @return: created element
-        """
-        assert selectable in ("no", "single")
-        if not items and columns is None:
-            raise exceptions.DataError(_("either items or columns need do be filled"))
-        if headers is None:
-            headers = []
-        if items is None:
-            items = []
-        super(AdvancedListContainer, self).__init__(xmlui, parent)
-        if columns is None:
-            columns = len(items[0])
-        self._columns = columns
-        self._item_idx = 0
-        self.current_row = None
-        if headers:
-            if len(headers) != self._columns:
-                raise exceptions.DataError(
-                    _("Headers lenght doesn't correspond to columns")
-                )
-            self.add_headers(headers)
-        if items:
-            self.add_items(items)
-        self.elem.setAttribute("columns", str(self._columns))
-        if callback_id is not None:
-            self.elem.setAttribute("callback", callback_id)
-        self.elem.setAttribute("selectable", selectable)
-        self.auto_index = auto_index
-        if auto_index:
-            self.elem.setAttribute("auto_index", "true")
-        self.next_row_idx = None
-
-    def add_headers(self, headers):
-        for header in headers:
-            self.addHeader(header)
-
-    def addHeader(self, header):
-        pass  # TODO
-
-    def add_items(self, items):
-        for item in items:
-            self.append(item)
-
-    def set_row_index(self, idx):
-        """ Set index for next row
-
-        index are returned when a row is selected, in data's "index" key
-        @param idx: string index to associate to the next row
-        """
-        self.next_row_idx = idx
-
-    def append(self, child):
-        if isinstance(child, RowElement):
-            return super(AdvancedListContainer, self).append(child)
-        if self._item_idx % self._columns == 0:
-            self.current_row = RowElement(self)
-        self.current_row.append(child)
-        self._item_idx += 1
-
-    def end(self):
-        """ Called when we have finished list
-
-        change current container to first container parent
-        """
-        if self._item_idx % self._columns != 0:
-            raise exceptions.DataError(_("Incorrect number of items in list"))
-        parent_container = self.get_parent_container()
-        self.xmlui.change_container(parent_container)
-
-
-## Widgets ##
-
-
-class Widget(Element):
-    type = None
-
-    def __init__(self, xmlui, name=None, parent=None):
-        """Create an element
-
-        @param xmlui: XMLUI instance
-        @param name: name of the element or None
-        @param parent: parent element or None
-        """
-        self.elem = xmlui.doc.createElement("widget")
-        super(Widget, self).__init__(xmlui, parent)
-        if name:
-            self.elem.setAttribute("name", name)
-            if name in xmlui.named_widgets:
-                raise exceptions.ConflictError(
-                    _('A widget with the name "{name}" already exists.').format(
-                        name=name
-                    )
-                )
-            xmlui.named_widgets[name] = self
-        self.elem.setAttribute("type", self.type)
-
-    def set_internal_callback(self, callback, fields, data_elts=None):
-        """Set an internal UI callback when the widget value is changed.
-
-        The internal callbacks are NO callback ids, they are strings from
-        a predefined set of actions that are running in the scope of XMLUI.
-        @param callback (string): a value from:
-            - 'copy': process the widgets given in 'fields' two by two, by
-                copying the values of one widget to the other. Target widgets
-                of type List do not accept the empty value.
-            - 'move': same than copy but moves the values if the source widget
-                is not a List.
-            - 'groups_of_contact': process the widgets two by two, assume A is
-                is a list of JID and B a list of groups, select in B the groups
-                to which the JID selected in A belongs.
-            - more operation to be added when necessary...
-        @param fields (list): a list of widget names (string)
-        @param data_elts (list[Element]): extra data elements
-        """
-        self.elem.setAttribute("internal_callback", callback)
-        if fields:
-            for field in fields:
-                InternalFieldElement(self, field)
-        if data_elts:
-            InternalDataElement(self, data_elts)
-
-
-class EmptyWidget(Widget):
-    """Place holder widget"""
-
-    type = "empty"
-
-
-class TextWidget(Widget):
-    """Used for blob of text"""
-
-    type = "text"
-
-    def __init__(self, xmlui, value, name=None, parent=None):
-        super(TextWidget, self).__init__(xmlui, name, parent)
-        value_elt = self.xmlui.doc.createElement("value")
-        text = self.xmlui.doc.createTextNode(value)
-        value_elt.appendChild(text)
-        self.elem.appendChild(value_elt)
-
-    @property
-    def value(self):
-        return self.elem.firstChild.firstChild.wholeText
-
-
-class LabelWidget(Widget):
-    """One line blob of text
-
-    used most of time to display the desciption or name of the next widget
-    """
-    type = "label"
-
-    def __init__(self, xmlui, label, name=None, parent=None):
-        super(LabelWidget, self).__init__(xmlui, name, parent)
-        self.elem.setAttribute("value", label)
-
-
-class HiddenWidget(Widget):
-    """Not displayed widget, frontends will just copy the value(s)"""
-    type = "hidden"
-
-    def __init__(self, xmlui, value, name, parent=None):
-        super(HiddenWidget, self).__init__(xmlui, name, parent)
-        value_elt = self.xmlui.doc.createElement("value")
-        text = self.xmlui.doc.createTextNode(value)
-        value_elt.appendChild(text)
-        self.elem.appendChild(value_elt)
-
-    @property
-    def value(self):
-        return self.elem.firstChild.firstChild.wholeText
-
-
-class JidWidget(Widget):
-    """Used to display a Jabber ID, some specific methods can be added"""
-
-    type = "jid"
-
-    def __init__(self, xmlui, jid, name=None, parent=None):
-        super(JidWidget, self).__init__(xmlui, name, parent)
-        try:
-            self.elem.setAttribute("value", jid.full())
-        except AttributeError:
-            self.elem.setAttribute("value", str(jid))
-
-    @property
-    def value(self):
-        return self.elem.getAttribute("value")
-
-
-class DividerWidget(Widget):
-    type = "divider"
-
-    def __init__(self, xmlui, style="line", name=None, parent=None):
-        """ Create a divider
-
-        @param xmlui: XMLUI instance
-        @param style: one of:
-            - line: a simple line
-            - dot: a line of dots
-            - dash: a line of dashes
-            - plain: a full thick line
-            - blank: a blank line/space
-        @param name: name of the widget
-        @param parent: parent container
-
-        """
-        super(DividerWidget, self).__init__(xmlui, name, parent)
-        self.elem.setAttribute("style", style)
-
-
-### Inputs ###
-
-
-class InputWidget(Widget):
-    """Widget which can accept user inputs
-
-    used mainly in forms
-    """
-
-    def __init__(self, xmlui, name=None, parent=None, read_only=False):
-        super(InputWidget, self).__init__(xmlui, name, parent)
-        if read_only:
-            self.elem.setAttribute("read_only", "true")
-
-
-class StringWidget(InputWidget):
-    type = "string"
-
-    def __init__(self, xmlui, value=None, name=None, parent=None, read_only=False):
-        super(StringWidget, self).__init__(xmlui, name, parent, read_only=read_only)
-        if value:
-            value_elt = self.xmlui.doc.createElement("value")
-            text = self.xmlui.doc.createTextNode(value)
-            value_elt.appendChild(text)
-            self.elem.appendChild(value_elt)
-
-    @property
-    def value(self):
-        return self.elem.firstChild.firstChild.wholeText
-
-
-class PasswordWidget(StringWidget):
-    type = "password"
-
-
-class TextBoxWidget(StringWidget):
-    type = "textbox"
-
-
-class XHTMLBoxWidget(StringWidget):
-    """Specialized textbox to manipulate XHTML"""
-    type = "xhtmlbox"
-
-    def __init__(self, xmlui, value, name=None, parent=None, read_only=False, clean=True):
-        """
-        @param clean(bool): if True, the XHTML is considered insecure and will be cleaned
-            Only set to False if you are absolutely sure that the XHTML is safe (in other
-            word, set to False only if you made the XHTML yourself)
-        """
-        if clean:
-            if clean_xhtml is None:
-                raise exceptions.NotFound(
-                    "No cleaning method set, can't clean the XHTML")
-            value = clean_xhtml(value)
-
-        super(XHTMLBoxWidget, self).__init__(
-            xmlui, value=value, name=name, parent=parent, read_only=read_only)
-
-
-class JidInputWidget(StringWidget):
-    type = "jid_input"
-
-
-# TODO handle min and max values
-class IntWidget(StringWidget):
-    type = "int"
-
-    def __init__(self, xmlui, value=0, name=None, parent=None, read_only=False):
-        try:
-            int(value)
-        except ValueError:
-            raise exceptions.DataError(_("Value must be an integer"))
-        super(IntWidget, self).__init__(xmlui, value, name, parent, read_only=read_only)
-
-
-class BoolWidget(InputWidget):
-    type = "bool"
-
-    def __init__(self, xmlui, value="false", name=None, parent=None, read_only=False):
-        if isinstance(value, bool):
-            value = "true" if value else "false"
-        elif value == "0":
-            value = "false"
-        elif value == "1":
-            value = "true"
-        if value not in ("true", "false"):
-            raise exceptions.DataError(_("Value must be 0, 1, false or true"))
-        super(BoolWidget, self).__init__(xmlui, name, parent, read_only=read_only)
-        self.elem.setAttribute("value", value)
-
-
-class ButtonWidget(Widget):
-    type = "button"
-
-    def __init__(
-        self, xmlui, callback_id, value=None, fields_back=None, name=None, parent=None
-    ):
-        """Add a button
-
-        @param callback_id: callback which will be called if button is pressed
-        @param value: label of the button
-        @param fields_back: list of names of field to give back when pushing the button
-        @param name: name
-        @param parent: parent container
-        """
-        if fields_back is None:
-            fields_back = []
-        super(ButtonWidget, self).__init__(xmlui, name, parent)
-        self.elem.setAttribute("callback", callback_id)
-        if value:
-            self.elem.setAttribute("value", value)
-        for field in fields_back:
-            FieldBackElement(self, field)
-
-
-class ListWidget(InputWidget):
-    type = "list"
-    STYLES = ("multi", "noselect", "extensible", "reducible", "inline")
-
-    def __init__(
-        self, xmlui, options, selected=None, styles=None, name=None, parent=None
-    ):
-        """
-
-        @param xmlui
-        @param options (list[option]): each option can be given as:
-            - a single string if the label and the value are the same
-            - a tuple with a couple of string (value,label) if the label and the
-              value differ
-        @param selected (list[string]): list of the selected values
-        @param styles (iterable[string]): flags to set the behaviour of the list
-            can be:
-                - multi: multiple selection is allowed
-                - noselect: no selection is allowed
-                    useful when only the list itself is needed
-                - extensible: can be extended by user (i.e. new options can be added)
-                - reducible: can be reduced by user (i.e. options can be removed)
-                - inline: hint that this list should be displayed on a single line
-                          (e.g. list of labels)
-        @param name (string)
-        @param parent
-        """
-        styles = set() if styles is None else set(styles)
-        if styles is None:
-            styles = set()
-        else:
-            styles = set(styles)
-        if "noselect" in styles and ("multi" in styles or selected):
-            raise exceptions.DataError(
-                _(
-                    '"multi" flag and "selected" option are not compatible with '
-                    '"noselect" flag'
-                )
-            )
-        if not options:
-            # we can have no options if we get a submitted data form
-            # but we can't use submitted values directly,
-            # because we would not have the labels
-            log.warning(_('empty "options" list'))
-        super(ListWidget, self).__init__(xmlui, name, parent)
-        self.add_options(options, selected)
-        self.set_styles(styles)
-
-    def add_options(self, options, selected=None):
-        """Add options to a multi-values element (e.g. list) """
-        if selected:
-            if isinstance(selected, str):
-                selected = [selected]
-        else:
-            selected = []
-        for option in options:
-            assert isinstance(option, str) or isinstance(option, tuple)
-            value = option if isinstance(option, str) else option[0]
-            OptionElement(self, option, value in selected)
-
-    def set_styles(self, styles):
-        if not styles.issubset(self.STYLES):
-            raise exceptions.DataError(_("invalid styles"))
-        for style in styles:
-            self.elem.setAttribute(style, "yes")
-        # TODO: check flags incompatibily (noselect and multi) like in __init__
-
-    def setStyle(self, style):
-        self.set_styles([style])
-
-    @property
-    def value(self):
-        """Return the value of first selected option"""
-        for child in self.elem.childNodes:
-            if child.tagName == "option" and child.getAttribute("selected") == "true":
-                return child.getAttribute("value")
-        return ""
-
-
-class JidsListWidget(InputWidget):
-    """A list of text or jids where elements can be added/removed or modified"""
-
-    type = "jids_list"
-
-    def __init__(self, xmlui, jids, styles=None, name=None, parent=None):
-        """
-
-        @param xmlui
-        @param jids (list[jid.JID]): base jids
-        @param styles (iterable[string]): flags to set the behaviour of the list
-        @param name (string)
-        @param parent
-        """
-        super(JidsListWidget, self).__init__(xmlui, name, parent)
-        styles = set() if styles is None else set(styles)
-        if not styles.issubset([]):  # TODO
-            raise exceptions.DataError(_("invalid styles"))
-        for style in styles:
-            self.elem.setAttribute(style, "yes")
-        if not jids:
-            log.debug("empty jids list")
-        else:
-            self.add_jids(jids)
-
-    def add_jids(self, jids):
-        for jid_ in jids:
-            JidElement(self, jid_)
-
-
-## Dialog Elements ##
-
-
-class DialogElement(Element):
-    """Main dialog element """
-
-    type = "dialog"
-
-    def __init__(self, parent, type_, level=None):
-        if not isinstance(parent, TopElement):
-            raise exceptions.DataError(
-                _("DialogElement must be a direct child of TopElement")
-            )
-        super(DialogElement, self).__init__(parent.xmlui, parent)
-        self.elem.setAttribute(C.XMLUI_DATA_TYPE, type_)
-        self.elem.setAttribute(C.XMLUI_DATA_LVL, level or C.XMLUI_DATA_LVL_DEFAULT)
-
-
-class MessageElement(Element):
-    """Element with the instruction message"""
-
-    type = C.XMLUI_DATA_MESS
-
-    def __init__(self, parent, message):
-        if not isinstance(parent, DialogElement):
-            raise exceptions.DataError(
-                _("MessageElement must be a direct child of DialogElement")
-            )
-        super(MessageElement, self).__init__(parent.xmlui, parent)
-        message_txt = self.xmlui.doc.createTextNode(message)
-        self.elem.appendChild(message_txt)
-
-
-class ButtonsElement(Element):
-    """Buttons element which indicate which set to use"""
-
-    type = "buttons"
-
-    def __init__(self, parent, set_):
-        if not isinstance(parent, DialogElement):
-            raise exceptions.DataError(
-                _("ButtonsElement must be a direct child of DialogElement")
-            )
-        super(ButtonsElement, self).__init__(parent.xmlui, parent)
-        self.elem.setAttribute("set", set_)
-
-
-class FileElement(Element):
-    """File element used for FileDialog"""
-
-    type = "file"
-
-    def __init__(self, parent, type_):
-        if not isinstance(parent, DialogElement):
-            raise exceptions.DataError(
-                _("FileElement must be a direct child of DialogElement")
-            )
-        super(FileElement, self).__init__(parent.xmlui, parent)
-        self.elem.setAttribute("type", type_)
-
-
-## XMLUI main class
-
-
-class XMLUI(object):
-    """This class is used to create a user interface (form/window/parameters/etc) using SàT XML"""
-
-    def __init__(self, panel_type="window", container="vertical", dialog_opt=None,
-        title=None, submit_id=None, session_id=None):
-        """Init SàT XML Panel
-
-        @param panel_type: one of
-            - C.XMLUI_WINDOW (new window)
-            - C.XMLUI_POPUP
-            - C.XMLUI_FORM (form, depend of the frontend, usually a panel with
-              cancel/submit buttons)
-            - C.XMLUI_PARAM (parameters, presentation depend of the frontend)
-            - C.XMLUI_DIALOG (one common dialog, presentation depend of frontend)
-        @param container: disposition of elements, one of:
-            - vertical: elements are disposed up to bottom
-            - horizontal: elements are disposed left to right
-            - pairs: elements come on two aligned columns
-              (usually one for a label, the next for the element)
-            - label: associations of one LabelWidget or EmptyWidget with an other widget
-                similar to pairs but specialized in LabelWidget,
-                and not necessarily arranged in 2 columns
-            - tabs: elemens are in categories with tabs (notebook)
-        @param dialog_opt: only used if panel_type == C.XMLUI_DIALOG.
-            Dictionnary (string/string) where key can be:
-            - C.XMLUI_DATA_TYPE: type of dialog, value can be:
-                - C.XMLUI_DIALOG_MESSAGE (default): an information/error message.
-                  Action of user is necessary to close the dialog.
-                  Usually the frontend display a classic popup.
-                - C.XMLUI_DIALOG_NOTE: like a C.XMLUI_DIALOG_MESSAGE, but action of user
-                  is not necessary to close, at frontend choice (it can be closed after
-                  a timeout). Usually the frontend display as a timed out notification
-                - C.XMLUI_DIALOG_CONFIRM: dialog with 2 choices (usualy "Ok"/"Cancel").
-                    returned data can contain:
-                        - "answer": "true" if answer is "ok", "yes" or equivalent,
-                                    "false" else
-                - C.XLMUI_DIALOG_FILE: a file selection dialog
-                    returned data can contain:
-                        - "cancelled": "true" if dialog has been cancelled, not present
-                                       or "false" else
-                        - "path": path of the choosed file/dir
-            - C.XMLUI_DATA_MESS: message shown in dialog
-            - C.XMLUI_DATA_LVL: one of:
-                - C.XMLUI_DATA_LVL_INFO (default): normal message
-                - C.XMLUI_DATA_LVL_WARNING: attention of user is important
-                - C.XMLUI_DATA_LVL_ERROR: something went wrong
-            - C.XMLUI_DATA_BTNS_SET: one of:
-                - C.XMLUI_DATA_BTNS_SET_OKCANCEL (default): classical "OK" and "Cancel"
-                  set
-                - C.XMLUI_DATA_BTNS_SET_YESNO: a translated "yes" for OK, and "no" for
-                  Cancel
-            - C.XMLUI_DATA_FILETYPE: only used for file dialogs, one of:
-                - C.XMLUI_DATA_FILETYPE_FILE: a file path is requested
-                - C.XMLUI_DATA_FILETYPE_DIR: a dir path is requested
-                - C.XMLUI_DATA_FILETYPE_DEFAULT: same as C.XMLUI_DATA_FILETYPE_FILE
-
-        @param title: title or default if None
-        @param submit_id: callback id to call for panel_type we can submit (form, param,
-                          dialog)
-        @param session_id: use to keep a session attached to the dialog, must be
-                           returned by frontends
-        @attribute named_widgets(dict): map from name to widget
-        """
-        if panel_type not in [
-            C.XMLUI_WINDOW,
-            C.XMLUI_FORM,
-            C.XMLUI_PARAM,
-            C.XMLUI_POPUP,
-            C.XMLUI_DIALOG,
-        ]:
-            raise exceptions.DataError(_("Unknown panel type [%s]") % panel_type)
-        if panel_type == C.XMLUI_FORM and submit_id is None:
-            raise exceptions.DataError(_("form XMLUI need a submit_id"))
-        if not isinstance(container, str):
-            raise exceptions.DataError(_("container argument must be a string"))
-        if dialog_opt is not None and panel_type != C.XMLUI_DIALOG:
-            raise exceptions.DataError(
-                _("dialog_opt can only be used with dialog panels")
-            )
-        self.type = panel_type
-        impl = minidom.getDOMImplementation()
-
-        self.doc = impl.createDocument(None, "sat_xmlui", None)
-        top_element = self.doc.documentElement
-        top_element.setAttribute("type", panel_type)
-        if title:
-            top_element.setAttribute("title", title)
-        self.submit_id = submit_id
-        self.session_id = session_id
-        if panel_type == C.XMLUI_DIALOG:
-            if dialog_opt is None:
-                dialog_opt = {}
-            self._create_dialog(dialog_opt)
-            return
-        self.main_container = self._create_container(container, TopElement(self))
-        self.current_container = self.main_container
-        self.named_widgets = {}
-
-    @staticmethod
-    def creator_wrapper(widget_cls, is_input):
-        # TODO: once moved to Python 3, use functools.partialmethod and
-        #       remove the creator_wrapper
-        def create_widget(self, *args, **kwargs):
-            if self.type == C.XMLUI_DIALOG:
-                raise exceptions.InternalError(_(
-                    "create_widget can't be used with dialogs"))
-            if "parent" not in kwargs:
-                kwargs["parent"] = self.current_container
-            if "name" not in kwargs and is_input:
-                # name can be given as first argument or in keyword
-                # arguments for InputWidgets
-                args = list(args)
-                kwargs["name"] = args.pop(0)
-            return widget_cls(self, *args, **kwargs)
-        return create_widget
-
-    @classmethod
-    def _introspect(cls):
-        """ Introspect module to find Widgets and Containers, and create addXXX methods"""
-        # FIXME: we can't log anything because this file is used
-        #        in bin/sat script then evaluated
-        #        bin/sat should be refactored
-        # log.debug(u'introspecting XMLUI widgets and containers')
-        cls._containers = {}
-        cls._widgets = {}
-        for obj in list(globals().values()):
-            try:
-                if issubclass(obj, Widget):
-                    if obj.__name__ == "Widget":
-                        continue
-                    cls._widgets[obj.type] = obj
-                    creator_name = "add" + obj.__name__
-                    if creator_name.endswith('Widget'):
-                        creator_name = creator_name[:-6]
-                    is_input = issubclass(obj, InputWidget)
-                    # FIXME: cf. above comment
-                    # log.debug(u"Adding {creator_name} creator (is_input={is_input}))"
-                    #     .format(creator_name=creator_name, is_input=is_input))
-
-                    assert not hasattr(cls, creator_name)
-                    # XXX: we need to use creator_wrapper because we are in a loop
-                    #      and Python 2 doesn't support default values in kwargs
-                    #      when using *args, **kwargs
-                    setattr(cls, creator_name, cls.creator_wrapper(obj, is_input))
-
-                elif issubclass(obj, Container):
-                    if obj.__name__ == "Container":
-                        continue
-                    cls._containers[obj.type] = obj
-            except TypeError:
-                pass
-
-    def __del__(self):
-        self.doc.unlink()
-
-    @property
-    def submit_id(self):
-        top_element = self.doc.documentElement
-        if not top_element.hasAttribute("submit"):
-            # getAttribute never return None (it return empty string it attribute doesn't exists)
-            # so we have to manage None here
-            return None
-        value = top_element.getAttribute("submit")
-        return value
-
-    @submit_id.setter
-    def submit_id(self, value):
-        top_element = self.doc.documentElement
-        if value is None:
-            try:
-                top_element.removeAttribute("submit")
-            except NotFoundErr:
-                pass
-        else:  # submit_id can be the empty string to bypass form restriction
-            top_element.setAttribute("submit", value)
-
-    @property
-    def session_id(self):
-        top_element = self.doc.documentElement
-        value = top_element.getAttribute("session_id")
-        return value or None
-
-    @session_id.setter
-    def session_id(self, value):
-        top_element = self.doc.documentElement
-        if value is None:
-            try:
-                top_element.removeAttribute("session_id")
-            except NotFoundErr:
-                pass
-        elif value:
-            top_element.setAttribute("session_id", value)
-        else:
-            raise exceptions.DataError("session_id can't be empty")
-
-    def _create_dialog(self, dialog_opt):
-        dialog_type = dialog_opt.setdefault(C.XMLUI_DATA_TYPE, C.XMLUI_DIALOG_MESSAGE)
-        if (
-            dialog_type in [C.XMLUI_DIALOG_CONFIRM, C.XMLUI_DIALOG_FILE]
-            and self.submit_id is None
-        ):
-            raise exceptions.InternalError(
-                _("Submit ID must be filled for this kind of dialog")
-            )
-        top_element = TopElement(self)
-        level = dialog_opt.get(C.XMLUI_DATA_LVL)
-        dialog_elt = DialogElement(top_element, dialog_type, level)
-
-        try:
-            MessageElement(dialog_elt, dialog_opt[C.XMLUI_DATA_MESS])
-        except KeyError:
-            pass
-
-        try:
-            ButtonsElement(dialog_elt, dialog_opt[C.XMLUI_DATA_BTNS_SET])
-        except KeyError:
-            pass
-
-        try:
-            FileElement(dialog_elt, dialog_opt[C.XMLUI_DATA_FILETYPE])
-        except KeyError:
-            pass
-
-    def _create_container(self, container, parent=None, **kwargs):
-        """Create a container element
-
-        @param type: container type (cf init doc)
-        @parent: parent element or None
-        """
-        if container not in self._containers:
-            raise exceptions.DataError(_("Unknown container type [%s]") % container)
-        cls = self._containers[container]
-        new_container = cls(self, parent=parent, **kwargs)
-        return new_container
-
-    def change_container(self, container, **kwargs):
-        """Change the current container
-
-        @param container: either container type (container it then created),
-                          or an Container instance"""
-        if isinstance(container, str):
-            self.current_container = self._create_container(
-                container,
-                self.current_container.get_parent_container() or self.main_container,
-                **kwargs
-            )
-        else:
-            self.current_container = (
-                self.main_container if container is None else container
-            )
-        assert isinstance(self.current_container, Container)
-        return self.current_container
-
-    def add_widget(self, type_, *args, **kwargs):
-        """Convenience method to add an element"""
-        if "parent" not in kwargs:
-            kwargs["parent"] = self.current_container
-        try:
-            cls = self._widgets[type_]
-        except KeyError:
-            raise exceptions.DataError(_("Invalid type [{type_}]").format(type_=type_))
-        return cls(self, *args, **kwargs)
-
-    def toXml(self):
-        """return the XML representation of the panel"""
-        return self.doc.toxml()
-
-
-# we call this to have automatic discovery of containers and widgets
-XMLUI._introspect()
-
-
-# Some sugar for XMLUI dialogs
-
-
-def note(message, title="", level=C.XMLUI_DATA_LVL_INFO):
-    """sugar to easily create a Note Dialog
-
-    @param message(unicode): body of the note
-    @param title(unicode): title of the note
-    @param level(unicode): one of C.XMLUI_DATA_LVL_*
-    @return(XMLUI): instance of XMLUI
-    """
-    note_xmlui = XMLUI(
-        C.XMLUI_DIALOG,
-        dialog_opt={
-            C.XMLUI_DATA_TYPE: C.XMLUI_DIALOG_NOTE,
-            C.XMLUI_DATA_MESS: message,
-            C.XMLUI_DATA_LVL: level,
-        },
-        title=title,
-    )
-    return note_xmlui
-
-
-def quick_note(host, client, message, title="", level=C.XMLUI_DATA_LVL_INFO):
-    """more sugar to do the whole note process"""
-    note_ui = note(message, title, level)
-    host.action_new({"xmlui": note_ui.toXml()}, profile=client.profile)
-
-
-def deferred_ui(host, xmlui, chained=False):
-    """create a deferred linked to XMLUI
-
-    @param xmlui(XMLUI): instance of the XMLUI
-        Must be an XMLUI that you can submit, with submit_id set to ''
-    @param chained(bool): True if the Deferred result must be returned to the frontend
-        useful when backend is in a series of dialogs with an ui
-    @return (D(data)): a deferred which fire the data
-    """
-    assert xmlui.submit_id == ""
-    xmlui_d = defer.Deferred()
-
-    def on_submit(data, profile):
-        xmlui_d.callback(data)
-        return xmlui_d if chained else {}
-
-    xmlui.submit_id = host.register_callback(on_submit, with_data=True, one_shot=True)
-    return xmlui_d
-
-
-def defer_xmlui(host, xmlui, action_extra=None, security_limit=C.NO_SECURITY_LIMIT,
-    chained=False, profile=C.PROF_KEY_NONE):
-    """Create a deferred linked to XMLUI
-
-    @param xmlui(XMLUI): instance of the XMLUI
-        Must be an XMLUI that you can submit, with submit_id set to ''
-    @param profile: %(doc_profile)s
-    @param action_extra(None, dict): extra action to merge with xmlui
-        mainly used to add meta informations (see action_new doc)
-    @param security_limit: %(doc_security_limit)s
-    @param chained(bool): True if the Deferred result must be returned to the frontend
-        useful when backend is in a series of dialogs with an ui
-    @return (data): a deferred which fire the data
-    """
-    xmlui_d = deferred_ui(host, xmlui, chained)
-    action_data = {"xmlui": xmlui.toXml()}
-    if action_extra is not None:
-        action_data.update(action_extra)
-    host.action_new(
-        action_data,
-        security_limit=security_limit,
-        keep_id=xmlui.submit_id,
-        profile=profile,
-    )
-    return xmlui_d
-
-
-def defer_dialog(
-    host,
-    message: str,
-    title: str = "Please confirm",
-    type_: str = C.XMLUI_DIALOG_CONFIRM,
-    options: Optional[dict] = None,
-    action_extra: Optional[dict] = None,
-    security_limit: int = C.NO_SECURITY_LIMIT,
-    chained: bool = False,
-    profile: str = C.PROF_KEY_NONE
-) -> defer.Deferred:
-    """Create a submitable dialog and manage it with a deferred
-
-    @param message: message to display
-    @param title: title of the dialog
-    @param type: dialog type (C.XMLUI_DIALOG_* or plugin specific string)
-    @param options: if not None, will be used to update (extend) dialog_opt arguments of
-        XMLUI
-    @param action_extra: extra action to merge with xmlui
-        mainly used to add meta informations (see action_new doc)
-    @param security_limit: %(doc_security_limit)s
-    @param chained: True if the Deferred result must be returned to the frontend
-        useful when backend is in a series of dialogs with an ui
-    @param profile: %(doc_profile)s
-    @return: answer dict
-    """
-    assert profile is not None
-    dialog_opt = {"type": type_, "message": message}
-    if options is not None:
-        dialog_opt.update(options)
-    dialog = XMLUI(C.XMLUI_DIALOG, title=title, dialog_opt=dialog_opt, submit_id="")
-    return defer_xmlui(host, dialog, action_extra, security_limit, chained, profile)
-
-
-def defer_confirm(*args, **kwargs):
-    """call defer_dialog and return a boolean instead of the whole data dict"""
-    d = defer_dialog(*args, **kwargs)
-    d.addCallback(lambda data: C.bool(data["answer"]))
-    return d
-
-
-# Misc other funtions
-
-def element_copy(
-    element: domish.Element,
-    with_parent: bool = True,
-    with_children: bool = True
-) -> domish.Element:
-    """Make a copy of a domish.Element
-
-    The copy will have its own children list, so other elements
-    can be added as direct children without modifying orignal one.
-    Children are not deeply copied, so if an element is added to a child or grandchild,
-    it will also affect original element.
-    @param element: Element to clone
-    """
-    new_elt = domish.Element(
-        (element.uri, element.name),
-        defaultUri = element.defaultUri,
-        attribs = element.attributes,
-        localPrefixes = element.localPrefixes)
-    if with_parent:
-        new_elt.parent = element.parent
-    if with_children:
-        new_elt.children = element.children[:]
-    return new_elt
-
-
-def is_xhtml_field(field):
-    """Check if a data_form.Field is an XHTML one"""
-    return (field.fieldType is None and field.ext_type == "xml" and
-            field.value.uri == C.NS_XHTML)
-
-
-class ElementParser:
-    """Callable class to parse XML string into Element"""
-
-    # XXX: Found at http://stackoverflow.com/questions/2093400/how-to-create-twisted-words-xish-domish-element-entirely-from-raw-xml/2095942#2095942
-
-    def _escape_html(self, matchobj):
-        entity = matchobj.group(1)
-        if entity in XML_ENTITIES:
-            # we don't escape XML entities
-            return matchobj.group(0)
-        else:
-            try:
-                return chr(html.entities.name2codepoint[entity])
-            except KeyError:
-                log.warning("removing unknown entity {}".format(entity))
-                return ""
-
-    def __call__(self, raw_xml, force_spaces=False, namespace=None):
-        """
-        @param raw_xml(unicode): the raw XML
-        @param force_spaces (bool): if True, replace occurrences of '\n' and '\t'
-                                    with ' '.
-        @param namespace(unicode, None): if set, use this namespace for the wrapping
-                                         element
-        """
-        # we need to wrap element in case
-        # there is not a unique one on the top
-        if namespace is not None:
-            raw_xml = "<div xmlns='{}'>{}</div>".format(namespace, raw_xml)
-        else:
-            raw_xml = "<div>{}</div>".format(raw_xml)
-
-        # avoid ParserError on HTML escaped chars
-        raw_xml = html_entity_re.sub(self._escape_html, raw_xml)
-
-        self.result = None
-
-        def on_start(elem):
-            self.result = elem
-
-        def on_end():
-            pass
-
-        def onElement(elem):
-            self.result.addChild(elem)
-
-        parser = domish.elementStream()
-        parser.DocumentStartEvent = on_start
-        parser.ElementEvent = onElement
-        parser.DocumentEndEvent = on_end
-        tmp = domish.Element((None, "s"))
-        if force_spaces:
-            raw_xml = raw_xml.replace("\n", " ").replace("\t", " ")
-        tmp.addRawXml(raw_xml)
-        parser.parse(tmp.toXml().encode("utf-8"))
-        top_elt = self.result.firstChildElement()
-        # we now can check if there was a unique element on the top
-        # and remove our wrapping <div/> is this is the case
-        top_elt_children = list(top_elt.elements())
-        if len(top_elt_children) == 1:
-            top_elt = top_elt_children[0]
-        return top_elt
-
-
-parse = ElementParser()
-
-
-# FIXME: this method is duplicated from frontends.tools.xmlui.get_text
-def get_text(node):
-    """Get child text nodes of a domish.Element.
-
-    @param node (domish.Element)
-    @return: joined unicode text of all nodes
-    """
-    data = []
-    for child in node.childNodes:
-        if child.nodeType == child.TEXT_NODE:
-            data.append(child.wholeText)
-    return "".join(data)
-
-
-def find_all(elt, namespaces=None, names=None):
-    """Find child element at any depth matching criteria
-
-    @param elt(domish.Element): top parent of the elements to find
-    @param names(iterable[unicode], basestring, None): names to match
-        None to accept every names
-    @param namespace(iterable[unicode], basestring, None): URIs to match
-        None to accept every namespaces
-    @return ((G)domish.Element): found elements
-    """
-    if isinstance(namespaces, str):
-        namespaces = tuple((namespaces,))
-    if isinstance(names, str):
-        names = tuple((names,))
-
-    for child in elt.elements():
-        if (
-            domish.IElement.providedBy(child)
-            and (not names or child.name in names)
-            and (not namespaces or child.uri in namespaces)
-        ):
-            yield child
-        for found in find_all(child, namespaces, names):
-            yield found
-
-
-def find_ancestor(
-    elt,
-    name: str,
-    namespace: Optional[Union[str, Iterable[str]]] = None
-    ) -> domish.Element:
-    """Retrieve ancestor of an element
-
-    @param elt: starting element, its parent will be checked recursively until the
-        required one if found
-    @param name: name of the element to find
-    @param namespace: namespace of the element to find
-        - None to find any element with that name
-        - a simple string to find the namespace
-        - several namespaces can be specified in an iterable, if an element with any of
-          this namespace and given name is found, it will match
-
-    """
-    if isinstance(namespace, str):
-        namespace = [namespace]
-    current = elt.parent
-    while True:
-        if current is None:
-            raise exceptions.NotFound(
-                f"Can't find any ancestor {name!r} (xmlns: {namespace!r})"
-            )
-        if current.name == name and (namespace is None or current.uri in namespace):
-            return current
-        current = current.parent
-
-
-def p_fmt_elt(elt, indent=0, defaultUri=""):
-    """Pretty format a domish.Element"""
-    strings = []
-    for child in elt.children:
-        if domish.IElement.providedBy(child):
-            strings.append(p_fmt_elt(child, indent+2, defaultUri=elt.defaultUri))
-        else:
-            strings.append(f"{(indent+2)*' '}{child!s}")
-    if elt.children:
-        nochild_elt = domish.Element(
-            (elt.uri, elt.name), elt.defaultUri, elt.attributes, elt.localPrefixes
-        )
-        strings.insert(0, f"{indent*' '}{nochild_elt.toXml(defaultUri=defaultUri)[:-2]}>")
-        strings.append(f"{indent*' '}</{nochild_elt.name}>")
-    else:
-        strings.append(f"{indent*' '}{elt.toXml(defaultUri=defaultUri)}")
-    return '\n'.join(strings)
-
-
-def pp_elt(elt):
-    """Pretty print a domish.Element"""
-    print(p_fmt_elt(elt))
-
-
-# ElementTree
-
-def et_get_namespace_and_name(et_elt: ET.Element) -> Tuple[Optional[str], str]:
-    """Retrieve element namespace and name from ElementTree element
-
-    @param et_elt: ElementTree element
-    @return: namespace and name of the element
-        if not namespace if specified, None is returned
-    """
-    name = et_elt.tag
-    if not name:
-        raise ValueError("no name set in ET element")
-    elif name[0] != "{":
-        return None, name
-    end_idx = name.find("}")
-    if end_idx == -1:
-        raise ValueError("Invalid ET name")
-    return name[1:end_idx], name[end_idx+1:]
-
-
-def et_elt_2_domish_elt(et_elt: Union[ET.Element, etree.Element]) -> domish.Element:
-    """Convert ElementTree element to Twisted's domish.Element
-
-    Note: this is a naive implementation, adapted to XMPP, and some content are ignored
-        (attributes namespaces, tail)
-    """
-    namespace, name = et_get_namespace_and_name(et_elt)
-    elt = domish.Element((namespace, name), attribs=et_elt.attrib)
-    if et_elt.text:
-        elt.addContent(et_elt.text)
-    for child in et_elt:
-        elt.addChild(et_elt_2_domish_elt(child))
-    return elt
-
-
-@overload
-def domish_elt_2_et_elt(elt: domish.Element, lxml: Literal[False]) -> ET.Element:
-    ...
-
-@overload
-def domish_elt_2_et_elt(elt: domish.Element, lxml: Literal[True]) -> etree.Element:
-    ...
-
-@overload
-def domish_elt_2_et_elt(
-    elt: domish.Element, lxml: bool
-) -> Union[ET.Element, etree.Element]:
-    ...
-
-def domish_elt_2_et_elt(elt, lxml = False):
-    """Convert Twisted's domish.Element to ElementTree equivalent
-
-    Note: this is a naive implementation, adapted to XMPP, and some text content may be
-        missing (content put after a tag, i.e. what would go to the "tail" attribute of ET
-        Element)
-    """
-    tag = f"{{{elt.uri}}}{elt.name}" if elt.uri else elt.name
-    if lxml:
-        et_elt = etree.Element(tag, attr=elt.attributes)
-    else:
-        et_elt = ET.Element(tag, attrib=elt.attributes)
-    content = str(elt)
-    if content:
-        et_elt.text = str(elt)
-    for child in elt.elements():
-        et_elt.append(domish_elt_2_et_elt(child, lxml=lxml))
-    return et_elt
-
-
-def domish_elt_2_et_elt2(element: domish.Element) -> ET.Element:
-    """
-    WIP, originally from the OMEMO plugin
-    """
-
-    element_name = element.name
-    if element.uri is not None:
-        element_name = "{" + element.uri + "}" + element_name
-
-    attrib: Dict[str, str] = {}
-    for qname, value in element.attributes.items():
-        attribute_name = qname[1] if isinstance(qname, tuple) else qname
-        attribute_namespace = qname[0] if isinstance(qname, tuple) else None
-        if attribute_namespace is not None:
-            attribute_name = "{" + attribute_namespace + "}" + attribute_name
-
-        attrib[attribute_name] = value
-
-    result = ET.Element(element_name, attrib)
-
-    last_child: Optional[ET.Element] = None
-    for child in element.children:
-        if isinstance(child, str):
-            if last_child is None:
-                result.text = child
-            else:
-                last_child.tail = child
-        else:
-            last_child = domish_elt_2_et_elt2(child)
-            result.append(last_child)
-
-    return result
--- a/sat/tools/xmpp_datetime.py	Thu Jun 01 21:37:34 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,194 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: XMPP Date and Time profiles as per XEP-0082
-# Copyright (C) 2022-2022 Tim Henkes (me@syndace.dev)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from datetime import date, datetime, time, timezone
-import re
-from typing import Optional, Tuple
-
-from sat.core import exceptions
-
-
-__all__ = [  # pylint: disable=unused-variable
-    "format_date",
-    "parse_date",
-    "format_datetime",
-    "parse_datetime",
-    "format_time",
-    "parse_time"
-]
-
-
-def __parse_fraction_of_a_second(value: str) -> Tuple[str, Optional[int]]:
-    """
-    datetime's strptime only supports up to six digits of the fraction of a seconds, while
-    the XEP-0082 specification allows for any number of digits. This function parses and
-    removes the optional fraction of a second from the input string.
-
-    @param value: The input string, containing a section of the format [.sss].
-    @return: The input string with the fraction of a second removed, and the fraction of a
-        second parsed with microsecond resolution. Returns the unaltered input string and
-        ``None`` if no fraction of a second was found in the input string.
-    """
-
-    #  The following regex matches the optional fraction of a seconds for manual
-    # processing.
-    match = re.search(r"\.(\d*)", value)
-    microsecond: Optional[int] = None
-    if match is not None:
-        # Remove the fraction of a second from the input string
-        value = value[:match.start()] + value[match.end():]
-
-        # datetime supports microsecond resolution for the fraction of a second, thus
-        # limit/pad the parsed fraction of a second to six digits
-        microsecond = int(match.group(1)[:6].ljust(6, '0'))
-
-    return value, microsecond
-
-
-def format_date(value: Optional[date] = None) -> str:
-    """
-    @param value: The date for format. Defaults to the current date in the UTC timezone.
-    @return: The date formatted according to the Date profile specified in XEP-0082.
-
-    @warning: Formatting of the current date in the local timezone may leak geographical
-        information of the sender. Thus, it is advised to only format the current date in
-        UTC.
-    """
-    # CCYY-MM-DD
-
-    # The Date profile of XEP-0082 is equal to the ISO 8601 format.
-    return (datetime.now(timezone.utc).date() if value is None else value).isoformat()
-
-
-def parse_date(value: str) -> date:
-    """
-    @param value: A string containing date information formatted according to the Date
-        profile specified in XEP-0082.
-    @return: The date parsed from the input string.
-    @raise exceptions.ParsingError: if the input string is not correctly formatted.
-    """
-    # CCYY-MM-DD
-
-    # The Date profile of XEP-0082 is equal to the ISO 8601 format.
-    try:
-        return date.fromisoformat(value)
-    except ValueError as e:
-        raise exceptions.ParsingError() from e
-
-
-def format_datetime(
-    value: Optional[datetime] = None,
-    include_microsecond: bool = False
-) -> str:
-    """
-    @param value: The datetime to format. Defaults to the current datetime.
-        must be an aware datetime object (timezone must be specified)
-    @param include_microsecond: Include the microsecond of the datetime in the output.
-    @return: The datetime formatted according to the DateTime profile specified in
-        XEP-0082. The datetime is always converted to UTC before formatting to avoid
-        leaking geographical information of the sender.
-    """
-    # CCYY-MM-DDThh:mm:ss[.sss]TZD
-
-    # We format the time in UTC, since the %z formatter of strftime doesn't include colons
-    # to separate hours and minutes which is required by XEP-0082. UTC allows us to put a
-    # simple letter 'Z' as the time zone definition.
-    if value is not None:
-        if value.tzinfo is None:
-            raise exceptions.InternalError(
-                "an aware datetime object must be used, but a naive one has been provided"
-            )
-        value = value.astimezone(timezone.utc)  # pylint: disable=no-member
-    else:
-        value = datetime.now(timezone.utc)
-
-    if include_microsecond:
-        return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
-
-    return value.strftime("%Y-%m-%dT%H:%M:%SZ")
-
-
-def parse_datetime(value: str) -> datetime:
-    """
-    @param value: A string containing datetime information formatted according to the
-        DateTime profile specified in XEP-0082.
-    @return: The datetime parsed from the input string.
-    @raise exceptions.ParsingError: if the input string is not correctly formatted.
-    """
-    # CCYY-MM-DDThh:mm:ss[.sss]TZD
-
-    value, microsecond = __parse_fraction_of_a_second(value)
-
-    try:
-        result = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S%z")
-    except ValueError as e:
-        raise exceptions.ParsingError() from e
-
-    if microsecond is not None:
-        result = result.replace(microsecond=microsecond)
-
-    return result
-
-
-def format_time(value: Optional[time] = None, include_microsecond: bool = False) -> str:
-    """
-    @param value: The time to format. Defaults to the current time in the UTC timezone.
-    @param include_microsecond: Include the microsecond of the time in the output.
-    @return: The time formatted according to the Time profile specified in XEP-0082.
-
-    @warning: Since accurate timezone conversion requires the date to be known, this
-        function cannot convert input times to UTC before formatting. This means that
-        geographical information of the sender may be leaked if a time in local timezone
-        is formatted. Thus, when passing a time to format, it is advised to pass the time
-        in UTC if possible.
-    """
-    # hh:mm:ss[.sss][TZD]
-
-    if value is None:
-        # There is no time.now() method as one might expect, but the current time can be
-        # extracted from a datetime object including time zone information.
-        value = datetime.now(timezone.utc).timetz()
-
-    # The format created by time.isoformat complies with the XEP-0082 Time profile.
-    return value.isoformat("auto" if include_microsecond else "seconds")
-
-
-def parse_time(value: str) -> time:
-    """
-    @param value: A string containing time information formatted according to the Time
-        profile specified in XEP-0082.
-    @return: The time parsed from the input string.
-    @raise exceptions.ParsingError: if the input string is not correctly formatted.
-    """
-    # hh:mm:ss[.sss][TZD]
-
-    value, microsecond = __parse_fraction_of_a_second(value)
-
-    # The format parsed by time.fromisoformat mostly complies with the XEP-0082 Time
-    # profile, except that it doesn't handle the letter Z as time zone information for
-    # UTC. This can be fixed with a simple string replacement of 'Z' with "+00:00", which
-    # is another way to represent UTC.
-    try:
-        result = time.fromisoformat(value.replace('Z', "+00:00"))
-    except ValueError as e:
-        raise exceptions.ParsingError() from e
-
-    if microsecond is not None:
-        result = result.replace(microsecond=microsecond)
-
-    return result
--- a/sat_frontends/bridge/dbus_bridge.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/bridge/dbus_bridge.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,10 +19,10 @@
 import asyncio
 import dbus
 import ast
-from sat.core.i18n import _
-from sat.tools import config
-from sat.core.log import getLogger
-from sat.core.exceptions import BridgeExceptionNoService, BridgeInitError
+from libervia.backend.core.i18n import _
+from libervia.backend.tools import config
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.exceptions import BridgeExceptionNoService, BridgeInitError
 from dbus.mainloop.glib import DBusGMainLoop
 from .bridge_frontend import BridgeException
 
--- a/sat_frontends/bridge/pb.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/bridge/pb.py	Fri Jun 02 11:49:51 2023 +0200
@@ -23,8 +23,8 @@
 from twisted.spread import pb
 from twisted.internet import reactor, defer
 from twisted.internet.error import ConnectionRefusedError, ConnectError
-from sat.core import exceptions
-from sat.tools import config
+from libervia.backend.core import exceptions
+from libervia.backend.tools import config
 from sat_frontends.bridge.bridge_frontend import BridgeException
 
 log = getLogger(__name__)
--- a/sat_frontends/jp/arg_tools.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/arg_tools.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,8 +17,8 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
-from sat.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
 
 
 def escape(arg, smart=True):
--- a/sat_frontends/jp/base.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/base.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,7 +17,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import asyncio
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 
 ### logging ###
 import logging as log
@@ -37,14 +37,14 @@
 from typing import Optional, Set, Union
 from importlib import import_module
 from sat_frontends.tools.jid import JID
-from sat.tools import config
-from sat.tools.common import dynamic_import
-from sat.tools.common import uri
-from sat.tools.common import date_utils
-from sat.tools.common import utils
-from sat.tools.common import data_format
-from sat.tools.common.ansi import ANSI as A
-from sat.core import exceptions
+from libervia.backend.tools import config
+from libervia.backend.tools.common import dynamic_import
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common import date_utils
+from libervia.backend.tools.common import utils
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.core import exceptions
 import sat_frontends.jp
 from sat_frontends.jp.loops import QuitException, get_jp_loop
 from sat_frontends.jp.constants import Const as C
--- a/sat_frontends/jp/cmd_account.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_account.py	Fri Jun 02 11:49:51 2023 +0200
@@ -21,8 +21,8 @@
 
 from sat_frontends.jp.constants import Const as C
 from sat_frontends.bridge.bridge_frontend import BridgeException
-from sat.core.log import getLogger
-from sat.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import _
 from sat_frontends.jp import base
 from sat_frontends.tools import jid
 
--- a/sat_frontends/jp/cmd_adhoc.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_adhoc.py	Fri Jun 02 11:49:51 2023 +0200
@@ -18,7 +18,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from . import base
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from sat_frontends.jp.constants import Const as C
 from sat_frontends.jp import xmlui_manager
 
--- a/sat_frontends/jp/cmd_application.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_application.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,8 +17,8 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from . import base
-from sat.core.i18n import _
-from sat.tools.common import data_format
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
 from sat_frontends.jp.constants import Const as C
 
 __commands__ = ["Application"]
--- a/sat_frontends/jp/cmd_avatar.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_avatar.py	Fri Jun 02 11:49:51 2023 +0200
@@ -22,10 +22,10 @@
 import os.path
 import asyncio
 from . import base
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from sat_frontends.jp.constants import Const as C
-from sat.tools import config
-from sat.tools.common import data_format
+from libervia.backend.tools import config
+from libervia.backend.tools.common import data_format
 
 
 __commands__ = ["Avatar"]
--- a/sat_frontends/jp/cmd_blocking.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_blocking.py	Fri Jun 02 11:49:51 2023 +0200
@@ -20,8 +20,8 @@
 
 import json
 import os
-from sat.core.i18n import _
-from sat.tools.common import data_format
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
 from sat_frontends.jp import common
 from sat_frontends.jp.constants import Const as C
 from . import base
--- a/sat_frontends/jp/cmd_blog.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_blog.py	Fri Jun 02 11:49:51 2023 +0200
@@ -31,11 +31,11 @@
 import tempfile
 from urllib.parse import urlparse
 
-from sat.core.i18n import _
-from sat.tools import config
-from sat.tools.common import uri
-from sat.tools.common import data_format
-from sat.tools.common.ansi import ANSI as A
+from libervia.backend.core.i18n import _
+from libervia.backend.tools import config
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common.ansi import ANSI as A
 from sat_frontends.jp import common
 from sat_frontends.jp.constants import Const as C
 
--- a/sat_frontends/jp/cmd_bookmarks.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_bookmarks.py	Fri Jun 02 11:49:51 2023 +0200
@@ -18,7 +18,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from . import base
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from sat_frontends.jp.constants import Const as C
 
 __commands__ = ["Bookmarks"]
--- a/sat_frontends/jp/cmd_debug.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_debug.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,9 +19,9 @@
 
 
 from . import base
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from sat_frontends.jp.constants import Const as C
-from sat.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common.ansi import ANSI as A
 import json
 
 __commands__ = ["Debug"]
--- a/sat_frontends/jp/cmd_encryption.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_encryption.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,8 +19,8 @@
 
 from sat_frontends.jp import base
 from sat_frontends.jp.constants import Const as C
-from sat.core.i18n import _
-from sat.tools.common import data_format
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
 from sat_frontends.jp import xmlui_manager
 
 __commands__ = ["Encryption"]
--- a/sat_frontends/jp/cmd_event.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_event.py	Fri Jun 02 11:49:51 2023 +0200
@@ -23,13 +23,13 @@
 
 from sqlalchemy import desc
 
-from sat.core.i18n import _
-from sat.core.i18n import _
-from sat.tools.common import data_format
-from sat.tools.common import data_format
-from sat.tools.common import date_utils
-from sat.tools.common.ansi import ANSI as A
-from sat.tools.common.ansi import ANSI as A
+from libervia.backend.core.i18n import _
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import date_utils
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common.ansi import ANSI as A
 from sat_frontends.jp import common
 from sat_frontends.jp.constants import Const as C
 from sat_frontends.jp.constants import Const as C
--- a/sat_frontends/jp/cmd_file.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_file.py	Fri Jun 02 11:49:51 2023 +0200
@@ -24,13 +24,13 @@
 import os
 import os.path
 import tarfile
-from sat.core.i18n import _
-from sat.tools.common import data_format
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
 from sat_frontends.jp.constants import Const as C
 from sat_frontends.jp import common
 from sat_frontends.tools import jid
-from sat.tools.common.ansi import ANSI as A
-from sat.tools.common import utils
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import utils
 from urllib.parse import urlparse
 from pathlib import Path
 import tempfile
--- a/sat_frontends/jp/cmd_forums.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_forums.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,10 +19,10 @@
 
 
 from . import base
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from sat_frontends.jp.constants import Const as C
 from sat_frontends.jp import common
-from sat.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common.ansi import ANSI as A
 import codecs
 import json
 
--- a/sat_frontends/jp/cmd_identity.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_identity.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,9 +19,9 @@
 
 
 from . import base
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from sat_frontends.jp.constants import Const as C
-from sat.tools.common import data_format
+from libervia.backend.tools.common import data_format
 
 __commands__ = ["Identity"]
 
--- a/sat_frontends/jp/cmd_info.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_info.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,9 +19,9 @@
 
 from pprint import pformat
 
-from sat.core.i18n import _
-from sat.tools.common import data_format, date_utils
-from sat.tools.common.ansi import ANSI as A
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format, date_utils
+from libervia.backend.tools.common.ansi import ANSI as A
 from sat_frontends.jp import common
 from sat_frontends.jp.constants import Const as C
 
--- a/sat_frontends/jp/cmd_input.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_input.py	Fri Jun 02 11:49:51 2023 +0200
@@ -24,10 +24,10 @@
 import shlex
 import asyncio
 from . import base
-from sat.core.i18n import _
-from sat.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
 from sat_frontends.jp.constants import Const as C
-from sat.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common.ansi import ANSI as A
 
 __commands__ = ["Input"]
 OPT_STDIN = "stdin"
--- a/sat_frontends/jp/cmd_invitation.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_invitation.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,10 +19,10 @@
 
 
 from . import base
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from sat_frontends.jp.constants import Const as C
-from sat.tools.common.ansi import ANSI as A
-from sat.tools.common import data_format
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import data_format
 
 __commands__ = ["Invitation"]
 
--- a/sat_frontends/jp/cmd_list.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_list.py	Fri Jun 02 11:49:51 2023 +0200
@@ -20,8 +20,8 @@
 
 import json
 import os
-from sat.core.i18n import _
-from sat.tools.common import data_format
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
 from sat_frontends.jp import common
 from sat_frontends.jp.constants import Const as C
 from . import base
--- a/sat_frontends/jp/cmd_merge_request.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_merge_request.py	Fri Jun 02 11:49:51 2023 +0200
@@ -20,8 +20,8 @@
 
 import os.path
 from . import base
-from sat.core.i18n import _
-from sat.tools.common import data_format
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
 from sat_frontends.jp.constants import Const as C
 from sat_frontends.jp import xmlui_manager
 from sat_frontends.jp import common
--- a/sat_frontends/jp/cmd_message.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_message.py	Fri Jun 02 11:49:51 2023 +0200
@@ -22,10 +22,10 @@
 
 from twisted.python import filepath
 
-from sat.core.i18n import _
-from sat.tools.common import data_format
-from sat.tools.common.ansi import ANSI as A
-from sat.tools.utils import clean_ustr
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.utils import clean_ustr
 from sat_frontends.jp import base
 from sat_frontends.jp.constants import Const as C
 from sat_frontends.tools import jid
--- a/sat_frontends/jp/cmd_param.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_param.py	Fri Jun 02 11:49:51 2023 +0200
@@ -20,7 +20,7 @@
 
 
 from . import base
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from .constants import Const as C
 
 __commands__ = ["Param"]
--- a/sat_frontends/jp/cmd_ping.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_ping.py	Fri Jun 02 11:49:51 2023 +0200
@@ -18,7 +18,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from . import base
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from sat_frontends.jp.constants import Const as C
 
 __commands__ = ["Ping"]
--- a/sat_frontends/jp/cmd_pipe.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_pipe.py	Fri Jun 02 11:49:51 2023 +0200
@@ -23,8 +23,8 @@
 import socket
 import sys
 
-from sat.core.i18n import _
-from sat.tools.common import data_format
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
 from sat_frontends.jp import base
 from sat_frontends.jp import xmlui_manager
 from sat_frontends.jp.constants import Const as C
--- a/sat_frontends/jp/cmd_profile.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_profile.py	Fri Jun 02 11:49:51 2023 +0200
@@ -21,8 +21,8 @@
 and retrieve information about a profile."""
 
 from sat_frontends.jp.constants import Const as C
-from sat.core.log import getLogger
-from sat.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import _
 from sat_frontends.jp import base
 
 log = getLogger(__name__)
--- a/sat_frontends/jp/cmd_pubsub.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_pubsub.py	Fri Jun 02 11:49:51 2023 +0200
@@ -26,17 +26,17 @@
 import asyncio
 import json
 from . import base
-from sat.core.i18n import _
-from sat.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
 from sat_frontends.jp.constants import Const as C
 from sat_frontends.jp import common
 from sat_frontends.jp import arg_tools
 from sat_frontends.jp import xml_tools
 from functools import partial
-from sat.tools.common import data_format
-from sat.tools.common import uri
-from sat.tools.common.ansi import ANSI as A
-from sat.tools.common import date_utils
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import date_utils
 from sat_frontends.tools import jid, strings
 from sat_frontends.bridge.bridge_frontend import BridgeException
 
--- a/sat_frontends/jp/cmd_roster.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_roster.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,10 +19,10 @@
 
 from . import base
 from collections import OrderedDict
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from sat_frontends.jp.constants import Const as C
 from sat_frontends.tools import jid
-from sat.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common.ansi import ANSI as A
 
 __commands__ = ["Roster"]
 
--- a/sat_frontends/jp/cmd_shell.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_shell.py	Fri Jun 02 11:49:51 2023 +0200
@@ -23,11 +23,11 @@
 import shlex
 import subprocess
 from . import base
-from sat.core.i18n import _
-from sat.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
 from sat_frontends.jp.constants import Const as C
 from sat_frontends.jp import arg_tools
-from sat.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common.ansi import ANSI as A
 
 __commands__ = ["Shell"]
 INTRO = _(
--- a/sat_frontends/jp/cmd_uri.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/cmd_uri.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,9 +19,9 @@
 
 
 from . import base
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from sat_frontends.jp.constants import Const as C
-from sat.tools.common import uri
+from libervia.backend.tools.common import uri
 
 __commands__ = ["Uri"]
 
--- a/sat_frontends/jp/common.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/common.py	Fri Jun 02 11:49:51 2023 +0200
@@ -27,12 +27,12 @@
 import re
 from pathlib import Path
 from sat_frontends.jp.constants import Const as C
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.tools.common import regex
-from sat.tools.common.ansi import ANSI as A
-from sat.tools.common import uri as xmpp_uri
-from sat.tools import config
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.tools.common import regex
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import uri as xmpp_uri
+from libervia.backend.tools import config
 from configparser import NoSectionError, NoOptionError
 from collections import namedtuple
 
--- a/sat_frontends/jp/constants.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/constants.py	Fri Jun 02 11:49:51 2023 +0200
@@ -18,7 +18,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from sat_frontends.quick_frontend import constants
-from sat.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common.ansi import ANSI as A
 
 
 class Const(constants.Const):
--- a/sat_frontends/jp/loops.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/loops.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,7 +19,7 @@
 import sys
 import asyncio
 import logging as log
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from sat_frontends.jp.constants import Const as C
 
 log.basicConfig(level=log.WARNING,
--- a/sat_frontends/jp/output_std.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/output_std.py	Fri Jun 02 11:49:51 2023 +0200
@@ -21,8 +21,8 @@
 
 from sat_frontends.jp.constants import Const as C
 from sat_frontends.tools import jid
-from sat.tools.common.ansi import ANSI as A
-from sat.tools.common import date_utils
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.tools.common import date_utils
 import json
 
 __outputs__ = ["Simple", "Json"]
--- a/sat_frontends/jp/output_template.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/output_template.py	Fri Jun 02 11:49:51 2023 +0200
@@ -20,9 +20,9 @@
 
 
 from sat_frontends.jp.constants import Const as C
-from sat.core.i18n import _
-from sat.core import log
-from sat.tools.common import template
+from libervia.backend.core.i18n import _
+from libervia.backend.core import log
+from libervia.backend.tools.common import template
 from functools import partial
 import logging
 import webbrowser
--- a/sat_frontends/jp/output_xml.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/output_xml.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,9 +19,9 @@
 
 
 from sat_frontends.jp.constants import Const as C
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from lxml import etree
-from sat.core.log import getLogger
+from libervia.backend.core.log import getLogger
 
 log = getLogger(__name__)
 import sys
--- a/sat_frontends/jp/output_xmlui.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/output_xmlui.py	Fri Jun 02 11:49:51 2023 +0200
@@ -21,7 +21,7 @@
 
 from sat_frontends.jp.constants import Const as C
 from sat_frontends.jp import xmlui_manager
-from sat.core.log import getLogger
+from libervia.backend.core.log import getLogger
 
 log = getLogger(__name__)
 
--- a/sat_frontends/jp/xml_tools.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/xml_tools.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,7 +17,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from sat_frontends.jp.constants import Const as C
 
 def etree_parse(cmd, raw_xml, reraise=False):
--- a/sat_frontends/jp/xmlui_manager.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/jp/xmlui_manager.py	Fri Jun 02 11:49:51 2023 +0200
@@ -18,12 +18,12 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 from functools import partial
-from sat.core.log import getLogger
+from libervia.backend.core.log import getLogger
 from sat_frontends.tools import xmlui as xmlui_base
 from sat_frontends.jp.constants import Const as C
-from sat.tools.common.ansi import ANSI as A
-from sat.core.i18n import _
-from sat.tools.common import data_format
+from libervia.backend.tools.common.ansi import ANSI as A
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import data_format
 
 log = getLogger(__name__)
 
--- a/sat_frontends/primitivus/base.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/primitivus/base.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,13 +17,13 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 
-from sat.core.i18n import _, D_
+from libervia.backend.core.i18n import _, D_
 from sat_frontends.primitivus.constants import Const as C
-from sat.core import log_config
+from libervia.backend.core import log_config
 log_config.sat_configure(C.LOG_BACKEND_STANDARD, C)
-from sat.core import log as logging
+from libervia.backend.core import log as logging
 log = logging.getLogger(__name__)
-from sat.tools import config as sat_config
+from libervia.backend.tools import config as sat_config
 import urwid
 from urwid.util import is_wide_char
 from urwid_satext import sat_widgets
@@ -39,7 +39,7 @@
 from sat_frontends.primitivus.keys import action_key_map as a_key
 from sat_frontends.primitivus import config
 from sat_frontends.tools.misc import InputHistory
-from sat.tools.common import dynamic_import
+from libervia.backend.tools.common import dynamic_import
 from sat_frontends.tools import jid
 import signal
 import sys
--- a/sat_frontends/primitivus/chat.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/primitivus/chat.py	Fri Jun 02 11:49:51 2023 +0200
@@ -22,8 +22,8 @@
 import bisect
 import urwid
 from urwid_satext import sat_widgets
-from sat.core.i18n import _
-from sat.core import log as logging
+from libervia.backend.core.i18n import _
+from libervia.backend.core import log as logging
 from sat_frontends.quick_frontend import quick_widgets
 from sat_frontends.quick_frontend import quick_chat
 from sat_frontends.quick_frontend import quick_games
--- a/sat_frontends/primitivus/contact_list.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/primitivus/contact_list.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,7 +17,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 import urwid
 from urwid_satext import sat_widgets
 from sat_frontends.quick_frontend.quick_contact_list import QuickContactList
@@ -26,7 +26,7 @@
 from sat_frontends.primitivus.keys import action_key_map as a_key
 from sat_frontends.primitivus.widget import PrimitivusWidget
 from sat_frontends.tools import jid
-from sat.core import log as logging
+from libervia.backend.core import log as logging
 
 log = logging.getLogger(__name__)
 from sat_frontends.quick_frontend import quick_widgets
--- a/sat_frontends/primitivus/game_tarot.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/primitivus/game_tarot.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,7 +17,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 import urwid
 from urwid_satext import sat_widgets
 from sat_frontends.tools.games import TarotCard
--- a/sat_frontends/primitivus/profile_manager.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/primitivus/profile_manager.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,8 +17,8 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
-from sat.core import log as logging
+from libervia.backend.core.i18n import _
+from libervia.backend.core import log as logging
 
 log = logging.getLogger(__name__)
 from sat_frontends.quick_frontend.quick_profile_manager import QuickProfileManager
--- a/sat_frontends/primitivus/progress.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/primitivus/progress.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,7 +17,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 import urwid
 from urwid_satext import sat_widgets
 from sat_frontends.quick_frontend import quick_widgets
--- a/sat_frontends/primitivus/status.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/primitivus/status.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,7 +17,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 import urwid
 from urwid_satext import sat_widgets
 from sat_frontends.quick_frontend.constants import Const as commonConst
--- a/sat_frontends/primitivus/widget.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/primitivus/widget.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,7 +17,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core import log as logging
+from libervia.backend.core import log as logging
 
 log = logging.getLogger(__name__)
 import urwid
--- a/sat_frontends/primitivus/xmlui.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/primitivus/xmlui.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,13 +17,13 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 import urwid
 import copy
-from sat.core import exceptions
+from libervia.backend.core import exceptions
 from urwid_satext import sat_widgets
 from urwid_satext import files_management
-from sat.core.log import getLogger
+from libervia.backend.core.log import getLogger
 
 log = getLogger(__name__)
 from sat_frontends.primitivus.constants import Const as C
--- a/sat_frontends/quick_frontend/constants.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/quick_frontend/constants.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,8 +17,8 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core import constants
-from sat.core.i18n import _
+from libervia.backend.core import constants
+from libervia.backend.core.i18n import _
 from collections import OrderedDict  # only available from python 2.7
 
 
--- a/sat_frontends/quick_frontend/quick_app.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/quick_frontend/quick_app.py	Fri Jun 02 11:49:51 2023 +0200
@@ -16,11 +16,11 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.log import getLogger
-from sat.core.i18n import _
-from sat.core import exceptions
-from sat.tools import trigger
-from sat.tools.common import data_format
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.backend.tools import trigger
+from libervia.backend.tools.common import data_format
 
 from sat_frontends.tools import jid
 from sat_frontends.quick_frontend import quick_widgets
--- a/sat_frontends/quick_frontend/quick_blog.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/quick_frontend/quick_blog.py	Fri Jun 02 11:49:51 2023 +0200
@@ -18,7 +18,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 # from sat.core.i18n import _, D_
-from sat.core.log import getLogger
+from libervia.backend.core.log import getLogger
 
 log = getLogger(__name__)
 
@@ -26,7 +26,7 @@
 from sat_frontends.quick_frontend.constants import Const as C
 from sat_frontends.quick_frontend import quick_widgets
 from sat_frontends.tools import jid
-from sat.tools.common import data_format
+from libervia.backend.tools.common import data_format
 
 try:
     # FIXME: to be removed when an acceptable solution is here
--- a/sat_frontends/quick_frontend/quick_chat.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/quick_frontend/quick_chat.py	Fri Jun 02 11:49:51 2023 +0200
@@ -16,10 +16,10 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools.common import data_format
-from sat.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+from libervia.backend.core import exceptions
 from sat_frontends.quick_frontend import quick_widgets
 from sat_frontends.quick_frontend.constants import Const as C
 from collections import OrderedDict
--- a/sat_frontends/quick_frontend/quick_contact_list.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/quick_frontend/quick_contact_list.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,9 +19,9 @@
 """Contact List handling multi profiles at once,
     should replace quick_contact_list module in the future"""
 
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
 from sat_frontends.quick_frontend.quick_widgets import QuickWidget
 from sat_frontends.quick_frontend.constants import Const as C
 from sat_frontends.tools import jid
--- a/sat_frontends/quick_frontend/quick_contact_management.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/quick_frontend/quick_contact_management.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,8 +17,8 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
-from sat.core.log import getLogger
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
 
 log = getLogger(__name__)
 from sat_frontends.tools.jid import JID
--- a/sat_frontends/quick_frontend/quick_game_tarot.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/quick_frontend/quick_game_tarot.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,7 +17,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.log import getLogger
+from libervia.backend.core.log import getLogger
 
 log = getLogger(__name__)
 from sat_frontends.tools.jid import JID
--- a/sat_frontends/quick_frontend/quick_games.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/quick_frontend/quick_games.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,11 +17,11 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.log import getLogger
+from libervia.backend.core.log import getLogger
 
 log = getLogger(__name__)
 
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 
 from sat_frontends.tools import jid
 from sat_frontends.tools import games
--- a/sat_frontends/quick_frontend/quick_menus.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/quick_frontend/quick_menus.py	Fri Jun 02 11:49:51 2023 +0200
@@ -26,8 +26,8 @@
 ):  # Error raised is not the same depending on pyjsbuild options
     str = str
 
-from sat.core.log import getLogger
-from sat.core.i18n import _, language_switch
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import _, language_switch
 
 log = getLogger(__name__)
 from sat_frontends.quick_frontend.constants import Const as C
--- a/sat_frontends/quick_frontend/quick_profile_manager.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/quick_frontend/quick_profile_manager.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,8 +17,8 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
-from sat.core import log as logging
+from libervia.backend.core.i18n import _
+from libervia.backend.core import log as logging
 
 log = logging.getLogger(__name__)
 from sat_frontends.primitivus.constants import Const as C
--- a/sat_frontends/quick_frontend/quick_utils.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/quick_frontend/quick_utils.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,7 +17,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
+from libervia.backend.core.i18n import _
 from os.path import exists, splitext
 from optparse import OptionParser
 
--- a/sat_frontends/quick_frontend/quick_widgets.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/quick_frontend/quick_widgets.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,10 +17,10 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.log import getLogger
+from libervia.backend.core.log import getLogger
 
 log = getLogger(__name__)
-from sat.core import exceptions
+from libervia.backend.core import exceptions
 from sat_frontends.quick_frontend.constants import Const as C
 
 
--- a/sat_frontends/tools/css_color.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/tools/css_color.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,7 +17,7 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.log import getLogger
+from libervia.backend.core.log import getLogger
 
 log = getLogger(__name__)
 
--- a/sat_frontends/tools/xmlui.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/sat_frontends/tools/xmlui.py	Fri Jun 02 11:49:51 2023 +0200
@@ -17,12 +17,12 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
-from sat.core.i18n import _
-from sat.core.log import getLogger
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
 
 log = getLogger(__name__)
 from sat_frontends.quick_frontend.constants import Const as C
-from sat.core import exceptions
+from libervia.backend.core import exceptions
 
 
 _class_map = {}
--- a/setup.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/setup.py	Fri Jun 02 11:49:51 2023 +0200
@@ -22,7 +22,7 @@
 
 NAME = "libervia-backend"
 # NOTE: directory is still "sat" for compatibility reason, should be changed for 0.9
-DIR_NAME = "sat"
+DIR_NAME = "libervia/backend"
 
 install_requires = [
     'babel < 3',
@@ -102,9 +102,9 @@
                      "architecture. It's multi frontend (desktop, web, console "
                      "interface, CLI, etc) and multipurpose (instant messaging, "
                      "microblogging, games, file sharing, etc).",
-    author="Association « Salut à Toi »",
+    author="Libervia dev team",
     author_email="contact@goffi.org",
-    url="https://salut-a-toi.org",
+    url="https://libervia.org",
     classifiers=[
         "Programming Language :: Python :: 3 :: Only",
         "Programming Language :: Python :: 3.7",
@@ -120,7 +120,7 @@
     ],
     packages=find_packages() + ["twisted.plugins"],
     data_files=[("share/locale/fr/LC_MESSAGES",
-                 ["i18n/fr/LC_MESSAGES/sat.mo"]),
+                 ["i18n/fr/LC_MESSAGES/libervia_backend.mo"]),
                 (os.path.join("share/doc", NAME),
                  ["CHANGELOG", "COPYING", "INSTALL", "README", "README4TRANSLATORS"]),
                 (os.path.join("share", DBUS_DIR), [DBUS_FILE]),
@@ -128,8 +128,8 @@
     entry_points={
         "console_scripts": [
             # backend + alias
-            "libervia-backend = sat.core.launcher:Launcher.run",
-            "sat = sat.core.launcher:Launcher.run",
+            "libervia-backend = libervia.backend.core.launcher:Launcher.run",
+            "sat = libervia.backend.core.launcher:Launcher.run",
 
             # CLI + aliases
             "libervia-cli = sat_frontends.jp.base:LiberviaCli.run",
@@ -146,6 +146,6 @@
     use_scm_version=sat_dev_version if is_dev_version else False,
     install_requires=install_requires,
     extras_require=extras_require,
-    package_data={"sat": ["VERSION", "memory/migration/alembic.ini"]},
+    package_data={"libervia.backend": ["VERSION", "memory/migration/alembic.ini"]},
     python_requires=">=3.7",
 )
--- a/tests/e2e/libervia-cli/conftest.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/e2e/libervia-cli/conftest.py	Fri Jun 02 11:49:51 2023 +0200
@@ -54,7 +54,7 @@
 
     def __init__(self):
         super().__init__()
-        from sat.tools.xml_tools import ElementParser
+        from libervia.backend.tools.xml_tools import ElementParser
         self.parser = ElementParser()
 
     def __call__(self, *args, **kwargs):
--- a/tests/e2e/libervia-cli/test_libervia-cli.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/e2e/libervia-cli/test_libervia-cli.py	Fri Jun 02 11:49:51 2023 +0200
@@ -25,10 +25,10 @@
 from sh import li
 
 import pytest
-from sat.plugins.plugin_sec_oxps import NS_OXPS
-from sat.plugins.plugin_sec_pte import NS_PTE
-from sat.plugins.plugin_xep_0277 import NS_ATOM
-from sat.tools.common import uri
+from libervia.backend.plugins.plugin_sec_oxps import NS_OXPS
+from libervia.backend.plugins.plugin_sec_pte import NS_PTE
+from libervia.backend.plugins.plugin_xep_0277 import NS_ATOM
+from libervia.backend.tools.common import uri
 
 
 if os.getenv("LIBERVIA_TEST_ENV_E2E") is None:
--- a/tests/e2e/run_e2e.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/e2e/run_e2e.py	Fri Jun 02 11:49:51 2023 +0200
@@ -27,8 +27,8 @@
 import io
 import sat_templates
 import libervia
-from sat.core import exceptions
-from sat.tools.common import regex
+from libervia.backend.core import exceptions
+from libervia.backend.tools.common import regex
 import yaml
 try:
     from yaml import CLoader as Loader, CDumper as Dumper
--- a/tests/unit/conftest.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/conftest.py	Fri Jun 02 11:49:51 2023 +0200
@@ -21,9 +21,9 @@
 from pytest import fixture
 from twisted.internet import defer
 from twisted.words.protocols.jabber import jid
-from sat.core.sat_main import SAT
-from sat.tools import async_trigger as trigger
-from sat.core import xmpp
+from libervia.backend.core.sat_main import SAT
+from libervia.backend.tools import async_trigger as trigger
+from libervia.backend.core import xmpp
 
 
 @fixture(scope="session")
--- a/tests/unit/test_ap-gateway.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/test_ap-gateway.py	Fri Jun 02 11:49:51 2023 +0200
@@ -34,20 +34,20 @@
 from twisted.words.xish import domish
 from wokkel import pubsub, rsm
 
-from sat.core import exceptions
-from sat.core.constants import Const as C
-from sat.memory.sqla_mapping import SubscriptionState
-from sat.plugins import plugin_comp_ap_gateway
-from sat.plugins.plugin_comp_ap_gateway import constants as ap_const
-from sat.plugins.plugin_comp_ap_gateway import TYPE_ACTOR
-from sat.plugins.plugin_comp_ap_gateway.http_server import HTTPServer
-from sat.plugins.plugin_xep_0277 import NS_ATOM
-from sat.plugins.plugin_xep_0422 import NS_FASTEN
-from sat.plugins.plugin_xep_0424 import NS_MESSAGE_RETRACT
-from sat.plugins.plugin_xep_0465 import NS_PPS
-from sat.tools import xml_tools
-from sat.tools.common import uri as xmpp_uri
-from sat.tools.utils import xmpp_date
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.memory.sqla_mapping import SubscriptionState
+from libervia.backend.plugins import plugin_comp_ap_gateway
+from libervia.backend.plugins.plugin_comp_ap_gateway import constants as ap_const
+from libervia.backend.plugins.plugin_comp_ap_gateway import TYPE_ACTOR
+from libervia.backend.plugins.plugin_comp_ap_gateway.http_server import HTTPServer
+from libervia.backend.plugins.plugin_xep_0277 import NS_ATOM
+from libervia.backend.plugins.plugin_xep_0422 import NS_FASTEN
+from libervia.backend.plugins.plugin_xep_0424 import NS_MESSAGE_RETRACT
+from libervia.backend.plugins.plugin_xep_0465 import NS_PPS
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools.common import uri as xmpp_uri
+from libervia.backend.tools.utils import xmpp_date
 
 
 TEST_BASE_URL = "https://example.org"
--- a/tests/unit/test_plugin_xep_0082.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/test_plugin_xep_0082.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,9 +19,9 @@
 from datetime import date, datetime, time, timezone
 
 import pytest
-from sat.core import exceptions
+from libervia.backend.core import exceptions
 
-from sat.plugins.plugin_xep_0082 import XEP_0082
+from libervia.backend.plugins.plugin_xep_0082 import XEP_0082
 
 
 __all__ = [  # pylint: disable=unused-variable
--- a/tests/unit/test_plugin_xep_0167.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/test_plugin_xep_0167.py	Fri Jun 02 11:49:51 2023 +0200
@@ -23,11 +23,11 @@
 from pytest import raises
 from twisted.words.protocols.jabber import jid
 
-from sat.plugins.plugin_xep_0166 import XEP_0166
-from sat.plugins.plugin_xep_0167 import XEP_0167, mapping
-from sat.plugins.plugin_xep_0167.constants import NS_JINGLE_RTP, NS_JINGLE_RTP_INFO
-from sat.tools import xml_tools
-from sat.tools.common import data_format
+from libervia.backend.plugins.plugin_xep_0166 import XEP_0166
+from libervia.backend.plugins.plugin_xep_0167 import XEP_0167, mapping
+from libervia.backend.plugins.plugin_xep_0167.constants import NS_JINGLE_RTP, NS_JINGLE_RTP_INFO
+from libervia.backend.tools import xml_tools
+from libervia.backend.tools.common import data_format
 
 
 @fixture(autouse=True)
--- a/tests/unit/test_plugin_xep_0176.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/test_plugin_xep_0176.py	Fri Jun 02 11:49:51 2023 +0200
@@ -21,9 +21,9 @@
 from pytest import fixture
 from pytest_twisted import ensureDeferred as ed
 
-from sat.plugins.plugin_xep_0166 import XEP_0166
-from sat.plugins.plugin_xep_0176 import NS_JINGLE_ICE_UDP, XEP_0176
-from sat.tools import xml_tools
+from libervia.backend.plugins.plugin_xep_0166 import XEP_0166
+from libervia.backend.plugins.plugin_xep_0176 import NS_JINGLE_ICE_UDP, XEP_0176
+from libervia.backend.tools import xml_tools
 
 
 @fixture(autouse=True)
--- a/tests/unit/test_plugin_xep_0215.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/test_plugin_xep_0215.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,8 +19,8 @@
 from twisted.internet import defer
 from pytest_twisted import ensureDeferred as ed
 from unittest.mock import MagicMock
-from sat.plugins.plugin_xep_0215 import XEP_0215
-from sat.tools import xml_tools
+from libervia.backend.plugins.plugin_xep_0215 import XEP_0215
+from libervia.backend.tools import xml_tools
 from twisted.words.protocols.jabber import jid
 
 
--- a/tests/unit/test_plugin_xep_0293.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/test_plugin_xep_0293.py	Fri Jun 02 11:49:51 2023 +0200
@@ -18,9 +18,9 @@
 
 from twisted.words.xish import domish
 
-from sat.plugins.plugin_xep_0167.constants import NS_JINGLE_RTP
-from sat.plugins.plugin_xep_0293 import NS_JINGLE_RTP_RTCP_FB, RTCP_FB_KEY, XEP_0293
-from sat.tools import xml_tools
+from libervia.backend.plugins.plugin_xep_0167.constants import NS_JINGLE_RTP
+from libervia.backend.plugins.plugin_xep_0293 import NS_JINGLE_RTP_RTCP_FB, RTCP_FB_KEY, XEP_0293
+from libervia.backend.tools import xml_tools
 
 class TestXEP0293:
 
--- a/tests/unit/test_plugin_xep_0294.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/test_plugin_xep_0294.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,8 +19,8 @@
 
 from twisted.words.xish import domish
 
-from sat.plugins.plugin_xep_0294 import NS_JINGLE_RTP_HDREXT, XEP_0294
-from sat.tools.xml_tools import parse
+from libervia.backend.plugins.plugin_xep_0294 import NS_JINGLE_RTP_HDREXT, XEP_0294
+from libervia.backend.tools.xml_tools import parse
 
 
 class TestXEP0294:
--- a/tests/unit/test_plugin_xep_0320.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/test_plugin_xep_0320.py	Fri Jun 02 11:49:51 2023 +0200
@@ -18,8 +18,8 @@
 
 from twisted.words.xish import domish
 
-from sat.plugins.plugin_xep_0320 import NS_JINGLE_DTLS, XEP_0320
-from sat.tools import xml_tools
+from libervia.backend.plugins.plugin_xep_0320 import NS_JINGLE_DTLS, XEP_0320
+from libervia.backend.tools import xml_tools
 
 
 class TestXEP0320:
--- a/tests/unit/test_plugin_xep_0338.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/test_plugin_xep_0338.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,8 +19,8 @@
 
 from twisted.words.xish import domish
 
-from sat.plugins.plugin_xep_0338 import NS_JINGLE_GROUPING, XEP_0338
-from sat.tools.xml_tools import parse
+from libervia.backend.plugins.plugin_xep_0338 import NS_JINGLE_GROUPING, XEP_0338
+from libervia.backend.tools.xml_tools import parse
 
 
 class TestXEP0338:
--- a/tests/unit/test_plugin_xep_0339.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/test_plugin_xep_0339.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,8 +19,8 @@
 
 from twisted.words.xish import domish
 
-from sat.plugins.plugin_xep_0339 import NS_JINGLE_RTP_SSMA, XEP_0339
-from sat.tools.xml_tools import parse
+from libervia.backend.plugins.plugin_xep_0339 import NS_JINGLE_RTP_SSMA, XEP_0339
+from libervia.backend.tools.xml_tools import parse
 
 
 class TestXEP0339:
--- a/tests/unit/test_plugin_xep_0373.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/test_plugin_xep_0373.py	Fri Jun 02 11:49:51 2023 +0200
@@ -1,12 +1,12 @@
 from datetime import datetime, timedelta, timezone
 
 import pytest
-from sat.core import exceptions
+from libervia.backend.core import exceptions
 try:
-    from sat.plugins.plugin_xep_0373 import XEP_0373, NS_OX
+    from libervia.backend.plugins.plugin_xep_0373 import XEP_0373, NS_OX
 except exceptions.MissingModule as e:
     pytest.skip(f"Can't test XEP-0373: {e}", allow_module_level=True)
-from sat.tools.xmpp_datetime import parse_datetime
+from libervia.backend.tools.xmpp_datetime import parse_datetime
 from twisted.words.protocols.jabber import jid
 
 
--- a/tests/unit/test_plugin_xep_0420.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/test_plugin_xep_0420.py	Fri Jun 02 11:49:51 2023 +0200
@@ -20,14 +20,14 @@
 from typing import Callable, cast
 
 import pytest
-from sat.core import exceptions
+from libervia.backend.core import exceptions
 
-from sat.plugins.plugin_xep_0334 import NS_HINTS
-from sat.plugins.plugin_xep_0420 import (
+from libervia.backend.plugins.plugin_xep_0334 import NS_HINTS
+from libervia.backend.plugins.plugin_xep_0420 import (
     NS_SCE, XEP_0420, AffixVerificationFailed, ProfileRequirementsNotMet, SCEAffixPolicy,
     SCECustomAffix, SCEProfile
 )
-from sat.tools.xml_tools import ElementParser
+from libervia.backend.tools.xml_tools import ElementParser
 from twisted.words.xish import domish
 
 
--- a/tests/unit/test_pubsub-cache.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/tests/unit/test_pubsub-cache.py	Fri Jun 02 11:49:51 2023 +0200
@@ -19,8 +19,8 @@
 from twisted.internet import defer
 from pytest_twisted import ensureDeferred as ed
 from unittest.mock import MagicMock, patch
-from sat.memory.sqla import PubsubNode, SyncState
-from sat.core.constants import Const as C
+from libervia.backend.memory.sqla import PubsubNode, SyncState
+from libervia.backend.core.constants import Const as C
 
 
 class TestPubsubCache:
--- a/twisted/plugins/sat_plugin.py	Thu Jun 01 21:37:34 2023 +0200
+++ b/twisted/plugins/sat_plugin.py	Fri Jun 02 11:49:51 2023 +0200
@@ -23,8 +23,8 @@
 from twisted.application.service import IServiceMaker
 
 # XXX: We need to configure logs before any log method is used, so here is the best place.
-from sat.core.constants import Const as C
-from sat.core.i18n import _
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.i18n import _
 
 from sat_tmp.wokkel import install as install_wokkel_patches
 
@@ -35,7 +35,7 @@
 def initialise(options):
     """Method to initialise global modules"""
     # XXX: We need to configure logs before any log method is used, so here is the best place.
-    from sat.core import log_config
+    from libervia.backend.core import log_config
     log_config.sat_configure(C.LOG_BACKEND_TWISTED, C, backend_data=options)
 
 
@@ -69,7 +69,7 @@
         # XXX: Libervia must be imported after log configuration,
         #      because it write stuff to logs
         initialise(options.parent)
-        from sat.core.sat_main import SAT
+        from libervia.backend.core.sat_main import SAT
         return SAT()