From 12a18d4d553b6c11a7f45f686ab699aa72c3312f Mon Sep 17 00:00:00 2001 From: Joffrey JAFFEUX Date: Fri, 17 Mar 2023 14:24:38 +0100 Subject: [PATCH] DEV: properly namespace chat (#20690) This commit main goal was to comply with Zeitwerk and properly rely on autoloading. To achieve this, most resources have been namespaced under the `Chat` module. - Given all models are now namespaced with `Chat::` and would change the stored types in DB when using polymorphism or STI (single table inheritance), this commit uses various Rails methods to ensure proper class is loaded and the stored name in DB is unchanged, eg: `Chat::Message` model will be stored as `"ChatMessage"`, and `"ChatMessage"` will correctly load `Chat::Message` model. - Jobs are now using constants only, eg: `Jobs::Chat::Foo` and should only be enqueued this way Notes: - This commit also used this opportunity to limit the number of registered css files in plugin.rb - `discourse_dev` support has been removed within this commit and will be reintroduced later --- app/models/reviewable.rb | 2 +- ...admin_incoming_chat_webhooks_controller.rb | 60 --- .../api/chat_channels_status_controller.rb | 11 - .../chat_current_user_channels_controller.rb | 8 - .../chat/app/controllers/api_controller.rb | 29 -- .../admin/incoming_webhooks_controller.rb | 64 +++ .../api/category_chatables_controller.rb | 0 .../api/channel_threads_controller.rb} | 6 +- .../api/channels_archives_controller.rb} | 8 +- .../api/channels_controller.rb} | 28 +- ...els_current_user_membership_controller.rb} | 6 +- ...user_notifications_settings_controller.rb} | 4 +- .../api/channels_memberships_controller.rb} | 6 +- .../channels_messages_moves_controller.rb} | 7 +- .../chat/api/channels_status_controller.rb | 11 + .../api/chatables_controller.rb} | 11 +- .../api/current_user_channels_controller.rb | 8 + .../{ => chat}/api/hints_controller.rb | 0 .../app/controllers/chat/api_controller.rb | 32 ++ .../app/controllers/chat/base_controller.rb | 22 + .../app/controllers/chat/chat_controller.rb | 481 ++++++++++++++++++ .../chat/direct_messages_controller.rb | 57 +++ .../app/controllers/chat/emojis_controller.rb | 10 + .../chat/incoming_webhooks_controller.rb | 113 ++++ .../app/controllers/chat_base_controller.rb | 20 - .../chat/app/controllers/chat_controller.rb | 472 ----------------- .../controllers/direct_messages_controller.rb | 55 -- .../chat/app/controllers/emojis_controller.rb | 8 - .../incoming_chat_webhooks_controller.rb | 111 ---- plugins/chat/app/core_ext/plugin_instance.rb | 15 - .../helpers/{ => chat}/with_service_helper.rb | 2 +- .../jobs/regular/auto_join_channel_batch.rb | 81 --- .../auto_manage_channel_memberships.rb | 79 --- .../regular/chat/auto_join_channel_batch.rb | 83 +++ .../chat/auto_manage_channel_memberships.rb | 81 +++ .../app/jobs/regular/chat/channel_archive.rb | 40 ++ .../app/jobs/regular/chat/channel_delete.rb | 63 +++ .../jobs/regular/chat/delete_user_messages.rb | 15 + .../app/jobs/regular/chat/notify_mentioned.rb | 148 ++++++ .../app/jobs/regular/chat/notify_watching.rb | 88 ++++ .../app/jobs/regular/chat/process_message.rb | 27 + .../chat/send_message_notifications.rb | 23 + .../regular/chat/update_channel_user_count.rb | 20 + .../app/jobs/regular/chat_channel_archive.rb | 38 -- .../app/jobs/regular/chat_channel_delete.rb | 58 --- .../app/jobs/regular/chat_notify_mentioned.rb | 146 ------ .../app/jobs/regular/chat_notify_watching.rb | 84 --- .../app/jobs/regular/delete_user_messages.rb | 13 - .../app/jobs/regular/process_chat_message.rb | 22 - .../regular/send_message_notifications.rb | 21 - .../jobs/regular/update_channel_user_count.rb | 18 - .../app/jobs/scheduled/auto_join_users.rb | 15 - .../jobs/scheduled/chat/auto_join_users.rb | 17 + .../scheduled/chat/delete_old_messages.rb | 40 ++ .../scheduled/chat/email_notifications.rb | 15 + .../jobs/scheduled/chat/periodical_updates.rb | 16 + .../chat/update_user_counts_for_channels.rb | 30 ++ .../jobs/scheduled/chat_periodical_updates.rb | 14 - .../scheduled/delete_old_chat_messages.rb | 38 -- .../scheduled/email_chat_notifications.rb | 13 - .../update_user_counts_for_chat_channels.rb | 28 - plugins/chat/app/models/category_channel.rb | 45 -- .../chat/app/models/chat/category_channel.rb | 51 ++ plugins/chat/app/models/chat/channel.rb | 196 +++++++ .../channel_archive.rb} | 27 +- plugins/chat/app/models/chat/deleted_user.rb | 17 + .../chat/app/models/chat/direct_message.rb | 72 +++ .../app/models/chat/direct_message_channel.rb | 35 ++ .../models/{ => chat}/direct_message_user.rb | 12 +- .../models/{chat_draft.rb => chat/draft.rb} | 18 +- .../incoming_webhook.rb} | 16 +- .../{chat_mention.rb => chat/mention.rb} | 12 +- plugins/chat/app/models/chat/message.rb | 360 +++++++++++++ .../message_reaction.rb} | 10 +- .../message_revision.rb} | 10 +- .../app/models/chat/reviewable_message.rb | 159 ++++++ .../models/{chat_thread.rb => chat/thread.rb} | 41 +- .../models/{chat_upload.rb => chat/upload.rb} | 12 +- .../user_chat_channel_membership.rb | 24 +- plugins/chat/app/models/chat/view.rb | 95 ++++ .../webhook_event.rb} | 14 +- plugins/chat/app/models/chat_channel.rb | 176 ------- plugins/chat/app/models/chat_message.rb | 297 ----------- plugins/chat/app/models/chat_view.rb | 87 ---- .../chat/app/models/concerns/chat/chatable.rb | 55 ++ plugins/chat/app/models/concerns/chatable.rb | 19 - plugins/chat/app/models/deleted_chat_user.rb | 15 - plugins/chat/app/models/direct_message.rb | 59 --- .../chat/app/models/direct_message_channel.rb | 29 -- .../app/models/reviewable_chat_message.rb | 149 ------ .../queries/chat/channel_memberships_query.rb | 49 ++ .../channel_unreads_query.rb} | 26 +- .../queries/chat_channel_memberships_query.rb | 47 -- .../admin_chat_index_serializer.rb | 14 - ...base_chat_channel_membership_serializer.rb | 12 - .../chat/admin_chat_index_serializer.rb | 16 + .../base_channel_membership_serializer.rb | 14 + .../chat/channel_index_serializer.rb | 11 + .../chat/channel_search_serializer.rb | 11 + .../serializers/chat/channel_serializer.rb | 131 +++++ .../chat/direct_message_serializer.rb | 14 + .../chat/in_reply_to_serializer.rb | 18 + .../chat/incoming_webhook_serializer.rb | 9 + .../serializers/chat/message_serializer.rb | 155 ++++++ .../chat/message_user_serializer.rb | 28 + .../chat/reviewable_message_serializer.rb | 21 + .../chat/structured_channel_serializer.rb | 98 ++++ .../thread_original_message_serializer.rb | 13 + .../app/serializers/chat/thread_serializer.rb | 10 + .../user_channel_membership_serializer.rb | 11 + .../chat/user_message_bookmark_serializer.rb | 38 ++ ...ith_custom_fields_and_status_serializer.rb | 15 + .../app/serializers/chat/view_serializer.rb | 38 ++ .../chat/webhook_event_serializer.rb | 7 + .../chat_channel_index_serializer.rb | 9 - .../chat_channel_search_serializer.rb | 9 - .../serializers/chat_channel_serializer.rb | 129 ----- .../chat_in_reply_to_serializer.rb | 16 - .../serializers/chat_message_serializer.rb | 153 ------ .../chat_message_user_serializer.rb | 26 - ...chat_thread_original_message_serializer.rb | 11 - .../app/serializers/chat_thread_serializer.rb | 8 - .../app/serializers/chat_view_serializer.rb | 34 -- .../chat_webhook_event_serializer.rb | 5 - .../serializers/direct_message_serializer.rb | 12 - .../incoming_chat_webhook_serializer.rb | 7 - .../reviewable_chat_message_serializer.rb | 19 - .../structured_channel_serializer.rb | 94 ---- ...user_chat_channel_membership_serializer.rb | 9 - .../user_chat_message_bookmark_serializer.rb | 36 -- ...ith_custom_fields_and_status_serializer.rb | 13 - plugins/chat/app/services/base.rb | 430 ---------------- .../chat/app/services/chat/lookup_thread.rb | 58 +++ .../app/services/chat/message_destroyer.rb | 40 ++ plugins/chat/app/services/chat/publisher.rb | 268 ++++++++++ .../chat/app/services/chat/trash_channel.rb | 64 +++ .../chat/app/services/chat/update_channel.rb | 83 +++ .../services/chat/update_channel_status.rb | 44 ++ .../services/chat/update_user_last_read.rb | 79 +++ .../app/services/chat_message_destroyer.rb | 38 -- plugins/chat/app/services/chat_publisher.rb | 266 ---------- plugins/chat/app/services/lookup_thread.rb | 60 --- plugins/chat/app/services/service.rb | 96 ++++ plugins/chat/app/services/service/base.rb | 336 ++++++++++++ plugins/chat/app/services/trash_channel.rb | 66 --- plugins/chat/app/services/update_channel.rb | 88 ---- .../app/services/update_channel_status.rb | 46 -- .../app/services/update_user_last_read.rb | 81 --- .../chat/allow_uploads_validator.rb | 24 + .../chat/default_channel_validator.rb | 17 + ...direct_message_enabled_groups_validator.rb | 17 + .../discourse/components/chat-message.js | 2 +- .../common/{common.scss => base-common.scss} | 0 .../stylesheets/common/chat-message.scss | 41 -- .../chat/assets/stylesheets/common/index.scss | 45 ++ .../{ => common}/sidebar-extensions.scss | 0 .../{desktop.scss => base-desktop.scss} | 0 .../assets/stylesheets/desktop/index.scss | 9 + .../stylesheets/mixins/chat-reaction.scss | 40 ++ .../chat/assets/stylesheets/mixins/index.scss | 2 + .../mobile/{mobile.scss => base-mobile.scss} | 0 .../chat/assets/stylesheets/mobile/index.scss | 7 + plugins/chat/config/routes.rb | 92 ++++ plugins/chat/config/settings.yml | 6 +- plugins/chat/db/fixtures/600_chat_channels.rb | 2 +- plugins/chat/lib/chat/bookmark_extension.rb | 22 + plugins/chat/lib/chat/category_extension.rb | 30 ++ .../chat/lib/chat/channel_archive_service.rb | 310 +++++++++++ plugins/chat/lib/chat/channel_fetcher.rb | 263 ++++++++++ .../lib/chat/channel_hashtag_data_source.rb | 83 +++ .../lib/chat/channel_membership_manager.rb | 81 +++ .../chat/direct_message_channel_creator.rb | 133 +++++ .../lib/chat/duplicate_message_validator.rb | 46 ++ plugins/chat/lib/chat/engine.rb | 23 + plugins/chat/lib/chat/guardian_extensions.rb | 193 +++++++ plugins/chat/lib/chat/mailer.rb | 68 +++ plugins/chat/lib/chat/message_bookmarkable.rb | 78 +++ plugins/chat/lib/chat/message_creator.rb | 202 ++++++++ plugins/chat/lib/chat/message_mentions.rb | 101 ++++ plugins/chat/lib/chat/message_mover.rb | 244 +++++++++ plugins/chat/lib/chat/message_processor.rb | 35 ++ plugins/chat/lib/chat/message_rate_limiter.rb | 52 ++ plugins/chat/lib/chat/message_reactor.rb | 86 ++++ plugins/chat/lib/chat/message_updater.rb | 97 ++++ plugins/chat/lib/chat/notifier.rb | 317 ++++++++++++ .../lib/chat/plugin_instance_extension.rb | 19 + .../lib/chat/post_notification_handler.rb | 42 ++ plugins/chat/lib/chat/review_queue.rb | 211 ++++++++ plugins/chat/lib/chat/reviewable_extension.rb | 27 + .../lib/chat/secure_uploads_compatibility.rb | 25 + plugins/chat/lib/chat/seeder.rb | 30 ++ plugins/chat/lib/chat/slack_compatibility.rb | 62 +++ plugins/chat/lib/chat/statistics.rb | 53 ++ .../chat/lib/{ => chat}/steps_inspector.rb | 2 +- plugins/chat/lib/chat/transcript_service.rb | 179 +++++++ plugins/chat/lib/chat/user_email_extension.rb | 18 + plugins/chat/lib/chat/user_extension.rb | 15 + .../lib/chat/user_notifications_extension.rb | 144 ++++++ .../chat/lib/chat/user_option_extension.rb | 25 + .../chat/lib/chat_channel_archive_service.rb | 311 ----------- plugins/chat/lib/chat_channel_fetcher.rb | 257 ---------- .../lib/chat_channel_hashtag_data_source.rb | 84 --- .../lib/chat_channel_membership_manager.rb | 79 --- plugins/chat/lib/chat_mailer.rb | 63 --- plugins/chat/lib/chat_message_bookmarkable.rb | 69 --- plugins/chat/lib/chat_message_creator.rb | 203 -------- plugins/chat/lib/chat_message_mentions.rb | 99 ---- plugins/chat/lib/chat_message_processor.rb | 33 -- plugins/chat/lib/chat_message_rate_limiter.rb | 49 -- plugins/chat/lib/chat_message_reactor.rb | 83 --- plugins/chat/lib/chat_message_updater.rb | 95 ---- plugins/chat/lib/chat_notifier.rb | 315 ------------ plugins/chat/lib/chat_review_queue.rb | 208 -------- plugins/chat/lib/chat_seeder.rb | 28 - plugins/chat/lib/chat_statistics.rb | 51 -- plugins/chat/lib/chat_transcript_service.rb | 177 ------- .../lib/direct_message_channel_creator.rb | 130 ----- .../chat/lib/discourse_dev/direct_channel.rb | 31 -- plugins/chat/lib/discourse_dev/message.rb | 30 -- .../chat/lib/discourse_dev/public_channel.rb | 44 -- .../chat/lib/duplicate_message_validator.rb | 46 -- .../chat/lib/extensions/category_extension.rb | 19 - .../lib/extensions/user_email_extension.rb | 15 - plugins/chat/lib/extensions/user_extension.rb | 11 - .../user_notifications_extension.rb | 142 ------ .../lib/extensions/user_option_extension.rb | 23 - plugins/chat/lib/guardian_extensions.rb | 189 ------- plugins/chat/lib/message_mover.rb | 242 --------- plugins/chat/lib/post_notification_handler.rb | 40 -- .../chat/lib/secure_uploads_compatibility.rb | 23 - plugins/chat/lib/service_runner.rb | 7 +- plugins/chat/lib/slack_compatibility.rb | 60 --- plugins/chat/lib/tasks/chat.rake | 23 - plugins/chat/lib/tasks/chat_message.rake | 11 +- .../chat_allow_uploads_validator.rb | 22 - .../chat_default_channel_validator.rb | 15 - ...direct_message_enabled_groups_validator.rb | 15 - plugins/chat/plugin.rb | 403 ++------------- .../mailer_spec.rb} | 2 +- .../message_creator_spec.rb} | 198 ++++--- .../message_rate_limiter_spec.rb} | 2 +- .../message_updater_spec.rb} | 66 +-- .../seeder_spec.rb} | 20 +- .../chat/spec/fabricators/chat_fabricator.rb | 40 +- .../integration/custom_api_key_scopes_spec.rb | 16 +- .../spec/integration/post_chat_quote_spec.rb | 4 +- .../auto_join_channel_batch_spec.rb | 17 +- .../auto_manage_channel_memberships_spec.rb | 14 +- .../chat/channel_archive_spec.rb} | 6 +- .../chat/channel_delete_spec.rb} | 28 +- .../{ => chat}/delete_user_messages_spec.rb | 4 +- .../notify_mentioned_spec.rb} | 28 +- .../notify_watching_spec.rb} | 22 +- .../chat/process_message_spec.rb} | 8 +- .../send_message_notifications_spec.rb | 22 +- .../update_channel_user_count_spec.rb | 8 +- .../jobs/scheduled/auto_join_users_spec.rb | 6 +- .../delete_old_chat_messages_spec.rb | 8 +- ...ns_spec.rb => email_notifications_spec.rb} | 10 +- .../periodical_updates_spec.rb} | 4 +- .../update_user_counts_for_channels_spec.rb} | 10 +- .../channel_archive_service_spec.rb} | 46 +- .../channel_fetcher_spec.rb} | 158 +++--- .../channel_hashtag_data_source_spec.rb} | 2 +- .../channel_membership_manager_spec.rb} | 22 +- .../direct_message_channel_creator_spec.rb | 151 +++--- .../duplicate_message_validator_spec.rb | 0 .../{ => chat}/guardian_extensions_spec.rb | 10 +- .../message_bookmarkable_spec.rb} | 12 +- .../message_mentions_spec.rb} | 26 +- .../spec/lib/{ => chat}/message_mover_spec.rb | 36 +- .../message_processor_spec.rb} | 4 +- .../message_reactor_spec.rb} | 36 +- .../notifier_spec.rb} | 6 +- .../post_notification_handler_spec.rb | 4 +- .../review_queue_spec.rb} | 29 +- .../{ => chat}/slack_compatibility_spec.rb | 0 .../statistics_spec.rb} | 0 .../lib/{ => chat}/steps_inspector_spec.rb | 38 +- .../transcript_service_spec.rb} | 12 +- plugins/chat/spec/lib/service_runner_spec.rb | 26 +- .../spec/mailers/user_notifications_spec.rb | 7 +- plugins/chat/spec/models/category_spec.rb | 2 +- .../{ => chat}/category_channel_spec.rb | 2 +- .../channel_spec.rb} | 2 +- .../{ => chat}/deleted_chat_user_spec.rb | 2 +- .../{ => chat}/direct_message_channel_spec.rb | 2 +- .../models/{ => chat}/direct_message_spec.rb | 4 +- .../draft_spec.rb} | 2 +- .../message_spec.rb} | 73 +-- .../reviewable_chat_message_spec.rb | 6 +- plugins/chat/spec/plugin_helper.rb | 7 +- plugins/chat/spec/plugin_spec.rb | 14 +- .../channel_memberships_query_spec.rb} | 72 ++- .../channel_unreads_query_spec.rb} | 4 +- .../incoming_webhooks_controller_spec.rb} | 10 +- .../api/category_chatables_controller_spec.rb | 0 .../api/channel_threads_controller_spec.rb} | 2 +- .../api/channels_archives_controller_spec.rb} | 18 +- .../api/channels_controller_spec.rb} | 20 +- .../channels_current_user_membership_spec.rb} | 14 +- ...notifications_settings_controller_spec.rb} | 14 +- .../api/channels_moves_controller_spec.rb} | 4 +- .../api/channels_status_controller_spec.rb} | 2 +- .../api/chatables_controller_spec.rb} | 10 +- .../api/current_user_channels_spec.rb} | 2 +- .../{ => chat}/api/hints_controller_spec.rb | 0 .../spec/requests/chat_controller_spec.rb | 103 ++-- .../core_ext/categories_controller_spec.rb | 2 +- .../core_ext/users_controller_spec.rb | 2 +- .../direct_messages_controller_spec.rb | 20 +- .../incoming_chat_webhooks_controller_spec.rb | 26 +- .../channel_serializer_spec.rb} | 6 +- .../chat_message_serializer_spec.rb | 6 +- .../chat_message_user_serializer_spec.rb | 2 +- .../direct_message_serializer_spec.rb | 2 +- .../in_reply_to_serializer_spec.rb} | 2 +- .../structured_channel_serializer_spec.rb | 4 +- .../current_user_serializer_spec.rb | 0 .../services/{ => chat}/lookup_thread_spec.rb | 4 +- .../message_destroyer_spec.rb} | 18 +- .../publisher_spec.rb} | 4 +- .../services/{ => chat}/trash_channel_spec.rb | 4 +- .../{ => chat}/update_channel_spec.rb | 8 +- .../{ => chat}/update_channel_status_spec.rb | 4 +- .../{ => chat}/update_user_last_read_spec.rb | 6 +- plugins/chat/spec/support/chat_helper.rb | 2 +- .../support/examples/chat_channel_model.rb | 20 +- .../chat/spec/system/archive_channel_spec.rb | 4 +- .../spec/system/channel_about_page_spec.rb | 2 +- .../spec/system/channel_members_page_spec.rb | 4 +- plugins/chat/spec/system/chat_channel_spec.rb | 2 +- .../chat/spec/system/create_channel_spec.rb | 10 +- .../spec/system/hashtag_autocomplete_spec.rb | 2 +- .../chat/spec/system/react_to_message_spec.rb | 10 +- plugins/chat/spec/system/transcript_spec.rb | 4 +- plugins/chat/spec/system/update_last_read.rb | 2 +- plugins/chat/spec/system/uploads_spec.rb | 6 +- plugins/chat/spec/system/user_card_spec.rb | 2 +- .../user_menu_notifications/sidebar_spec.rb | 8 +- .../chat_allow_uploads_validator_spec.rb | 2 +- .../chat_default_channel_validator_spec.rb | 2 +- .../chat-retention-reminder-text-test.js | 4 +- 343 files changed, 9077 insertions(+), 8745 deletions(-) delete mode 100644 plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb delete mode 100644 plugins/chat/app/controllers/api/chat_channels_status_controller.rb delete mode 100644 plugins/chat/app/controllers/api/chat_current_user_channels_controller.rb delete mode 100644 plugins/chat/app/controllers/api_controller.rb create mode 100644 plugins/chat/app/controllers/chat/admin/incoming_webhooks_controller.rb rename plugins/chat/app/controllers/{ => chat}/api/category_chatables_controller.rb (100%) rename plugins/chat/app/controllers/{api/chat_channel_threads_controller.rb => chat/api/channel_threads_controller.rb} (59%) rename plugins/chat/app/controllers/{api/chat_channels_archives_controller.rb => chat/api/channels_archives_controller.rb} (82%) rename plugins/chat/app/controllers/{api/chat_channels_controller.rb => chat/api/channels_controller.rb} (82%) rename plugins/chat/app/controllers/{api/chat_channels_current_user_membership_controller.rb => chat/api/channels_current_user_membership_controller.rb} (65%) rename plugins/chat/app/controllers/{api/chat_channels_current_user_notifications_settings_controller.rb => chat/api/channels_current_user_notifications_settings_controller.rb} (71%) rename plugins/chat/app/controllers/{api/chat_channels_memberships_controller.rb => chat/api/channels_memberships_controller.rb} (79%) rename plugins/chat/app/controllers/{api/chat_channels_messages_moves_controller.rb => chat/api/channels_messages_moves_controller.rb} (82%) create mode 100644 plugins/chat/app/controllers/chat/api/channels_status_controller.rb rename plugins/chat/app/controllers/{api/chat_chatables_controller.rb => chat/api/chatables_controller.rb} (89%) create mode 100644 plugins/chat/app/controllers/chat/api/current_user_channels_controller.rb rename plugins/chat/app/controllers/{ => chat}/api/hints_controller.rb (100%) create mode 100644 plugins/chat/app/controllers/chat/api_controller.rb create mode 100644 plugins/chat/app/controllers/chat/base_controller.rb create mode 100644 plugins/chat/app/controllers/chat/chat_controller.rb create mode 100644 plugins/chat/app/controllers/chat/direct_messages_controller.rb create mode 100644 plugins/chat/app/controllers/chat/emojis_controller.rb create mode 100644 plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb delete mode 100644 plugins/chat/app/controllers/chat_base_controller.rb delete mode 100644 plugins/chat/app/controllers/chat_controller.rb delete mode 100644 plugins/chat/app/controllers/direct_messages_controller.rb delete mode 100644 plugins/chat/app/controllers/emojis_controller.rb delete mode 100644 plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb delete mode 100644 plugins/chat/app/core_ext/plugin_instance.rb rename plugins/chat/app/helpers/{ => chat}/with_service_helper.rb (88%) delete mode 100644 plugins/chat/app/jobs/regular/auto_join_channel_batch.rb delete mode 100644 plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb create mode 100644 plugins/chat/app/jobs/regular/chat/auto_join_channel_batch.rb create mode 100644 plugins/chat/app/jobs/regular/chat/auto_manage_channel_memberships.rb create mode 100644 plugins/chat/app/jobs/regular/chat/channel_archive.rb create mode 100644 plugins/chat/app/jobs/regular/chat/channel_delete.rb create mode 100644 plugins/chat/app/jobs/regular/chat/delete_user_messages.rb create mode 100644 plugins/chat/app/jobs/regular/chat/notify_mentioned.rb create mode 100644 plugins/chat/app/jobs/regular/chat/notify_watching.rb create mode 100644 plugins/chat/app/jobs/regular/chat/process_message.rb create mode 100644 plugins/chat/app/jobs/regular/chat/send_message_notifications.rb create mode 100644 plugins/chat/app/jobs/regular/chat/update_channel_user_count.rb delete mode 100644 plugins/chat/app/jobs/regular/chat_channel_archive.rb delete mode 100644 plugins/chat/app/jobs/regular/chat_channel_delete.rb delete mode 100644 plugins/chat/app/jobs/regular/chat_notify_mentioned.rb delete mode 100644 plugins/chat/app/jobs/regular/chat_notify_watching.rb delete mode 100644 plugins/chat/app/jobs/regular/delete_user_messages.rb delete mode 100644 plugins/chat/app/jobs/regular/process_chat_message.rb delete mode 100644 plugins/chat/app/jobs/regular/send_message_notifications.rb delete mode 100644 plugins/chat/app/jobs/regular/update_channel_user_count.rb delete mode 100644 plugins/chat/app/jobs/scheduled/auto_join_users.rb create mode 100644 plugins/chat/app/jobs/scheduled/chat/auto_join_users.rb create mode 100644 plugins/chat/app/jobs/scheduled/chat/delete_old_messages.rb create mode 100644 plugins/chat/app/jobs/scheduled/chat/email_notifications.rb create mode 100644 plugins/chat/app/jobs/scheduled/chat/periodical_updates.rb create mode 100644 plugins/chat/app/jobs/scheduled/chat/update_user_counts_for_channels.rb delete mode 100644 plugins/chat/app/jobs/scheduled/chat_periodical_updates.rb delete mode 100644 plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb delete mode 100644 plugins/chat/app/jobs/scheduled/email_chat_notifications.rb delete mode 100644 plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb delete mode 100644 plugins/chat/app/models/category_channel.rb create mode 100644 plugins/chat/app/models/chat/category_channel.rb create mode 100644 plugins/chat/app/models/chat/channel.rb rename plugins/chat/app/models/{chat_channel_archive.rb => chat/channel_archive.rb} (60%) create mode 100644 plugins/chat/app/models/chat/deleted_user.rb create mode 100644 plugins/chat/app/models/chat/direct_message.rb create mode 100644 plugins/chat/app/models/chat/direct_message_channel.rb rename plugins/chat/app/models/{ => chat}/direct_message_user.rb (64%) rename plugins/chat/app/models/{chat_draft.rb => chat/draft.rb} (52%) rename plugins/chat/app/models/{incoming_chat_webhook.rb => chat/incoming_webhook.rb} (61%) rename plugins/chat/app/models/{chat_mention.rb => chat/mention.rb} (67%) create mode 100644 plugins/chat/app/models/chat/message.rb rename plugins/chat/app/models/{chat_message_reaction.rb => chat/message_reaction.rb} (69%) rename plugins/chat/app/models/{chat_message_revision.rb => chat/message_revision.rb} (74%) create mode 100644 plugins/chat/app/models/chat/reviewable_message.rb rename plugins/chat/app/models/{chat_thread.rb => chat/thread.rb} (50%) rename plugins/chat/app/models/{chat_upload.rb => chat/upload.rb} (76%) rename plugins/chat/app/models/{ => chat}/user_chat_channel_membership.rb (63%) create mode 100644 plugins/chat/app/models/chat/view.rb rename plugins/chat/app/models/{chat_webhook_event.rb => chat/webhook_event.rb} (58%) delete mode 100644 plugins/chat/app/models/chat_channel.rb delete mode 100644 plugins/chat/app/models/chat_message.rb delete mode 100644 plugins/chat/app/models/chat_view.rb create mode 100644 plugins/chat/app/models/concerns/chat/chatable.rb delete mode 100644 plugins/chat/app/models/concerns/chatable.rb delete mode 100644 plugins/chat/app/models/deleted_chat_user.rb delete mode 100644 plugins/chat/app/models/direct_message.rb delete mode 100644 plugins/chat/app/models/direct_message_channel.rb delete mode 100644 plugins/chat/app/models/reviewable_chat_message.rb create mode 100644 plugins/chat/app/queries/chat/channel_memberships_query.rb rename plugins/chat/app/queries/{chat_channel_unreads_query.rb => chat/channel_unreads_query.rb} (79%) delete mode 100644 plugins/chat/app/queries/chat_channel_memberships_query.rb delete mode 100644 plugins/chat/app/serializers/admin_chat_index_serializer.rb delete mode 100644 plugins/chat/app/serializers/base_chat_channel_membership_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/admin_chat_index_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/channel_index_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/channel_search_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/channel_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/direct_message_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/in_reply_to_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/incoming_webhook_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/message_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/message_user_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/reviewable_message_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/structured_channel_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/thread_original_message_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/thread_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/user_channel_membership_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/user_message_bookmark_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/user_with_custom_fields_and_status_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/view_serializer.rb create mode 100644 plugins/chat/app/serializers/chat/webhook_event_serializer.rb delete mode 100644 plugins/chat/app/serializers/chat_channel_index_serializer.rb delete mode 100644 plugins/chat/app/serializers/chat_channel_search_serializer.rb delete mode 100644 plugins/chat/app/serializers/chat_channel_serializer.rb delete mode 100644 plugins/chat/app/serializers/chat_in_reply_to_serializer.rb delete mode 100644 plugins/chat/app/serializers/chat_message_serializer.rb delete mode 100644 plugins/chat/app/serializers/chat_message_user_serializer.rb delete mode 100644 plugins/chat/app/serializers/chat_thread_original_message_serializer.rb delete mode 100644 plugins/chat/app/serializers/chat_thread_serializer.rb delete mode 100644 plugins/chat/app/serializers/chat_view_serializer.rb delete mode 100644 plugins/chat/app/serializers/chat_webhook_event_serializer.rb delete mode 100644 plugins/chat/app/serializers/direct_message_serializer.rb delete mode 100644 plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb delete mode 100644 plugins/chat/app/serializers/reviewable_chat_message_serializer.rb delete mode 100644 plugins/chat/app/serializers/structured_channel_serializer.rb delete mode 100644 plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb delete mode 100644 plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb delete mode 100644 plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb delete mode 100644 plugins/chat/app/services/base.rb create mode 100644 plugins/chat/app/services/chat/lookup_thread.rb create mode 100644 plugins/chat/app/services/chat/message_destroyer.rb create mode 100644 plugins/chat/app/services/chat/publisher.rb create mode 100644 plugins/chat/app/services/chat/trash_channel.rb create mode 100644 plugins/chat/app/services/chat/update_channel.rb create mode 100644 plugins/chat/app/services/chat/update_channel_status.rb create mode 100644 plugins/chat/app/services/chat/update_user_last_read.rb delete mode 100644 plugins/chat/app/services/chat_message_destroyer.rb delete mode 100644 plugins/chat/app/services/chat_publisher.rb delete mode 100644 plugins/chat/app/services/lookup_thread.rb create mode 100644 plugins/chat/app/services/service.rb create mode 100644 plugins/chat/app/services/service/base.rb delete mode 100644 plugins/chat/app/services/trash_channel.rb delete mode 100644 plugins/chat/app/services/update_channel.rb delete mode 100644 plugins/chat/app/services/update_channel_status.rb delete mode 100644 plugins/chat/app/services/update_user_last_read.rb create mode 100644 plugins/chat/app/validators/chat/allow_uploads_validator.rb create mode 100644 plugins/chat/app/validators/chat/default_channel_validator.rb create mode 100644 plugins/chat/app/validators/chat/direct_message_enabled_groups_validator.rb rename plugins/chat/assets/stylesheets/common/{common.scss => base-common.scss} (100%) create mode 100644 plugins/chat/assets/stylesheets/common/index.scss rename plugins/chat/assets/stylesheets/{ => common}/sidebar-extensions.scss (100%) rename plugins/chat/assets/stylesheets/desktop/{desktop.scss => base-desktop.scss} (100%) create mode 100644 plugins/chat/assets/stylesheets/desktop/index.scss create mode 100644 plugins/chat/assets/stylesheets/mixins/chat-reaction.scss create mode 100644 plugins/chat/assets/stylesheets/mixins/index.scss rename plugins/chat/assets/stylesheets/mobile/{mobile.scss => base-mobile.scss} (100%) create mode 100644 plugins/chat/assets/stylesheets/mobile/index.scss create mode 100644 plugins/chat/config/routes.rb create mode 100644 plugins/chat/lib/chat/bookmark_extension.rb create mode 100644 plugins/chat/lib/chat/category_extension.rb create mode 100644 plugins/chat/lib/chat/channel_archive_service.rb create mode 100644 plugins/chat/lib/chat/channel_fetcher.rb create mode 100644 plugins/chat/lib/chat/channel_hashtag_data_source.rb create mode 100644 plugins/chat/lib/chat/channel_membership_manager.rb create mode 100644 plugins/chat/lib/chat/direct_message_channel_creator.rb create mode 100644 plugins/chat/lib/chat/duplicate_message_validator.rb create mode 100644 plugins/chat/lib/chat/engine.rb create mode 100644 plugins/chat/lib/chat/guardian_extensions.rb create mode 100644 plugins/chat/lib/chat/mailer.rb create mode 100644 plugins/chat/lib/chat/message_bookmarkable.rb create mode 100644 plugins/chat/lib/chat/message_creator.rb create mode 100644 plugins/chat/lib/chat/message_mentions.rb create mode 100644 plugins/chat/lib/chat/message_mover.rb create mode 100644 plugins/chat/lib/chat/message_processor.rb create mode 100644 plugins/chat/lib/chat/message_rate_limiter.rb create mode 100644 plugins/chat/lib/chat/message_reactor.rb create mode 100644 plugins/chat/lib/chat/message_updater.rb create mode 100644 plugins/chat/lib/chat/notifier.rb create mode 100644 plugins/chat/lib/chat/plugin_instance_extension.rb create mode 100644 plugins/chat/lib/chat/post_notification_handler.rb create mode 100644 plugins/chat/lib/chat/review_queue.rb create mode 100644 plugins/chat/lib/chat/reviewable_extension.rb create mode 100644 plugins/chat/lib/chat/secure_uploads_compatibility.rb create mode 100644 plugins/chat/lib/chat/seeder.rb create mode 100644 plugins/chat/lib/chat/slack_compatibility.rb create mode 100644 plugins/chat/lib/chat/statistics.rb rename plugins/chat/lib/{ => chat}/steps_inspector.rb (97%) create mode 100644 plugins/chat/lib/chat/transcript_service.rb create mode 100644 plugins/chat/lib/chat/user_email_extension.rb create mode 100644 plugins/chat/lib/chat/user_extension.rb create mode 100644 plugins/chat/lib/chat/user_notifications_extension.rb create mode 100644 plugins/chat/lib/chat/user_option_extension.rb delete mode 100644 plugins/chat/lib/chat_channel_archive_service.rb delete mode 100644 plugins/chat/lib/chat_channel_fetcher.rb delete mode 100644 plugins/chat/lib/chat_channel_hashtag_data_source.rb delete mode 100644 plugins/chat/lib/chat_channel_membership_manager.rb delete mode 100644 plugins/chat/lib/chat_mailer.rb delete mode 100644 plugins/chat/lib/chat_message_bookmarkable.rb delete mode 100644 plugins/chat/lib/chat_message_creator.rb delete mode 100644 plugins/chat/lib/chat_message_mentions.rb delete mode 100644 plugins/chat/lib/chat_message_processor.rb delete mode 100644 plugins/chat/lib/chat_message_rate_limiter.rb delete mode 100644 plugins/chat/lib/chat_message_reactor.rb delete mode 100644 plugins/chat/lib/chat_message_updater.rb delete mode 100644 plugins/chat/lib/chat_notifier.rb delete mode 100644 plugins/chat/lib/chat_review_queue.rb delete mode 100644 plugins/chat/lib/chat_seeder.rb delete mode 100644 plugins/chat/lib/chat_statistics.rb delete mode 100644 plugins/chat/lib/chat_transcript_service.rb delete mode 100644 plugins/chat/lib/direct_message_channel_creator.rb delete mode 100644 plugins/chat/lib/discourse_dev/direct_channel.rb delete mode 100644 plugins/chat/lib/discourse_dev/message.rb delete mode 100644 plugins/chat/lib/discourse_dev/public_channel.rb delete mode 100644 plugins/chat/lib/duplicate_message_validator.rb delete mode 100644 plugins/chat/lib/extensions/category_extension.rb delete mode 100644 plugins/chat/lib/extensions/user_email_extension.rb delete mode 100644 plugins/chat/lib/extensions/user_extension.rb delete mode 100644 plugins/chat/lib/extensions/user_notifications_extension.rb delete mode 100644 plugins/chat/lib/extensions/user_option_extension.rb delete mode 100644 plugins/chat/lib/guardian_extensions.rb delete mode 100644 plugins/chat/lib/message_mover.rb delete mode 100644 plugins/chat/lib/post_notification_handler.rb delete mode 100644 plugins/chat/lib/secure_uploads_compatibility.rb delete mode 100644 plugins/chat/lib/slack_compatibility.rb delete mode 100644 plugins/chat/lib/tasks/chat.rake delete mode 100644 plugins/chat/lib/validators/chat_allow_uploads_validator.rb delete mode 100644 plugins/chat/lib/validators/chat_default_channel_validator.rb delete mode 100644 plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb rename plugins/chat/spec/components/{chat_mailer_spec.rb => chat/mailer_spec.rb} (99%) rename plugins/chat/spec/components/{chat_message_creator_spec.rb => chat/message_creator_spec.rb} (86%) rename plugins/chat/spec/components/{chat_message_rate_limiter_spec.rb => chat/message_rate_limiter_spec.rb} (98%) rename plugins/chat/spec/components/{chat_message_updater_spec.rb => chat/message_updater_spec.rb} (93%) rename plugins/chat/spec/components/{chat_seeder_spec.rb => chat/seeder_spec.rb} (76%) rename plugins/chat/spec/jobs/regular/{ => chat}/auto_join_channel_batch_spec.rb (92%) rename plugins/chat/spec/jobs/regular/{ => chat}/auto_manage_channel_memberships_spec.rb (87%) rename plugins/chat/spec/jobs/{chat_channel_archive_spec.rb => regular/chat/channel_archive_spec.rb} (88%) rename plugins/chat/spec/jobs/{chat_channel_delete_spec.rb => regular/chat/channel_delete_spec.rb} (76%) rename plugins/chat/spec/jobs/regular/{ => chat}/delete_user_messages_spec.rb (86%) rename plugins/chat/spec/jobs/regular/{chat_notify_mentioned_spec.rb => chat/notify_mentioned_spec.rb} (92%) rename plugins/chat/spec/jobs/regular/{chat_notify_watching_spec.rb => chat/notify_watching_spec.rb} (91%) rename plugins/chat/spec/jobs/{process_chat_message_spec.rb => regular/chat/process_message_spec.rb} (88%) rename plugins/chat/spec/jobs/regular/{ => chat}/send_message_notifications_spec.rb (59%) rename plugins/chat/spec/jobs/regular/{ => chat}/update_channel_user_count_spec.rb (82%) rename plugins/chat/spec/jobs/{ => scheduled}/delete_old_chat_messages_spec.rb (95%) rename plugins/chat/spec/jobs/scheduled/{email_chat_notifications_spec.rb => email_notifications_spec.rb} (55%) rename plugins/chat/spec/jobs/{chat_periodical_updates_spec.rb => scheduled/periodical_updates_spec.rb} (53%) rename plugins/chat/spec/jobs/{update_user_counts_for_chat_channels_spec.rb => scheduled/update_user_counts_for_channels_spec.rb} (88%) rename plugins/chat/spec/lib/{chat_channel_archive_service_spec.rb => chat/channel_archive_service_spec.rb} (89%) rename plugins/chat/spec/lib/{chat_channel_fetcher_spec.rb => chat/channel_fetcher_spec.rb} (66%) rename plugins/chat/spec/lib/{chat_channel_hashtag_data_source_spec.rb => chat/channel_hashtag_data_source_spec.rb} (99%) rename plugins/chat/spec/lib/{chat_channel_membership_manager_spec.rb => chat/channel_membership_manager_spec.rb} (86%) rename plugins/chat/spec/lib/{ => chat}/direct_message_channel_creator_spec.rb (64%) rename plugins/chat/spec/lib/{ => chat}/duplicate_message_validator_spec.rb (100%) rename plugins/chat/spec/lib/{ => chat}/guardian_extensions_spec.rb (98%) rename plugins/chat/spec/lib/{chat_message_bookmarkable_spec.rb => chat/message_bookmarkable_spec.rb} (94%) rename plugins/chat/spec/lib/{chat_message_mentions_spec.rb => chat/message_mentions_spec.rb} (86%) rename plugins/chat/spec/lib/{ => chat}/message_mover_spec.rb (88%) rename plugins/chat/spec/lib/{chat_message_processor_spec.rb => chat/message_processor_spec.rb} (57%) rename plugins/chat/spec/lib/{chat_message_reactor_spec.rb => chat/message_reactor_spec.rb} (78%) rename plugins/chat/spec/lib/{chat_notifier_spec.rb => chat/notifier_spec.rb} (99%) rename plugins/chat/spec/lib/{ => chat}/post_notification_handler_spec.rb (94%) rename plugins/chat/spec/lib/{chat_review_queue_spec.rb => chat/review_queue_spec.rb} (94%) rename plugins/chat/spec/lib/{ => chat}/slack_compatibility_spec.rb (100%) rename plugins/chat/spec/lib/{chat_statistics_spec.rb => chat/statistics_spec.rb} (100%) rename plugins/chat/spec/lib/{ => chat}/steps_inspector_spec.rb (86%) rename plugins/chat/spec/lib/{chat_transcript_service_spec.rb => chat/transcript_service_spec.rb} (97%) rename plugins/chat/spec/models/{ => chat}/category_channel_spec.rb (99%) rename plugins/chat/spec/models/{chat_channel_spec.rb => chat/channel_spec.rb} (98%) rename plugins/chat/spec/models/{ => chat}/deleted_chat_user_spec.rb (92%) rename plugins/chat/spec/models/{ => chat}/direct_message_channel_spec.rb (97%) rename plugins/chat/spec/models/{ => chat}/direct_message_spec.rb (96%) rename plugins/chat/spec/models/{chat_draft_spec.rb => chat/draft_spec.rb} (94%) rename plugins/chat/spec/models/{chat_message_spec.rb => chat/message_spec.rb} (91%) rename plugins/chat/spec/models/{ => chat}/reviewable_chat_message_spec.rb (91%) rename plugins/chat/spec/queries/{chat_channel_memberships_query_spec.rb => chat/channel_memberships_query_spec.rb} (80%) rename plugins/chat/spec/queries/{chat_channel_unreads_query_spec.rb => chat/channel_unreads_query_spec.rb} (91%) rename plugins/chat/spec/requests/{admin/admin_incoming_chat_webhooks_controller_spec.rb => chat/admin/incoming_webhooks_controller_spec.rb} (95%) rename plugins/chat/spec/requests/{ => chat}/api/category_chatables_controller_spec.rb (100%) rename plugins/chat/spec/requests/{api/chat_channel_threads_controller_spec.rb => chat/api/channel_threads_controller_spec.rb} (98%) rename plugins/chat/spec/requests/{api/chat_channels_archives_controller_spec.rb => chat/api/channels_archives_controller_spec.rb} (91%) rename plugins/chat/spec/requests/{api/chat_channels_controller_spec.rb => chat/api/channels_controller_spec.rb} (96%) rename plugins/chat/spec/requests/{api/chat_channels_current_user_membership_spec.rb => chat/api/channels_current_user_membership_spec.rb} (90%) rename plugins/chat/spec/requests/{api/chat_channels_current_user_notifications_settings_controller_spec.rb => chat/api/channels_current_user_notifications_settings_controller_spec.rb} (91%) rename plugins/chat/spec/requests/{api/chat_channels_moves_controller_spec.rb => chat/api/channels_moves_controller_spec.rb} (96%) rename plugins/chat/spec/requests/{api/chat_channels_status_controller_spec.rb => chat/api/channels_status_controller_spec.rb} (97%) rename plugins/chat/spec/requests/{api/chat_chatables_controller_spec.rb => chat/api/chatables_controller_spec.rb} (96%) rename plugins/chat/spec/requests/{api/chat_current_user_channels_spec.rb => chat/api/current_user_channels_spec.rb} (98%) rename plugins/chat/spec/requests/{ => chat}/api/hints_controller_spec.rb (100%) rename plugins/chat/spec/serializer/{chat_channel_serializer_spec.rb => chat/channel_serializer_spec.rb} (91%) rename plugins/chat/spec/serializer/{ => chat}/chat_message_serializer_spec.rb (97%) rename plugins/chat/spec/serializer/{ => chat}/chat_message_user_serializer_spec.rb (97%) rename plugins/chat/spec/serializer/{ => chat}/direct_message_serializer_spec.rb (97%) rename plugins/chat/spec/serializer/{chat_in_reply_to_serializer_spec.rb => chat/in_reply_to_serializer_spec.rb} (95%) rename plugins/chat/spec/serializer/{ => chat}/structured_channel_serializer_spec.rb (97%) rename plugins/chat/spec/serializer/{core_ext => }/current_user_serializer_spec.rb (100%) rename plugins/chat/spec/services/{ => chat}/lookup_thread_spec.rb (95%) rename plugins/chat/spec/services/{chat_message_destroyer_spec.rb => chat/message_destroyer_spec.rb} (82%) rename plugins/chat/spec/services/{chat_publisher_spec.rb => chat/publisher_spec.rb} (73%) rename plugins/chat/spec/services/{ => chat}/trash_channel_spec.rb (92%) rename plugins/chat/spec/services/{ => chat}/update_channel_spec.rb (92%) rename plugins/chat/spec/services/{ => chat}/update_channel_status_spec.rb (90%) rename plugins/chat/spec/services/{ => chat}/update_user_last_read_spec.rb (95%) diff --git a/app/models/reviewable.rb b/app/models/reviewable.rb index 02b1b47421b..759b8e6e4aa 100644 --- a/app/models/reviewable.rb +++ b/app/models/reviewable.rb @@ -129,7 +129,7 @@ class Reviewable < ActiveRecord::Base update_args = { status: statuses[:pending], id: target.id, - type: target.class.name, + type: target.class.sti_name, potential_spam: potential_spam == true ? true : nil, } diff --git a/plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb b/plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb deleted file mode 100644 index 24bcd25abda..00000000000 --- a/plugins/chat/app/controllers/admin/admin_incoming_chat_webhooks_controller.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -class Chat::AdminIncomingChatWebhooksController < Admin::AdminController - requires_plugin Chat::PLUGIN_NAME - - def index - render_serialized( - { - chat_channels: ChatChannel.public_channels, - incoming_chat_webhooks: IncomingChatWebhook.includes(:chat_channel).all, - }, - AdminChatIndexSerializer, - root: false, - ) - end - - def create - params.require(%i[name chat_channel_id]) - - chat_channel = ChatChannel.find_by(id: params[:chat_channel_id]) - raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel? - - webhook = IncomingChatWebhook.new(name: params[:name], chat_channel: chat_channel) - if webhook.save - render_serialized(webhook, IncomingChatWebhookSerializer, root: false) - else - render_json_error(webhook) - end - end - - def update - params.require(%i[incoming_chat_webhook_id name chat_channel_id]) - - webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id]) - raise Discourse::NotFound unless webhook - - chat_channel = ChatChannel.find_by(id: params[:chat_channel_id]) - raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel? - - if webhook.update( - name: params[:name], - description: params[:description], - emoji: params[:emoji], - username: params[:username], - chat_channel: chat_channel, - ) - render json: success_json - else - render_json_error(webhook) - end - end - - def destroy - params.require(:incoming_chat_webhook_id) - - webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id]) - webhook.destroy if webhook - render json: success_json - end -end diff --git a/plugins/chat/app/controllers/api/chat_channels_status_controller.rb b/plugins/chat/app/controllers/api/chat_channels_status_controller.rb deleted file mode 100644 index 863ee6f4f33..00000000000 --- a/plugins/chat/app/controllers/api/chat_channels_status_controller.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class Chat::Api::ChatChannelsStatusController < Chat::Api::ChatChannelsController - def update - with_service(Chat::Service::UpdateChannelStatus) do - on_success { render_serialized(result.channel, ChatChannelSerializer, root: "channel") } - on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound } - on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess } - end - end -end diff --git a/plugins/chat/app/controllers/api/chat_current_user_channels_controller.rb b/plugins/chat/app/controllers/api/chat_current_user_channels_controller.rb deleted file mode 100644 index ecc01163606..00000000000 --- a/plugins/chat/app/controllers/api/chat_current_user_channels_controller.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class Chat::Api::ChatCurrentUserChannelsController < Chat::Api - def index - structured = Chat::ChatChannelFetcher.structured(guardian) - render_serialized(structured, ChatChannelIndexSerializer, root: false) - end -end diff --git a/plugins/chat/app/controllers/api_controller.rb b/plugins/chat/app/controllers/api_controller.rb deleted file mode 100644 index 70bf35dc60c..00000000000 --- a/plugins/chat/app/controllers/api_controller.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -class Chat::Api < Chat::ChatBaseController - before_action :ensure_logged_in - before_action :ensure_can_chat - - include Chat::WithServiceHelper - - private - - def ensure_can_chat - raise Discourse::NotFound unless SiteSetting.chat_enabled - guardian.ensure_can_chat! - end - - def default_actions_for_service - proc do - on_success { render(json: success_json) } - on_failure { render(json: failed_json, status: 422) } - on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess } - on_failed_contract do - render( - json: failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages), - status: 400, - ) - end - end - end -end diff --git a/plugins/chat/app/controllers/chat/admin/incoming_webhooks_controller.rb b/plugins/chat/app/controllers/chat/admin/incoming_webhooks_controller.rb new file mode 100644 index 00000000000..14932e9212f --- /dev/null +++ b/plugins/chat/app/controllers/chat/admin/incoming_webhooks_controller.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Chat + module Admin + class IncomingWebhooksController < ::Admin::AdminController + requires_plugin Chat::PLUGIN_NAME + + def index + render_serialized( + { + chat_channels: Chat::Channel.public_channels, + incoming_chat_webhooks: Chat::IncomingWebhook.includes(:chat_channel).all, + }, + Chat::AdminChatIndexSerializer, + root: false, + ) + end + + def create + params.require(%i[name chat_channel_id]) + + chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id]) + raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel? + + webhook = Chat::IncomingWebhook.new(name: params[:name], chat_channel: chat_channel) + if webhook.save + render_serialized(webhook, Chat::IncomingWebhookSerializer, root: false) + else + render_json_error(webhook) + end + end + + def update + params.require(%i[incoming_chat_webhook_id name chat_channel_id]) + + webhook = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id]) + raise Discourse::NotFound unless webhook + + chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id]) + raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel? + + if webhook.update( + name: params[:name], + description: params[:description], + emoji: params[:emoji], + username: params[:username], + chat_channel: chat_channel, + ) + render json: success_json + else + render_json_error(webhook) + end + end + + def destroy + params.require(:incoming_chat_webhook_id) + + webhook = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id]) + webhook.destroy if webhook + render json: success_json + end + end + end +end diff --git a/plugins/chat/app/controllers/api/category_chatables_controller.rb b/plugins/chat/app/controllers/chat/api/category_chatables_controller.rb similarity index 100% rename from plugins/chat/app/controllers/api/category_chatables_controller.rb rename to plugins/chat/app/controllers/chat/api/category_chatables_controller.rb diff --git a/plugins/chat/app/controllers/api/chat_channel_threads_controller.rb b/plugins/chat/app/controllers/chat/api/channel_threads_controller.rb similarity index 59% rename from plugins/chat/app/controllers/api/chat_channel_threads_controller.rb rename to plugins/chat/app/controllers/chat/api/channel_threads_controller.rb index 58baa9bb8a4..62a3525e749 100644 --- a/plugins/chat/app/controllers/api/chat_channel_threads_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channel_threads_controller.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelThreadsController < Chat::Api +class Chat::Api::ChannelThreadsController < Chat::ApiController def show - with_service(Chat::Service::LookupThread) do - on_success { render_serialized(result.thread, ChatThreadSerializer, root: "thread") } + with_service(::Chat::LookupThread) do + on_success { render_serialized(result.thread, ::Chat::ThreadSerializer, root: "thread") } on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound } on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound } on_model_not_found(:thread) { raise Discourse::NotFound } diff --git a/plugins/chat/app/controllers/api/chat_channels_archives_controller.rb b/plugins/chat/app/controllers/chat/api/channels_archives_controller.rb similarity index 82% rename from plugins/chat/app/controllers/api/chat_channels_archives_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_archives_controller.rb index ca5640e9925..51ac0be0fdb 100644 --- a/plugins/chat/app/controllers/api/chat_channels_archives_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_archives_controller.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsArchivesController < Chat::Api::ChannelsController def create existing_archive = channel_from_params.chat_channel_archive if existing_archive.present? guardian.ensure_can_change_channel_status!(channel_from_params, :archived) raise Discourse::InvalidAccess if !existing_archive.failed? - Chat::ChatChannelArchiveService.retry_archive_process(chat_channel: channel_from_params) + Chat::ChannelArchiveService.retry_archive_process(chat_channel: channel_from_params) return render json: success_json end @@ -20,12 +20,12 @@ class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsControl end begin - Chat::ChatChannelArchiveService.create_archive_process( + Chat::ChannelArchiveService.create_archive_process( chat_channel: channel_from_params, acting_user: current_user, topic_params: topic_params, ) - rescue Chat::ChatChannelArchiveService::ArchiveValidationError => err + rescue Chat::ChannelArchiveService::ArchiveValidationError => err return render json: failed_json.merge(errors: err.errors), status: 400 end diff --git a/plugins/chat/app/controllers/api/chat_channels_controller.rb b/plugins/chat/app/controllers/chat/api/channels_controller.rb similarity index 82% rename from plugins/chat/app/controllers/api/chat_channels_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_controller.rb index 992c58cd6e9..0cd2c03f55d 100644 --- a/plugins/chat/app/controllers/api/chat_channels_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_controller.rb @@ -3,19 +3,19 @@ CHANNEL_EDITABLE_PARAMS = %i[name description slug] CATEGORY_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users allow_channel_wide_mentions] -class Chat::Api::ChatChannelsController < Chat::Api +class Chat::Api::ChannelsController < Chat::ApiController def index permitted = params.permit(:filter, :limit, :offset, :status) options = { filter: permitted[:filter], limit: (permitted[:limit] || 25).to_i } options[:offset] = permitted[:offset].to_i - options[:status] = ChatChannel.statuses[permitted[:status]] ? permitted[:status] : nil + options[:status] = Chat::Channel.statuses[permitted[:status]] ? permitted[:status] : nil - memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user) - channels = Chat::ChatChannelFetcher.secured_public_channels(guardian, memberships, options) + memberships = Chat::ChannelMembershipManager.all_for_user(current_user) + channels = Chat::ChannelFetcher.secured_public_channels(guardian, memberships, options) serialized_channels = channels.map do |channel| - ChatChannelSerializer.new( + Chat::ChannelSerializer.new( channel, scope: Guardian.new(current_user), membership: memberships.find { |membership| membership.chat_channel_id == channel.id }, @@ -29,7 +29,7 @@ class Chat::Api::ChatChannelsController < Chat::Api end def destroy - with_service Chat::Service::TrashChannel do + with_service Chat::TrashChannel do on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound } end end @@ -43,7 +43,7 @@ class Chat::Api::ChatChannelsController < Chat::Api raise Discourse::InvalidParameters.new(:name) end - if ChatChannel.exists?( + if Chat::Channel.exists?( chatable_type: "Category", chatable_id: channel_params[:chatable_id], name: channel_params[:name], @@ -69,12 +69,12 @@ class Chat::Api::ChatChannelsController < Chat::Api channel.user_chat_channel_memberships.create!(user: current_user, following: true) if channel.auto_join_users - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships + Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships end render_serialized( channel, - ChatChannelSerializer, + Chat::ChannelSerializer, membership: channel.membership_for(current_user), root: "channel", ) @@ -83,7 +83,7 @@ class Chat::Api::ChatChannelsController < Chat::Api def show render_serialized( channel_from_params, - ChatChannelSerializer, + Chat::ChannelSerializer, membership: channel_from_params.membership_for(current_user), root: "channel", ) @@ -96,11 +96,11 @@ class Chat::Api::ChatChannelsController < Chat::Api auto_join_limiter(channel_from_params).performed! end - with_service(Chat::Service::UpdateChannel, **params_to_edit) do + with_service(Chat::UpdateChannel, **params_to_edit) do on_success do render_serialized( result.channel, - ChatChannelSerializer, + Chat::ChannelSerializer, root: "channel", membership: result.channel.membership_for(current_user), ) @@ -116,7 +116,7 @@ class Chat::Api::ChatChannelsController < Chat::Api def channel_from_params @channel ||= begin - channel = ChatChannel.find(params.require(:channel_id)) + channel = Chat::Channel.find(params.require(:channel_id)) guardian.ensure_can_preview_chat_channel!(channel) channel end @@ -126,7 +126,7 @@ class Chat::Api::ChatChannelsController < Chat::Api @membership ||= begin membership = - Chat::ChatChannelMembershipManager.new(channel_from_params).find_for_user(current_user) + Chat::ChannelMembershipManager.new(channel_from_params).find_for_user(current_user) raise Discourse::NotFound if membership.blank? membership end diff --git a/plugins/chat/app/controllers/api/chat_channels_current_user_membership_controller.rb b/plugins/chat/app/controllers/chat/api/channels_current_user_membership_controller.rb similarity index 65% rename from plugins/chat/app/controllers/api/chat_channels_current_user_membership_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_current_user_membership_controller.rb index 91422f9d673..5f1e4b2af14 100644 --- a/plugins/chat/app/controllers/api/chat_channels_current_user_membership_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_current_user_membership_controller.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsCurrentUserMembershipController < Chat::Api::ChannelsController def create guardian.ensure_can_join_chat_channel!(channel_from_params) render_serialized( channel_from_params.add(current_user), - UserChatChannelMembershipSerializer, + Chat::UserChannelMembershipSerializer, root: "membership", ) end @@ -14,7 +14,7 @@ class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatCh def destroy render_serialized( channel_from_params.remove(current_user), - UserChatChannelMembershipSerializer, + Chat::UserChannelMembershipSerializer, root: "membership", ) end diff --git a/plugins/chat/app/controllers/api/chat_channels_current_user_notifications_settings_controller.rb b/plugins/chat/app/controllers/chat/api/channels_current_user_notifications_settings_controller.rb similarity index 71% rename from plugins/chat/app/controllers/api/chat_channels_current_user_notifications_settings_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_current_user_notifications_settings_controller.rb index d9a8f4ac57e..6c39585d3ec 100644 --- a/plugins/chat/app/controllers/api/chat_channels_current_user_notifications_settings_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_current_user_notifications_settings_controller.rb @@ -2,13 +2,13 @@ MEMBERSHIP_EDITABLE_PARAMS = %i[muted desktop_notification_level mobile_notification_level] -class Chat::Api::ChatChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChannelsController def update settings_params = params.require(:notifications_settings).permit(MEMBERSHIP_EDITABLE_PARAMS) membership_from_params.update!(settings_params.to_h) render_serialized( membership_from_params, - UserChatChannelMembershipSerializer, + Chat::UserChannelMembershipSerializer, root: "membership", ) end diff --git a/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb b/plugins/chat/app/controllers/chat/api/channels_memberships_controller.rb similarity index 79% rename from plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_memberships_controller.rb index d6fe2fd4ad9..0392b3bb177 100644 --- a/plugins/chat/app/controllers/api/chat_channels_memberships_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_memberships_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController def index params.permit(:username, :offset, :limit) @@ -8,7 +8,7 @@ class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsCont limit = (params[:limit] || 50).to_i.clamp(1, 50) memberships = - ChatChannelMembershipsQuery.call( + Chat::ChannelMembershipsQuery.call( channel: channel_from_params, offset: offset, limit: limit, @@ -17,7 +17,7 @@ class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsCont render_serialized( memberships, - UserChatChannelMembershipSerializer, + Chat::UserChannelMembershipSerializer, root: "memberships", meta: { total_rows: channel_from_params.user_count, diff --git a/plugins/chat/app/controllers/api/chat_channels_messages_moves_controller.rb b/plugins/chat/app/controllers/chat/api/channels_messages_moves_controller.rb similarity index 82% rename from plugins/chat/app/controllers/api/chat_channels_messages_moves_controller.rb rename to plugins/chat/app/controllers/chat/api/channels_messages_moves_controller.rb index d0d3ff1777f..100b9330492 100644 --- a/plugins/chat/app/controllers/api/chat_channels_messages_moves_controller.rb +++ b/plugins/chat/app/controllers/chat/api/channels_messages_moves_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsController +class Chat::Api::ChannelsMessagesMovesController < Chat::Api::ChannelsController def create move_params = params.require(:move) move_params.require(:message_ids) @@ -8,10 +8,7 @@ class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsCo raise Discourse::InvalidAccess if !guardian.can_move_chat_messages?(channel_from_params) destination_channel = - Chat::ChatChannelFetcher.find_with_access_check( - move_params[:destination_channel_id], - guardian, - ) + Chat::ChannelFetcher.find_with_access_check(move_params[:destination_channel_id], guardian) begin message_ids = move_params[:message_ids].map(&:to_i) diff --git a/plugins/chat/app/controllers/chat/api/channels_status_controller.rb b/plugins/chat/app/controllers/chat/api/channels_status_controller.rb new file mode 100644 index 00000000000..38e83f23104 --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/channels_status_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Chat::Api::ChannelsStatusController < Chat::Api::ChannelsController + def update + with_service(Chat::UpdateChannelStatus) do + on_success { render_serialized(result.channel, Chat::ChannelSerializer, root: "channel") } + on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound } + on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess } + end + end +end diff --git a/plugins/chat/app/controllers/api/chat_chatables_controller.rb b/plugins/chat/app/controllers/chat/api/chatables_controller.rb similarity index 89% rename from plugins/chat/app/controllers/api/chat_chatables_controller.rb rename to plugins/chat/app/controllers/chat/api/chatables_controller.rb index 9eaec32b89b..c58a4796456 100644 --- a/plugins/chat/app/controllers/api/chat_chatables_controller.rb +++ b/plugins/chat/app/controllers/chat/api/chatables_controller.rb @@ -1,13 +1,14 @@ # frozen_string_literal: true -class Chat::Api::ChatChatablesController < Chat::Api +class Chat::Api::ChatablesController < Chat::ApiController def index params.require(:filter) filter = params[:filter].downcase - memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user) + memberships = Chat::ChannelMembershipManager.all_for_user(current_user) + public_channels = - Chat::ChatChannelFetcher.secured_public_channels( + Chat::ChannelFetcher.secured_public_channels( guardian, memberships, filter: filter, @@ -41,7 +42,7 @@ class Chat::Api::ChatChatablesController < Chat::Api direct_message_channels = if users.count > 0 # FIXME: investigate the cost of this query - ChatChannel + Chat::Channel .includes(chatable: :users) .joins(direct_message: :direct_message_users) .group(1) @@ -75,7 +76,7 @@ class Chat::Api::ChatChatablesController < Chat::Api users: users_without_channel, memberships: memberships, }, - ChatChannelSearchSerializer, + Chat::ChannelSearchSerializer, root: false, ) end diff --git a/plugins/chat/app/controllers/chat/api/current_user_channels_controller.rb b/plugins/chat/app/controllers/chat/api/current_user_channels_controller.rb new file mode 100644 index 00000000000..613af229090 --- /dev/null +++ b/plugins/chat/app/controllers/chat/api/current_user_channels_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Chat::Api::CurrentUserChannelsController < Chat::ApiController + def index + structured = Chat::ChannelFetcher.structured(guardian) + render_serialized(structured, Chat::ChannelIndexSerializer, root: false) + end +end diff --git a/plugins/chat/app/controllers/api/hints_controller.rb b/plugins/chat/app/controllers/chat/api/hints_controller.rb similarity index 100% rename from plugins/chat/app/controllers/api/hints_controller.rb rename to plugins/chat/app/controllers/chat/api/hints_controller.rb diff --git a/plugins/chat/app/controllers/chat/api_controller.rb b/plugins/chat/app/controllers/chat/api_controller.rb new file mode 100644 index 00000000000..b95446c6cd8 --- /dev/null +++ b/plugins/chat/app/controllers/chat/api_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Chat + class ApiController < ::Chat::BaseController + before_action :ensure_logged_in + before_action :ensure_can_chat + + include Chat::WithServiceHelper + + private + + def ensure_can_chat + raise Discourse::NotFound unless SiteSetting.chat_enabled + guardian.ensure_can_chat! + end + + def default_actions_for_service + proc do + on_success { render(json: success_json) } + on_failure { render(json: failed_json, status: 422) } + on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess } + on_failed_contract do + render( + json: + failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages), + status: 400, + ) + end + end + end + end +end diff --git a/plugins/chat/app/controllers/chat/base_controller.rb b/plugins/chat/app/controllers/chat/base_controller.rb new file mode 100644 index 00000000000..3f7e2691c29 --- /dev/null +++ b/plugins/chat/app/controllers/chat/base_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Chat + class BaseController < ::ApplicationController + before_action :ensure_logged_in + before_action :ensure_can_chat + + private + + def ensure_can_chat + raise Discourse::NotFound unless SiteSetting.chat_enabled + guardian.ensure_can_chat! + end + + def set_channel_and_chatable_with_access_check(chat_channel_id: nil) + params.require(:chat_channel_id) if chat_channel_id.blank? + id_or_name = chat_channel_id || params[:chat_channel_id] + @chat_channel = Chat::ChannelFetcher.find_with_access_check(id_or_name, guardian) + @chatable = @chat_channel.chatable + end + end +end diff --git a/plugins/chat/app/controllers/chat/chat_controller.rb b/plugins/chat/app/controllers/chat/chat_controller.rb new file mode 100644 index 00000000000..fcb918f1e43 --- /dev/null +++ b/plugins/chat/app/controllers/chat/chat_controller.rb @@ -0,0 +1,481 @@ +# frozen_string_literal: true + +module Chat + class ChatController < ::Chat::BaseController + PAST_MESSAGE_LIMIT = 40 + FUTURE_MESSAGE_LIMIT = 40 + PAST = "past" + FUTURE = "future" + CHAT_DIRECTIONS = [PAST, FUTURE] + + # Other endpoints use set_channel_and_chatable_with_access_check, but + # these endpoints require a standalone find because they need to be + # able to get deleted channels and recover them. + before_action :find_chatable, only: %i[enable_chat disable_chat] + before_action :find_chat_message, + only: %i[delete restore lookup_message edit_message rebake message_link] + before_action :set_channel_and_chatable_with_access_check, + except: %i[ + respond + enable_chat + disable_chat + message_link + lookup_message + set_user_chat_status + dismiss_retention_reminder + flag + ] + + def respond + render + end + + def enable_chat + chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable) + + guardian.ensure_can_join_chat_channel!(chat_channel) if chat_channel + + if chat_channel && chat_channel.trashed? + chat_channel.recover! + elsif chat_channel + return render_json_error I18n.t("chat.already_enabled") + else + chat_channel = @chatable.chat_channel + guardian.ensure_can_join_chat_channel!(chat_channel) + end + + success = chat_channel.save + if success && chat_channel.chatable_has_custom_fields? + @chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true + @chatable.save! + end + + if success + membership = Chat::ChannelMembershipManager.new(channel).follow(user) + render_serialized(chat_channel, Chat::ChannelSerializer, membership: membership) + else + render_json_error(chat_channel) + end + + Chat::ChannelMembershipManager.new(channel).follow(user) + end + + def disable_chat + chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable) + guardian.ensure_can_join_chat_channel!(chat_channel) + return render json: success_json if chat_channel.trashed? + chat_channel.trash!(current_user) + + success = chat_channel.save + if success + if chat_channel.chatable_has_custom_fields? + @chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED) + @chatable.save! + end + + render json: success_json + else + render_json_error(chat_channel) + end + end + + def create_message + raise Discourse::InvalidAccess if current_user.silenced? + + Chat::MessageRateLimiter.run!(current_user) + + @user_chat_channel_membership = + Chat::ChannelMembershipManager.new(@chat_channel).find_for_user( + current_user, + following: true, + ) + raise Discourse::InvalidAccess unless @user_chat_channel_membership + + reply_to_msg_id = params[:in_reply_to_id] + if reply_to_msg_id + rm = Chat::Message.find(reply_to_msg_id) + raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id + end + + content = params[:message] + + chat_message_creator = + Chat::MessageCreator.create( + chat_channel: @chat_channel, + user: current_user, + in_reply_to_id: reply_to_msg_id, + content: content, + staged_id: params[:staged_id], + upload_ids: params[:upload_ids], + ) + + return render_json_error(chat_message_creator.error) if chat_message_creator.failed? + + @user_chat_channel_membership.update!( + last_read_message_id: chat_message_creator.chat_message.id, + ) + + if @chat_channel.direct_message_channel? + # If any of the channel users is ignoring, muting, or preventing DMs from + # the current user then we shold not auto-follow the channel once again or + # publish the new channel. + user_ids_allowing_communication = + UserCommScreener.new( + acting_user: current_user, + target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id), + ).allowing_actor_communication + + if user_ids_allowing_communication.any? + Chat::Publisher.publish_new_channel( + @chat_channel, + @chat_channel.chatable.users.where(id: user_ids_allowing_communication), + ) + + @chat_channel + .user_chat_channel_memberships + .where(user_id: user_ids_allowing_communication) + .update_all(following: true) + end + end + + Chat::Publisher.publish_user_tracking_state( + current_user, + @chat_channel.id, + chat_message_creator.chat_message.id, + ) + render json: success_json + end + + def edit_message + chat_message_updater = + Chat::MessageUpdater.update( + guardian: guardian, + chat_message: @message, + new_content: params[:new_message], + upload_ids: params[:upload_ids] || [], + ) + + return render_json_error(chat_message_updater.error) if chat_message_updater.failed? + + render json: success_json + end + + def update_user_last_read + membership = + Chat::ChannelMembershipManager.new(@chat_channel).find_for_user( + current_user, + following: true, + ) + raise Discourse::NotFound if membership.nil? + + if membership.last_read_message_id && + params[:message_id].to_i < membership.last_read_message_id + raise Discourse::InvalidParameters.new(:message_id) + end + + unless Chat::Message.with_deleted.exists?( + chat_channel_id: @chat_channel.id, + id: params[:message_id], + ) + raise Discourse::NotFound + end + + membership.update!(last_read_message_id: params[:message_id]) + + Notification + .where(notification_type: Notification.types[:chat_mention]) + .where(user: current_user) + .where(read: false) + .joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id") + .joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id") + .where("chat_messages.id <= ?", params[:message_id].to_i) + .where("chat_messages.chat_channel_id = ?", @chat_channel.id) + .update_all(read: true) + + Chat::Publisher.publish_user_tracking_state( + current_user, + @chat_channel.id, + params[:message_id], + ) + + render json: success_json + end + + def messages + page_size = params[:page_size]&.to_i || 1000 + direction = params[:direction].to_s + message_id = params[:message_id] + if page_size > 50 || + ( + message_id.blank? ^ direction.blank? && + (direction.present? && !CHAT_DIRECTIONS.include?(direction)) + ) + raise Discourse::InvalidParameters + end + + messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) + messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) + + if message_id.present? + condition = direction == PAST ? "<" : ">" + messages = messages.where("id #{condition} ?", message_id.to_i) + end + + # NOTE: This order is reversed when we return the Chat::View below if the direction + # is not FUTURE. + order = direction == FUTURE ? "ASC" : "DESC" + messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a + + can_load_more_past = nil + can_load_more_future = nil + + if direction == FUTURE + can_load_more_future = messages.size == page_size + elsif direction == PAST + can_load_more_past = messages.size == page_size + else + # When direction is blank, we'll return the latest messages. + can_load_more_future = false + can_load_more_past = messages.size == page_size + end + + chat_view = + Chat::View.new( + chat_channel: @chat_channel, + chat_messages: direction == FUTURE ? messages : messages.reverse, + user: current_user, + can_load_more_past: can_load_more_past, + can_load_more_future: can_load_more_future, + ) + render_serialized(chat_view, Chat::ViewSerializer, root: false) + end + + def react + params.require(%i[message_id emoji react_action]) + guardian.ensure_can_react! + + Chat::MessageReactor.new(current_user, @chat_channel).react!( + message_id: params[:message_id], + react_action: params[:react_action].to_sym, + emoji: params[:emoji], + ) + + render json: success_json + end + + def delete + guardian.ensure_can_delete_chat!(@message, @chatable) + + Chat::MessageDestroyer.new.trash_message(@message, current_user) + + head :ok + end + + def restore + chat_channel = @message.chat_channel + guardian.ensure_can_restore_chat!(@message, chat_channel.chatable) + updated = @message.recover! + if updated + Chat::Publisher.publish_restore!(chat_channel, @message) + render json: success_json + else + render_json_error(@message) + end + end + + def rebake + guardian.ensure_can_rebake_chat_message!(@message) + @message.rebake!(invalidate_oneboxes: true) + render json: success_json + end + + def message_link + raise Discourse::NotFound if @message.blank? || @message.deleted_at.present? + raise Discourse::NotFound if @message.chat_channel.blank? + set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id) + render json: + success_json.merge( + chat_channel_id: @chat_channel.id, + chat_channel_title: @chat_channel.title(current_user), + ) + end + + def lookup_message + set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id) + + messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) + messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) + + past_messages = + messages + .where("created_at < ?", @message.created_at) + .order(created_at: :desc) + .limit(PAST_MESSAGE_LIMIT) + + future_messages = + messages + .where("created_at > ?", @message.created_at) + .order(created_at: :asc) + .limit(FUTURE_MESSAGE_LIMIT) + + can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT + can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT + messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat) + chat_view = + Chat::View.new( + chat_channel: @chat_channel, + chat_messages: messages, + user: current_user, + can_load_more_past: can_load_more_past, + can_load_more_future: can_load_more_future, + ) + render_serialized(chat_view, Chat::ViewSerializer, root: false) + end + + def set_user_chat_status + params.require(:chat_enabled) + + current_user.user_option.update(chat_enabled: params[:chat_enabled]) + render json: { chat_enabled: current_user.user_option.chat_enabled } + end + + def invite_users + params.require(:user_ids) + + users = + User + .includes(:groups) + .joins(:user_option) + .where(user_options: { chat_enabled: true }) + .not_suspended + .where(id: params[:user_ids]) + users.each do |user| + guardian = Guardian.new(user) + if guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel) + data = { + message: "chat.invitation_notification", + chat_channel_id: @chat_channel.id, + chat_channel_title: @chat_channel.title(user), + chat_channel_slug: @chat_channel.slug, + invited_by_username: current_user.username, + } + data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id] + user.notifications.create( + notification_type: Notification.types[:chat_invitation], + high_priority: true, + data: data.to_json, + ) + end + end + + render json: success_json + end + + def dismiss_retention_reminder + params.require(:chatable_type) + guardian.ensure_can_chat! + unless Chat::Channel.chatable_types.include?(params[:chatable_type]) + raise Discourse::InvalidParameters + end + + field = + ( + if Chat::Channel.public_channel_chatable_types.include?(params[:chatable_type]) + :dismissed_channel_retention_reminder + else + :dismissed_dm_retention_reminder + end + ) + current_user.user_option.update(field => true) + render json: success_json + end + + def quote_messages + params.require(:message_ids) + + message_ids = params[:message_ids].map(&:to_i) + markdown = + Chat::TranscriptService.new( + @chat_channel, + current_user, + messages_or_ids: message_ids, + ).generate_markdown + render json: success_json.merge(markdown: markdown) + end + + def flag + RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed! + + permitted_params = + params.permit( + %i[chat_message_id flag_type_id message is_warning take_action queue_for_review], + ) + + chat_message = + Chat::Message.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id]) + + flag_type_id = permitted_params[:flag_type_id].to_i + + if !ReviewableScore.types.values.include?(flag_type_id) + raise Discourse::InvalidParameters.new(:flag_type_id) + end + + set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id) + + result = + Chat::ReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params) + + if result[:success] + render json: success_json + else + render_json_error(result[:errors]) + end + end + + def set_draft + if params[:data].present? + Chat::Draft.find_or_initialize_by( + user: current_user, + chat_channel_id: @chat_channel.id, + ).update!(data: params[:data]) + else + Chat::Draft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all + end + + render json: success_json + end + + private + + def preloaded_chat_message_query + query = + Chat::Message + .includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]]) + .includes(:revisions) + .includes(user: :primary_group) + .includes(chat_webhook_event: :incoming_chat_webhook) + .includes(reactions: :user) + .includes(:bookmarks) + .includes(:uploads) + .includes(chat_channel: :chatable) + + query = query.includes(user: :user_status) if SiteSetting.enable_user_status + + query + end + + def find_chatable + @chatable = Category.find_by(id: params[:chatable_id]) + guardian.ensure_can_moderate_chat!(@chatable) + end + + def find_chat_message + @message = preloaded_chat_message_query.with_deleted + @message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[ + :chat_channel_id + ] + @message = @message.find_by(id: params[:message_id]) + raise Discourse::NotFound unless @message + end + end +end diff --git a/plugins/chat/app/controllers/chat/direct_messages_controller.rb b/plugins/chat/app/controllers/chat/direct_messages_controller.rb new file mode 100644 index 00000000000..cb7d6981005 --- /dev/null +++ b/plugins/chat/app/controllers/chat/direct_messages_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Chat + class DirectMessagesController < ::Chat::BaseController + # NOTE: For V1 of chat channel archiving and deleting we are not doing + # anything for DM channels, their behaviour will stay as is. + def create + guardian.ensure_can_chat! + users = users_from_usernames(current_user, params) + + begin + chat_channel = + Chat::DirectMessageChannelCreator.create!(acting_user: current_user, target_users: users) + render_serialized( + chat_channel, + Chat::ChannelSerializer, + root: "channel", + membership: chat_channel.membership_for(current_user), + ) + rescue Chat::DirectMessageChannelCreator::NotAllowed => err + render_json_error(err.message) + end + end + + def index + guardian.ensure_can_chat! + users = users_from_usernames(current_user, params) + + direct_message = Chat::DirectMessage.for_user_ids(users.map(&:id).uniq) + if direct_message + chat_channel = Chat::Channel.find_by(chatable_id: direct_message) + render_serialized( + chat_channel, + Chat::ChannelSerializer, + root: "channel", + membership: chat_channel.membership_for(current_user), + ) + else + render body: nil, status: 404 + end + end + + private + + def users_from_usernames(current_user, params) + params.require(:usernames) + + usernames = + (params[:usernames].is_a?(String) ? params[:usernames].split(",") : params[:usernames]) + + users = [current_user] + other_usernames = usernames - [current_user.username] + users.concat(User.where(username: other_usernames).to_a) if other_usernames.any? + users + end + end +end diff --git a/plugins/chat/app/controllers/chat/emojis_controller.rb b/plugins/chat/app/controllers/chat/emojis_controller.rb new file mode 100644 index 00000000000..6d70cc4af96 --- /dev/null +++ b/plugins/chat/app/controllers/chat/emojis_controller.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Chat + class EmojisController < ::Chat::BaseController + def index + emojis = Emoji.all.group_by(&:group) + render json: MultiJson.dump(emojis) + end + end +end diff --git a/plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb b/plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb new file mode 100644 index 00000000000..a9f57bc7062 --- /dev/null +++ b/plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Chat + class IncomingWebhooksController < ::ApplicationController + WEBHOOK_MESSAGES_PER_MINUTE_LIMIT = 10 + + skip_before_action :verify_authenticity_token, :redirect_to_login_if_required + + before_action :validate_payload + + def create_message + debug_payload + + process_webhook_payload(text: params[:text], key: params[:key]) + end + + # See https://api.slack.com/reference/messaging/payload for the + # slack message payload format. For now we only support the + # text param, which we preprocess lightly to remove the slack-isms + # in the formatting. + def create_message_slack_compatible + debug_payload + + # See note in validate_payload on why this is needed + attachments = + if params[:payload].present? + payload = params[:payload] + if String === payload + payload = JSON.parse(payload) + payload.deep_symbolize_keys! + end + payload[:attachments] + else + params[:attachments] + end + + if params[:text].present? + text = Chat::SlackCompatibility.process_text(params[:text]) + else + text = Chat::SlackCompatibility.process_legacy_attachments(attachments) + end + + process_webhook_payload(text: text, key: params[:key]) + rescue JSON::ParserError + raise Discourse::InvalidParameters + end + + private + + def process_webhook_payload(text:, key:) + validate_message_length(text) + webhook = find_and_rate_limit_webhook(key) + + chat_message_creator = + Chat::MessageCreator.create( + chat_channel: webhook.chat_channel, + user: Discourse.system_user, + content: text, + incoming_chat_webhook: webhook, + ) + if chat_message_creator.failed? + render_json_error(chat_message_creator.error) + else + render json: success_json + end + end + + def find_and_rate_limit_webhook(key) + webhook = Chat::IncomingWebhook.includes(:chat_channel).find_by(key: key) + raise Discourse::NotFound unless webhook + + # Rate limit to 10 messages per-minute. We can move to a site setting in the future if needed. + RateLimiter.new( + nil, + "incoming_chat_webhook_#{webhook.id}", + WEBHOOK_MESSAGES_PER_MINUTE_LIMIT, + 1.minute, + ).performed! + webhook + end + + def validate_message_length(message) + return if message.length <= SiteSetting.chat_maximum_message_length + raise Discourse::InvalidParameters.new( + "Body cannot be over #{SiteSetting.chat_maximum_message_length} characters", + ) + end + + # The webhook POST body can be in 3 different formats: + # + # * { text: "message text" }, which is the most basic method, and also mirrors Slack payloads + # * { attachments: [ text: "message text" ] }, which is a variant of Slack payloads using legacy attachments + # * { payload: "", attachments: null, text: null }, where JSON STRING can look + # like the `attachments` example above (along with other attributes), which is fired by OpsGenie + def validate_payload + params.require(:key) + + if !params[:text] && !params[:payload] && !params[:attachments] + raise Discourse::InvalidParameters + end + end + + def debug_payload + return if !SiteSetting.chat_debug_webhook_payloads + Rails.logger.warn( + "Debugging chat webhook payload for endpoint #{params[:key]}: " + + JSON.dump( + { payload: params[:payload], attachments: params[:attachments], text: params[:text] }, + ), + ) + end + end +end diff --git a/plugins/chat/app/controllers/chat_base_controller.rb b/plugins/chat/app/controllers/chat_base_controller.rb deleted file mode 100644 index 6e014502ddb..00000000000 --- a/plugins/chat/app/controllers/chat_base_controller.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatBaseController < ::ApplicationController - before_action :ensure_logged_in - before_action :ensure_can_chat - - private - - def ensure_can_chat - raise Discourse::NotFound unless SiteSetting.chat_enabled - guardian.ensure_can_chat! - end - - def set_channel_and_chatable_with_access_check(chat_channel_id: nil) - params.require(:chat_channel_id) if chat_channel_id.blank? - id_or_name = chat_channel_id || params[:chat_channel_id] - @chat_channel = Chat::ChatChannelFetcher.find_with_access_check(id_or_name, guardian) - @chatable = @chat_channel.chatable - end -end diff --git a/plugins/chat/app/controllers/chat_controller.rb b/plugins/chat/app/controllers/chat_controller.rb deleted file mode 100644 index 6b6f8f57a6b..00000000000 --- a/plugins/chat/app/controllers/chat_controller.rb +++ /dev/null @@ -1,472 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatController < Chat::ChatBaseController - PAST_MESSAGE_LIMIT = 40 - FUTURE_MESSAGE_LIMIT = 40 - PAST = "past" - FUTURE = "future" - CHAT_DIRECTIONS = [PAST, FUTURE] - - # Other endpoints use set_channel_and_chatable_with_access_check, but - # these endpoints require a standalone find because they need to be - # able to get deleted channels and recover them. - before_action :find_chatable, only: %i[enable_chat disable_chat] - before_action :find_chat_message, - only: %i[delete restore lookup_message edit_message rebake message_link] - before_action :set_channel_and_chatable_with_access_check, - except: %i[ - respond - enable_chat - disable_chat - message_link - lookup_message - set_user_chat_status - dismiss_retention_reminder - flag - ] - - def respond - render - end - - def enable_chat - chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable) - - guardian.ensure_can_join_chat_channel!(chat_channel) if chat_channel - - if chat_channel && chat_channel.trashed? - chat_channel.recover! - elsif chat_channel - return render_json_error I18n.t("chat.already_enabled") - else - chat_channel = @chatable.chat_channel - guardian.ensure_can_join_chat_channel!(chat_channel) - end - - success = chat_channel.save - if success && chat_channel.chatable_has_custom_fields? - @chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true - @chatable.save! - end - - if success - membership = Chat::ChatChannelMembershipManager.new(channel).follow(user) - render_serialized(chat_channel, ChatChannelSerializer, membership: membership) - else - render_json_error(chat_channel) - end - - Chat::ChatChannelMembershipManager.new(channel).follow(user) - end - - def disable_chat - chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable) - guardian.ensure_can_join_chat_channel!(chat_channel) - return render json: success_json if chat_channel.trashed? - chat_channel.trash!(current_user) - - success = chat_channel.save - if success - if chat_channel.chatable_has_custom_fields? - @chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED) - @chatable.save! - end - - render json: success_json - else - render_json_error(chat_channel) - end - end - - def create_message - raise Discourse::InvalidAccess if current_user.silenced? - - Chat::ChatMessageRateLimiter.run!(current_user) - - @user_chat_channel_membership = - Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user( - current_user, - following: true, - ) - raise Discourse::InvalidAccess unless @user_chat_channel_membership - - reply_to_msg_id = params[:in_reply_to_id] - if reply_to_msg_id - rm = ChatMessage.find(reply_to_msg_id) - raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id - end - - content = params[:message] - - chat_message_creator = - Chat::ChatMessageCreator.create( - chat_channel: @chat_channel, - user: current_user, - in_reply_to_id: reply_to_msg_id, - content: content, - staged_id: params[:staged_id], - upload_ids: params[:upload_ids], - ) - - return render_json_error(chat_message_creator.error) if chat_message_creator.failed? - - @user_chat_channel_membership.update!( - last_read_message_id: chat_message_creator.chat_message.id, - ) - - if @chat_channel.direct_message_channel? - # If any of the channel users is ignoring, muting, or preventing DMs from - # the current user then we shold not auto-follow the channel once again or - # publish the new channel. - user_ids_allowing_communication = - UserCommScreener.new( - acting_user: current_user, - target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id), - ).allowing_actor_communication - - if user_ids_allowing_communication.any? - ChatPublisher.publish_new_channel( - @chat_channel, - @chat_channel.chatable.users.where(id: user_ids_allowing_communication), - ) - - @chat_channel - .user_chat_channel_memberships - .where(user_id: user_ids_allowing_communication) - .update_all(following: true) - end - end - - ChatPublisher.publish_user_tracking_state( - current_user, - @chat_channel.id, - chat_message_creator.chat_message.id, - ) - render json: success_json - end - - def edit_message - chat_message_updater = - Chat::ChatMessageUpdater.update( - guardian: guardian, - chat_message: @message, - new_content: params[:new_message], - upload_ids: params[:upload_ids] || [], - ) - - return render_json_error(chat_message_updater.error) if chat_message_updater.failed? - - render json: success_json - end - - def update_user_last_read - membership = - Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user( - current_user, - following: true, - ) - raise Discourse::NotFound if membership.nil? - - if membership.last_read_message_id && params[:message_id].to_i < membership.last_read_message_id - raise Discourse::InvalidParameters.new(:message_id) - end - - unless ChatMessage.with_deleted.exists?( - chat_channel_id: @chat_channel.id, - id: params[:message_id], - ) - raise Discourse::NotFound - end - - membership.update!(last_read_message_id: params[:message_id]) - - Notification - .where(notification_type: Notification.types[:chat_mention]) - .where(user: current_user) - .where(read: false) - .joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id") - .joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id") - .where("chat_messages.id <= ?", params[:message_id].to_i) - .where("chat_messages.chat_channel_id = ?", @chat_channel.id) - .update_all(read: true) - - ChatPublisher.publish_user_tracking_state(current_user, @chat_channel.id, params[:message_id]) - - render json: success_json - end - - def messages - page_size = params[:page_size]&.to_i || 1000 - direction = params[:direction].to_s - message_id = params[:message_id] - if page_size > 50 || - ( - message_id.blank? ^ direction.blank? && - (direction.present? && !CHAT_DIRECTIONS.include?(direction)) - ) - raise Discourse::InvalidParameters - end - - messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) - messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) - - if message_id.present? - condition = direction == PAST ? "<" : ">" - messages = messages.where("id #{condition} ?", message_id.to_i) - end - - # NOTE: This order is reversed when we return the ChatView below if the direction - # is not FUTURE. - order = direction == FUTURE ? "ASC" : "DESC" - messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a - - can_load_more_past = nil - can_load_more_future = nil - - if direction == FUTURE - can_load_more_future = messages.size == page_size - elsif direction == PAST - can_load_more_past = messages.size == page_size - else - # When direction is blank, we'll return the latest messages. - can_load_more_future = false - can_load_more_past = messages.size == page_size - end - - chat_view = - ChatView.new( - chat_channel: @chat_channel, - chat_messages: direction == FUTURE ? messages : messages.reverse, - user: current_user, - can_load_more_past: can_load_more_past, - can_load_more_future: can_load_more_future, - ) - render_serialized(chat_view, ChatViewSerializer, root: false) - end - - def react - params.require(%i[message_id emoji react_action]) - guardian.ensure_can_react! - - Chat::ChatMessageReactor.new(current_user, @chat_channel).react!( - message_id: params[:message_id], - react_action: params[:react_action].to_sym, - emoji: params[:emoji], - ) - - render json: success_json - end - - def delete - guardian.ensure_can_delete_chat!(@message, @chatable) - - ChatMessageDestroyer.new.trash_message(@message, current_user) - - head :ok - end - - def restore - chat_channel = @message.chat_channel - guardian.ensure_can_restore_chat!(@message, chat_channel.chatable) - updated = @message.recover! - if updated - ChatPublisher.publish_restore!(chat_channel, @message) - render json: success_json - else - render_json_error(@message) - end - end - - def rebake - guardian.ensure_can_rebake_chat_message!(@message) - @message.rebake!(invalidate_oneboxes: true) - render json: success_json - end - - def message_link - raise Discourse::NotFound if @message.blank? || @message.deleted_at.present? - raise Discourse::NotFound if @message.chat_channel.blank? - set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id) - render json: - success_json.merge( - chat_channel_id: @chat_channel.id, - chat_channel_title: @chat_channel.title(current_user), - ) - end - - def lookup_message - set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id) - - messages = preloaded_chat_message_query.where(chat_channel: @chat_channel) - messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable) - - past_messages = - messages - .where("created_at < ?", @message.created_at) - .order(created_at: :desc) - .limit(PAST_MESSAGE_LIMIT) - - future_messages = - messages - .where("created_at > ?", @message.created_at) - .order(created_at: :asc) - .limit(FUTURE_MESSAGE_LIMIT) - - can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT - can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT - messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat) - chat_view = - ChatView.new( - chat_channel: @chat_channel, - chat_messages: messages, - user: current_user, - can_load_more_past: can_load_more_past, - can_load_more_future: can_load_more_future, - ) - render_serialized(chat_view, ChatViewSerializer, root: false) - end - - def set_user_chat_status - params.require(:chat_enabled) - - current_user.user_option.update(chat_enabled: params[:chat_enabled]) - render json: { chat_enabled: current_user.user_option.chat_enabled } - end - - def invite_users - params.require(:user_ids) - - users = - User - .includes(:groups) - .joins(:user_option) - .where(user_options: { chat_enabled: true }) - .not_suspended - .where(id: params[:user_ids]) - users.each do |user| - guardian = Guardian.new(user) - if guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel) - data = { - message: "chat.invitation_notification", - chat_channel_id: @chat_channel.id, - chat_channel_title: @chat_channel.title(user), - chat_channel_slug: @chat_channel.slug, - invited_by_username: current_user.username, - } - data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id] - user.notifications.create( - notification_type: Notification.types[:chat_invitation], - high_priority: true, - data: data.to_json, - ) - end - end - - render json: success_json - end - - def dismiss_retention_reminder - params.require(:chatable_type) - guardian.ensure_can_chat! - unless ChatChannel.chatable_types.include?(params[:chatable_type]) - raise Discourse::InvalidParameters - end - - field = - ( - if ChatChannel.public_channel_chatable_types.include?(params[:chatable_type]) - :dismissed_channel_retention_reminder - else - :dismissed_dm_retention_reminder - end - ) - current_user.user_option.update(field => true) - render json: success_json - end - - def quote_messages - params.require(:message_ids) - - message_ids = params[:message_ids].map(&:to_i) - markdown = - ChatTranscriptService.new( - @chat_channel, - current_user, - messages_or_ids: message_ids, - ).generate_markdown - render json: success_json.merge(markdown: markdown) - end - - def flag - RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed! - - permitted_params = - params.permit( - %i[chat_message_id flag_type_id message is_warning take_action queue_for_review], - ) - - chat_message = - ChatMessage.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id]) - - flag_type_id = permitted_params[:flag_type_id].to_i - - if !ReviewableScore.types.values.include?(flag_type_id) - raise Discourse::InvalidParameters.new(:flag_type_id) - end - - set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id) - - result = - Chat::ChatReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params) - - if result[:success] - render json: success_json - else - render_json_error(result[:errors]) - end - end - - def set_draft - if params[:data].present? - ChatDraft.find_or_initialize_by( - user: current_user, - chat_channel_id: @chat_channel.id, - ).update!(data: params[:data]) - else - ChatDraft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all - end - - render json: success_json - end - - private - - def preloaded_chat_message_query - query = - ChatMessage - .includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]]) - .includes(:revisions) - .includes(user: :primary_group) - .includes(chat_webhook_event: :incoming_chat_webhook) - .includes(reactions: :user) - .includes(:bookmarks) - .includes(:uploads) - .includes(chat_channel: :chatable) - - query = query.includes(user: :user_status) if SiteSetting.enable_user_status - - query - end - - def find_chatable - @chatable = Category.find_by(id: params[:chatable_id]) - guardian.ensure_can_moderate_chat!(@chatable) - end - - def find_chat_message - @message = preloaded_chat_message_query.with_deleted - @message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[:chat_channel_id] - @message = @message.find_by(id: params[:message_id]) - raise Discourse::NotFound unless @message - end -end diff --git a/plugins/chat/app/controllers/direct_messages_controller.rb b/plugins/chat/app/controllers/direct_messages_controller.rb deleted file mode 100644 index b0100a95a89..00000000000 --- a/plugins/chat/app/controllers/direct_messages_controller.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -class Chat::DirectMessagesController < Chat::ChatBaseController - # NOTE: For V1 of chat channel archiving and deleting we are not doing - # anything for DM channels, their behaviour will stay as is. - def create - guardian.ensure_can_chat! - users = users_from_usernames(current_user, params) - - begin - chat_channel = - Chat::DirectMessageChannelCreator.create!(acting_user: current_user, target_users: users) - render_serialized( - chat_channel, - ChatChannelSerializer, - root: "channel", - membership: chat_channel.membership_for(current_user), - ) - rescue Chat::DirectMessageChannelCreator::NotAllowed => err - render_json_error(err.message) - end - end - - def index - guardian.ensure_can_chat! - users = users_from_usernames(current_user, params) - - direct_message = DirectMessage.for_user_ids(users.map(&:id).uniq) - if direct_message - chat_channel = ChatChannel.find_by(chatable: direct_message) - render_serialized( - chat_channel, - ChatChannelSerializer, - root: "channel", - membership: chat_channel.membership_for(current_user), - ) - else - render body: nil, status: 404 - end - end - - private - - def users_from_usernames(current_user, params) - params.require(:usernames) - - usernames = - (params[:usernames].is_a?(String) ? params[:usernames].split(",") : params[:usernames]) - - users = [current_user] - other_usernames = usernames - [current_user.username] - users.concat(User.where(username: other_usernames).to_a) if other_usernames.any? - users - end -end diff --git a/plugins/chat/app/controllers/emojis_controller.rb b/plugins/chat/app/controllers/emojis_controller.rb deleted file mode 100644 index 8d895e2bd70..00000000000 --- a/plugins/chat/app/controllers/emojis_controller.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class Chat::EmojisController < Chat::ChatBaseController - def index - emojis = Emoji.all.group_by(&:group) - render json: MultiJson.dump(emojis) - end -end diff --git a/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb b/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb deleted file mode 100644 index 58d730cbc65..00000000000 --- a/plugins/chat/app/controllers/incoming_chat_webhooks_controller.rb +++ /dev/null @@ -1,111 +0,0 @@ -# frozen_string_literal: true - -class Chat::IncomingChatWebhooksController < ApplicationController - WEBHOOK_MESSAGES_PER_MINUTE_LIMIT = 10 - - skip_before_action :verify_authenticity_token, :redirect_to_login_if_required - - before_action :validate_payload - - def create_message - debug_payload - - process_webhook_payload(text: params[:text], key: params[:key]) - end - - # See https://api.slack.com/reference/messaging/payload for the - # slack message payload format. For now we only support the - # text param, which we preprocess lightly to remove the slack-isms - # in the formatting. - def create_message_slack_compatible - debug_payload - - # See note in validate_payload on why this is needed - attachments = - if params[:payload].present? - payload = params[:payload] - if String === payload - payload = JSON.parse(payload) - payload.deep_symbolize_keys! - end - payload[:attachments] - else - params[:attachments] - end - - if params[:text].present? - text = Chat::SlackCompatibility.process_text(params[:text]) - else - text = Chat::SlackCompatibility.process_legacy_attachments(attachments) - end - - process_webhook_payload(text: text, key: params[:key]) - rescue JSON::ParserError - raise Discourse::InvalidParameters - end - - private - - def process_webhook_payload(text:, key:) - validate_message_length(text) - webhook = find_and_rate_limit_webhook(key) - - chat_message_creator = - Chat::ChatMessageCreator.create( - chat_channel: webhook.chat_channel, - user: Discourse.system_user, - content: text, - incoming_chat_webhook: webhook, - ) - if chat_message_creator.failed? - render_json_error(chat_message_creator.error) - else - render json: success_json - end - end - - def find_and_rate_limit_webhook(key) - webhook = IncomingChatWebhook.includes(:chat_channel).find_by(key: key) - raise Discourse::NotFound unless webhook - - # Rate limit to 10 messages per-minute. We can move to a site setting in the future if needed. - RateLimiter.new( - nil, - "incoming_chat_webhook_#{webhook.id}", - WEBHOOK_MESSAGES_PER_MINUTE_LIMIT, - 1.minute, - ).performed! - webhook - end - - def validate_message_length(message) - return if message.length <= SiteSetting.chat_maximum_message_length - raise Discourse::InvalidParameters.new( - "Body cannot be over #{SiteSetting.chat_maximum_message_length} characters", - ) - end - - # The webhook POST body can be in 3 different formats: - # - # * { text: "message text" }, which is the most basic method, and also mirrors Slack payloads - # * { attachments: [ text: "message text" ] }, which is a variant of Slack payloads using legacy attachments - # * { payload: "", attachments: null, text: null }, where JSON STRING can look - # like the `attachments` example above (along with other attributes), which is fired by OpsGenie - def validate_payload - params.require(:key) - - if !params[:text] && !params[:payload] && !params[:attachments] - raise Discourse::InvalidParameters - end - end - - def debug_payload - return if !SiteSetting.chat_debug_webhook_payloads - Rails.logger.warn( - "Debugging chat webhook payload for endpoint #{params[:key]}: " + - JSON.dump( - { payload: params[:payload], attachments: params[:attachments], text: params[:text] }, - ), - ) - end -end diff --git a/plugins/chat/app/core_ext/plugin_instance.rb b/plugins/chat/app/core_ext/plugin_instance.rb deleted file mode 100644 index 9e38199f2ed..00000000000 --- a/plugins/chat/app/core_ext/plugin_instance.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -DiscoursePluginRegistry.define_register(:chat_markdown_features, Set) - -class Plugin::Instance - def chat - ChatPluginApiExtensions - end - - module ChatPluginApiExtensions - def self.enable_markdown_feature(name) - DiscoursePluginRegistry.chat_markdown_features << name - end - end -end diff --git a/plugins/chat/app/helpers/with_service_helper.rb b/plugins/chat/app/helpers/chat/with_service_helper.rb similarity index 88% rename from plugins/chat/app/helpers/with_service_helper.rb rename to plugins/chat/app/helpers/chat/with_service_helper.rb index 78b4d923ba7..c8e820cc2c3 100644 --- a/plugins/chat/app/helpers/with_service_helper.rb +++ b/plugins/chat/app/helpers/chat/with_service_helper.rb @@ -12,7 +12,7 @@ module Chat instance_exec(&object.method(:default_actions_for_service).call) if default_actions instance_exec(&(block || proc {})) end - Chat::ServiceRunner.call(service, object, **dependencies, &merged_block) + ServiceRunner.call(service, object, **dependencies, &merged_block) end def run_service(service, dependencies) diff --git a/plugins/chat/app/jobs/regular/auto_join_channel_batch.rb b/plugins/chat/app/jobs/regular/auto_join_channel_batch.rb deleted file mode 100644 index 16d01e96a94..00000000000 --- a/plugins/chat/app/jobs/regular/auto_join_channel_batch.rb +++ /dev/null @@ -1,81 +0,0 @@ -# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well. -# frozen_string_literal: true - -module Jobs - class AutoJoinChannelBatch < ::Jobs::Base - def execute(args) - return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank? - start_user_id = args[:starts_at].to_i - end_user_id = args[:ends_at].to_i - - return "End is higher than start" if end_user_id < start_user_id - - channel = - ChatChannel.find_by( - id: args[:chat_channel_id], - auto_join_users: true, - chatable_type: "Category", - ) - - return if !channel - - category = channel.chatable - return if !category - - query_args = { - chat_channel_id: channel.id, - start: start_user_id, - end: end_user_id, - suspended_until: Time.zone.now, - last_seen_at: 3.months.ago, - channel_category: channel.chatable_id, - mode: UserChatChannelMembership.join_modes[:automatic], - } - - new_member_ids = DB.query_single(create_memberships_query(category), query_args) - - # Only do this if we are running auto-join for a single user, if we - # are doing it for many then we should do it after all batches are - # complete for the channel in Jobs::AutoManageChannelMemberships - if start_user_id == end_user_id - Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count - end - - ChatPublisher.publish_new_channel(channel.reload, User.where(id: new_member_ids)) - end - - private - - def create_memberships_query(category) - query = <<~SQL - INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode) - SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode - FROM users - INNER JOIN user_options uo ON uo.user_id = users.id - LEFT OUTER JOIN user_chat_channel_memberships uccm ON - uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id - SQL - - query += <<~SQL if category.read_restricted? - INNER JOIN group_users gu ON gu.user_id = users.id - LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id - SQL - - query += <<~SQL - WHERE (users.id >= :start AND users.id <= :end) AND - users.staged IS FALSE AND users.active AND - NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND - (suspended_till IS NULL OR suspended_till <= :suspended_until) AND - (last_seen_at > :last_seen_at) AND - uo.chat_enabled AND - uccm.id IS NULL - SQL - - query += <<~SQL if category.read_restricted? - AND cg.category_id = :channel_category - SQL - - query += "RETURNING user_chat_channel_memberships.user_id" - end - end -end diff --git a/plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb b/plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb deleted file mode 100644 index 9785db5c920..00000000000 --- a/plugins/chat/app/jobs/regular/auto_manage_channel_memberships.rb +++ /dev/null @@ -1,79 +0,0 @@ -# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well. -# frozen_string_literal: true - -module Jobs - class AutoManageChannelMemberships < ::Jobs::Base - def execute(args) - channel = - ChatChannel.includes(:chatable).find_by( - id: args[:chat_channel_id], - auto_join_users: true, - chatable_type: "Category", - ) - - return if !channel&.chatable - - processed = - UserChatChannelMembership.where( - chat_channel: channel, - following: true, - join_mode: UserChatChannelMembership.join_modes[:automatic], - ).count - - auto_join_query(channel).find_in_batches do |batch| - break if processed >= SiteSetting.max_chat_auto_joined_users - - starts_at = batch.first.query_user_id - ends_at = batch.last.query_user_id - - Jobs.enqueue( - :auto_join_channel_batch, - chat_channel_id: channel.id, - starts_at: starts_at, - ends_at: ends_at, - ) - - processed += batch.size - end - - # The Jobs::AutoJoinChannelBatch job will only do this recalculation - # if it's operating on one user, so we need to make sure we do it for - # the channel here once this job is complete. - Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count - end - - private - - def auto_join_query(channel) - category = channel.chatable - - users = - User - .real - .activated - .not_suspended - .not_staged - .distinct - .select(:id, "users.id AS query_user_id") - .where("last_seen_at > ?", 3.months.ago) - .joins(:user_option) - .where(user_options: { chat_enabled: true }) - .joins(<<~SQL) - LEFT OUTER JOIN user_chat_channel_memberships uccm - ON uccm.chat_channel_id = #{channel.id} AND - uccm.user_id = users.id - SQL - .where("uccm.id IS NULL") - - if category.read_restricted? - users = - users - .joins(:group_users) - .joins("INNER JOIN category_groups cg ON cg.group_id = group_users.group_id") - .where("cg.category_id = ?", channel.chatable_id) - end - - users - end - end -end diff --git a/plugins/chat/app/jobs/regular/chat/auto_join_channel_batch.rb b/plugins/chat/app/jobs/regular/chat/auto_join_channel_batch.rb new file mode 100644 index 00000000000..19ca7e5b03a --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/auto_join_channel_batch.rb @@ -0,0 +1,83 @@ +# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well. +# frozen_string_literal: true + +module Jobs + module Chat + class AutoJoinChannelBatch < ::Jobs::Base + def execute(args) + return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank? + start_user_id = args[:starts_at].to_i + end_user_id = args[:ends_at].to_i + + return "End is higher than start" if end_user_id < start_user_id + + channel = + ::Chat::Channel.find_by( + id: args[:chat_channel_id], + auto_join_users: true, + chatable_type: "Category", + ) + + return if !channel + + category = channel.chatable + return if !category + + query_args = { + chat_channel_id: channel.id, + start: start_user_id, + end: end_user_id, + suspended_until: Time.zone.now, + last_seen_at: 3.months.ago, + channel_category: channel.chatable_id, + mode: ::Chat::UserChatChannelMembership.join_modes[:automatic], + } + + new_member_ids = DB.query_single(create_memberships_query(category), query_args) + + # Only do this if we are running auto-join for a single user, if we + # are doing it for many then we should do it after all batches are + # complete for the channel in Jobs::Chat::AutoManageChannelMemberships + if start_user_id == end_user_id + ::Chat::ChannelMembershipManager.new(channel).recalculate_user_count + end + + ::Chat::Publisher.publish_new_channel(channel.reload, User.where(id: new_member_ids)) + end + + private + + def create_memberships_query(category) + query = <<~SQL + INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode) + SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode + FROM users + INNER JOIN user_options uo ON uo.user_id = users.id + LEFT OUTER JOIN user_chat_channel_memberships uccm ON + uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id + SQL + + query += <<~SQL if category.read_restricted? + INNER JOIN group_users gu ON gu.user_id = users.id + LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id + SQL + + query += <<~SQL + WHERE (users.id >= :start AND users.id <= :end) AND + users.staged IS FALSE AND users.active AND + NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND + (suspended_till IS NULL OR suspended_till <= :suspended_until) AND + (last_seen_at > :last_seen_at) AND + uo.chat_enabled AND + uccm.id IS NULL + SQL + + query += <<~SQL if category.read_restricted? + AND cg.category_id = :channel_category + SQL + + query += "RETURNING user_chat_channel_memberships.user_id" + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/auto_manage_channel_memberships.rb b/plugins/chat/app/jobs/regular/chat/auto_manage_channel_memberships.rb new file mode 100644 index 00000000000..8fe8fbe4826 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/auto_manage_channel_memberships.rb @@ -0,0 +1,81 @@ +# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well. +# frozen_string_literal: true + +module Jobs + module Chat + class AutoManageChannelMemberships < ::Jobs::Base + def execute(args) + channel = + ::Chat::Channel.includes(:chatable).find_by( + id: args[:chat_channel_id], + auto_join_users: true, + chatable_type: "Category", + ) + + return if !channel&.chatable + + processed = + ::Chat::UserChatChannelMembership.where( + chat_channel: channel, + following: true, + join_mode: ::Chat::UserChatChannelMembership.join_modes[:automatic], + ).count + + auto_join_query(channel).find_in_batches do |batch| + break if processed >= ::SiteSetting.max_chat_auto_joined_users + + starts_at = batch.first.query_user_id + ends_at = batch.last.query_user_id + + ::Jobs.enqueue( + ::Jobs::Chat::AutoJoinChannelBatch, + chat_channel_id: channel.id, + starts_at: starts_at, + ends_at: ends_at, + ) + + processed += batch.size + end + + # The Jobs::Chat::AutoJoinChannelBatch job will only do this recalculation + # if it's operating on one user, so we need to make sure we do it for + # the channel here once this job is complete. + ::Chat::ChannelMembershipManager.new(channel).recalculate_user_count + end + + private + + def auto_join_query(channel) + category = channel.chatable + + users = + ::User + .real + .activated + .not_suspended + .not_staged + .distinct + .select(:id, "users.id AS query_user_id") + .where("last_seen_at > ?", 3.months.ago) + .joins(:user_option) + .where(user_options: { chat_enabled: true }) + .joins(<<~SQL) + LEFT OUTER JOIN user_chat_channel_memberships uccm + ON uccm.chat_channel_id = #{channel.id} AND + uccm.user_id = users.id + SQL + .where("uccm.id IS NULL") + + if category.read_restricted? + users = + users + .joins(:group_users) + .joins("INNER JOIN category_groups cg ON cg.group_id = group_users.group_id") + .where("cg.category_id = ?", channel.chatable_id) + end + + users + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/channel_archive.rb b/plugins/chat/app/jobs/regular/chat/channel_archive.rb new file mode 100644 index 00000000000..49594fb87dc --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/channel_archive.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class ChannelArchive < ::Jobs::Base + sidekiq_options retry: false + + def execute(args = {}) + channel_archive = ::Chat::ChannelArchive.find_by(id: args[:chat_channel_archive_id]) + + # this should not really happen, but better to do this than throw an error + if channel_archive.blank? + ::Rails.logger.warn( + "Chat channel archive #{args[:chat_channel_archive_id]} could not be found, aborting archive job.", + ) + return + end + + if channel_archive.complete? + channel_archive.chat_channel.update!(status: :archived) + + ::Chat::Publisher.publish_archive_status( + channel_archive.chat_channel, + archive_status: :success, + archived_messages: channel_archive.archived_messages, + archive_topic_id: channel_archive.destination_topic_id, + total_messages: channel_archive.total_messages, + ) + + return + end + + ::DistributedMutex.synchronize( + "archive_chat_channel_#{channel_archive.chat_channel_id}", + validity: 20.minutes, + ) { ::Chat::ChannelArchiveService.new(channel_archive).execute } + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/channel_delete.rb b/plugins/chat/app/jobs/regular/chat/channel_delete.rb new file mode 100644 index 00000000000..894fb95bdb5 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/channel_delete.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class ChannelDelete < ::Jobs::Base + def execute(args = {}) + chat_channel = ::Chat::Channel.with_deleted.find_by(id: args[:chat_channel_id]) + + # this should not really happen, but better to do this than throw an error + if chat_channel.blank? + ::Rails.logger.warn( + "Chat channel #{args[:chat_channel_id]} could not be found, aborting delete job.", + ) + return + end + + ::DistributedMutex.synchronize("delete_chat_channel_#{chat_channel.id}") do + ::Rails.logger.debug("Deleting webhooks and events for channel #{chat_channel.id}") + ::Chat::Message.transaction do + webhooks = ::Chat::IncomingWebhook.where(chat_channel: chat_channel) + ::Chat::WebhookEvent.where(incoming_chat_webhook_id: webhooks.select(:id)).delete_all + webhooks.delete_all + end + + ::Rails.logger.debug("Deleting drafts and memberships for channel #{chat_channel.id}") + ::Chat::Draft.where(chat_channel: chat_channel).delete_all + ::Chat::UserChatChannelMembership.where(chat_channel: chat_channel).delete_all + + ::Rails.logger.debug( + "Deleting chat messages, mentions, revisions, and uploads for channel #{chat_channel.id}", + ) + chat_messages = ::Chat::Message.where(chat_channel: chat_channel) + delete_messages_and_related_records(chat_channel, chat_messages) if chat_messages.any? + end + end + + def delete_messages_and_related_records(chat_channel, chat_messages) + message_ids = chat_messages.pluck(:id) + + ::Chat::Message.transaction do + ::Chat::Mention.where(chat_message_id: message_ids).delete_all + ::Chat::MessageRevision.where(chat_message_id: message_ids).delete_all + ::Chat::MessageReaction.where(chat_message_id: message_ids).delete_all + + # if the uploads are not used anywhere else they will be deleted + # by the CleanUpUploads job in core + ::DB.exec("DELETE FROM chat_uploads WHERE chat_message_id IN (#{message_ids.join(",")})") + ::UploadReference.where( + target_id: message_ids, + target_type: ::Chat::Message.sti_name, + ).delete_all + + # only the messages and the channel are Trashable, everything else gets + # permanently destroyed + chat_messages.update_all( + deleted_by_id: chat_channel.deleted_by_id, + deleted_at: Time.zone.now, + ) + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/delete_user_messages.rb b/plugins/chat/app/jobs/regular/chat/delete_user_messages.rb new file mode 100644 index 00000000000..a97d1d55c38 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/delete_user_messages.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class DeleteUserMessages < ::Jobs::Base + def execute(args) + return if args[:user_id].nil? + + ::Chat::MessageDestroyer.new.destroy_in_batches( + ::Chat::Message.with_deleted.where(user_id: args[:user_id]), + ) + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/notify_mentioned.rb b/plugins/chat/app/jobs/regular/chat/notify_mentioned.rb new file mode 100644 index 00000000000..e88798a74c0 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/notify_mentioned.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class NotifyMentioned < ::Jobs::Base + def execute(args = {}) + @chat_message = + ::Chat::Message.includes(:user, :revisions, chat_channel: :chatable).find_by( + id: args[:chat_message_id], + ) + if @chat_message.nil? || + @chat_message.revisions.where("created_at > ?", args[:timestamp]).any? + return + end + + @creator = @chat_message.user + @chat_channel = @chat_message.chat_channel + @already_notified_user_ids = args[:already_notified_user_ids] || [] + user_ids_to_notify = args[:to_notify_ids_map] || {} + user_ids_to_notify.each { |mention_type, ids| process_mentions(ids, mention_type.to_sym) } + end + + private + + def get_memberships(user_ids) + query = + ::Chat::UserChatChannelMembership.includes(:user).where( + user_id: (user_ids - @already_notified_user_ids), + chat_channel_id: @chat_message.chat_channel_id, + ) + query = query.where(following: true) if @chat_channel.public_channel? + query + end + + def build_data_for(membership, identifier_type:) + data = { + chat_message_id: @chat_message.id, + chat_channel_id: @chat_channel.id, + mentioned_by_username: @creator.username, + is_direct_message_channel: @chat_channel.direct_message_channel?, + } + + if !@is_direct_message_channel + data[:chat_channel_title] = @chat_channel.title(membership.user) + data[:chat_channel_slug] = @chat_channel.slug + end + + return data if identifier_type == :direct_mentions + + case identifier_type + when :here_mentions + data[:identifier] = "here" + when :global_mentions + data[:identifier] = "all" + else + data[:identifier] = identifier_type if identifier_type + data[:is_group_mention] = true + end + + data + end + + def build_payload_for(membership, identifier_type:) + payload = { + notification_type: ::Notification.types[:chat_mention], + username: @creator.username, + tag: ::Chat::Notifier.push_notification_tag(:mention, @chat_channel.id), + excerpt: @chat_message.push_notification_excerpt, + post_url: "#{@chat_channel.relative_url}/#{@chat_message.id}", + } + + translation_prefix = + ( + if @chat_channel.direct_message_channel? + "discourse_push_notifications.popup.direct_message_chat_mention" + else + "discourse_push_notifications.popup.chat_mention" + end + ) + + translation_suffix = identifier_type == :direct_mentions ? "direct" : "other_type" + identifier_text = + case identifier_type + when :here_mentions + "@here" + when :global_mentions + "@all" + when :direct_mentions + "" + else + "@#{identifier_type}" + end + + payload[:translated_title] = ::I18n.t( + "#{translation_prefix}.#{translation_suffix}", + username: @creator.username, + identifier: identifier_text, + channel: @chat_channel.title(membership.user), + ) + + payload + end + + def create_notification!(membership, mention, mention_type) + notification_data = build_data_for(membership, identifier_type: mention_type) + is_read = ::Chat::Notifier.user_has_seen_message?(membership, @chat_message.id) + notification = + ::Notification.create!( + notification_type: ::Notification.types[:chat_mention], + user_id: membership.user_id, + high_priority: true, + data: notification_data.to_json, + read: is_read, + ) + + mention.update!(notification: notification) + end + + def send_notifications(membership, mention_type) + payload = build_payload_for(membership, identifier_type: mention_type) + + if !membership.desktop_notifications_never? && !membership.muted? + ::MessageBus.publish( + "/chat/notification-alert/#{membership.user_id}", + payload, + user_ids: [membership.user_id], + ) + end + + if !membership.mobile_notifications_never? && !membership.muted? + ::PostAlerter.push_notification(membership.user, payload) + end + end + + def process_mentions(user_ids, mention_type) + memberships = get_memberships(user_ids) + + memberships.each do |membership| + mention = ::Chat::Mention.find_by(user: membership.user, chat_message: @chat_message) + if mention.present? + create_notification!(membership, mention, mention_type) + send_notifications(membership, mention_type) + end + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/notify_watching.rb b/plugins/chat/app/jobs/regular/chat/notify_watching.rb new file mode 100644 index 00000000000..3bdae6f0fe6 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/notify_watching.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class NotifyWatching < ::Jobs::Base + def execute(args = {}) + @chat_message = + ::Chat::Message.includes(:user, chat_channel: :chatable).find_by( + id: args[:chat_message_id], + ) + return if @chat_message.nil? + + @creator = @chat_message.user + @chat_channel = @chat_message.chat_channel + @is_direct_message_channel = @chat_channel.direct_message_channel? + + always_notification_level = ::Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always] + + members = + ::Chat::UserChatChannelMembership + .includes(user: :groups) + .joins(user: :user_option) + .where(user_option: { chat_enabled: true }) + .where.not(user_id: args[:except_user_ids]) + .where(chat_channel_id: @chat_channel.id) + .where(following: true) + .where( + "desktop_notification_level = ? OR mobile_notification_level = ?", + always_notification_level, + always_notification_level, + ) + .merge(User.not_suspended) + + if @is_direct_message_channel + ::UserCommScreener + .new(acting_user: @creator, target_user_ids: members.map(&:user_id)) + .allowing_actor_communication + .each do |user_id| + send_notifications(members.find { |member| member.user_id == user_id }) + end + else + members.each { |member| send_notifications(member) } + end + end + + def send_notifications(membership) + user = membership.user + guardian = ::Guardian.new(user) + return unless guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel) + return if ::Chat::Notifier.user_has_seen_message?(membership, @chat_message.id) + return if online_user_ids.include?(user.id) + + translation_key = + ( + if @is_direct_message_channel + "discourse_push_notifications.popup.new_direct_chat_message" + else + "discourse_push_notifications.popup.new_chat_message" + end + ) + + translation_args = { username: @creator.username } + translation_args[:channel] = @chat_channel.title(user) unless @is_direct_message_channel + + payload = { + username: @creator.username, + notification_type: ::Notification.types[:chat_message], + post_url: @chat_channel.relative_url, + translated_title: ::I18n.t(translation_key, translation_args), + tag: ::Chat::Notifier.push_notification_tag(:message, @chat_channel.id), + excerpt: @chat_message.push_notification_excerpt, + } + + if membership.desktop_notifications_always? && !membership.muted? + ::MessageBus.publish("/chat/notification-alert/#{user.id}", payload, user_ids: [user.id]) + end + + if membership.mobile_notifications_always? && !membership.muted? + ::PostAlerter.push_notification(user, payload) + end + end + + def online_user_ids + @online_user_ids ||= ::PresenceChannel.new("/chat/online").user_ids + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/process_message.rb b/plugins/chat/app/jobs/regular/chat/process_message.rb new file mode 100644 index 00000000000..33fcc43b565 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/process_message.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class ProcessMessage < ::Jobs::Base + def execute(args = {}) + ::DistributedMutex.synchronize( + "jobs_chat_process_message_#{args[:chat_message_id]}", + validity: 10.minutes, + ) do + chat_message = ::Chat::Message.find_by(id: args[:chat_message_id]) + return if !chat_message + processor = ::Chat::MessageProcessor.new(chat_message) + processor.run! + + if args[:is_dirty] || processor.dirty? + chat_message.update( + cooked: processor.html, + cooked_version: ::Chat::Message::BAKED_VERSION, + ) + ::Chat::Publisher.publish_processed!(chat_message) + end + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/send_message_notifications.rb b/plugins/chat/app/jobs/regular/chat/send_message_notifications.rb new file mode 100644 index 00000000000..4724f5a6f94 --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/send_message_notifications.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class SendMessageNotifications < ::Jobs::Base + def execute(args) + reason = args[:reason] + valid_reasons = %w[new edit] + return unless valid_reasons.include?(reason) + + return if (timestamp = args[:timestamp]).blank? + + return if (message = ::Chat::Message.find_by(id: args[:chat_message_id])).nil? + + if reason == "new" + ::Chat::Notifier.new(message, timestamp).notify_new + elsif reason == "edit" + ::Chat::Notifier.new(message, timestamp).notify_edit + end + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat/update_channel_user_count.rb b/plugins/chat/app/jobs/regular/chat/update_channel_user_count.rb new file mode 100644 index 00000000000..8608fd305fb --- /dev/null +++ b/plugins/chat/app/jobs/regular/chat/update_channel_user_count.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class UpdateChannelUserCount < Jobs::Base + def execute(args = {}) + channel = ::Chat::Channel.find_by(id: args[:chat_channel_id]) + return if channel.blank? + return if !channel.user_count_stale + + channel.update!( + user_count: ::Chat::ChannelMembershipsQuery.count(channel), + user_count_stale: false, + ) + + ::Chat::Publisher.publish_chat_channel_metadata(channel) + end + end + end +end diff --git a/plugins/chat/app/jobs/regular/chat_channel_archive.rb b/plugins/chat/app/jobs/regular/chat_channel_archive.rb deleted file mode 100644 index 33e270dd220..00000000000 --- a/plugins/chat/app/jobs/regular/chat_channel_archive.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class ChatChannelArchive < ::Jobs::Base - sidekiq_options retry: false - - def execute(args = {}) - channel_archive = ::ChatChannelArchive.find_by(id: args[:chat_channel_archive_id]) - - # this should not really happen, but better to do this than throw an error - if channel_archive.blank? - Rails.logger.warn( - "Chat channel archive #{args[:chat_channel_archive_id]} could not be found, aborting archive job.", - ) - return - end - - if channel_archive.complete? - channel_archive.chat_channel.update!(status: :archived) - - ChatPublisher.publish_archive_status( - channel_archive.chat_channel, - archive_status: :success, - archived_messages: channel_archive.archived_messages, - archive_topic_id: channel_archive.destination_topic_id, - total_messages: channel_archive.total_messages, - ) - - return - end - - DistributedMutex.synchronize( - "archive_chat_channel_#{channel_archive.chat_channel_id}", - validity: 20.minutes, - ) { Chat::ChatChannelArchiveService.new(channel_archive).execute } - end - end -end diff --git a/plugins/chat/app/jobs/regular/chat_channel_delete.rb b/plugins/chat/app/jobs/regular/chat_channel_delete.rb deleted file mode 100644 index 3d407caf9cd..00000000000 --- a/plugins/chat/app/jobs/regular/chat_channel_delete.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class ChatChannelDelete < ::Jobs::Base - def execute(args = {}) - chat_channel = ::ChatChannel.with_deleted.find_by(id: args[:chat_channel_id]) - - # this should not really happen, but better to do this than throw an error - if chat_channel.blank? - Rails.logger.warn( - "Chat channel #{args[:chat_channel_id]} could not be found, aborting delete job.", - ) - return - end - - DistributedMutex.synchronize("delete_chat_channel_#{chat_channel.id}") do - Rails.logger.debug("Deleting webhooks and events for channel #{chat_channel.id}") - ChatMessage.transaction do - webhooks = IncomingChatWebhook.where(chat_channel: chat_channel) - ChatWebhookEvent.where(incoming_chat_webhook_id: webhooks.select(:id)).delete_all - webhooks.delete_all - end - - Rails.logger.debug("Deleting drafts and memberships for channel #{chat_channel.id}") - ChatDraft.where(chat_channel: chat_channel).delete_all - UserChatChannelMembership.where(chat_channel: chat_channel).delete_all - - Rails.logger.debug( - "Deleting chat messages, mentions, revisions, and uploads for channel #{chat_channel.id}", - ) - chat_messages = ChatMessage.where(chat_channel: chat_channel) - delete_messages_and_related_records(chat_channel, chat_messages) if chat_messages.any? - end - end - - def delete_messages_and_related_records(chat_channel, chat_messages) - message_ids = chat_messages.pluck(:id) - - ChatMessage.transaction do - ChatMention.where(chat_message_id: message_ids).delete_all - ChatMessageRevision.where(chat_message_id: message_ids).delete_all - ChatMessageReaction.where(chat_message_id: message_ids).delete_all - - # if the uploads are not used anywhere else they will be deleted - # by the CleanUpUploads job in core - DB.exec("DELETE FROM chat_uploads WHERE chat_message_id IN (#{message_ids.join(",")})") - UploadReference.where(target_id: message_ids, target_type: "ChatMessage").delete_all - - # only the messages and the channel are Trashable, everything else gets - # permanently destroyed - chat_messages.update_all( - deleted_by_id: chat_channel.deleted_by_id, - deleted_at: Time.zone.now, - ) - end - end - end -end diff --git a/plugins/chat/app/jobs/regular/chat_notify_mentioned.rb b/plugins/chat/app/jobs/regular/chat_notify_mentioned.rb deleted file mode 100644 index f0102aa7a68..00000000000 --- a/plugins/chat/app/jobs/regular/chat_notify_mentioned.rb +++ /dev/null @@ -1,146 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class ChatNotifyMentioned < ::Jobs::Base - def execute(args = {}) - @chat_message = - ChatMessage.includes(:user, :revisions, chat_channel: :chatable).find_by( - id: args[:chat_message_id], - ) - if @chat_message.nil? || - @chat_message.revisions.where("created_at > ?", args[:timestamp]).any? - return - end - - @creator = @chat_message.user - @chat_channel = @chat_message.chat_channel - @already_notified_user_ids = args[:already_notified_user_ids] || [] - user_ids_to_notify = args[:to_notify_ids_map] || {} - user_ids_to_notify.each { |mention_type, ids| process_mentions(ids, mention_type.to_sym) } - end - - private - - def get_memberships(user_ids) - query = - UserChatChannelMembership.includes(:user).where( - user_id: (user_ids - @already_notified_user_ids), - chat_channel_id: @chat_message.chat_channel_id, - ) - query = query.where(following: true) if @chat_channel.public_channel? - query - end - - def build_data_for(membership, identifier_type:) - data = { - chat_message_id: @chat_message.id, - chat_channel_id: @chat_channel.id, - mentioned_by_username: @creator.username, - is_direct_message_channel: @chat_channel.direct_message_channel?, - } - - if !@is_direct_message_channel - data[:chat_channel_title] = @chat_channel.title(membership.user) - data[:chat_channel_slug] = @chat_channel.slug - end - - return data if identifier_type == :direct_mentions - - case identifier_type - when :here_mentions - data[:identifier] = "here" - when :global_mentions - data[:identifier] = "all" - else - data[:identifier] = identifier_type if identifier_type - data[:is_group_mention] = true - end - - data - end - - def build_payload_for(membership, identifier_type:) - payload = { - notification_type: Notification.types[:chat_mention], - username: @creator.username, - tag: Chat::ChatNotifier.push_notification_tag(:mention, @chat_channel.id), - excerpt: @chat_message.push_notification_excerpt, - post_url: "#{@chat_channel.relative_url}/#{@chat_message.id}", - } - - translation_prefix = - ( - if @chat_channel.direct_message_channel? - "discourse_push_notifications.popup.direct_message_chat_mention" - else - "discourse_push_notifications.popup.chat_mention" - end - ) - - translation_suffix = identifier_type == :direct_mentions ? "direct" : "other_type" - identifier_text = - case identifier_type - when :here_mentions - "@here" - when :global_mentions - "@all" - when :direct_mentions - "" - else - "@#{identifier_type}" - end - - payload[:translated_title] = I18n.t( - "#{translation_prefix}.#{translation_suffix}", - username: @creator.username, - identifier: identifier_text, - channel: @chat_channel.title(membership.user), - ) - - payload - end - - def create_notification!(membership, mention, mention_type) - notification_data = build_data_for(membership, identifier_type: mention_type) - is_read = Chat::ChatNotifier.user_has_seen_message?(membership, @chat_message.id) - notification = - Notification.create!( - notification_type: Notification.types[:chat_mention], - user_id: membership.user_id, - high_priority: true, - data: notification_data.to_json, - read: is_read, - ) - - mention.update!(notification: notification) - end - - def send_notifications(membership, mention_type) - payload = build_payload_for(membership, identifier_type: mention_type) - - if !membership.desktop_notifications_never? && !membership.muted? - MessageBus.publish( - "/chat/notification-alert/#{membership.user_id}", - payload, - user_ids: [membership.user_id], - ) - end - - if !membership.mobile_notifications_never? && !membership.muted? - PostAlerter.push_notification(membership.user, payload) - end - end - - def process_mentions(user_ids, mention_type) - memberships = get_memberships(user_ids) - - memberships.each do |membership| - mention = ChatMention.find_by(user: membership.user, chat_message: @chat_message) - if mention.present? - create_notification!(membership, mention, mention_type) - send_notifications(membership, mention_type) - end - end - end - end -end diff --git a/plugins/chat/app/jobs/regular/chat_notify_watching.rb b/plugins/chat/app/jobs/regular/chat_notify_watching.rb deleted file mode 100644 index 4ac3fca4fcf..00000000000 --- a/plugins/chat/app/jobs/regular/chat_notify_watching.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class ChatNotifyWatching < ::Jobs::Base - def execute(args = {}) - @chat_message = - ChatMessage.includes(:user, chat_channel: :chatable).find_by(id: args[:chat_message_id]) - return if @chat_message.nil? - - @creator = @chat_message.user - @chat_channel = @chat_message.chat_channel - @is_direct_message_channel = @chat_channel.direct_message_channel? - - always_notification_level = UserChatChannelMembership::NOTIFICATION_LEVELS[:always] - - members = - UserChatChannelMembership - .includes(user: :groups) - .joins(user: :user_option) - .where(user_option: { chat_enabled: true }) - .where.not(user_id: args[:except_user_ids]) - .where(chat_channel_id: @chat_channel.id) - .where(following: true) - .where( - "desktop_notification_level = ? OR mobile_notification_level = ?", - always_notification_level, - always_notification_level, - ) - .merge(User.not_suspended) - - if @is_direct_message_channel - UserCommScreener - .new(acting_user: @creator, target_user_ids: members.map(&:user_id)) - .allowing_actor_communication - .each do |user_id| - send_notifications(members.find { |member| member.user_id == user_id }) - end - else - members.each { |member| send_notifications(member) } - end - end - - def send_notifications(membership) - user = membership.user - guardian = Guardian.new(user) - return unless guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel) - return if Chat::ChatNotifier.user_has_seen_message?(membership, @chat_message.id) - return if online_user_ids.include?(user.id) - - translation_key = - ( - if @is_direct_message_channel - "discourse_push_notifications.popup.new_direct_chat_message" - else - "discourse_push_notifications.popup.new_chat_message" - end - ) - - translation_args = { username: @creator.username } - translation_args[:channel] = @chat_channel.title(user) unless @is_direct_message_channel - - payload = { - username: @creator.username, - notification_type: Notification.types[:chat_message], - post_url: @chat_channel.relative_url, - translated_title: I18n.t(translation_key, translation_args), - tag: Chat::ChatNotifier.push_notification_tag(:message, @chat_channel.id), - excerpt: @chat_message.push_notification_excerpt, - } - - if membership.desktop_notifications_always? && !membership.muted? - MessageBus.publish("/chat/notification-alert/#{user.id}", payload, user_ids: [user.id]) - end - - if membership.mobile_notifications_always? && !membership.muted? - PostAlerter.push_notification(user, payload) - end - end - - def online_user_ids - @online_user_ids ||= PresenceChannel.new("/chat/online").user_ids - end - end -end diff --git a/plugins/chat/app/jobs/regular/delete_user_messages.rb b/plugins/chat/app/jobs/regular/delete_user_messages.rb deleted file mode 100644 index 22c35624ef9..00000000000 --- a/plugins/chat/app/jobs/regular/delete_user_messages.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class DeleteUserMessages < ::Jobs::Base - def execute(args) - return if args[:user_id].nil? - - ChatMessageDestroyer.new.destroy_in_batches( - ChatMessage.with_deleted.where(user_id: args[:user_id]), - ) - end - end -end diff --git a/plugins/chat/app/jobs/regular/process_chat_message.rb b/plugins/chat/app/jobs/regular/process_chat_message.rb deleted file mode 100644 index 612978bb23f..00000000000 --- a/plugins/chat/app/jobs/regular/process_chat_message.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class ProcessChatMessage < ::Jobs::Base - def execute(args = {}) - DistributedMutex.synchronize( - "process_chat_message_#{args[:chat_message_id]}", - validity: 10.minutes, - ) do - chat_message = ChatMessage.find_by(id: args[:chat_message_id]) - return if !chat_message - processor = Chat::ChatMessageProcessor.new(chat_message) - processor.run! - - if args[:is_dirty] || processor.dirty? - chat_message.update(cooked: processor.html, cooked_version: ChatMessage::BAKED_VERSION) - ChatPublisher.publish_processed!(chat_message) - end - end - end - end -end diff --git a/plugins/chat/app/jobs/regular/send_message_notifications.rb b/plugins/chat/app/jobs/regular/send_message_notifications.rb deleted file mode 100644 index 5fa778467e4..00000000000 --- a/plugins/chat/app/jobs/regular/send_message_notifications.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class SendMessageNotifications < ::Jobs::Base - def execute(args) - reason = args[:reason] - valid_reasons = %w[new edit] - return unless valid_reasons.include?(reason) - - return if (timestamp = args[:timestamp]).blank? - - return if (message = ChatMessage.find_by(id: args[:chat_message_id])).nil? - - if reason == "new" - Chat::ChatNotifier.new(message, timestamp).notify_new - elsif reason == "edit" - Chat::ChatNotifier.new(message, timestamp).notify_edit - end - end - end -end diff --git a/plugins/chat/app/jobs/regular/update_channel_user_count.rb b/plugins/chat/app/jobs/regular/update_channel_user_count.rb deleted file mode 100644 index 0790a52e167..00000000000 --- a/plugins/chat/app/jobs/regular/update_channel_user_count.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class UpdateChannelUserCount < Jobs::Base - def execute(args = {}) - channel = ChatChannel.find_by(id: args[:chat_channel_id]) - return if channel.blank? - return if !channel.user_count_stale - - channel.update!( - user_count: ChatChannelMembershipsQuery.count(channel), - user_count_stale: false, - ) - - ChatPublisher.publish_chat_channel_metadata(channel) - end - end -end diff --git a/plugins/chat/app/jobs/scheduled/auto_join_users.rb b/plugins/chat/app/jobs/scheduled/auto_join_users.rb deleted file mode 100644 index 061a3dce8db..00000000000 --- a/plugins/chat/app/jobs/scheduled/auto_join_users.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class AutoJoinUsers < ::Jobs::Scheduled - every 1.hour - - def execute(_args) - ChatChannel - .where(auto_join_users: true) - .each do |channel| - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships - end - end - end -end diff --git a/plugins/chat/app/jobs/scheduled/chat/auto_join_users.rb b/plugins/chat/app/jobs/scheduled/chat/auto_join_users.rb new file mode 100644 index 00000000000..c22ee543fec --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/auto_join_users.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class AutoJoinUsers < ::Jobs::Scheduled + every 1.hour + + def execute(_args) + ::Chat::Channel + .where(auto_join_users: true) + .each do |channel| + ::Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships + end + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat/delete_old_messages.rb b/plugins/chat/app/jobs/scheduled/chat/delete_old_messages.rb new file mode 100644 index 00000000000..8c0f065b025 --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/delete_old_messages.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class DeleteOldMessages < ::Jobs::Scheduled + daily at: 0.hours + + def execute(args = {}) + delete_public_channel_messages + delete_dm_channel_messages + end + + private + + def delete_public_channel_messages + return unless valid_day_value?(:chat_channel_retention_days) + + ::Chat::MessageDestroyer.new.destroy_in_batches( + ::Chat::Message.in_public_channel.with_deleted.created_before( + ::SiteSetting.chat_channel_retention_days.days.ago, + ), + ) + end + + def delete_dm_channel_messages + return unless valid_day_value?(:chat_dm_retention_days) + + ::Chat::MessageDestroyer.new.destroy_in_batches( + ::Chat::Message.in_dm_channel.with_deleted.created_before( + ::SiteSetting.chat_dm_retention_days.days.ago, + ), + ) + end + + def valid_day_value?(setting_name) + (::SiteSetting.public_send(setting_name) || 0).positive? + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat/email_notifications.rb b/plugins/chat/app/jobs/scheduled/chat/email_notifications.rb new file mode 100644 index 00000000000..a5adac40c0c --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/email_notifications.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class EmailNotifications < ::Jobs::Scheduled + every 5.minutes + + def execute(args = {}) + return unless ::SiteSetting.chat_enabled + + ::Chat::Mailer.send_unread_mentions_summary + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat/periodical_updates.rb b/plugins/chat/app/jobs/scheduled/chat/periodical_updates.rb new file mode 100644 index 00000000000..7b6c1f3318e --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/periodical_updates.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Jobs + module Chat + class PeriodicalUpdates < ::Jobs::Scheduled + every 15.minutes + + def execute(args = nil) + # TODO: Add rebaking of old messages (baked_version < + # Chat::Message::BAKED_VERSION or baked_version IS NULL) + ::Chat::Channel.ensure_consistency! + nil + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat/update_user_counts_for_channels.rb b/plugins/chat/app/jobs/scheduled/chat/update_user_counts_for_channels.rb new file mode 100644 index 00000000000..4478bd4f910 --- /dev/null +++ b/plugins/chat/app/jobs/scheduled/chat/update_user_counts_for_channels.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Jobs + # TODO (martin) Move into Chat::Channel.ensure_consistency! so it + # is run with Jobs::Chat::PeriodicalUpdates + module Chat + class UpdateUserCountsForChannels < ::Jobs::Scheduled + every 1.hour + + # FIXME: This could become huge as the amount of channels grows, we + # need a different approach here. Perhaps we should only bother for + # channels updated or with new messages in the past N days? Perhaps + # we could update all the counts in a single query as well? + def execute(args = {}) + ::Chat::Channel + .where(status: %i[open closed]) + .find_each { |chat_channel| set_user_count(chat_channel) } + end + + def set_user_count(chat_channel) + current_count = chat_channel.user_count || 0 + new_count = ::Chat::ChannelMembershipsQuery.count(chat_channel) + return if current_count == new_count + + chat_channel.update(user_count: new_count, user_count_stale: false) + ::Chat::Publisher.publish_chat_channel_metadata(chat_channel) + end + end + end +end diff --git a/plugins/chat/app/jobs/scheduled/chat_periodical_updates.rb b/plugins/chat/app/jobs/scheduled/chat_periodical_updates.rb deleted file mode 100644 index c7ca56fcb15..00000000000 --- a/plugins/chat/app/jobs/scheduled/chat_periodical_updates.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class ChatPeriodicalUpdates < ::Jobs::Scheduled - every 15.minutes - - def execute(args = nil) - # TODO: Add rebaking of old messages (baked_version < - # ChatMessage::BAKED_VERSION or baked_version IS NULL) - ChatChannel.ensure_consistency! - nil - end - end -end diff --git a/plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb b/plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb deleted file mode 100644 index 0fbc06141be..00000000000 --- a/plugins/chat/app/jobs/scheduled/delete_old_chat_messages.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class DeleteOldChatMessages < ::Jobs::Scheduled - daily at: 0.hours - - def execute(args = {}) - delete_public_channel_messages - delete_dm_channel_messages - end - - private - - def delete_public_channel_messages - return unless valid_day_value?(:chat_channel_retention_days) - - ChatMessageDestroyer.new.destroy_in_batches( - ChatMessage.in_public_channel.with_deleted.created_before( - SiteSetting.chat_channel_retention_days.days.ago, - ), - ) - end - - def delete_dm_channel_messages - return unless valid_day_value?(:chat_dm_retention_days) - - ChatMessageDestroyer.new.destroy_in_batches( - ChatMessage.in_dm_channel.with_deleted.created_before( - SiteSetting.chat_dm_retention_days.days.ago, - ), - ) - end - - def valid_day_value?(setting_name) - (SiteSetting.public_send(setting_name) || 0).positive? - end - end -end diff --git a/plugins/chat/app/jobs/scheduled/email_chat_notifications.rb b/plugins/chat/app/jobs/scheduled/email_chat_notifications.rb deleted file mode 100644 index 470c6aa2152..00000000000 --- a/plugins/chat/app/jobs/scheduled/email_chat_notifications.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Jobs - class EmailChatNotifications < ::Jobs::Scheduled - every 5.minutes - - def execute(args = {}) - return unless SiteSetting.chat_enabled - - Chat::ChatMailer.send_unread_mentions_summary - end - end -end diff --git a/plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb b/plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb deleted file mode 100644 index 8880732b8e5..00000000000 --- a/plugins/chat/app/jobs/scheduled/update_user_counts_for_chat_channels.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Jobs - # TODO (martin) Move into ChatChannel.ensure_consistency! so it - # is run with ChatPeriodicalUpdates - class UpdateUserCountsForChatChannels < ::Jobs::Scheduled - every 1.hour - - # FIXME: This could become huge as the amount of channels grows, we - # need a different approach here. Perhaps we should only bother for - # channels updated or with new messages in the past N days? Perhaps - # we could update all the counts in a single query as well? - def execute(args = {}) - ChatChannel - .where(status: %i[open closed]) - .find_each { |chat_channel| set_user_count(chat_channel) } - end - - def set_user_count(chat_channel) - current_count = chat_channel.user_count || 0 - new_count = ChatChannelMembershipsQuery.count(chat_channel) - return if current_count == new_count - - chat_channel.update(user_count: new_count, user_count_stale: false) - ChatPublisher.publish_chat_channel_metadata(chat_channel) - end - end -end diff --git a/plugins/chat/app/models/category_channel.rb b/plugins/chat/app/models/category_channel.rb deleted file mode 100644 index b205e82b4ac..00000000000 --- a/plugins/chat/app/models/category_channel.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -class CategoryChannel < ChatChannel - alias_attribute :category, :chatable - - delegate :read_restricted?, to: :category - delegate :url, to: :chatable, prefix: true - - %i[category_channel? public_channel? chatable_has_custom_fields?].each do |name| - define_method(name) { true } - end - - def allowed_group_ids - return if !read_restricted? - - staff_groups = Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values - category.secure_group_ids.to_a.concat(staff_groups) - end - - def title(_ = nil) - name.presence || category.name - end - - def generate_auto_slug - return if self.slug.present? - self.slug = Slug.for(self.title.strip, "") - self.slug = "" if duplicate_slug? - end - - def ensure_slug_ok - if self.slug.present? - # if we don't unescape it first we strip the % from the encoded version - slug = SiteSetting.slug_generation_method == "encoded" ? CGI.unescape(self.slug) : self.slug - self.slug = Slug.for(slug, "", method: :encoded) - - if self.slug.blank? - errors.add(:slug, :invalid) - elsif SiteSetting.slug_generation_method == "ascii" && !CGI.unescape(self.slug).ascii_only? - errors.add(:slug, I18n.t("chat.category_channel.errors.slug_contains_non_ascii_chars")) - elsif duplicate_slug? - errors.add(:slug, I18n.t("chat.category_channel.errors.is_already_in_use")) - end - end - end -end diff --git a/plugins/chat/app/models/chat/category_channel.rb b/plugins/chat/app/models/chat/category_channel.rb new file mode 100644 index 00000000000..aa546265e0f --- /dev/null +++ b/plugins/chat/app/models/chat/category_channel.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Chat + class CategoryChannel < Channel + alias_attribute :category, :chatable + + delegate :read_restricted?, to: :category + delegate :url, to: :chatable, prefix: true + + def self.polymorphic_class_for(name) + Chat::Chatable.polymorphic_class_for(name) || super(name) + end + + %i[category_channel? public_channel? chatable_has_custom_fields?].each do |name| + define_method(name) { true } + end + + def allowed_group_ids + return if !read_restricted? + + staff_groups = Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values + category.secure_group_ids.to_a.concat(staff_groups) + end + + def title(_ = nil) + name.presence || category.name + end + + def generate_auto_slug + return if self.slug.present? + self.slug = Slug.for(self.title.strip, "") + self.slug = "" if duplicate_slug? + end + + def ensure_slug_ok + if self.slug.present? + # if we don't unescape it first we strip the % from the encoded version + slug = SiteSetting.slug_generation_method == "encoded" ? CGI.unescape(self.slug) : self.slug + self.slug = Slug.for(slug, "", method: :encoded) + + if self.slug.blank? + errors.add(:slug, :invalid) + elsif SiteSetting.slug_generation_method == "ascii" && !CGI.unescape(self.slug).ascii_only? + errors.add(:slug, I18n.t("chat.category_channel.errors.slug_contains_non_ascii_chars")) + elsif duplicate_slug? + errors.add(:slug, I18n.t("chat.category_channel.errors.is_already_in_use")) + end + end + end + end +end diff --git a/plugins/chat/app/models/chat/channel.rb b/plugins/chat/app/models/chat/channel.rb new file mode 100644 index 00000000000..c5c67d23d95 --- /dev/null +++ b/plugins/chat/app/models/chat/channel.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +module Chat + class Channel < ActiveRecord::Base + include Trashable + + self.table_name = "chat_channels" + + belongs_to :chatable, polymorphic: true + + def self.sti_class_for(type_name) + Chat::Chatable.sti_class_for(type_name) || super(type_name) + end + + def self.sti_name + Chat::Chatable.sti_name_for(self) || super + end + + belongs_to :direct_message, + class_name: "Chat::DirectMessage", + foreign_key: :chatable_id, + inverse_of: :direct_message_channel, + optional: true + + has_many :chat_messages, class_name: "Chat::Message", foreign_key: :chat_channel_id + has_many :user_chat_channel_memberships, + class_name: "Chat::UserChatChannelMembership", + foreign_key: :chat_channel_id + has_one :chat_channel_archive, class_name: "Chat::ChannelArchive", foreign_key: :chat_channel_id + + enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false + + validates :name, + length: { + maximum: Proc.new { SiteSetting.max_topic_title_length }, + }, + presence: true, + allow_nil: true + validate :ensure_slug_ok, if: :slug_changed? + before_validation :generate_auto_slug + + scope :public_channels, + -> { + where(chatable_type: public_channel_chatable_types).where( + "categories.id IS NOT NULL", + ).joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + } + + delegate :empty?, to: :chat_messages, prefix: true + + class << self + def editable_statuses + statuses.filter { |k, _| !%w[read_only archived].include?(k) } + end + + def public_channel_chatable_types + %w[Category] + end + + def direct_channel_chatable_types + %w[DirectMessage] + end + + def chatable_types + public_channel_chatable_types + direct_channel_chatable_types + end + end + + statuses.keys.each do |status| + define_method("#{status}!") { |acting_user| change_status(acting_user, status.to_sym) } + end + + %i[ + category_channel? + direct_message_channel? + public_channel? + chatable_has_custom_fields? + read_restricted? + ].each { |name| define_method(name) { false } } + + %i[allowed_user_ids allowed_group_ids chatable_url].each { |name| define_method(name) { nil } } + + def membership_for(user) + user_chat_channel_memberships.find_by(user: user) + end + + def add(user) + Chat::ChannelMembershipManager.new(self).follow(user) + end + + def remove(user) + Chat::ChannelMembershipManager.new(self).unfollow(user) + end + + def url + "#{Discourse.base_url}/chat/c/#{self.slug || "-"}/#{self.id}" + end + + def relative_url + "#{Discourse.base_path}/chat/c/#{self.slug || "-"}/#{self.id}" + end + + def self.ensure_consistency! + update_counts + end + + # TODO (martin) Move Jobs::Chat::UpdateUserCountsForChannels into here + def self.update_counts + # NOTE: Chat::Channel#messages_count is not updated every time + # a message is created or deleted in a channel, so it should not + # be displayed in the UI. It is updated eventually via Jobs::Chat::PeriodicalUpdates + DB.exec <<~SQL + UPDATE chat_channels channels + SET messages_count = subquery.messages_count + FROM ( + SELECT COUNT(*) AS messages_count, chat_channel_id + FROM chat_messages + WHERE chat_messages.deleted_at IS NULL + GROUP BY chat_channel_id + ) subquery + WHERE channels.id = subquery.chat_channel_id + AND channels.deleted_at IS NULL + AND subquery.messages_count != channels.messages_count + SQL + end + + private + + def change_status(acting_user, target_status) + return if !Guardian.new(acting_user).can_change_channel_status?(self, target_status) + self.update!(status: target_status) + log_channel_status_change(acting_user: acting_user) + end + + def log_channel_status_change(acting_user:) + DiscourseEvent.trigger( + :chat_channel_status_change, + channel: self, + old_status: status_previously_was, + new_status: status, + ) + + StaffActionLogger.new(acting_user).log_custom( + "chat_channel_status_change", + { + chat_channel_id: self.id, + chat_channel_name: self.name, + previous_value: status_previously_was, + new_value: status, + }, + ) + + Chat::Publisher.publish_channel_status(self) + end + + def duplicate_slug? + Chat::Channel.where(slug: self.slug).where.not(id: self.id).any? + end + end +end + +# == Schema Information +# +# Table name: chat_channels +# +# id :bigint not null, primary key +# chatable_id :integer not null +# deleted_at :datetime +# deleted_by_id :integer +# featured_in_category_id :integer +# delete_after_seconds :integer +# chatable_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# name :string +# description :text +# status :integer default("open"), not null +# user_count :integer default(0), not null +# last_message_sent_at :datetime not null +# auto_join_users :boolean default(FALSE), not null +# allow_channel_wide_mentions :boolean default(TRUE), not null +# user_count_stale :boolean default(FALSE), not null +# slug :string +# type :string +# threading_enabled :boolean default(FALSE), not null +# +# Indexes +# +# index_chat_channels_on_messages_count (messages_count) +# index_chat_channels_on_chatable_id (chatable_id) +# index_chat_channels_on_chatable_id_and_chatable_type (chatable_id,chatable_type) +# index_chat_channels_on_slug (slug) UNIQUE +# index_chat_channels_on_status (status) +# diff --git a/plugins/chat/app/models/chat_channel_archive.rb b/plugins/chat/app/models/chat/channel_archive.rb similarity index 60% rename from plugins/chat/app/models/chat_channel_archive.rb rename to plugins/chat/app/models/chat/channel_archive.rb index 057af4e5bf9..e8c88b4b932 100644 --- a/plugins/chat/app/models/chat_channel_archive.rb +++ b/plugins/chat/app/models/chat/channel_archive.rb @@ -1,21 +1,24 @@ # frozen_string_literal: true -class ChatChannelArchive < ActiveRecord::Base - belongs_to :chat_channel - belongs_to :archived_by, class_name: "User" +module Chat + class ChannelArchive < ActiveRecord::Base + belongs_to :chat_channel, class_name: "Chat::Channel" + belongs_to :archived_by, class_name: "User" + belongs_to :destination_topic, class_name: "Topic" - belongs_to :destination_topic, class_name: "Topic" + self.table_name = "chat_channel_archives" - def complete? - self.archived_messages >= self.total_messages && self.chat_channel.chat_messages.count.zero? - end + def complete? + self.archived_messages >= self.total_messages && self.chat_channel.chat_messages.count.zero? + end - def failed? - !complete? && self.archive_error.present? - end + def failed? + !complete? && self.archive_error.present? + end - def new_topic? - self.destination_topic_title.present? + def new_topic? + self.destination_topic_title.present? + end end end diff --git a/plugins/chat/app/models/chat/deleted_user.rb b/plugins/chat/app/models/chat/deleted_user.rb new file mode 100644 index 00000000000..b97d775500d --- /dev/null +++ b/plugins/chat/app/models/chat/deleted_user.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Chat + class DeletedUser < User + def username + I18n.t("chat.deleted_chat_username") + end + + def avatar_template + "/plugins/chat/images/deleted-chat-user-avatar.png" + end + + def bot? + false + end + end +end diff --git a/plugins/chat/app/models/chat/direct_message.rb b/plugins/chat/app/models/chat/direct_message.rb new file mode 100644 index 00000000000..5780a879760 --- /dev/null +++ b/plugins/chat/app/models/chat/direct_message.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Chat + class DirectMessage < ActiveRecord::Base + self.table_name = "direct_message_channels" + + include Chatable + + def self.polymorphic_name + Chat::Chatable.polymorphic_name_for(self) || super + end + + has_many :direct_message_users, + class_name: "Chat::DirectMessageUser", + foreign_key: :direct_message_channel_id + has_many :users, through: :direct_message_users + + has_one :direct_message_channel, as: :chatable, class_name: "Chat::DirectMessageChannel" + + def self.for_user_ids(user_ids) + joins(:users) + .group("direct_message_channels.id") + .having("ARRAY[?] = ARRAY_AGG(users.id ORDER BY users.id)", user_ids.sort) + &.first + end + + def user_can_access?(user) + users.include?(user) + end + + def chat_channel_title_for_user(chat_channel, acting_user) + users = + (direct_message_users.map(&:user) - [acting_user]).map do |user| + user || Chat::DeletedUser.new + end + + # direct message to self + if users.empty? + return I18n.t("chat.channel.dm_title.single_user", username: "@#{acting_user.username}") + end + + # all users deleted + return chat_channel.id if !users.first + + usernames_formatted = users.sort_by(&:username).map { |u| "@#{u.username}" } + if usernames_formatted.size > 5 + return( + I18n.t( + "chat.channel.dm_title.multi_user_truncated", + comma_separated_usernames: + usernames_formatted[0..4].join(I18n.t("word_connector.comma")), + count: usernames_formatted.length - 5, + ) + ) + end + + I18n.t( + "chat.channel.dm_title.multi_user", + comma_separated_usernames: usernames_formatted.join(I18n.t("word_connector.comma")), + ) + end + end +end + +# == Schema Information +# +# Table name: direct_message_channels +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/plugins/chat/app/models/chat/direct_message_channel.rb b/plugins/chat/app/models/chat/direct_message_channel.rb new file mode 100644 index 00000000000..a63b0af7376 --- /dev/null +++ b/plugins/chat/app/models/chat/direct_message_channel.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Chat + class DirectMessageChannel < Channel + alias_attribute :direct_message, :chatable + + def self.polymorphic_class_for(name) + Chat::Chatable.polymorphic_class_for(name) || super(name) + end + + def direct_message_channel? + true + end + + def allowed_user_ids + direct_message.user_ids + end + + def read_restricted? + true + end + + def title(user) + direct_message.chat_channel_title_for_user(self, user) + end + + def ensure_slug_ok + true + end + + def generate_auto_slug + self.slug = nil + end + end +end diff --git a/plugins/chat/app/models/direct_message_user.rb b/plugins/chat/app/models/chat/direct_message_user.rb similarity index 64% rename from plugins/chat/app/models/direct_message_user.rb rename to plugins/chat/app/models/chat/direct_message_user.rb index f8cfc6664ff..aae155f6210 100644 --- a/plugins/chat/app/models/direct_message_user.rb +++ b/plugins/chat/app/models/chat/direct_message_user.rb @@ -1,8 +1,14 @@ # frozen_string_literal: true -class DirectMessageUser < ActiveRecord::Base - belongs_to :direct_message, foreign_key: :direct_message_channel_id - belongs_to :user +module Chat + class DirectMessageUser < ActiveRecord::Base + self.table_name = "direct_message_users" + + belongs_to :direct_message, + class_name: "Chat::DirectMessage", + foreign_key: :direct_message_channel_id + belongs_to :user + end end # == Schema Information diff --git a/plugins/chat/app/models/chat_draft.rb b/plugins/chat/app/models/chat/draft.rb similarity index 52% rename from plugins/chat/app/models/chat_draft.rb rename to plugins/chat/app/models/chat/draft.rb index 7dc1b7feeb0..6b1dc2d59e7 100644 --- a/plugins/chat/app/models/chat_draft.rb +++ b/plugins/chat/app/models/chat/draft.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true -class ChatDraft < ActiveRecord::Base - belongs_to :user - belongs_to :chat_channel +module Chat + class Draft < ActiveRecord::Base + belongs_to :user + belongs_to :chat_channel, class_name: "Chat::Channel" - validate :data_length - def data_length - if self.data && self.data.length > SiteSetting.max_chat_draft_length - self.errors.add(:base, I18n.t("chat.errors.draft_too_long")) + self.table_name = "chat_drafts" + + validate :data_length + def data_length + if self.data && self.data.length > SiteSetting.max_chat_draft_length + self.errors.add(:base, I18n.t("chat.errors.draft_too_long")) + end end end end diff --git a/plugins/chat/app/models/incoming_chat_webhook.rb b/plugins/chat/app/models/chat/incoming_webhook.rb similarity index 61% rename from plugins/chat/app/models/incoming_chat_webhook.rb rename to plugins/chat/app/models/chat/incoming_webhook.rb index e71b539a037..cb76ffebc66 100644 --- a/plugins/chat/app/models/incoming_chat_webhook.rb +++ b/plugins/chat/app/models/chat/incoming_webhook.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true -class IncomingChatWebhook < ActiveRecord::Base - belongs_to :chat_channel - has_many :chat_webhook_events +module Chat + class IncomingWebhook < ActiveRecord::Base + self.table_name = "incoming_chat_webhooks" - before_create { self.key = SecureRandom.hex(12) } + belongs_to :chat_channel, class_name: "Chat::Channel" + has_many :chat_webhook_events, class_name: "Chat::WebhookEvent" - def url - "#{Discourse.base_url}/chat/hooks/#{key}.json" + before_create { self.key = SecureRandom.hex(12) } + + def url + "#{Discourse.base_url}/chat/hooks/#{key}.json" + end end end diff --git a/plugins/chat/app/models/chat_mention.rb b/plugins/chat/app/models/chat/mention.rb similarity index 67% rename from plugins/chat/app/models/chat_mention.rb rename to plugins/chat/app/models/chat/mention.rb index 649303ca0a9..ab3bbee9925 100644 --- a/plugins/chat/app/models/chat_mention.rb +++ b/plugins/chat/app/models/chat/mention.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true -class ChatMention < ActiveRecord::Base - belongs_to :user - belongs_to :chat_message - belongs_to :notification, dependent: :destroy +module Chat + class Mention < ActiveRecord::Base + self.table_name = "chat_mentions" + + belongs_to :user + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :notification, dependent: :destroy + end end # == Schema Information diff --git a/plugins/chat/app/models/chat/message.rb b/plugins/chat/app/models/chat/message.rb new file mode 100644 index 00000000000..b2ca8cc0c20 --- /dev/null +++ b/plugins/chat/app/models/chat/message.rb @@ -0,0 +1,360 @@ +# frozen_string_literal: true + +module Chat + class Message < ActiveRecord::Base + include Trashable + + self.table_name = "chat_messages" + + attribute :has_oneboxes, default: false + + BAKED_VERSION = 2 + + belongs_to :chat_channel, class_name: "Chat::Channel" + belongs_to :user + belongs_to :in_reply_to, class_name: "Chat::Message" + belongs_to :last_editor, class_name: "User" + belongs_to :thread, class_name: "Chat::Thread" + + has_many :replies, + class_name: "Chat::Message", + foreign_key: "in_reply_to_id", + dependent: :nullify + has_many :revisions, + class_name: "Chat::MessageRevision", + dependent: :destroy, + foreign_key: :chat_message_id + has_many :reactions, + class_name: "Chat::MessageReaction", + dependent: :destroy, + foreign_key: :chat_message_id + has_many :bookmarks, + -> { + unscope(where: :bookmarkable_type).where(bookmarkable_type: Chat::Message.sti_name) + }, + as: :bookmarkable, + dependent: :destroy + has_many :upload_references, + -> { unscope(where: :target_type).where(target_type: Chat::Message.sti_name) }, + dependent: :destroy, + foreign_key: :target_id + has_many :uploads, through: :upload_references, class_name: "::Upload" + + CLASS_MAPPING = { "ChatMessage" => Chat::Message } + + # the model used when loading type column + def self.sti_class_for(name) + CLASS_MAPPING[name] if CLASS_MAPPING.key?(name) + end + # the type column value + def self.sti_name + CLASS_MAPPING.invert.fetch(self) + end + + # the model used when loading chatable_type column + def self.polymorphic_class_for(name) + CLASS_MAPPING[name] if CLASS_MAPPING.key?(name) + end + # the type stored in *_type column of polymorphic associations + def self.polymorphic_name + CLASS_MAPPING.invert.fetch(self) || super + end + + # TODO (martin) Remove this when we drop the ChatUpload table + has_many :chat_uploads, + dependent: :destroy, + class_name: "Chat::Upload", + foreign_key: :chat_message_id + has_one :chat_webhook_event, + dependent: :destroy, + class_name: "Chat::WebhookEvent", + foreign_key: :chat_message_id + has_many :chat_mentions, + dependent: :destroy, + class_name: "Chat::Mention", + foreign_key: :chat_message_id + + scope :in_public_channel, + -> { + joins(:chat_channel).where( + chat_channel: { + chatable_type: Chat::Channel.public_channel_chatable_types, + }, + ) + } + + scope :in_dm_channel, + -> { + joins(:chat_channel).where( + chat_channel: { + chatable_type: Chat::Channel.direct_channel_chatable_types, + }, + ) + } + + scope :created_before, ->(date) { where("chat_messages.created_at < ?", date) } + + before_save { ensure_last_editor_id } + + def validate_message(has_uploads:) + WatchedWordsValidator.new(attributes: [:message]).validate(self) + + if self.new_record? || self.changed.include?("message") + Chat::DuplicateMessageValidator.new(self).validate + end + + if !has_uploads && message_too_short? + self.errors.add( + :base, + I18n.t( + "chat.errors.minimum_length_not_met", + count: SiteSetting.chat_minimum_message_length, + ), + ) + end + + if message_too_long? + self.errors.add( + :base, + I18n.t("chat.errors.message_too_long", count: SiteSetting.chat_maximum_message_length), + ) + end + end + + def attach_uploads(uploads) + return if uploads.blank? || self.new_record? + + now = Time.now + ref_record_attrs = + uploads.map do |upload| + { + upload_id: upload.id, + target_id: self.id, + target_type: self.class.sti_name, + created_at: now, + updated_at: now, + } + end + UploadReference.insert_all!(ref_record_attrs) + end + + def excerpt(max_length: 50) + # just show the URL if the whole message is a URL, because we cannot excerpt oneboxes + return message if UrlHelper.relaxed_parse(message).is_a?(URI) + + # upload-only messages are better represented as the filename + return uploads.first.original_filename if cooked.blank? && uploads.present? + + # this may return blank for some complex things like quotes, that is acceptable + PrettyText.excerpt(message, max_length, { text_entities: true }) + end + + def cooked_for_excerpt + (cooked.blank? && uploads.present?) ? "

#{uploads.first.original_filename}

" : cooked + end + + def push_notification_excerpt + Emoji.gsub_emoji_to_unicode(message).truncate(400) + end + + def to_markdown + upload_markdown = + self + .upload_references + .includes(:upload) + .order(:created_at) + .map(&:to_markdown) + .reject(&:empty?) + + return self.message if upload_markdown.empty? + + return ["#{self.message}\n"].concat(upload_markdown).join("\n") if self.message.present? + + upload_markdown.join("\n") + end + + def cook + ensure_last_editor_id + + self.cooked = self.class.cook(self.message, user_id: self.last_editor_id) + self.cooked_version = BAKED_VERSION + end + + def rebake!(invalidate_oneboxes: false, priority: nil) + ensure_last_editor_id + + previous_cooked = self.cooked + new_cooked = + self.class.cook( + message, + invalidate_oneboxes: invalidate_oneboxes, + user_id: self.last_editor_id, + ) + update_columns(cooked: new_cooked, cooked_version: BAKED_VERSION) + args = { chat_message_id: self.id } + args[:queue] = priority.to_s if priority && priority != :normal + args[:is_dirty] = true if previous_cooked != new_cooked + + Jobs.enqueue(Jobs::Chat::ProcessMessage, args) + end + + def self.uncooked + where("cooked_version <> ? or cooked_version IS NULL", BAKED_VERSION) + end + + MARKDOWN_FEATURES = %w[ + anchor + bbcode-block + bbcode-inline + code + category-hashtag + censored + chat-transcript + discourse-local-dates + emoji + emojiShortcuts + inlineEmoji + html-img + hashtag-autocomplete + mentions + unicodeUsernames + onebox + quotes + spoiler-alert + table + text-post-process + upload-protocol + watched-words + ] + + MARKDOWN_IT_RULES = %w[ + autolink + list + backticks + newline + code + fence + image + table + linkify + link + strikethrough + blockquote + emphasis + ] + + def self.cook(message, opts = {}) + # A rule in our Markdown pipeline may have Guardian checks that require a + # user to be present. The last editing user of the message will be more + # generally up to date than the creating user. For example, we use + # this when cooking #hashtags to determine whether we should render + # the found hashtag based on whether the user can access the channel it + # is referencing. + cooked = + PrettyText.cook( + message, + features_override: + MARKDOWN_FEATURES + DiscoursePluginRegistry.chat_markdown_features.to_a, + markdown_it_rules: MARKDOWN_IT_RULES, + force_quote_link: true, + user_id: opts[:user_id], + hashtag_context: "chat-composer", + ) + + result = + Oneboxer.apply(cooked) do |url| + if opts[:invalidate_oneboxes] + Oneboxer.invalidate(url) + InlineOneboxer.invalidate(url) + end + onebox = Oneboxer.cached_onebox(url) + onebox + end + + cooked = result.to_html if result.changed? + cooked + end + + def full_url + "#{Discourse.base_url}#{url}" + end + + def url + "/chat/c/-/#{self.chat_channel_id}/#{self.id}" + end + + def create_mentions(user_ids) + return if user_ids.empty? + + now = Time.zone.now + mentions = [] + User + .where(id: user_ids) + .find_each do |user| + mentions << { + chat_message_id: self.id, + user_id: user.id, + created_at: now, + updated_at: now, + } + end + + Chat::Mention.insert_all(mentions) + end + + def update_mentions(mentioned_user_ids) + old_mentions = chat_mentions.pluck(:user_id) + updated_mentions = mentioned_user_ids + mentioned_user_ids_to_drop = old_mentions - updated_mentions + mentioned_user_ids_to_add = updated_mentions - old_mentions + + delete_mentions(mentioned_user_ids_to_drop) + create_mentions(mentioned_user_ids_to_add) + end + + private + + def delete_mentions(user_ids) + chat_mentions.where(user_id: user_ids).destroy_all + end + + def message_too_short? + message.length < SiteSetting.chat_minimum_message_length + end + + def message_too_long? + message.length > SiteSetting.chat_maximum_message_length + end + + def ensure_last_editor_id + self.last_editor_id ||= self.user_id + end + end +end + +# == Schema Information +# +# Table name: chat_messages +# +# id :bigint not null, primary key +# chat_channel_id :integer not null +# user_id :integer +# created_at :datetime not null +# updated_at :datetime not null +# deleted_at :datetime +# deleted_by_id :integer +# in_reply_to_id :integer +# message :text +# cooked :text +# cooked_version :integer +# last_editor_id :integer not null +# thread_id :integer +# +# Indexes +# +# idx_chat_messages_by_created_at_not_deleted (created_at) WHERE (deleted_at IS NULL) +# index_chat_messages_on_chat_channel_id_and_created_at (chat_channel_id,created_at) +# index_chat_messages_on_chat_channel_id_and_id (chat_channel_id,id) WHERE (deleted_at IS NULL) +# index_chat_messages_on_last_editor_id (last_editor_id) +# index_chat_messages_on_thread_id (thread_id) +# diff --git a/plugins/chat/app/models/chat_message_reaction.rb b/plugins/chat/app/models/chat/message_reaction.rb similarity index 69% rename from plugins/chat/app/models/chat_message_reaction.rb rename to plugins/chat/app/models/chat/message_reaction.rb index f101b2ec353..3b378dd0481 100644 --- a/plugins/chat/app/models/chat_message_reaction.rb +++ b/plugins/chat/app/models/chat/message_reaction.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true -class ChatMessageReaction < ActiveRecord::Base - belongs_to :chat_message - belongs_to :user +module Chat + class MessageReaction < ActiveRecord::Base + self.table_name = "chat_message_reactions" + + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :user + end end # == Schema Information diff --git a/plugins/chat/app/models/chat_message_revision.rb b/plugins/chat/app/models/chat/message_revision.rb similarity index 74% rename from plugins/chat/app/models/chat_message_revision.rb rename to plugins/chat/app/models/chat/message_revision.rb index e13cf507e17..3b01ee03339 100644 --- a/plugins/chat/app/models/chat_message_revision.rb +++ b/plugins/chat/app/models/chat/message_revision.rb @@ -1,8 +1,12 @@ # frozen_string_literal: true -class ChatMessageRevision < ActiveRecord::Base - belongs_to :chat_message - belongs_to :user +module Chat + class MessageRevision < ActiveRecord::Base + self.table_name = "chat_message_revisions" + + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :user + end end # == Schema Information diff --git a/plugins/chat/app/models/chat/reviewable_message.rb b/plugins/chat/app/models/chat/reviewable_message.rb new file mode 100644 index 00000000000..a7a0b4713e1 --- /dev/null +++ b/plugins/chat/app/models/chat/reviewable_message.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +module Chat + class ReviewableMessage < Reviewable + def serializer + Chat::ReviewableMessageSerializer + end + + def self.action_aliases + { + agree_and_keep_hidden: :agree_and_delete, + agree_and_silence: :agree_and_delete, + agree_and_suspend: :agree_and_delete, + delete_and_agree: :agree_and_delete, + } + end + + def self.score_to_silence_user + sensitivity_score(SiteSetting.chat_silence_user_sensitivity, scale: 0.6) + end + + def chat_message + @chat_message ||= (target || Chat::Message.with_deleted.find_by(id: target_id)) + end + + def chat_message_creator + @chat_message_creator ||= chat_message.user + end + + def flagged_by_user_ids + @flagged_by_user_ids ||= reviewable_scores.map(&:user_id) + end + + def post + nil + end + + def build_actions(actions, guardian, args) + return unless pending? + return if chat_message.blank? + + agree = + actions.add_bundle( + "#{id}-agree", + icon: "thumbs-up", + label: "reviewables.actions.agree.title", + ) + + if chat_message.deleted_at? + build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree) + build_action(actions, :agree_and_keep_deleted, icon: "thumbs-up", bundle: agree) + build_action(actions, :disagree_and_restore, icon: "thumbs-down") + else + build_action(actions, :agree_and_delete, icon: "far-eye-slash", bundle: agree) + build_action(actions, :agree_and_keep_message, icon: "thumbs-up", bundle: agree) + build_action(actions, :disagree, icon: "thumbs-down") + end + + if guardian.can_suspend?(chat_message_creator) + build_action( + actions, + :agree_and_suspend, + icon: "ban", + bundle: agree, + client_action: "suspend", + ) + build_action( + actions, + :agree_and_silence, + icon: "microphone-slash", + bundle: agree, + client_action: "silence", + ) + end + + build_action(actions, :ignore, icon: "external-link-alt") + + unless chat_message.deleted_at? + build_action(actions, :delete_and_agree, icon: "far-trash-alt") + end + end + + def perform_agree_and_keep_message(performed_by, args) + agree + end + + def perform_agree_and_restore(performed_by, args) + agree { chat_message.recover! } + end + + def perform_agree_and_delete(performed_by, args) + agree { chat_message.trash!(performed_by) } + end + + def perform_disagree_and_restore(performed_by, args) + disagree { chat_message.recover! } + end + + def perform_disagree(performed_by, args) + disagree + end + + def perform_ignore(performed_by, args) + ignore + end + + def perform_delete_and_ignore(performed_by, args) + ignore { chat_message.trash!(performed_by) } + end + + private + + def agree + yield if block_given? + create_result(:success, :approved) do |result| + result.update_flag_stats = { status: :agreed, user_ids: flagged_by_user_ids } + result.recalculate_score = true + end + end + + def disagree + yield if block_given? + + UserSilencer.unsilence(chat_message_creator) + + create_result(:success, :rejected) do |result| + result.update_flag_stats = { status: :disagreed, user_ids: flagged_by_user_ids } + result.recalculate_score = true + end + end + + def ignore + yield if block_given? + create_result(:success, :ignored) do |result| + result.update_flag_stats = { status: :ignored, user_ids: flagged_by_user_ids } + end + end + + def build_action( + actions, + id, + icon:, + button_class: nil, + bundle: nil, + client_action: nil, + confirm: false + ) + actions.add(id, bundle: bundle) do |action| + prefix = "reviewables.actions.#{id}" + action.icon = icon + action.button_class = button_class + action.label = "chat.#{prefix}.title" + action.description = "chat.#{prefix}.description" + action.client_action = client_action + action.confirm_message = "#{prefix}.confirm" if confirm + end + end + end +end diff --git a/plugins/chat/app/models/chat_thread.rb b/plugins/chat/app/models/chat/thread.rb similarity index 50% rename from plugins/chat/app/models/chat_thread.rb rename to plugins/chat/app/models/chat/thread.rb index c320281728d..25fb68f45b4 100644 --- a/plugins/chat/app/models/chat_thread.rb +++ b/plugins/chat/app/models/chat/thread.rb @@ -1,29 +1,34 @@ # frozen_string_literal: true -class ChatThread < ActiveRecord::Base - EXCERPT_LENGTH = 150 +module Chat + class Thread < ActiveRecord::Base + EXCERPT_LENGTH = 150 - belongs_to :channel, foreign_key: "channel_id", class_name: "ChatChannel" - belongs_to :original_message_user, foreign_key: "original_message_user_id", class_name: "User" - belongs_to :original_message, foreign_key: "original_message_id", class_name: "ChatMessage" + self.table_name = "chat_threads" - has_many :chat_messages, - -> { order("chat_messages.created_at ASC, chat_messages.id ASC") }, - foreign_key: :thread_id, - primary_key: :id + belongs_to :channel, foreign_key: "channel_id", class_name: "Chat::Channel" + belongs_to :original_message_user, foreign_key: "original_message_user_id", class_name: "User" + belongs_to :original_message, foreign_key: "original_message_id", class_name: "Chat::Message" - enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false + has_many :chat_messages, + -> { order("chat_messages.created_at ASC, chat_messages.id ASC") }, + foreign_key: :thread_id, + primary_key: :id, + class_name: "Chat::Message" - def url - "#{channel.url}/t/#{self.id}" - end + enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false - def relative_url - "#{channel.relative_url}/t/#{self.id}" - end + def url + "#{channel.url}/t/#{self.id}" + end - def excerpt - original_message.excerpt(max_length: EXCERPT_LENGTH) + def relative_url + "#{channel.relative_url}/t/#{self.id}" + end + + def excerpt + original_message.excerpt(max_length: EXCERPT_LENGTH) + end end end diff --git a/plugins/chat/app/models/chat_upload.rb b/plugins/chat/app/models/chat/upload.rb similarity index 76% rename from plugins/chat/app/models/chat_upload.rb rename to plugins/chat/app/models/chat/upload.rb index f9d969c40af..ae553d5faee 100644 --- a/plugins/chat/app/models/chat_upload.rb +++ b/plugins/chat/app/models/chat/upload.rb @@ -5,11 +5,15 @@ # # NOTE: Do not use this model anymore, chat messages are linked to uploads via # the UploadReference table now, just like everything else. -class ChatUpload < ActiveRecord::Base - belongs_to :chat_message - belongs_to :upload +module Chat + class Upload < ActiveRecord::Base + self.table_name = "chat_uploads" - deprecate *public_instance_methods(false) + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :upload + + deprecate *public_instance_methods(false) + end end # == Schema Information diff --git a/plugins/chat/app/models/user_chat_channel_membership.rb b/plugins/chat/app/models/chat/user_chat_channel_membership.rb similarity index 63% rename from plugins/chat/app/models/user_chat_channel_membership.rb rename to plugins/chat/app/models/chat/user_chat_channel_membership.rb index 643dcdb1a6e..c28b2524871 100644 --- a/plugins/chat/app/models/user_chat_channel_membership.rb +++ b/plugins/chat/app/models/chat/user_chat_channel_membership.rb @@ -1,18 +1,22 @@ # frozen_string_literal: true -class UserChatChannelMembership < ActiveRecord::Base - NOTIFICATION_LEVELS = { never: 0, mention: 1, always: 2 } +module Chat + class UserChatChannelMembership < ActiveRecord::Base + self.table_name = "user_chat_channel_memberships" - belongs_to :user - belongs_to :chat_channel - belongs_to :last_read_message, class_name: "ChatMessage", optional: true + NOTIFICATION_LEVELS = { never: 0, mention: 1, always: 2 } - enum :desktop_notification_level, NOTIFICATION_LEVELS, prefix: :desktop_notifications - enum :mobile_notification_level, NOTIFICATION_LEVELS, prefix: :mobile_notifications - enum :join_mode, { manual: 0, automatic: 1 } + belongs_to :user + belongs_to :last_read_message, class_name: "Chat::Message", optional: true + belongs_to :chat_channel, class_name: "Chat::Channel", foreign_key: :chat_channel_id - attribute :unread_count, default: 0 - attribute :unread_mentions, default: 0 + enum :desktop_notification_level, NOTIFICATION_LEVELS, prefix: :desktop_notifications + enum :mobile_notification_level, NOTIFICATION_LEVELS, prefix: :mobile_notifications + enum :join_mode, { manual: 0, automatic: 1 } + + attribute :unread_count, default: 0 + attribute :unread_mentions, default: 0 + end end # == Schema Information diff --git a/plugins/chat/app/models/chat/view.rb b/plugins/chat/app/models/chat/view.rb new file mode 100644 index 00000000000..fbb83c5c1f0 --- /dev/null +++ b/plugins/chat/app/models/chat/view.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Chat + class View + attr_reader :user, :chat_channel, :chat_messages, :can_load_more_past, :can_load_more_future + + def initialize( + chat_channel:, + chat_messages:, + user:, + can_load_more_past: nil, + can_load_more_future: nil + ) + @chat_channel = chat_channel + @chat_messages = chat_messages + @user = user + @can_load_more_past = can_load_more_past + @can_load_more_future = can_load_more_future + end + + def reviewable_ids + return @reviewable_ids if defined?(@reviewable_ids) + + @reviewable_ids = @user.staff? ? get_reviewable_ids : nil + end + + def user_flag_statuses + return @user_flag_statuses if defined?(@user_flag_statuses) + + @user_flag_statuses = get_user_flag_statuses + end + + private + + def get_reviewable_ids + sql = <<~SQL + SELECT + target_id, + MAX(r.id) reviewable_id + FROM + reviewables r + JOIN + reviewable_scores s ON reviewable_id = r.id + WHERE + r.target_id IN (:message_ids) AND + r.target_type = :target_type AND + s.status = :pending + GROUP BY + target_id + SQL + + ids = {} + + DB + .query( + sql, + pending: ReviewableScore.statuses[:pending], + message_ids: @chat_messages.map(&:id), + target_type: Chat::Message.sti_name, + ) + .each { |row| ids[row.target_id] = row.reviewable_id } + + ids + end + + def get_user_flag_statuses + sql = <<~SQL + SELECT + target_id, + s.status + FROM + reviewables r + JOIN + reviewable_scores s ON reviewable_id = r.id + WHERE + s.user_id = :user_id AND + r.target_id IN (:message_ids) AND + r.target_type = :target_type + SQL + + statuses = {} + + DB + .query( + sql, + message_ids: @chat_messages.map(&:id), + user_id: @user.id, + target_type: Chat::Message.sti_name, + ) + .each { |row| statuses[row.target_id] = row.status } + + statuses + end + end +end diff --git a/plugins/chat/app/models/chat_webhook_event.rb b/plugins/chat/app/models/chat/webhook_event.rb similarity index 58% rename from plugins/chat/app/models/chat_webhook_event.rb rename to plugins/chat/app/models/chat/webhook_event.rb index acda4ffd9b0..fe2aecb2bcf 100644 --- a/plugins/chat/app/models/chat_webhook_event.rb +++ b/plugins/chat/app/models/chat/webhook_event.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true -class ChatWebhookEvent < ActiveRecord::Base - belongs_to :chat_message - belongs_to :incoming_chat_webhook +module Chat + class WebhookEvent < ActiveRecord::Base + self.table_name = "chat_webhook_events" - delegate :username, to: :incoming_chat_webhook - delegate :emoji, to: :incoming_chat_webhook + belongs_to :chat_message, class_name: "Chat::Message" + belongs_to :incoming_chat_webhook, class_name: "Chat::IncomingWebhook" + + delegate :username, to: :incoming_chat_webhook + delegate :emoji, to: :incoming_chat_webhook + end end # == Schema Information diff --git a/plugins/chat/app/models/chat_channel.rb b/plugins/chat/app/models/chat_channel.rb deleted file mode 100644 index 35f427a2049..00000000000 --- a/plugins/chat/app/models/chat_channel.rb +++ /dev/null @@ -1,176 +0,0 @@ -# frozen_string_literal: true - -class ChatChannel < ActiveRecord::Base - include Trashable - - belongs_to :chatable, polymorphic: true - belongs_to :direct_message, - -> { where(chat_channels: { chatable_type: "DirectMessage" }) }, - foreign_key: "chatable_id" - - has_many :chat_messages - has_many :user_chat_channel_memberships - - has_one :chat_channel_archive - - enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false - - validates :name, - length: { - maximum: Proc.new { SiteSetting.max_topic_title_length }, - }, - presence: true, - allow_nil: true - validate :ensure_slug_ok, if: :slug_changed? - before_validation :generate_auto_slug - - scope :public_channels, - -> { - where(chatable_type: public_channel_chatable_types).where( - "categories.id IS NOT NULL", - ).joins( - "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", - ) - } - - delegate :empty?, to: :chat_messages, prefix: true - - class << self - def editable_statuses - statuses.filter { |k, _| !%w[read_only archived].include?(k) } - end - - def public_channel_chatable_types - ["Category"] - end - - def chatable_types - public_channel_chatable_types << "DirectMessage" - end - end - - statuses.keys.each do |status| - define_method("#{status}!") { |acting_user| change_status(acting_user, status.to_sym) } - end - - %i[ - category_channel? - direct_message_channel? - public_channel? - chatable_has_custom_fields? - read_restricted? - ].each { |name| define_method(name) { false } } - - %i[allowed_user_ids allowed_group_ids chatable_url].each { |name| define_method(name) { nil } } - - def membership_for(user) - user_chat_channel_memberships.find_by(user: user) - end - - def add(user) - Chat::ChatChannelMembershipManager.new(self).follow(user) - end - - def remove(user) - Chat::ChatChannelMembershipManager.new(self).unfollow(user) - end - - def url - "#{Discourse.base_url}/chat/c/#{self.slug || "-"}/#{self.id}" - end - - def relative_url - "#{Discourse.base_path}/chat/c/#{self.slug || "-"}/#{self.id}" - end - - def self.ensure_consistency! - update_counts - end - - # TODO (martin) Move UpdateUserCountsForChatChannels into here - def self.update_counts - # NOTE: ChatChannel#messages_count is not updated every time - # a message is created or deleted in a channel, so it should not - # be displayed in the UI. It is updated eventually via Jobs::ChatPeriodicalUpdates - DB.exec <<~SQL - UPDATE chat_channels channels - SET messages_count = subquery.messages_count - FROM ( - SELECT COUNT(*) AS messages_count, chat_channel_id - FROM chat_messages - WHERE chat_messages.deleted_at IS NULL - GROUP BY chat_channel_id - ) subquery - WHERE channels.id = subquery.chat_channel_id - AND channels.deleted_at IS NULL - AND subquery.messages_count != channels.messages_count - SQL - end - - private - - def change_status(acting_user, target_status) - return if !Guardian.new(acting_user).can_change_channel_status?(self, target_status) - self.update!(status: target_status) - log_channel_status_change(acting_user: acting_user) - end - - def log_channel_status_change(acting_user:) - DiscourseEvent.trigger( - :chat_channel_status_change, - channel: self, - old_status: status_previously_was, - new_status: status, - ) - - StaffActionLogger.new(acting_user).log_custom( - "chat_channel_status_change", - { - chat_channel_id: self.id, - chat_channel_name: self.name, - previous_value: status_previously_was, - new_value: status, - }, - ) - - ChatPublisher.publish_channel_status(self) - end - - def duplicate_slug? - ChatChannel.where(slug: self.slug).where.not(id: self.id).any? - end -end - -# == Schema Information -# -# Table name: chat_channels -# -# id :bigint not null, primary key -# chatable_id :integer not null -# deleted_at :datetime -# deleted_by_id :integer -# featured_in_category_id :integer -# delete_after_seconds :integer -# chatable_type :string not null -# created_at :datetime not null -# updated_at :datetime not null -# name :string -# description :text -# status :integer default("open"), not null -# user_count :integer default(0), not null -# last_message_sent_at :datetime not null -# auto_join_users :boolean default(FALSE), not null -# allow_channel_wide_mentions :boolean default(TRUE), not null -# user_count_stale :boolean default(FALSE), not null -# slug :string -# type :string -# threading_enabled :boolean default(FALSE), not null -# -# Indexes -# -# index_chat_channels_on_messages_count (messages_count) -# index_chat_channels_on_chatable_id (chatable_id) -# index_chat_channels_on_chatable_id_and_chatable_type (chatable_id,chatable_type) -# index_chat_channels_on_slug (slug) UNIQUE -# index_chat_channels_on_status (status) -# diff --git a/plugins/chat/app/models/chat_message.rb b/plugins/chat/app/models/chat_message.rb deleted file mode 100644 index be14d09e496..00000000000 --- a/plugins/chat/app/models/chat_message.rb +++ /dev/null @@ -1,297 +0,0 @@ -# frozen_string_literal: true - -class ChatMessage < ActiveRecord::Base - include Trashable - attribute :has_oneboxes, default: false - - BAKED_VERSION = 2 - - belongs_to :chat_channel - belongs_to :user - belongs_to :in_reply_to, class_name: "ChatMessage" - belongs_to :last_editor, class_name: "User" - belongs_to :thread, class_name: "ChatThread" - - has_many :replies, class_name: "ChatMessage", foreign_key: "in_reply_to_id", dependent: :nullify - has_many :revisions, class_name: "ChatMessageRevision", dependent: :destroy - has_many :reactions, class_name: "ChatMessageReaction", dependent: :destroy - has_many :bookmarks, as: :bookmarkable, dependent: :destroy - has_many :upload_references, as: :target, dependent: :destroy - has_many :uploads, through: :upload_references - - # TODO (martin) Remove this when we drop the ChatUpload table - has_many :chat_uploads, dependent: :destroy - has_one :chat_webhook_event, dependent: :destroy - has_many :chat_mentions, dependent: :destroy - - scope :in_public_channel, - -> { - joins(:chat_channel).where( - chat_channel: { - chatable_type: ChatChannel.public_channel_chatable_types, - }, - ) - } - - scope :in_dm_channel, - -> { joins(:chat_channel).where(chat_channel: { chatable_type: "DirectMessage" }) } - - scope :created_before, ->(date) { where("chat_messages.created_at < ?", date) } - - before_save { ensure_last_editor_id } - - def validate_message(has_uploads:) - WatchedWordsValidator.new(attributes: [:message]).validate(self) - - if self.new_record? || self.changed.include?("message") - Chat::DuplicateMessageValidator.new(self).validate - end - - if !has_uploads && message_too_short? - self.errors.add( - :base, - I18n.t( - "chat.errors.minimum_length_not_met", - count: SiteSetting.chat_minimum_message_length, - ), - ) - end - - if message_too_long? - self.errors.add( - :base, - I18n.t("chat.errors.message_too_long", count: SiteSetting.chat_maximum_message_length), - ) - end - end - - def attach_uploads(uploads) - return if uploads.blank? || self.new_record? - - now = Time.now - ref_record_attrs = - uploads.map do |upload| - { - upload_id: upload.id, - target_id: self.id, - target_type: "ChatMessage", - created_at: now, - updated_at: now, - } - end - UploadReference.insert_all!(ref_record_attrs) - end - - def excerpt(max_length: 50) - # just show the URL if the whole message is a URL, because we cannot excerpt oneboxes - return message if UrlHelper.relaxed_parse(message).is_a?(URI) - - # upload-only messages are better represented as the filename - return uploads.first.original_filename if cooked.blank? && uploads.present? - - # this may return blank for some complex things like quotes, that is acceptable - PrettyText.excerpt(message, max_length, { text_entities: true }) - end - - def cooked_for_excerpt - (cooked.blank? && uploads.present?) ? "

#{uploads.first.original_filename}

" : cooked - end - - def push_notification_excerpt - Emoji.gsub_emoji_to_unicode(message).truncate(400) - end - - def to_markdown - upload_markdown = - self - .upload_references - .includes(:upload) - .order(:created_at) - .map(&:to_markdown) - .reject(&:empty?) - - return self.message if upload_markdown.empty? - - return ["#{self.message}\n"].concat(upload_markdown).join("\n") if self.message.present? - - upload_markdown.join("\n") - end - - def cook - ensure_last_editor_id - - self.cooked = self.class.cook(self.message, user_id: self.last_editor_id) - self.cooked_version = BAKED_VERSION - end - - def rebake!(invalidate_oneboxes: false, priority: nil) - ensure_last_editor_id - - previous_cooked = self.cooked - new_cooked = - self.class.cook( - message, - invalidate_oneboxes: invalidate_oneboxes, - user_id: self.last_editor_id, - ) - update_columns(cooked: new_cooked, cooked_version: BAKED_VERSION) - args = { chat_message_id: self.id } - args[:queue] = priority.to_s if priority && priority != :normal - args[:is_dirty] = true if previous_cooked != new_cooked - - Jobs.enqueue(:process_chat_message, args) - end - - def self.uncooked - where("cooked_version <> ? or cooked_version IS NULL", BAKED_VERSION) - end - - MARKDOWN_FEATURES = %w[ - anchor - bbcode-block - bbcode-inline - code - category-hashtag - censored - chat-transcript - discourse-local-dates - emoji - emojiShortcuts - inlineEmoji - html-img - hashtag-autocomplete - mentions - unicodeUsernames - onebox - quotes - spoiler-alert - table - text-post-process - upload-protocol - watched-words - ] - - MARKDOWN_IT_RULES = %w[ - autolink - list - backticks - newline - code - fence - image - table - linkify - link - strikethrough - blockquote - emphasis - ] - - def self.cook(message, opts = {}) - # A rule in our Markdown pipeline may have Guardian checks that require a - # user to be present. The last editing user of the message will be more - # generally up to date than the creating user. For example, we use - # this when cooking #hashtags to determine whether we should render - # the found hashtag based on whether the user can access the channel it - # is referencing. - cooked = - PrettyText.cook( - message, - features_override: MARKDOWN_FEATURES + DiscoursePluginRegistry.chat_markdown_features.to_a, - markdown_it_rules: MARKDOWN_IT_RULES, - force_quote_link: true, - user_id: opts[:user_id], - hashtag_context: "chat-composer", - ) - - result = - Oneboxer.apply(cooked) do |url| - if opts[:invalidate_oneboxes] - Oneboxer.invalidate(url) - InlineOneboxer.invalidate(url) - end - onebox = Oneboxer.cached_onebox(url) - onebox - end - - cooked = result.to_html if result.changed? - cooked - end - - def full_url - "#{Discourse.base_url}#{url}" - end - - def url - "/chat/c/-/#{self.chat_channel_id}/#{self.id}" - end - - def create_mentions(user_ids) - return if user_ids.empty? - - now = Time.zone.now - mentions = [] - User - .where(id: user_ids) - .find_each do |user| - mentions << { chat_message_id: self.id, user_id: user.id, created_at: now, updated_at: now } - end - - ChatMention.insert_all(mentions) - end - - def update_mentions(mentioned_user_ids) - old_mentions = chat_mentions.pluck(:user_id) - updated_mentions = mentioned_user_ids - mentioned_user_ids_to_drop = old_mentions - updated_mentions - mentioned_user_ids_to_add = updated_mentions - old_mentions - - delete_mentions(mentioned_user_ids_to_drop) - create_mentions(mentioned_user_ids_to_add) - end - - private - - def delete_mentions(user_ids) - chat_mentions.where(user_id: user_ids).destroy_all - end - - def message_too_short? - message.length < SiteSetting.chat_minimum_message_length - end - - def message_too_long? - message.length > SiteSetting.chat_maximum_message_length - end - - def ensure_last_editor_id - self.last_editor_id ||= self.user_id - end -end - -# == Schema Information -# -# Table name: chat_messages -# -# id :bigint not null, primary key -# chat_channel_id :integer not null -# user_id :integer -# created_at :datetime not null -# updated_at :datetime not null -# deleted_at :datetime -# deleted_by_id :integer -# in_reply_to_id :integer -# message :text -# cooked :text -# cooked_version :integer -# last_editor_id :integer not null -# thread_id :integer -# -# Indexes -# -# idx_chat_messages_by_created_at_not_deleted (created_at) WHERE (deleted_at IS NULL) -# index_chat_messages_on_chat_channel_id_and_created_at (chat_channel_id,created_at) -# index_chat_messages_on_chat_channel_id_and_id (chat_channel_id,id) WHERE (deleted_at IS NULL) -# index_chat_messages_on_last_editor_id (last_editor_id) -# index_chat_messages_on_thread_id (thread_id) -# diff --git a/plugins/chat/app/models/chat_view.rb b/plugins/chat/app/models/chat_view.rb deleted file mode 100644 index 9df0df18ddf..00000000000 --- a/plugins/chat/app/models/chat_view.rb +++ /dev/null @@ -1,87 +0,0 @@ -# frozen_string_literal: true - -class ChatView - attr_reader :user, :chat_channel, :chat_messages, :can_load_more_past, :can_load_more_future - - def initialize( - chat_channel:, - chat_messages:, - user:, - can_load_more_past: nil, - can_load_more_future: nil - ) - @chat_channel = chat_channel - @chat_messages = chat_messages - @user = user - @can_load_more_past = can_load_more_past - @can_load_more_future = can_load_more_future - end - - def reviewable_ids - return @reviewable_ids if defined?(@reviewable_ids) - - @reviewable_ids = @user.staff? ? get_reviewable_ids : nil - end - - def user_flag_statuses - return @user_flag_statuses if defined?(@user_flag_statuses) - - @user_flag_statuses = get_user_flag_statuses - end - - private - - def get_reviewable_ids - sql = <<~SQL - SELECT - target_id, - MAX(r.id) reviewable_id - FROM - reviewables r - JOIN - reviewable_scores s ON reviewable_id = r.id - WHERE - r.target_id IN (:message_ids) AND - r.target_type = 'ChatMessage' AND - s.status = :pending - GROUP BY - target_id - SQL - - ids = {} - - DB - .query( - sql, - pending: ReviewableScore.statuses[:pending], - message_ids: @chat_messages.map(&:id), - ) - .each { |row| ids[row.target_id] = row.reviewable_id } - - ids - end - - def get_user_flag_statuses - sql = <<~SQL - SELECT - target_id, - s.status - FROM - reviewables r - JOIN - reviewable_scores s ON reviewable_id = r.id - WHERE - s.user_id = :user_id AND - r.target_id IN (:message_ids) AND - r.target_type = 'ChatMessage' - SQL - - statuses = {} - - DB - .query(sql, message_ids: @chat_messages.map(&:id), user_id: @user.id) - .each { |row| statuses[row.target_id] = row.status } - - statuses - end -end diff --git a/plugins/chat/app/models/concerns/chat/chatable.rb b/plugins/chat/app/models/concerns/chat/chatable.rb new file mode 100644 index 00000000000..8e1ced9e1f2 --- /dev/null +++ b/plugins/chat/app/models/concerns/chat/chatable.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Chat + module Chatable + extend ActiveSupport::Concern + + STI_CLASS_MAPPING = { + "CategoryChannel" => Chat::CategoryChannel, + "DirectMessageChannel" => Chat::DirectMessageChannel, + } + + # the model used when loading type column + def self.sti_class_for(name) + STI_CLASS_MAPPING[name] if STI_CLASS_MAPPING.key?(name) + end + + # the type column value + def self.sti_name_for(klass) + STI_CLASS_MAPPING.invert.fetch(klass) + end + + POLYMORPHIC_CLASS_MAPPING = { "DirectMessage" => Chat::DirectMessage } + + # the model used when loading chatable_type column + def self.polymorphic_class_for(name) + POLYMORPHIC_CLASS_MAPPING[name] if POLYMORPHIC_CLASS_MAPPING.key?(name) + end + + # the chatable_type column value + def self.polymorphic_name_for(klass) + POLYMORPHIC_CLASS_MAPPING.invert.fetch(klass) + end + + def chat_channel + channel_class.new(chatable: self) + end + + def create_chat_channel!(**args) + channel_class.create!(args.merge(chatable: self)) + end + + private + + def channel_class + case self + when Chat::DirectMessage + Chat::DirectMessageChannel + when Category + Chat::CategoryChannel + else + raise("Unknown chatable #{self}") + end + end + end +end diff --git a/plugins/chat/app/models/concerns/chatable.rb b/plugins/chat/app/models/concerns/chatable.rb deleted file mode 100644 index 2128a7cf4e4..00000000000 --- a/plugins/chat/app/models/concerns/chatable.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Chatable - extend ActiveSupport::Concern - - def chat_channel - channel_class.new(chatable: self) - end - - def create_chat_channel!(**args) - channel_class.create!(args.merge(chatable: self)) - end - - private - - def channel_class - "#{self.class}Channel".safe_constantize || raise("Unknown chatable #{self}") - end -end diff --git a/plugins/chat/app/models/deleted_chat_user.rb b/plugins/chat/app/models/deleted_chat_user.rb deleted file mode 100644 index 3d6222a4a9b..00000000000 --- a/plugins/chat/app/models/deleted_chat_user.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class DeletedChatUser < User - def username - I18n.t("chat.deleted_chat_username") - end - - def avatar_template - "/plugins/chat/images/deleted-chat-user-avatar.png" - end - - def bot? - false - end -end diff --git a/plugins/chat/app/models/direct_message.rb b/plugins/chat/app/models/direct_message.rb deleted file mode 100644 index 58427608f15..00000000000 --- a/plugins/chat/app/models/direct_message.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -class DirectMessage < ActiveRecord::Base - self.table_name = "direct_message_channels" - - include Chatable - - has_many :direct_message_users, foreign_key: :direct_message_channel_id - has_many :users, through: :direct_message_users - - def self.for_user_ids(user_ids) - joins(:users) - .group("direct_message_channels.id") - .having("ARRAY[?] = ARRAY_AGG(users.id ORDER BY users.id)", user_ids.sort) - &.first - end - - def user_can_access?(user) - users.include?(user) - end - - def chat_channel_title_for_user(chat_channel, acting_user) - users = - (direct_message_users.map(&:user) - [acting_user]).map { |user| user || DeletedChatUser.new } - - # direct message to self - if users.empty? - return I18n.t("chat.channel.dm_title.single_user", username: "@#{acting_user.username}") - end - - # all users deleted - return chat_channel.id if !users.first - - usernames_formatted = users.sort_by(&:username).map { |u| "@#{u.username}" } - if usernames_formatted.size > 5 - return( - I18n.t( - "chat.channel.dm_title.multi_user_truncated", - comma_separated_usernames: usernames_formatted[0..4].join(I18n.t("word_connector.comma")), - count: usernames_formatted.length - 5, - ) - ) - end - - I18n.t( - "chat.channel.dm_title.multi_user", - comma_separated_usernames: usernames_formatted.join(I18n.t("word_connector.comma")), - ) - end -end - -# == Schema Information -# -# Table name: direct_message_channels -# -# id :bigint not null, primary key -# created_at :datetime not null -# updated_at :datetime not null -# diff --git a/plugins/chat/app/models/direct_message_channel.rb b/plugins/chat/app/models/direct_message_channel.rb deleted file mode 100644 index 9d116643d7e..00000000000 --- a/plugins/chat/app/models/direct_message_channel.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -class DirectMessageChannel < ChatChannel - alias_attribute :direct_message, :chatable - - def direct_message_channel? - true - end - - def allowed_user_ids - direct_message.user_ids - end - - def read_restricted? - true - end - - def title(user) - direct_message.chat_channel_title_for_user(self, user) - end - - def ensure_slug_ok - true - end - - def generate_auto_slug - self.slug = nil - end -end diff --git a/plugins/chat/app/models/reviewable_chat_message.rb b/plugins/chat/app/models/reviewable_chat_message.rb deleted file mode 100644 index 75f03f305c7..00000000000 --- a/plugins/chat/app/models/reviewable_chat_message.rb +++ /dev/null @@ -1,149 +0,0 @@ -# frozen_string_literal: true - -require_dependency "reviewable" - -class ReviewableChatMessage < Reviewable - def self.action_aliases - { - agree_and_keep_hidden: :agree_and_delete, - agree_and_silence: :agree_and_delete, - agree_and_suspend: :agree_and_delete, - delete_and_agree: :agree_and_delete, - } - end - - def self.score_to_silence_user - sensitivity_score(SiteSetting.chat_silence_user_sensitivity, scale: 0.6) - end - - def chat_message - @chat_message ||= (target || ChatMessage.with_deleted.find_by(id: target_id)) - end - - def chat_message_creator - @chat_message_creator ||= chat_message.user - end - - def flagged_by_user_ids - @flagged_by_user_ids ||= reviewable_scores.map(&:user_id) - end - - def post - nil - end - - def build_actions(actions, guardian, args) - return unless pending? - return if chat_message.blank? - - agree = - actions.add_bundle("#{id}-agree", icon: "thumbs-up", label: "reviewables.actions.agree.title") - - if chat_message.deleted_at? - build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree) - build_action(actions, :agree_and_keep_deleted, icon: "thumbs-up", bundle: agree) - build_action(actions, :disagree_and_restore, icon: "thumbs-down") - else - build_action(actions, :agree_and_delete, icon: "far-eye-slash", bundle: agree) - build_action(actions, :agree_and_keep_message, icon: "thumbs-up", bundle: agree) - build_action(actions, :disagree, icon: "thumbs-down") - end - - if guardian.can_suspend?(chat_message_creator) - build_action( - actions, - :agree_and_suspend, - icon: "ban", - bundle: agree, - client_action: "suspend", - ) - build_action( - actions, - :agree_and_silence, - icon: "microphone-slash", - bundle: agree, - client_action: "silence", - ) - end - - build_action(actions, :ignore, icon: "external-link-alt") - - build_action(actions, :delete_and_agree, icon: "far-trash-alt") unless chat_message.deleted_at? - end - - def perform_agree_and_keep_message(performed_by, args) - agree - end - - def perform_agree_and_restore(performed_by, args) - agree { chat_message.recover! } - end - - def perform_agree_and_delete(performed_by, args) - agree { chat_message.trash!(performed_by) } - end - - def perform_disagree_and_restore(performed_by, args) - disagree { chat_message.recover! } - end - - def perform_disagree(performed_by, args) - disagree - end - - def perform_ignore(performed_by, args) - ignore - end - - def perform_delete_and_ignore(performed_by, args) - ignore { chat_message.trash!(performed_by) } - end - - private - - def agree - yield if block_given? - create_result(:success, :approved) do |result| - result.update_flag_stats = { status: :agreed, user_ids: flagged_by_user_ids } - result.recalculate_score = true - end - end - - def disagree - yield if block_given? - - UserSilencer.unsilence(chat_message_creator) - - create_result(:success, :rejected) do |result| - result.update_flag_stats = { status: :disagreed, user_ids: flagged_by_user_ids } - result.recalculate_score = true - end - end - - def ignore - yield if block_given? - create_result(:success, :ignored) do |result| - result.update_flag_stats = { status: :ignored, user_ids: flagged_by_user_ids } - end - end - - def build_action( - actions, - id, - icon:, - button_class: nil, - bundle: nil, - client_action: nil, - confirm: false - ) - actions.add(id, bundle: bundle) do |action| - prefix = "reviewables.actions.#{id}" - action.icon = icon - action.button_class = button_class - action.label = "chat.#{prefix}.title" - action.description = "chat.#{prefix}.description" - action.client_action = client_action - action.confirm_message = "#{prefix}.confirm" if confirm - end - end -end diff --git a/plugins/chat/app/queries/chat/channel_memberships_query.rb b/plugins/chat/app/queries/chat/channel_memberships_query.rb new file mode 100644 index 00000000000..294ca724c89 --- /dev/null +++ b/plugins/chat/app/queries/chat/channel_memberships_query.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Chat + class ChannelMembershipsQuery + def self.call(channel:, limit: 50, offset: 0, username: nil, count_only: false) + query = + Chat::UserChatChannelMembership + .joins(:user) + .includes(:user) + .where(user: User.activated.not_suspended.not_staged) + .where(chat_channel: channel, following: true) + + return query.count if count_only + + if channel.category_channel? && channel.read_restricted? && channel.allowed_group_ids + query = + query.where( + "user_id IN (SELECT user_id FROM group_users WHERE group_id IN (?))", + channel.allowed_group_ids, + ) + end + + if username.present? + if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names + query = query.where("users.username_lower ILIKE ?", "%#{username}%") + else + query = + query.where( + "LOWER(users.name) ILIKE ? OR users.username_lower ILIKE ?", + "%#{username}%", + "%#{username}%", + ) + end + end + + if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names + query = query.order("users.username_lower ASC") + else + query = query.order("users.name ASC, users.username_lower ASC") + end + + query.offset(offset).limit(limit) + end + + def self.count(channel) + call(channel: channel, count_only: true) + end + end +end diff --git a/plugins/chat/app/queries/chat_channel_unreads_query.rb b/plugins/chat/app/queries/chat/channel_unreads_query.rb similarity index 79% rename from plugins/chat/app/queries/chat_channel_unreads_query.rb rename to plugins/chat/app/queries/chat/channel_unreads_query.rb index 0d6e49ba0ea..114570dd415 100644 --- a/plugins/chat/app/queries/chat_channel_unreads_query.rb +++ b/plugins/chat/app/queries/chat/channel_unreads_query.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true -class ChatChannelUnreadsQuery - def self.call(channel_id:, user_id:) - sql = <<~SQL +module Chat + class ChannelUnreadsQuery + def self.call(channel_id:, user_id:) + sql = <<~SQL SELECT ( SELECT COUNT(*) AS unread_count FROM chat_messages @@ -27,14 +28,15 @@ class ChatChannelUnreadsQuery ) AS mention_count; SQL - DB - .query( - sql, - channel_id: channel_id, - user_id: user_id, - notification_type: Notification.types[:chat_mention], - ) - .first - .to_h + DB + .query( + sql, + channel_id: channel_id, + user_id: user_id, + notification_type: Notification.types[:chat_mention], + ) + .first + .to_h + end end end diff --git a/plugins/chat/app/queries/chat_channel_memberships_query.rb b/plugins/chat/app/queries/chat_channel_memberships_query.rb deleted file mode 100644 index e38f09eae1d..00000000000 --- a/plugins/chat/app/queries/chat_channel_memberships_query.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -class ChatChannelMembershipsQuery - def self.call(channel:, limit: 50, offset: 0, username: nil, count_only: false) - query = - UserChatChannelMembership - .joins(:user) - .includes(:user) - .where(user: User.activated.not_suspended.not_staged) - .where(chat_channel: channel, following: true) - - return query.count if count_only - - if channel.category_channel? && channel.read_restricted? && channel.allowed_group_ids - query = - query.where( - "user_id IN (SELECT user_id FROM group_users WHERE group_id IN (?))", - channel.allowed_group_ids, - ) - end - - if username.present? - if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names - query = query.where("users.username_lower ILIKE ?", "%#{username}%") - else - query = - query.where( - "LOWER(users.name) ILIKE ? OR users.username_lower ILIKE ?", - "%#{username}%", - "%#{username}%", - ) - end - end - - if SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names - query = query.order("users.username_lower ASC") - else - query = query.order("users.name ASC, users.username_lower ASC") - end - - query.offset(offset).limit(limit) - end - - def self.count(channel) - call(channel: channel, count_only: true) - end -end diff --git a/plugins/chat/app/serializers/admin_chat_index_serializer.rb b/plugins/chat/app/serializers/admin_chat_index_serializer.rb deleted file mode 100644 index c8af0dc2f19..00000000000 --- a/plugins/chat/app/serializers/admin_chat_index_serializer.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -class AdminChatIndexSerializer < ApplicationSerializer - has_many :chat_channels, serializer: ChatChannelSerializer, embed: :objects - has_many :incoming_chat_webhooks, serializer: IncomingChatWebhookSerializer, embed: :objects - - def chat_channels - object[:chat_channels] - end - - def incoming_chat_webhooks - object[:incoming_chat_webhooks] - end -end diff --git a/plugins/chat/app/serializers/base_chat_channel_membership_serializer.rb b/plugins/chat/app/serializers/base_chat_channel_membership_serializer.rb deleted file mode 100644 index 90cb7827eed..00000000000 --- a/plugins/chat/app/serializers/base_chat_channel_membership_serializer.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -class BaseChatChannelMembershipSerializer < ApplicationSerializer - attributes :following, - :muted, - :desktop_notification_level, - :mobile_notification_level, - :chat_channel_id, - :last_read_message_id, - :unread_count, - :unread_mentions -end diff --git a/plugins/chat/app/serializers/chat/admin_chat_index_serializer.rb b/plugins/chat/app/serializers/chat/admin_chat_index_serializer.rb new file mode 100644 index 00000000000..1a36d6cc584 --- /dev/null +++ b/plugins/chat/app/serializers/chat/admin_chat_index_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Chat + class AdminChatIndexSerializer < ApplicationSerializer + has_many :chat_channels, serializer: Chat::ChannelSerializer, embed: :objects + has_many :incoming_chat_webhooks, serializer: Chat::IncomingWebhookSerializer, embed: :objects + + def chat_channels + object[:chat_channels] + end + + def incoming_chat_webhooks + object[:incoming_chat_webhooks] + end + end +end diff --git a/plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb b/plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb new file mode 100644 index 00000000000..1aef99fd33e --- /dev/null +++ b/plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Chat + class BaseChannelMembershipSerializer < ApplicationSerializer + attributes :following, + :muted, + :desktop_notification_level, + :mobile_notification_level, + :chat_channel_id, + :last_read_message_id, + :unread_count, + :unread_mentions + end +end diff --git a/plugins/chat/app/serializers/chat/channel_index_serializer.rb b/plugins/chat/app/serializers/chat/channel_index_serializer.rb new file mode 100644 index 00000000000..31c3eebe286 --- /dev/null +++ b/plugins/chat/app/serializers/chat/channel_index_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Chat + class ChannelIndexSerializer < ::Chat::StructuredChannelSerializer + attributes :global_presence_channel_state + + def global_presence_channel_state + PresenceChannelStateSerializer.new(PresenceChannel.new("/chat/online").state, root: nil) + end + end +end diff --git a/plugins/chat/app/serializers/chat/channel_search_serializer.rb b/plugins/chat/app/serializers/chat/channel_search_serializer.rb new file mode 100644 index 00000000000..196f9f78e29 --- /dev/null +++ b/plugins/chat/app/serializers/chat/channel_search_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Chat + class ChannelSearchSerializer < ::Chat::StructuredChannelSerializer + has_many :users, serializer: BasicUserSerializer, embed: :objects + + def users + object[:users] + end + end +end diff --git a/plugins/chat/app/serializers/chat/channel_serializer.rb b/plugins/chat/app/serializers/chat/channel_serializer.rb new file mode 100644 index 00000000000..7f267a9d60b --- /dev/null +++ b/plugins/chat/app/serializers/chat/channel_serializer.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Chat + class ChannelSerializer < ApplicationSerializer + attributes :id, + :auto_join_users, + :allow_channel_wide_mentions, + :chatable, + :chatable_id, + :chatable_type, + :chatable_url, + :description, + :title, + :slug, + :last_message_sent_at, + :status, + :archive_failed, + :archive_completed, + :archived_messages, + :total_messages, + :archive_topic_id, + :memberships_count, + :current_user_membership, + :meta, + :threading_enabled + + def threading_enabled + SiteSetting.enable_experimental_chat_threaded_discussions && object.threading_enabled + end + + def initialize(object, opts) + super(object, opts) + + @opts = opts + @current_user_membership = opts[:membership] + end + + def include_description? + object.description.present? + end + + def memberships_count + object.user_count + end + + def chatable_url + object.chatable_url + end + + def title + object.name || object.title(scope.user) + end + + def chatable + case object.chatable_type + when "Category" + BasicCategorySerializer.new(object.chatable, root: false).as_json + when "DirectMessage" + Chat::DirectMessageSerializer.new(object.chatable, scope: scope, root: false).as_json + when "Site" + nil + end + end + + def archive + object.chat_channel_archive + end + + def include_archive_status? + !object.direct_message_channel? && scope.is_staff? && archive.present? + end + + def archive_completed + archive.complete? + end + + def archive_failed + archive.failed? + end + + def archived_messages + archive.archived_messages + end + + def total_messages + archive.total_messages + end + + def archive_topic_id + archive.destination_topic_id + end + + def include_auto_join_users? + scope.can_edit_chat_channel? + end + + def include_current_user_membership? + @current_user_membership.present? + end + + def current_user_membership + @current_user_membership.chat_channel = object + + Chat::BaseChannelMembershipSerializer.new( + @current_user_membership, + scope: scope, + root: false, + ).as_json + end + + def meta + { + message_bus_last_ids: { + channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.id}"), + new_messages: + @opts[:new_messages_message_bus_last_id] || + MessageBus.last_id(Chat::Publisher.new_messages_message_bus_channel(object.id)), + new_mentions: + @opts[:new_mentions_message_bus_last_id] || + MessageBus.last_id(Chat::Publisher.new_mentions_message_bus_channel(object.id)), + }, + } + end + + alias_method :include_archive_topic_id?, :include_archive_status? + alias_method :include_total_messages?, :include_archive_status? + alias_method :include_archived_messages?, :include_archive_status? + alias_method :include_archive_failed?, :include_archive_status? + alias_method :include_archive_completed?, :include_archive_status? + end +end diff --git a/plugins/chat/app/serializers/chat/direct_message_serializer.rb b/plugins/chat/app/serializers/chat/direct_message_serializer.rb new file mode 100644 index 00000000000..6cc97f22c8a --- /dev/null +++ b/plugins/chat/app/serializers/chat/direct_message_serializer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Chat + class DirectMessageSerializer < ApplicationSerializer + has_many :users, serializer: Chat::UserWithCustomFieldsAndStatusSerializer, embed: :objects + + def users + users = object.direct_message_users.map(&:user).map { |u| u || Chat::DeletedUser.new } + + return users - [scope.user] if users.count > 1 + users + end + end +end diff --git a/plugins/chat/app/serializers/chat/in_reply_to_serializer.rb b/plugins/chat/app/serializers/chat/in_reply_to_serializer.rb new file mode 100644 index 00000000000..f538c078593 --- /dev/null +++ b/plugins/chat/app/serializers/chat/in_reply_to_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Chat + class InReplyToSerializer < ApplicationSerializer + has_one :user, serializer: BasicUserSerializer, embed: :objects + has_one :chat_webhook_event, serializer: Chat::WebhookEventSerializer, embed: :objects + + attributes :id, :cooked, :excerpt + + def excerpt + WordWatcher.censor(object.excerpt) + end + + def user + object.user || Chat::DeletedUser.new + end + end +end diff --git a/plugins/chat/app/serializers/chat/incoming_webhook_serializer.rb b/plugins/chat/app/serializers/chat/incoming_webhook_serializer.rb new file mode 100644 index 00000000000..65518d077a8 --- /dev/null +++ b/plugins/chat/app/serializers/chat/incoming_webhook_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Chat + class IncomingWebhookSerializer < ApplicationSerializer + has_one :chat_channel, serializer: Chat::ChannelSerializer, embed: :objects + + attributes :id, :name, :description, :emoji, :url, :username, :updated_at + end +end diff --git a/plugins/chat/app/serializers/chat/message_serializer.rb b/plugins/chat/app/serializers/chat/message_serializer.rb new file mode 100644 index 00000000000..b6454b96c77 --- /dev/null +++ b/plugins/chat/app/serializers/chat/message_serializer.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +module Chat + class MessageSerializer < ::ApplicationSerializer + attributes :id, + :message, + :cooked, + :created_at, + :excerpt, + :deleted_at, + :deleted_by_id, + :reviewable_id, + :user_flag_status, + :edited, + :reactions, + :bookmark, + :available_flags, + :thread_id, + :chat_channel_id + + has_one :user, serializer: Chat::MessageUserSerializer, embed: :objects + has_one :chat_webhook_event, serializer: Chat::WebhookEventSerializer, embed: :objects + has_one :in_reply_to, serializer: Chat::InReplyToSerializer, embed: :objects + has_many :uploads, serializer: ::UploadSerializer, embed: :objects + + def channel + @channel ||= @options.dig(:chat_channel) || object.chat_channel + end + + def user + object.user || Chat::DeletedUser.new + end + + def excerpt + WordWatcher.censor(object.excerpt) + end + + def reactions + object + .reactions + .group_by(&:emoji) + .map do |emoji, reactions| + next unless Emoji.exists?(emoji) + + users = reactions.take(5).map(&:user) + + { + emoji: emoji, + count: reactions.count, + users: + ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer).as_json, + reacted: users_reactions.include?(emoji), + } + end + .compact + end + + def include_reactions? + object.reactions.any? + end + + def users_reactions + @users_reactions ||= + object.reactions.select { |reaction| reaction.user_id == scope&.user&.id }.map(&:emoji) + end + + def users_bookmark + @user_bookmark ||= object.bookmarks.find { |bookmark| bookmark.user_id == scope&.user&.id } + end + + def include_bookmark? + users_bookmark.present? + end + + def bookmark + { + id: users_bookmark.id, + reminder_at: users_bookmark.reminder_at, + name: users_bookmark.name, + auto_delete_preference: users_bookmark.auto_delete_preference, + bookmarkable_id: users_bookmark.bookmarkable_id, + bookmarkable_type: users_bookmark.bookmarkable_type, + } + end + + def edited + true + end + + def include_edited? + object.revisions.any? + end + + def deleted_at + object.user ? object.deleted_at : Time.zone.now + end + + def deleted_by_id + object.user ? object.deleted_by_id : Discourse.system_user.id + end + + def include_deleted_at? + object.user ? !object.deleted_at.nil? : true + end + + def include_deleted_by_id? + object.user ? !object.deleted_at.nil? : true + end + + def include_in_reply_to? + object.in_reply_to_id.presence + end + + def reviewable_id + return @reviewable_id if defined?(@reviewable_id) + return @reviewable_id = nil unless @options && @options[:reviewable_ids] + + @reviewable_id = @options[:reviewable_ids][object.id] + end + + def include_reviewable_id? + reviewable_id.present? + end + + def user_flag_status + return @user_flag_status if defined?(@user_flag_status) + return @user_flag_status = nil unless @options&.dig(:user_flag_statuses) + + @user_flag_status = @options[:user_flag_statuses][object.id] + end + + def include_user_flag_status? + user_flag_status.present? + end + + def available_flags + return [] if !scope.can_flag_chat_message?(object) + return [] if reviewable_id.present? && user_flag_status == ReviewableScore.statuses[:pending] + + PostActionType.flag_types.map do |sym, id| + next if channel.direct_message_channel? && %i[notify_moderators notify_user].include?(sym) + + if sym == :notify_user && + ( + scope.current_user == user || user.bot? || + !scope.current_user.in_any_groups?(SiteSetting.personal_message_enabled_groups_map) + ) + next + end + + sym + end + end + end +end diff --git a/plugins/chat/app/serializers/chat/message_user_serializer.rb b/plugins/chat/app/serializers/chat/message_user_serializer.rb new file mode 100644 index 00000000000..92d222a6782 --- /dev/null +++ b/plugins/chat/app/serializers/chat/message_user_serializer.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Chat + class MessageUserSerializer < BasicUserWithStatusSerializer + attributes :moderator?, :admin?, :staff?, :moderator?, :new_user?, :primary_group_name + + def moderator? + !!(object&.moderator?) + end + + def admin? + !!(object&.admin?) + end + + def staff? + !!(object&.staff?) + end + + def new_user? + object.trust_level == TrustLevel[0] + end + + def primary_group_name + return nil unless object && object.primary_group_id + object.primary_group.name if object.primary_group + end + end +end diff --git a/plugins/chat/app/serializers/chat/reviewable_message_serializer.rb b/plugins/chat/app/serializers/chat/reviewable_message_serializer.rb new file mode 100644 index 00000000000..41f74e31c81 --- /dev/null +++ b/plugins/chat/app/serializers/chat/reviewable_message_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_dependency "reviewable_serializer" + +module Chat + class ReviewableMessageSerializer < ReviewableSerializer + target_attributes :cooked + payload_attributes :transcript_topic_id, :message_cooked + attributes :target_id + + has_one :chat_channel, serializer: Chat::ChannelSerializer, root: false, embed: :objects + + def chat_channel + object.chat_message.chat_channel + end + + def target_id + object.target&.id + end + end +end diff --git a/plugins/chat/app/serializers/chat/structured_channel_serializer.rb b/plugins/chat/app/serializers/chat/structured_channel_serializer.rb new file mode 100644 index 00000000000..aca74386956 --- /dev/null +++ b/plugins/chat/app/serializers/chat/structured_channel_serializer.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Chat + class StructuredChannelSerializer < ApplicationSerializer + attributes :public_channels, :direct_message_channels, :meta + + def public_channels + object[:public_channels].map do |channel| + Chat::ChannelSerializer.new( + channel, + root: nil, + scope: scope, + membership: channel_membership(channel.id), + new_messages_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.new_messages_message_bus_channel(channel.id)], + new_mentions_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.new_mentions_message_bus_channel(channel.id)], + ) + end + end + + def direct_message_channels + object[:direct_message_channels].map do |channel| + Chat::ChannelSerializer.new( + channel, + root: nil, + scope: scope, + membership: channel_membership(channel.id), + new_messages_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.new_messages_message_bus_channel(channel.id)], + new_mentions_message_bus_last_id: + chat_message_bus_last_ids[Chat::Publisher.new_mentions_message_bus_channel(channel.id)], + ) + end + end + + def channel_membership(channel_id) + return if scope.anonymous? + object[:memberships].find { |membership| membership.chat_channel_id == channel_id } + end + + def meta + last_ids = { + channel_metadata: + chat_message_bus_last_ids[Chat::Publisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL], + channel_edits: + chat_message_bus_last_ids[Chat::Publisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL], + channel_status: + chat_message_bus_last_ids[Chat::Publisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL], + new_channel: chat_message_bus_last_ids[Chat::Publisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL], + archive_status: + chat_message_bus_last_ids[Chat::Publisher::CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL], + } + + if id = + chat_message_bus_last_ids[ + Chat::Publisher.user_tracking_state_message_bus_channel(scope.user.id) + ] + last_ids[:user_tracking_state] = id + end + + { message_bus_last_ids: last_ids } + end + + private + + def chat_message_bus_last_ids + @chat_message_bus_last_ids ||= + begin + message_bus_channels = [ + Chat::Publisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL, + Chat::Publisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL, + Chat::Publisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL, + Chat::Publisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL, + Chat::Publisher::CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL, + ] + + if !scope.anonymous? + message_bus_channels.push( + Chat::Publisher.user_tracking_state_message_bus_channel(scope.user.id), + ) + end + + object[:public_channels].each do |channel| + message_bus_channels.push(Chat::Publisher.new_messages_message_bus_channel(channel.id)) + message_bus_channels.push(Chat::Publisher.new_mentions_message_bus_channel(channel.id)) + end + + object[:direct_message_channels].each do |channel| + message_bus_channels.push(Chat::Publisher.new_messages_message_bus_channel(channel.id)) + message_bus_channels.push(Chat::Publisher.new_mentions_message_bus_channel(channel.id)) + end + + MessageBus.last_ids(*message_bus_channels) + end + end + end +end diff --git a/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb b/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb new file mode 100644 index 00000000000..57efe2c49c6 --- /dev/null +++ b/plugins/chat/app/serializers/chat/thread_original_message_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Chat + class ThreadOriginalMessageSerializer < ApplicationSerializer + attributes :id, :created_at, :excerpt, :thread_id + + has_one :chat_webhook_event, serializer: Chat::WebhookEventSerializer, embed: :objects + + def excerpt + WordWatcher.censor(object.excerpt(max_length: Chat::Thread::EXCERPT_LENGTH)) + end + end +end diff --git a/plugins/chat/app/serializers/chat/thread_serializer.rb b/plugins/chat/app/serializers/chat/thread_serializer.rb new file mode 100644 index 00000000000..0408387c0f2 --- /dev/null +++ b/plugins/chat/app/serializers/chat/thread_serializer.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Chat + class ThreadSerializer < ApplicationSerializer + has_one :original_message_user, serializer: BasicUserWithStatusSerializer, embed: :objects + has_one :original_message, serializer: Chat::ThreadOriginalMessageSerializer, embed: :objects + + attributes :id, :title, :status + end +end diff --git a/plugins/chat/app/serializers/chat/user_channel_membership_serializer.rb b/plugins/chat/app/serializers/chat/user_channel_membership_serializer.rb new file mode 100644 index 00000000000..8dd02a7ffa1 --- /dev/null +++ b/plugins/chat/app/serializers/chat/user_channel_membership_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Chat + class UserChannelMembershipSerializer < BaseChannelMembershipSerializer + has_one :user, serializer: BasicUserSerializer, embed: :objects + + def user + object.user + end + end +end diff --git a/plugins/chat/app/serializers/chat/user_message_bookmark_serializer.rb b/plugins/chat/app/serializers/chat/user_message_bookmark_serializer.rb new file mode 100644 index 00000000000..f1ba24bdc16 --- /dev/null +++ b/plugins/chat/app/serializers/chat/user_message_bookmark_serializer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Chat + class UserMessageBookmarkSerializer < UserBookmarkBaseSerializer + attr_reader :chat_message + + def title + fancy_title + end + + def fancy_title + @fancy_title ||= chat_message.chat_channel.title(scope.user) + end + + def cooked + chat_message.cooked + end + + def bookmarkable_user + @bookmarkable_user ||= chat_message.user + end + + def bookmarkable_url + chat_message.url + end + + def excerpt + return nil unless cooked + @excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true) + end + + private + + def chat_message + object.bookmarkable + end + end +end diff --git a/plugins/chat/app/serializers/chat/user_with_custom_fields_and_status_serializer.rb b/plugins/chat/app/serializers/chat/user_with_custom_fields_and_status_serializer.rb new file mode 100644 index 00000000000..d9589d730b8 --- /dev/null +++ b/plugins/chat/app/serializers/chat/user_with_custom_fields_and_status_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chat + class UserWithCustomFieldsAndStatusSerializer < ::UserWithCustomFieldsSerializer + attributes :status + + def include_status? + SiteSetting.enable_user_status && user.has_status? + end + + def status + ::UserStatusSerializer.new(user.user_status, root: false) + end + end +end diff --git a/plugins/chat/app/serializers/chat/view_serializer.rb b/plugins/chat/app/serializers/chat/view_serializer.rb new file mode 100644 index 00000000000..3acfffc2162 --- /dev/null +++ b/plugins/chat/app/serializers/chat/view_serializer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Chat + class ViewSerializer < ApplicationSerializer + attributes :meta, :chat_messages + + def chat_messages + ActiveModel::ArraySerializer.new( + object.chat_messages, + each_serializer: Chat::MessageSerializer, + reviewable_ids: object.reviewable_ids, + user_flag_statuses: object.user_flag_statuses, + chat_channel: object.chat_channel, + scope: scope, + ) + end + + def meta + meta_hash = { + channel_id: object.chat_channel.id, + can_flag: scope.can_flag_in_chat_channel?(object.chat_channel), + channel_status: object.chat_channel.status, + user_silenced: !scope.can_create_chat_message?, + can_moderate: scope.can_moderate_chat?(object.chat_channel.chatable), + can_delete_self: scope.can_delete_own_chats?(object.chat_channel.chatable), + can_delete_others: scope.can_delete_other_chats?(object.chat_channel.chatable), + channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.chat_channel.id}"), + } + meta_hash[ + :can_load_more_past + ] = object.can_load_more_past unless object.can_load_more_past.nil? + meta_hash[ + :can_load_more_future + ] = object.can_load_more_future unless object.can_load_more_future.nil? + meta_hash + end + end +end diff --git a/plugins/chat/app/serializers/chat/webhook_event_serializer.rb b/plugins/chat/app/serializers/chat/webhook_event_serializer.rb new file mode 100644 index 00000000000..309cd06bf16 --- /dev/null +++ b/plugins/chat/app/serializers/chat/webhook_event_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Chat + class WebhookEventSerializer < ApplicationSerializer + attributes :username, :emoji + end +end diff --git a/plugins/chat/app/serializers/chat_channel_index_serializer.rb b/plugins/chat/app/serializers/chat_channel_index_serializer.rb deleted file mode 100644 index 59c555a90f7..00000000000 --- a/plugins/chat/app/serializers/chat_channel_index_serializer.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class ChatChannelIndexSerializer < StructuredChannelSerializer - attributes :global_presence_channel_state - - def global_presence_channel_state - PresenceChannelStateSerializer.new(PresenceChannel.new("/chat/online").state, root: nil) - end -end diff --git a/plugins/chat/app/serializers/chat_channel_search_serializer.rb b/plugins/chat/app/serializers/chat_channel_search_serializer.rb deleted file mode 100644 index cf5bc083cc9..00000000000 --- a/plugins/chat/app/serializers/chat_channel_search_serializer.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class ChatChannelSearchSerializer < StructuredChannelSerializer - has_many :users, serializer: BasicUserSerializer, embed: :objects - - def users - object[:users] - end -end diff --git a/plugins/chat/app/serializers/chat_channel_serializer.rb b/plugins/chat/app/serializers/chat_channel_serializer.rb deleted file mode 100644 index d007ca651a2..00000000000 --- a/plugins/chat/app/serializers/chat_channel_serializer.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true - -class ChatChannelSerializer < ApplicationSerializer - attributes :id, - :auto_join_users, - :allow_channel_wide_mentions, - :chatable, - :chatable_id, - :chatable_type, - :chatable_url, - :description, - :title, - :slug, - :last_message_sent_at, - :status, - :archive_failed, - :archive_completed, - :archived_messages, - :total_messages, - :archive_topic_id, - :memberships_count, - :current_user_membership, - :meta, - :threading_enabled - - def threading_enabled - SiteSetting.enable_experimental_chat_threaded_discussions && object.threading_enabled - end - - def initialize(object, opts) - super(object, opts) - - @opts = opts - @current_user_membership = opts[:membership] - end - - def include_description? - object.description.present? - end - - def memberships_count - object.user_count - end - - def chatable_url - object.chatable_url - end - - def title - object.name || object.title(scope.user) - end - - def chatable - case object.chatable_type - when "Category" - BasicCategorySerializer.new(object.chatable, root: false).as_json - when "DirectMessage" - DirectMessageSerializer.new(object.chatable, scope: scope, root: false).as_json - when "Site" - nil - end - end - - def archive - object.chat_channel_archive - end - - def include_archive_status? - !object.direct_message_channel? && scope.is_staff? && archive.present? - end - - def archive_completed - archive.complete? - end - - def archive_failed - archive.failed? - end - - def archived_messages - archive.archived_messages - end - - def total_messages - archive.total_messages - end - - def archive_topic_id - archive.destination_topic_id - end - - def include_auto_join_users? - scope.can_edit_chat_channel? - end - - def include_current_user_membership? - @current_user_membership.present? - end - - def current_user_membership - @current_user_membership.chat_channel = object - - BaseChatChannelMembershipSerializer.new( - @current_user_membership, - scope: scope, - root: false, - ).as_json - end - - def meta - { - message_bus_last_ids: { - channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.id}"), - new_messages: - @opts[:new_messages_message_bus_last_id] || - MessageBus.last_id(ChatPublisher.new_messages_message_bus_channel(object.id)), - new_mentions: - @opts[:new_mentions_message_bus_last_id] || - MessageBus.last_id(ChatPublisher.new_mentions_message_bus_channel(object.id)), - }, - } - end - - alias_method :include_archive_topic_id?, :include_archive_status? - alias_method :include_total_messages?, :include_archive_status? - alias_method :include_archived_messages?, :include_archive_status? - alias_method :include_archive_failed?, :include_archive_status? - alias_method :include_archive_completed?, :include_archive_status? -end diff --git a/plugins/chat/app/serializers/chat_in_reply_to_serializer.rb b/plugins/chat/app/serializers/chat_in_reply_to_serializer.rb deleted file mode 100644 index 25cb08c8fde..00000000000 --- a/plugins/chat/app/serializers/chat_in_reply_to_serializer.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class ChatInReplyToSerializer < ApplicationSerializer - has_one :user, serializer: BasicUserSerializer, embed: :objects - has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects - - attributes :id, :cooked, :excerpt - - def excerpt - WordWatcher.censor(object.excerpt) - end - - def user - object.user || DeletedChatUser.new - end -end diff --git a/plugins/chat/app/serializers/chat_message_serializer.rb b/plugins/chat/app/serializers/chat_message_serializer.rb deleted file mode 100644 index 4ff2b7e5ff0..00000000000 --- a/plugins/chat/app/serializers/chat_message_serializer.rb +++ /dev/null @@ -1,153 +0,0 @@ -# frozen_string_literal: true - -class ChatMessageSerializer < ApplicationSerializer - attributes :id, - :message, - :cooked, - :created_at, - :excerpt, - :deleted_at, - :deleted_by_id, - :reviewable_id, - :user_flag_status, - :edited, - :reactions, - :bookmark, - :available_flags, - :thread_id, - :chat_channel_id - - has_one :user, serializer: ChatMessageUserSerializer, embed: :objects - has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects - has_one :in_reply_to, serializer: ChatInReplyToSerializer, embed: :objects - has_many :uploads, serializer: UploadSerializer, embed: :objects - - def channel - @channel ||= @options.dig(:chat_channel) || object.chat_channel - end - - def user - object.user || DeletedChatUser.new - end - - def excerpt - WordWatcher.censor(object.excerpt) - end - - def reactions - object - .reactions - .group_by(&:emoji) - .map do |emoji, reactions| - next unless Emoji.exists?(emoji) - - users = reactions.take(5).map(&:user) - - { - emoji: emoji, - count: reactions.count, - users: - ActiveModel::ArraySerializer.new(users, each_serializer: BasicUserSerializer).as_json, - reacted: users_reactions.include?(emoji), - } - end - .compact - end - - def include_reactions? - object.reactions.any? - end - - def users_reactions - @users_reactions ||= - object.reactions.select { |reaction| reaction.user_id == scope&.user&.id }.map(&:emoji) - end - - def users_bookmark - @user_bookmark ||= object.bookmarks.find { |bookmark| bookmark.user_id == scope&.user&.id } - end - - def include_bookmark? - users_bookmark.present? - end - - def bookmark - { - id: users_bookmark.id, - reminder_at: users_bookmark.reminder_at, - name: users_bookmark.name, - auto_delete_preference: users_bookmark.auto_delete_preference, - bookmarkable_id: users_bookmark.bookmarkable_id, - bookmarkable_type: users_bookmark.bookmarkable_type, - } - end - - def edited - true - end - - def include_edited? - object.revisions.any? - end - - def deleted_at - object.user ? object.deleted_at : Time.zone.now - end - - def deleted_by_id - object.user ? object.deleted_by_id : Discourse.system_user.id - end - - def include_deleted_at? - object.user ? !object.deleted_at.nil? : true - end - - def include_deleted_by_id? - object.user ? !object.deleted_at.nil? : true - end - - def include_in_reply_to? - object.in_reply_to_id.presence - end - - def reviewable_id - return @reviewable_id if defined?(@reviewable_id) - return @reviewable_id = nil unless @options && @options[:reviewable_ids] - - @reviewable_id = @options[:reviewable_ids][object.id] - end - - def include_reviewable_id? - reviewable_id.present? - end - - def user_flag_status - return @user_flag_status if defined?(@user_flag_status) - return @user_flag_status = nil unless @options&.dig(:user_flag_statuses) - - @user_flag_status = @options[:user_flag_statuses][object.id] - end - - def include_user_flag_status? - user_flag_status.present? - end - - def available_flags - return [] if !scope.can_flag_chat_message?(object) - return [] if reviewable_id.present? && user_flag_status == ReviewableScore.statuses[:pending] - - PostActionType.flag_types.map do |sym, id| - next if channel.direct_message_channel? && %i[notify_moderators notify_user].include?(sym) - - if sym == :notify_user && - ( - scope.current_user == user || user.bot? || - !scope.current_user.in_any_groups?(SiteSetting.personal_message_enabled_groups_map) - ) - next - end - - sym - end - end -end diff --git a/plugins/chat/app/serializers/chat_message_user_serializer.rb b/plugins/chat/app/serializers/chat_message_user_serializer.rb deleted file mode 100644 index 25e3f2518b8..00000000000 --- a/plugins/chat/app/serializers/chat_message_user_serializer.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -class ChatMessageUserSerializer < BasicUserWithStatusSerializer - attributes :moderator?, :admin?, :staff?, :moderator?, :new_user?, :primary_group_name - - def moderator? - !!(object&.moderator?) - end - - def admin? - !!(object&.admin?) - end - - def staff? - !!(object&.staff?) - end - - def new_user? - object.trust_level == TrustLevel[0] - end - - def primary_group_name - return nil unless object && object.primary_group_id - object.primary_group.name if object.primary_group - end -end diff --git a/plugins/chat/app/serializers/chat_thread_original_message_serializer.rb b/plugins/chat/app/serializers/chat_thread_original_message_serializer.rb deleted file mode 100644 index 0cbf498bcc0..00000000000 --- a/plugins/chat/app/serializers/chat_thread_original_message_serializer.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class ChatThreadOriginalMessageSerializer < ApplicationSerializer - attributes :id, :created_at, :excerpt, :thread_id - - has_one :chat_webhook_event, serializer: ChatWebhookEventSerializer, embed: :objects - - def excerpt - WordWatcher.censor(object.excerpt(max_length: ChatThread::EXCERPT_LENGTH)) - end -end diff --git a/plugins/chat/app/serializers/chat_thread_serializer.rb b/plugins/chat/app/serializers/chat_thread_serializer.rb deleted file mode 100644 index 614f5d79dbc..00000000000 --- a/plugins/chat/app/serializers/chat_thread_serializer.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class ChatThreadSerializer < ApplicationSerializer - has_one :original_message_user, serializer: BasicUserWithStatusSerializer, embed: :objects - has_one :original_message, serializer: ChatThreadOriginalMessageSerializer, embed: :objects - - attributes :id, :title, :status -end diff --git a/plugins/chat/app/serializers/chat_view_serializer.rb b/plugins/chat/app/serializers/chat_view_serializer.rb deleted file mode 100644 index 129cd31f17b..00000000000 --- a/plugins/chat/app/serializers/chat_view_serializer.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -class ChatViewSerializer < ApplicationSerializer - attributes :meta, :chat_messages - - def chat_messages - ActiveModel::ArraySerializer.new( - object.chat_messages, - each_serializer: ChatMessageSerializer, - reviewable_ids: object.reviewable_ids, - user_flag_statuses: object.user_flag_statuses, - chat_channel: object.chat_channel, - scope: scope, - ) - end - - def meta - meta_hash = { - channel_id: object.chat_channel.id, - can_flag: scope.can_flag_in_chat_channel?(object.chat_channel), - channel_status: object.chat_channel.status, - user_silenced: !scope.can_create_chat_message?, - can_moderate: scope.can_moderate_chat?(object.chat_channel.chatable), - can_delete_self: scope.can_delete_own_chats?(object.chat_channel.chatable), - can_delete_others: scope.can_delete_other_chats?(object.chat_channel.chatable), - channel_message_bus_last_id: MessageBus.last_id("/chat/#{object.chat_channel.id}"), - } - meta_hash[:can_load_more_past] = object.can_load_more_past unless object.can_load_more_past.nil? - meta_hash[ - :can_load_more_future - ] = object.can_load_more_future unless object.can_load_more_future.nil? - meta_hash - end -end diff --git a/plugins/chat/app/serializers/chat_webhook_event_serializer.rb b/plugins/chat/app/serializers/chat_webhook_event_serializer.rb deleted file mode 100644 index 3fb674c653f..00000000000 --- a/plugins/chat/app/serializers/chat_webhook_event_serializer.rb +++ /dev/null @@ -1,5 +0,0 @@ -# frozen_string_literal: true - -class ChatWebhookEventSerializer < ApplicationSerializer - attributes :username, :emoji -end diff --git a/plugins/chat/app/serializers/direct_message_serializer.rb b/plugins/chat/app/serializers/direct_message_serializer.rb deleted file mode 100644 index 817902467dd..00000000000 --- a/plugins/chat/app/serializers/direct_message_serializer.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -class DirectMessageSerializer < ApplicationSerializer - has_many :users, serializer: UserWithCustomFieldsAndStatusSerializer, embed: :objects - - def users - users = object.direct_message_users.map(&:user).map { |u| u || DeletedChatUser.new } - - return users - [scope.user] if users.count > 1 - users - end -end diff --git a/plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb b/plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb deleted file mode 100644 index 7f097e62bfd..00000000000 --- a/plugins/chat/app/serializers/incoming_chat_webhook_serializer.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -class IncomingChatWebhookSerializer < ApplicationSerializer - has_one :chat_channel, serializer: ChatChannelSerializer, embed: :objects - - attributes :id, :name, :description, :emoji, :url, :username, :updated_at -end diff --git a/plugins/chat/app/serializers/reviewable_chat_message_serializer.rb b/plugins/chat/app/serializers/reviewable_chat_message_serializer.rb deleted file mode 100644 index 5c56d39fb70..00000000000 --- a/plugins/chat/app/serializers/reviewable_chat_message_serializer.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require_dependency "reviewable_serializer" - -class ReviewableChatMessageSerializer < ReviewableSerializer - target_attributes :cooked - payload_attributes :transcript_topic_id, :message_cooked - attributes :target_id - - has_one :chat_channel, serializer: ChatChannelSerializer, root: false, embed: :objects - - def chat_channel - object.chat_message.chat_channel - end - - def target_id - object.target&.id - end -end diff --git a/plugins/chat/app/serializers/structured_channel_serializer.rb b/plugins/chat/app/serializers/structured_channel_serializer.rb deleted file mode 100644 index e88b9a00b5d..00000000000 --- a/plugins/chat/app/serializers/structured_channel_serializer.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -class StructuredChannelSerializer < ApplicationSerializer - attributes :public_channels, :direct_message_channels, :meta - - def public_channels - object[:public_channels].map do |channel| - ChatChannelSerializer.new( - channel, - root: nil, - scope: scope, - membership: channel_membership(channel.id), - new_messages_message_bus_last_id: - chat_message_bus_last_ids[ChatPublisher.new_messages_message_bus_channel(channel.id)], - new_mentions_message_bus_last_id: - chat_message_bus_last_ids[ChatPublisher.new_mentions_message_bus_channel(channel.id)], - ) - end - end - - def direct_message_channels - object[:direct_message_channels].map do |channel| - ChatChannelSerializer.new( - channel, - root: nil, - scope: scope, - membership: channel_membership(channel.id), - new_messages_message_bus_last_id: - chat_message_bus_last_ids[ChatPublisher.new_messages_message_bus_channel(channel.id)], - new_mentions_message_bus_last_id: - chat_message_bus_last_ids[ChatPublisher.new_mentions_message_bus_channel(channel.id)], - ) - end - end - - def channel_membership(channel_id) - return if scope.anonymous? - object[:memberships].find { |membership| membership.chat_channel_id == channel_id } - end - - def meta - last_ids = { - channel_metadata: - chat_message_bus_last_ids[ChatPublisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL], - channel_edits: chat_message_bus_last_ids[ChatPublisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL], - channel_status: chat_message_bus_last_ids[ChatPublisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL], - new_channel: chat_message_bus_last_ids[ChatPublisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL], - archive_status: - chat_message_bus_last_ids[ChatPublisher::CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL], - } - - if id = - chat_message_bus_last_ids[ - ChatPublisher.user_tracking_state_message_bus_channel(scope.user.id) - ] - last_ids[:user_tracking_state] = id - end - - { message_bus_last_ids: last_ids } - end - - private - - def chat_message_bus_last_ids - @chat_message_bus_last_ids ||= - begin - message_bus_channels = [ - ChatPublisher::CHANNEL_METADATA_MESSAGE_BUS_CHANNEL, - ChatPublisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL, - ChatPublisher::CHANNEL_STATUS_MESSAGE_BUS_CHANNEL, - ChatPublisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL, - ChatPublisher::CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL, - ] - - if !scope.anonymous? - message_bus_channels.push( - ChatPublisher.user_tracking_state_message_bus_channel(scope.user.id), - ) - end - - object[:public_channels].each do |channel| - message_bus_channels.push(ChatPublisher.new_messages_message_bus_channel(channel.id)) - message_bus_channels.push(ChatPublisher.new_mentions_message_bus_channel(channel.id)) - end - - object[:direct_message_channels].each do |channel| - message_bus_channels.push(ChatPublisher.new_messages_message_bus_channel(channel.id)) - message_bus_channels.push(ChatPublisher.new_mentions_message_bus_channel(channel.id)) - end - - MessageBus.last_ids(*message_bus_channels) - end - end -end diff --git a/plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb b/plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb deleted file mode 100644 index 18c26222e34..00000000000 --- a/plugins/chat/app/serializers/user_chat_channel_membership_serializer.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class UserChatChannelMembershipSerializer < BaseChatChannelMembershipSerializer - has_one :user, serializer: BasicUserSerializer, embed: :objects - - def user - object.user - end -end diff --git a/plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb b/plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb deleted file mode 100644 index 49f4c7af6f6..00000000000 --- a/plugins/chat/app/serializers/user_chat_message_bookmark_serializer.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -class UserChatMessageBookmarkSerializer < UserBookmarkBaseSerializer - attr_reader :chat_message - - def title - fancy_title - end - - def fancy_title - @fancy_title ||= chat_message.chat_channel.title(scope.user) - end - - def cooked - chat_message.cooked - end - - def bookmarkable_user - @bookmarkable_user ||= chat_message.user - end - - def bookmarkable_url - chat_message.url - end - - def excerpt - return nil unless cooked - @excerpt ||= PrettyText.excerpt(cooked, 300, keep_emoji_images: true) - end - - private - - def chat_message - object.bookmarkable - end -end diff --git a/plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb b/plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb deleted file mode 100644 index e0897abfd54..00000000000 --- a/plugins/chat/app/serializers/user_with_custom_fields_and_status_serializer.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -class UserWithCustomFieldsAndStatusSerializer < UserWithCustomFieldsSerializer - attributes :status - - def include_status? - SiteSetting.enable_user_status && user.has_status? - end - - def status - UserStatusSerializer.new(user.user_status, root: false) - end -end diff --git a/plugins/chat/app/services/base.rb b/plugins/chat/app/services/base.rb deleted file mode 100644 index ad440c2ac09..00000000000 --- a/plugins/chat/app/services/base.rb +++ /dev/null @@ -1,430 +0,0 @@ -# frozen_string_literal: true - -module Chat - module Service - # Module to be included to provide steps DSL to any class. This allows to - # create easy to understand services as the whole service cycle is visible - # simply by reading the beginning of its class. - # - # Steps are executed in the order they’re defined. They will use their name - # to execute the corresponding method defined in the service class. - # - # Currently, there are 5 types of steps: - # - # * +contract(name = :default)+: used to validate the input parameters, - # typically provided by a user calling an endpoint. A special embedded - # +Contract+ class has to be defined to holds the validations. If the - # validations fail, the step will fail. Otherwise, the resulting contract - # will be available in +context[:contract]+. When calling +step(name)+ or - # +model(name = :model)+ methods after validating a contract, the contract - # should be used as an argument instead of context attributes. - # * +model(name = :model)+: used to instantiate a model (either by building - # it or fetching it from the DB). If a falsy value is returned, then the - # step will fail. Otherwise the resulting object will be assigned in - # +context[name]+ (+context[:model]+ by default). - # * +policy(name = :default)+: used to perform a check on the state of the - # system. Typically used to run guardians. If a falsy value is returned, - # the step will fail. - # * +step(name)+: used to run small snippets of arbitrary code. The step - # doesn’t care about its return value, so to mark the service as failed, - # {#fail!} has to be called explicitly. - # * +transaction+: used to wrap other steps inside a DB transaction. - # - # The methods defined on the service are automatically provided with - # the whole context passed as keyword arguments. This allows to define in a - # very explicit way what dependencies are used by the method. If for - # whatever reason a key isn’t found in the current context, then Ruby will - # raise an exception when the method is called. - # - # Regarding contract classes, they automatically have {ActiveModel} modules - # included so all the {ActiveModel} API is available. - # - # @example An example from the {TrashChannel} service - # class TrashChannel - # include Base - # - # model :channel, :fetch_channel - # policy :invalid_access - # transaction do - # step :prevents_slug_collision - # step :soft_delete_channel - # step :log_channel_deletion - # end - # step :enqueue_delete_channel_relations_job - # - # private - # - # def fetch_channel(channel_id:, **) - # ChatChannel.find_by(id: channel_id) - # end - # - # def invalid_access(guardian:, channel:, **) - # guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel? - # end - # - # def prevents_slug_collision(channel:, **) - # … - # end - # - # def soft_delete_channel(guardian:, channel:, **) - # … - # end - # - # def log_channel_deletion(guardian:, channel:, **) - # … - # end - # - # def enqueue_delete_channel_relations_job(channel:, **) - # … - # end - # end - # @example An example from the {UpdateChannelStatus} service which uses a contract - # class UpdateChannelStatus - # include Base - # - # model :channel, :fetch_channel - # contract - # policy :check_channel_permission - # step :change_status - # - # class Contract - # attribute :status - # validates :status, inclusion: { in: ChatChannel.editable_statuses.keys } - # end - # - # … - # end - module Base - extend ActiveSupport::Concern - - # The only exception that can be raised by a service. - class Failure < StandardError - # @return [Context] - attr_reader :context - - # @!visibility private - def initialize(context = nil) - @context = context - super - end - end - - # Simple structure to hold the context of the service during its whole lifecycle. - class Context < OpenStruct - # @return [Boolean] returns +true+ if the conext is set as successful (default) - def success? - !failure? - end - - # @return [Boolean] returns +true+ if the context is set as failed - # @see #fail! - # @see #fail - def failure? - @failure || false - end - - # Marks the context as failed. - # @param context [Hash, Context] the context to merge into the current one - # @example - # context.fail!("failure": "something went wrong") - # @return [Context] - def fail!(context = {}) - fail(context) - raise Failure, self - end - - # Marks the context as failed without raising an exception. - # @param context [Hash, Context] the context to merge into the current one - # @example - # context.fail("failure": "something went wrong") - # @return [Context] - def fail(context = {}) - merge(context) - @failure = true - self - end - - # Merges the given context into the current one. - # @!visibility private - def merge(other_context = {}) - other_context.each { |key, value| self[key.to_sym] = value } - self - end - - private - - def self.build(context = {}) - self === context ? context : new(context) - end - end - - # Internal module to define available steps as DSL - # @!visibility private - module StepsHelpers - def model(name = :model, step_name = :"fetch_#{name}") - steps << ModelStep.new(name, step_name) - end - - def contract(name = :default, class_name: self::Contract, default_values_from: nil) - steps << ContractStep.new( - name, - class_name: class_name, - default_values_from: default_values_from, - ) - end - - def policy(name = :default) - steps << PolicyStep.new(name) - end - - def step(name) - steps << Step.new(name) - end - - def transaction(&block) - steps << TransactionStep.new(&block) - end - end - - # @!visibility private - class Step - attr_reader :name, :method_name, :class_name - - def initialize(name, method_name = name, class_name: nil) - @name = name - @method_name = method_name - @class_name = class_name - end - - def call(instance, context) - method = instance.method(method_name) - args = {} - args = context.to_h if method.arity.nonzero? - context[result_key] = Context.build - instance.instance_exec(**args, &method) - end - - private - - def type - self.class.name.split("::").last.downcase.sub(/^(\w+)step$/, "\\1") - end - - def result_key - "result.#{type}.#{name}" - end - end - - # @!visibility private - class ModelStep < Step - def call(instance, context) - context[name] = super - raise ArgumentError, "Model not found" if context[name].blank? - rescue ArgumentError => exception - context[result_key].fail(exception: exception) - context.fail! - end - end - - # @!visibility private - class PolicyStep < Step - def call(instance, context) - if !super - context[result_key].fail - context.fail! - end - end - end - - # @!visibility private - class ContractStep < Step - attr_reader :default_values_from - - def initialize(name, method_name = name, class_name: nil, default_values_from: nil) - super(name, method_name, class_name: class_name) - @default_values_from = default_values_from - end - - def call(instance, context) - attributes = class_name.attribute_names.map(&:to_sym) - default_values = {} - default_values = context[default_values_from].slice(*attributes) if default_values_from - contract = class_name.new(default_values.merge(context.to_h.slice(*attributes))) - context[contract_name] = contract - context[result_key] = Context.build - if contract.invalid? - context[result_key].fail(errors: contract.errors) - context.fail! - end - end - - private - - def contract_name - return :contract if name.to_sym == :default - :"#{name}_contract" - end - end - - # @!visibility private - class TransactionStep < Step - include StepsHelpers - - attr_reader :steps - - def initialize(&block) - @steps = [] - instance_exec(&block) - end - - def call(instance, context) - ActiveRecord::Base.transaction { steps.each { |step| step.call(instance, context) } } - end - end - - included do - # The global context which is available from any step. - attr_reader :context - - # @!visibility private - # Internal class used to setup the base contract of the service. - self::Contract = - Class.new do - include ActiveModel::API - include ActiveModel::Attributes - include ActiveModel::AttributeMethods - include ActiveModel::Validations::Callbacks - end - end - - class_methods do - include StepsHelpers - - def call(context = {}) - new(context).tap(&:run).context - end - - def call!(context = {}) - new(context).tap(&:run!).context - end - - def steps - @steps ||= [] - end - end - - # @!scope class - # @!method model(name = :model, step_name = :"fetch_#{name}") - # @param name [Symbol] name of the model - # @param step_name [Symbol] name of the method to call for this step - # Evaluates arbitrary code to build or fetch a model (typically from the - # DB). If the step returns a falsy value, then the step will fail. - # - # It stores the resulting model in +context[:model]+ by default (can be - # customized by providing the +name+ argument). - # - # @example - # model :channel, :fetch_channel - # - # private - # - # def fetch_channel(channel_id:, **) - # ChatChannel.find_by(id: channel_id) - # end - - # @!scope class - # @!method policy(name = :default) - # @param name [Symbol] name for this policy - # Performs checks related to the state of the system. If the - # step doesn’t return a truthy value, then the policy will fail. - # - # @example - # policy :no_direct_message_channel - # - # private - # - # def no_direct_message_channel(channel:, **) - # !channel.direct_message_channel? - # end - - # @!scope class - # @!method contract(name = :default, class_name: self::Contract, default_values_from: nil) - # @param name [Symbol] name for this contract - # @param class_name [Class] a class defining the contract - # @param default_values_from [Symbol] name of the model to get default values from - # Checks the validity of the input parameters. - # Implements ActiveModel::Validations and ActiveModel::Attributes. - # - # It stores the resulting contract in +context[:contract]+ by default - # (can be customized by providing the +name+ argument). - # - # @example - # contract - # - # class Contract - # attribute :name - # validates :name, presence: true - # end - - # @!scope class - # @!method step(name) - # @param name [Symbol] the name of this step - # Runs arbitrary code. To mark a step as failed, a call to {#fail!} needs - # to be made explicitly. - # - # @example - # step :update_channel - # - # private - # - # def update_channel(channel:, params_to_edit:, **) - # channel.update!(params_to_edit) - # end - # @example using {#fail!} in a step - # step :save_channel - # - # private - # - # def save_channel(channel:, **) - # fail!("something went wrong") if !channel.save - # end - - # @!scope class - # @!method transaction(&block) - # @param block [Proc] a block containing steps to be run inside a transaction - # Runs steps inside a DB transaction. - # - # @example - # transaction do - # step :prevents_slug_collision - # step :soft_delete_channel - # step :log_channel_deletion - # end - - # @!visibility private - def initialize(initial_context = {}) - @initial_context = initial_context.with_indifferent_access - @context = Context.build(initial_context.merge(__steps__: self.class.steps)) - end - - # @!visibility private - def run - run! - rescue Failure => exception - raise if context.object_id != exception.context.object_id - end - - # @!visibility private - def run! - self.class.steps.each { |step| step.call(self, context) } - end - - # @!visibility private - def fail!(message) - step_name = caller_locations(1, 1)[0].label - context["result.step.#{step_name}"].fail(error: message) - context.fail! - end - end - end -end diff --git a/plugins/chat/app/services/chat/lookup_thread.rb b/plugins/chat/app/services/chat/lookup_thread.rb new file mode 100644 index 00000000000..ead2e00e277 --- /dev/null +++ b/plugins/chat/app/services/chat/lookup_thread.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Chat + # Finds a thread within a channel. The thread_id and channel_id must + # match. For now we do not want to allow fetching threads if the + # enable_experimental_chat_threaded_discussions hidden site setting + # is not turned on, and the channel must specifically have threading + # enabled. + # + # @example + # Chat::LookupThread.call(thread_id: 88, channel_id: 2, guardian: guardian) + # + class LookupThread + include Service::Base + + # @!method call(thread_id:, channel_id:, guardian:) + # @param [Integer] thread_id + # @param [Integer] channel_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + policy :threaded_discussions_enabled + contract + model :thread, :fetch_thread + policy :invalid_access + policy :threading_enabled_for_channel + + # @!visibility private + class Contract + attribute :thread_id, :integer + attribute :channel_id, :integer + + validates :thread_id, :channel_id, presence: true + end + + private + + def threaded_discussions_enabled + SiteSetting.enable_experimental_chat_threaded_discussions + end + + def fetch_thread(contract:, **) + Chat::Thread.includes( + :channel, + original_message_user: :user_status, + original_message: :chat_webhook_event, + ).find_by(id: contract.thread_id, channel_id: contract.channel_id) + end + + def invalid_access(guardian:, thread:, **) + guardian.can_preview_chat_channel?(thread.channel) + end + + def threading_enabled_for_channel(thread:, **) + thread.channel.threading_enabled + end + end +end diff --git a/plugins/chat/app/services/chat/message_destroyer.rb b/plugins/chat/app/services/chat/message_destroyer.rb new file mode 100644 index 00000000000..c5dc28de5ed --- /dev/null +++ b/plugins/chat/app/services/chat/message_destroyer.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Chat + class MessageDestroyer + def destroy_in_batches(chat_messages_query, batch_size: 200) + chat_messages_query + .in_batches(of: batch_size) + .each do |relation| + destroyed_ids = relation.destroy_all.pluck(:id) + reset_last_read(destroyed_ids) + delete_flags(destroyed_ids) + end + end + + def trash_message(message, actor) + Chat::Message.transaction do + message.trash!(actor) + Chat::Mention.where(chat_message: message).destroy_all + DiscourseEvent.trigger(:chat_message_trashed, message, message.chat_channel, actor) + + # FIXME: We should do something to prevent the blue/green bubble + # of other channel members from getting out of sync when a message + # gets deleted. + Chat::Publisher.publish_delete!(message.chat_channel, message) + end + end + + private + + def reset_last_read(message_ids) + Chat::UserChatChannelMembership.where(last_read_message_id: message_ids).update_all( + last_read_message_id: nil, + ) + end + + def delete_flags(message_ids) + Chat::ReviewableMessage.where(target_id: message_ids).destroy_all + end + end +end diff --git a/plugins/chat/app/services/chat/publisher.rb b/plugins/chat/app/services/chat/publisher.rb new file mode 100644 index 00000000000..b04cfa069c9 --- /dev/null +++ b/plugins/chat/app/services/chat/publisher.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +module Chat + module Publisher + def self.new_messages_message_bus_channel(chat_channel_id) + "/chat/#{chat_channel_id}/new-messages" + end + + def self.publish_new!(chat_channel, chat_message, staged_id) + content = + Chat::MessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :sent + content[:staged_id] = staged_id + permissions = permissions(chat_channel) + + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions) + + MessageBus.publish( + self.new_messages_message_bus_channel(chat_channel.id), + { + channel_id: chat_channel.id, + message_id: chat_message.id, + user_id: chat_message.user.id, + username: chat_message.user.username, + thread_id: chat_message.thread_id, + }, + permissions, + ) + end + + def self.publish_processed!(chat_message) + chat_channel = chat_message.chat_channel + content = { + type: :processed, + chat_message: { + id: chat_message.id, + cooked: chat_message.cooked, + }, + } + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_edit!(chat_channel, chat_message) + content = + Chat::MessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :edit + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_refresh!(chat_channel, chat_message) + content = + Chat::MessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :refresh + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_reaction!(chat_channel, chat_message, action, user, emoji) + content = { + action: action, + user: BasicUserSerializer.new(user, root: false).as_json, + emoji: emoji, + type: :reaction, + chat_message_id: chat_message.id, + } + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_presence!(chat_channel, user, typ) + raise NotImplementedError + end + + def self.publish_delete!(chat_channel, chat_message) + MessageBus.publish( + "/chat/#{chat_channel.id}", + { type: "delete", deleted_id: chat_message.id, deleted_at: chat_message.deleted_at }, + permissions(chat_channel), + ) + end + + def self.publish_bulk_delete!(chat_channel, deleted_message_ids) + MessageBus.publish( + "/chat/#{chat_channel.id}", + { typ: "bulk_delete", deleted_ids: deleted_message_ids, deleted_at: Time.zone.now }, + permissions(chat_channel), + ) + end + + def self.publish_restore!(chat_channel, chat_message) + content = + Chat::MessageSerializer.new( + chat_message, + { scope: anonymous_guardian, root: :chat_message }, + ).as_json + content[:type] = :restore + MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) + end + + def self.publish_flag!(chat_message, user, reviewable, score) + # Publish to user who created flag + MessageBus.publish( + "/chat/#{chat_message.chat_channel_id}", + { + type: "self_flagged", + user_flag_status: score.status_for_database, + chat_message_id: chat_message.id, + }.as_json, + user_ids: [user.id], + ) + + # Publish flag with link to reviewable to staff + MessageBus.publish( + "/chat/#{chat_message.chat_channel_id}", + { type: "flag", chat_message_id: chat_message.id, reviewable_id: reviewable.id }.as_json, + group_ids: [Group::AUTO_GROUPS[:staff]], + ) + end + + def self.user_tracking_state_message_bus_channel(user_id) + "/chat/user-tracking-state/#{user_id}" + end + + def self.publish_user_tracking_state(user, chat_channel_id, chat_message_id) + data = { chat_channel_id: chat_channel_id, chat_message_id: chat_message_id }.merge( + Chat::ChannelUnreadsQuery.call(channel_id: chat_channel_id, user_id: user.id), + ) + + MessageBus.publish( + self.user_tracking_state_message_bus_channel(user.id), + data.as_json, + user_ids: [user.id], + ) + end + + def self.new_mentions_message_bus_channel(chat_channel_id) + "/chat/#{chat_channel_id}/new-mentions" + end + + def self.publish_new_mention(user_id, chat_channel_id, chat_message_id) + MessageBus.publish( + self.new_mentions_message_bus_channel(chat_channel_id), + { message_id: chat_message_id, channel_id: chat_channel_id }.as_json, + user_ids: [user_id], + ) + end + + NEW_CHANNEL_MESSAGE_BUS_CHANNEL = "/chat/new-channel" + + def self.publish_new_channel(chat_channel, users) + users.each do |user| + # FIXME: This could generate a lot of queries depending on the amount of users + membership = chat_channel.membership_for(user) + + # TODO: this event is problematic as some code will update the membership before calling it + # and other code will update it after calling it + # it means frontend must handle logic for both cases + serialized_channel = + Chat::ChannelSerializer.new( + chat_channel, + scope: Guardian.new(user), # We need a guardian here for direct messages + root: :channel, + membership: membership, + ).as_json + + MessageBus.publish(NEW_CHANNEL_MESSAGE_BUS_CHANNEL, serialized_channel, user_ids: [user.id]) + end + end + + def self.publish_inaccessible_mentions( + user_id, + chat_message, + cannot_chat_users, + without_membership, + too_many_members, + mentions_disabled + ) + MessageBus.publish( + "/chat/#{chat_message.chat_channel_id}", + { + type: :mention_warning, + chat_message_id: chat_message.id, + cannot_see: cannot_chat_users.map { |u| { username: u.username, id: u.id } }.as_json, + without_membership: + without_membership.map { |u| { username: u.username, id: u.id } }.as_json, + groups_with_too_many_members: too_many_members.map(&:name).as_json, + group_mentions_disabled: mentions_disabled.map(&:name).as_json, + }, + user_ids: [user_id], + ) + end + + CHANNEL_EDITS_MESSAGE_BUS_CHANNEL = "/chat/channel-edits" + + def self.publish_chat_channel_edit(chat_channel, acting_user) + MessageBus.publish( + CHANNEL_EDITS_MESSAGE_BUS_CHANNEL, + { + chat_channel_id: chat_channel.id, + name: chat_channel.title(acting_user), + description: chat_channel.description, + slug: chat_channel.slug, + }, + permissions(chat_channel), + ) + end + + CHANNEL_STATUS_MESSAGE_BUS_CHANNEL = "/chat/channel-status" + + def self.publish_channel_status(chat_channel) + MessageBus.publish( + CHANNEL_STATUS_MESSAGE_BUS_CHANNEL, + { chat_channel_id: chat_channel.id, status: chat_channel.status }, + permissions(chat_channel), + ) + end + + CHANNEL_METADATA_MESSAGE_BUS_CHANNEL = "/chat/channel-metadata" + + def self.publish_chat_channel_metadata(chat_channel) + MessageBus.publish( + CHANNEL_METADATA_MESSAGE_BUS_CHANNEL, + { chat_channel_id: chat_channel.id, memberships_count: chat_channel.user_count }, + permissions(chat_channel), + ) + end + + CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL = "/chat/channel-archive-status" + + def self.publish_archive_status( + chat_channel, + archive_status:, + archived_messages:, + archive_topic_id:, + total_messages: + ) + MessageBus.publish( + CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL, + { + chat_channel_id: chat_channel.id, + archive_failed: archive_status == :failed, + archive_completed: archive_status == :success, + archived_messages: archived_messages, + total_messages: total_messages, + archive_topic_id: archive_topic_id, + }, + permissions(chat_channel), + ) + end + + private + + def self.permissions(chat_channel) + { user_ids: chat_channel.allowed_user_ids, group_ids: chat_channel.allowed_group_ids } + end + + def self.anonymous_guardian + Guardian.new(nil) + end + end +end diff --git a/plugins/chat/app/services/chat/trash_channel.rb b/plugins/chat/app/services/chat/trash_channel.rb new file mode 100644 index 00000000000..33a80b2c018 --- /dev/null +++ b/plugins/chat/app/services/chat/trash_channel.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for trashing a chat channel. + # Note the slug is modified to prevent collisions. + # + # @example + # Chat::TrashChannel.call(channel_id: 2, guardian: guardian) + # + class TrashChannel + include Service::Base + + # @!method call(channel_id:, guardian:) + # @param [Integer] channel_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + DELETE_CHANNEL_LOG_KEY = "chat_channel_delete" + + model :channel, :fetch_channel + policy :invalid_access + transaction do + step :prevents_slug_collision + step :soft_delete_channel + step :log_channel_deletion + end + step :enqueue_delete_channel_relations_job + + private + + def fetch_channel(channel_id:, **) + Chat::Channel.find_by(id: channel_id) + end + + def invalid_access(guardian:, channel:, **) + guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel? + end + + def prevents_slug_collision(channel:, **) + channel.update!( + slug: + "#{Time.current.strftime("%Y%m%d-%H%M")}-#{channel.slug}-deleted".truncate( + SiteSetting.max_topic_title_length, + omission: "", + ), + ) + end + + def soft_delete_channel(guardian:, channel:, **) + channel.trash!(guardian.user) + end + + def log_channel_deletion(guardian:, channel:, **) + StaffActionLogger.new(guardian.user).log_custom( + DELETE_CHANNEL_LOG_KEY, + { chat_channel_id: channel.id, chat_channel_name: channel.title(guardian.user) }, + ) + end + + def enqueue_delete_channel_relations_job(channel:, **) + Jobs.enqueue(Jobs::Chat::ChannelDelete, chat_channel_id: channel.id) + end + end +end diff --git a/plugins/chat/app/services/chat/update_channel.rb b/plugins/chat/app/services/chat/update_channel.rb new file mode 100644 index 00000000000..08534a291d8 --- /dev/null +++ b/plugins/chat/app/services/chat/update_channel.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for updating a chat channel's name, slug, and description. + # + # For a CategoryChannel, the settings for auto_join_users and allow_channel_wide_mentions + # are also editable. + # + # @example + # Service::Chat::UpdateChannel.call( + # channel_id: 2, + # guardian: guardian, + # name: "SuperChannel", + # description: "This is the best channel", + # slug: "super-channel", + # ) + # + class UpdateChannel + include Service::Base + + # @!method call(channel_id:, guardian:, **params_to_edit) + # @param [Integer] channel_id + # @param [Guardian] guardian + # @param [Hash] params_to_edit + # @option params_to_edit [String,nil] name + # @option params_to_edit [String,nil] description + # @option params_to_edit [String,nil] slug + # @option params_to_edit [Boolean] auto_join_users Only valid for {CategoryChannel}. Whether active users + # with permission to see the category should automatically join the channel. + # @option params_to_edit [Boolean] allow_channel_wide_mentions Allow the use of @here and @all in the channel. + # @return [Service::Base::Context] + + model :channel, :fetch_channel + policy :no_direct_message_channel + policy :check_channel_permission + contract default_values_from: :channel + step :update_channel + step :publish_channel_update + step :auto_join_users_if_needed + + # @!visibility private + class Contract + attribute :name, :string + attribute :description, :string + attribute :slug, :string + attribute :auto_join_users, :boolean, default: false + attribute :allow_channel_wide_mentions, :boolean, default: true + + before_validation do + assign_attributes( + attributes.symbolize_keys.slice(:name, :description, :slug).transform_values(&:presence), + ) + end + end + + private + + def fetch_channel(channel_id:, **) + Chat::Channel.find_by(id: channel_id) + end + + def no_direct_message_channel(channel:, **) + !channel.direct_message_channel? + end + + def check_channel_permission(guardian:, channel:, **) + guardian.can_preview_chat_channel?(channel) && guardian.can_edit_chat_channel? + end + + def update_channel(channel:, contract:, **) + channel.update!(contract.attributes) + end + + def publish_channel_update(channel:, guardian:, **) + Chat::Publisher.publish_chat_channel_edit(channel, guardian.user) + end + + def auto_join_users_if_needed(channel:, **) + return unless channel.auto_join_users? + Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships + end + end +end diff --git a/plugins/chat/app/services/chat/update_channel_status.rb b/plugins/chat/app/services/chat/update_channel_status.rb new file mode 100644 index 00000000000..f3e84185942 --- /dev/null +++ b/plugins/chat/app/services/chat/update_channel_status.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for updating a chat channel status. + # + # @example + # Chat::UpdateChannelStatus.call(channel_id: 2, guardian: guardian, status: "open") + # + class UpdateChannelStatus + include Service::Base + + # @!method call(channel_id:, guardian:, status:) + # @param [Integer] channel_id + # @param [Guardian] guardian + # @param [String] status + # @return [Service::Base::Context] + + model :channel, :fetch_channel + contract + policy :check_channel_permission + step :change_status + + # @!visibility private + class Contract + attribute :status + validates :status, inclusion: { in: Chat::Channel.editable_statuses.keys } + end + + private + + def fetch_channel(channel_id:, **) + Chat::Channel.find_by(id: channel_id) + end + + def check_channel_permission(guardian:, channel:, status:, **) + guardian.can_preview_chat_channel?(channel) && + guardian.can_change_channel_status?(channel, status.to_sym) + end + + def change_status(channel:, status:, guardian:, **) + channel.public_send("#{status}!", guardian.user) + end + end +end diff --git a/plugins/chat/app/services/chat/update_user_last_read.rb b/plugins/chat/app/services/chat/update_user_last_read.rb new file mode 100644 index 00000000000..0eb01159b25 --- /dev/null +++ b/plugins/chat/app/services/chat/update_user_last_read.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Chat + # Service responsible for updating the last read message id of a membership. + # + # @example + # Chat::UpdateUserLastRead.call(user_id: 1, channel_id: 2, message_id: 3, guardian: guardian) + # + class UpdateUserLastRead + include Service::Base + + # @!method call(user_id:, channel_id:, message_id:, guardian:) + # @param [Integer] user_id + # @param [Integer] channel_id + # @param [Integer] message_id + # @param [Guardian] guardian + # @return [Service::Base::Context] + + contract + model :membership, :fetch_active_membership + policy :invalid_access + policy :ensure_message_id_recency + policy :ensure_message_exists + step :update_last_read_message_id + step :mark_associated_mentions_as_read + step :publish_new_last_read_to_clients + + # @!visibility private + class Contract + attribute :message_id, :integer + attribute :user_id, :integer + attribute :channel_id, :integer + + validates :message_id, :user_id, :channel_id, presence: true + end + + private + + def fetch_active_membership(user_id:, channel_id:, **) + Chat::UserChatChannelMembership.includes(:user, :chat_channel).find_by( + user_id: user_id, + chat_channel_id: channel_id, + following: true, + ) + end + + def invalid_access(guardian:, membership:, **) + guardian.can_join_chat_channel?(membership.chat_channel) + end + + def ensure_message_id_recency(message_id:, membership:, **) + !membership.last_read_message_id || message_id >= membership.last_read_message_id + end + + def ensure_message_exists(channel_id:, message_id:, **) + Chat::Message.with_deleted.exists?(chat_channel_id: channel_id, id: message_id) + end + + def update_last_read_message_id(message_id:, membership:, **) + membership.update!(last_read_message_id: message_id) + end + + def mark_associated_mentions_as_read(membership:, message_id:, **) + Notification + .where(notification_type: Notification.types[:chat_mention]) + .where(user: membership.user) + .where(read: false) + .joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id") + .joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id") + .where("chat_messages.id <= ?", message_id) + .where("chat_messages.chat_channel_id = ?", membership.chat_channel.id) + .update_all(read: true) + end + + def publish_new_last_read_to_clients(guardian:, channel_id:, message_id:, **) + Chat::Publisher.publish_user_tracking_state(guardian.user, channel_id, message_id) + end + end +end diff --git a/plugins/chat/app/services/chat_message_destroyer.rb b/plugins/chat/app/services/chat_message_destroyer.rb deleted file mode 100644 index b3216d6b3ee..00000000000 --- a/plugins/chat/app/services/chat_message_destroyer.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -class ChatMessageDestroyer - def destroy_in_batches(chat_messages_query, batch_size: 200) - chat_messages_query - .in_batches(of: batch_size) - .each do |relation| - destroyed_ids = relation.destroy_all.pluck(:id) - reset_last_read(destroyed_ids) - delete_flags(destroyed_ids) - end - end - - def trash_message(message, actor) - ChatMessage.transaction do - message.trash!(actor) - ChatMention.where(chat_message: message).destroy_all - DiscourseEvent.trigger(:chat_message_trashed, message, message.chat_channel, actor) - - # FIXME: We should do something to prevent the blue/green bubble - # of other channel members from getting out of sync when a message - # gets deleted. - ChatPublisher.publish_delete!(message.chat_channel, message) - end - end - - private - - def reset_last_read(message_ids) - UserChatChannelMembership.where(last_read_message_id: message_ids).update_all( - last_read_message_id: nil, - ) - end - - def delete_flags(message_ids) - ReviewableChatMessage.where(target_id: message_ids).destroy_all - end -end diff --git a/plugins/chat/app/services/chat_publisher.rb b/plugins/chat/app/services/chat_publisher.rb deleted file mode 100644 index 0778b655da1..00000000000 --- a/plugins/chat/app/services/chat_publisher.rb +++ /dev/null @@ -1,266 +0,0 @@ -# frozen_string_literal: true - -module ChatPublisher - def self.new_messages_message_bus_channel(chat_channel_id) - "/chat/#{chat_channel_id}/new-messages" - end - - def self.publish_new!(chat_channel, chat_message, staged_id) - content = - ChatMessageSerializer.new( - chat_message, - { scope: anonymous_guardian, root: :chat_message }, - ).as_json - content[:type] = :sent - content[:staged_id] = staged_id - permissions = permissions(chat_channel) - - MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions) - - MessageBus.publish( - self.new_messages_message_bus_channel(chat_channel.id), - { - channel_id: chat_channel.id, - message_id: chat_message.id, - user_id: chat_message.user.id, - username: chat_message.user.username, - thread_id: chat_message.thread_id, - }, - permissions, - ) - end - - def self.publish_processed!(chat_message) - chat_channel = chat_message.chat_channel - content = { - type: :processed, - chat_message: { - id: chat_message.id, - cooked: chat_message.cooked, - }, - } - MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) - end - - def self.publish_edit!(chat_channel, chat_message) - content = - ChatMessageSerializer.new( - chat_message, - { scope: anonymous_guardian, root: :chat_message }, - ).as_json - content[:type] = :edit - MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) - end - - def self.publish_refresh!(chat_channel, chat_message) - content = - ChatMessageSerializer.new( - chat_message, - { scope: anonymous_guardian, root: :chat_message }, - ).as_json - content[:type] = :refresh - MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) - end - - def self.publish_reaction!(chat_channel, chat_message, action, user, emoji) - content = { - action: action, - user: BasicUserSerializer.new(user, root: false).as_json, - emoji: emoji, - type: :reaction, - chat_message_id: chat_message.id, - } - MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) - end - - def self.publish_presence!(chat_channel, user, typ) - raise NotImplementedError - end - - def self.publish_delete!(chat_channel, chat_message) - MessageBus.publish( - "/chat/#{chat_channel.id}", - { type: "delete", deleted_id: chat_message.id, deleted_at: chat_message.deleted_at }, - permissions(chat_channel), - ) - end - - def self.publish_bulk_delete!(chat_channel, deleted_message_ids) - MessageBus.publish( - "/chat/#{chat_channel.id}", - { typ: "bulk_delete", deleted_ids: deleted_message_ids, deleted_at: Time.zone.now }, - permissions(chat_channel), - ) - end - - def self.publish_restore!(chat_channel, chat_message) - content = - ChatMessageSerializer.new( - chat_message, - { scope: anonymous_guardian, root: :chat_message }, - ).as_json - content[:type] = :restore - MessageBus.publish("/chat/#{chat_channel.id}", content.as_json, permissions(chat_channel)) - end - - def self.publish_flag!(chat_message, user, reviewable, score) - # Publish to user who created flag - MessageBus.publish( - "/chat/#{chat_message.chat_channel_id}", - { - type: "self_flagged", - user_flag_status: score.status_for_database, - chat_message_id: chat_message.id, - }.as_json, - user_ids: [user.id], - ) - - # Publish flag with link to reviewable to staff - MessageBus.publish( - "/chat/#{chat_message.chat_channel_id}", - { type: "flag", chat_message_id: chat_message.id, reviewable_id: reviewable.id }.as_json, - group_ids: [Group::AUTO_GROUPS[:staff]], - ) - end - - def self.user_tracking_state_message_bus_channel(user_id) - "/chat/user-tracking-state/#{user_id}" - end - - def self.publish_user_tracking_state(user, chat_channel_id, chat_message_id) - data = { chat_channel_id: chat_channel_id, chat_message_id: chat_message_id }.merge( - ChatChannelUnreadsQuery.call(channel_id: chat_channel_id, user_id: user.id), - ) - - MessageBus.publish( - self.user_tracking_state_message_bus_channel(user.id), - data.as_json, - user_ids: [user.id], - ) - end - - def self.new_mentions_message_bus_channel(chat_channel_id) - "/chat/#{chat_channel_id}/new-mentions" - end - - def self.publish_new_mention(user_id, chat_channel_id, chat_message_id) - MessageBus.publish( - self.new_mentions_message_bus_channel(chat_channel_id), - { message_id: chat_message_id, channel_id: chat_channel_id }.as_json, - user_ids: [user_id], - ) - end - - NEW_CHANNEL_MESSAGE_BUS_CHANNEL = "/chat/new-channel" - - def self.publish_new_channel(chat_channel, users) - users.each do |user| - # FIXME: This could generate a lot of queries depending on the amount of users - membership = chat_channel.membership_for(user) - - # TODO: this event is problematic as some code will update the membership before calling it - # and other code will update it after calling it - # it means frontend must handle logic for both cases - serialized_channel = - ChatChannelSerializer.new( - chat_channel, - scope: Guardian.new(user), # We need a guardian here for direct messages - root: :channel, - membership: membership, - ).as_json - - MessageBus.publish(NEW_CHANNEL_MESSAGE_BUS_CHANNEL, serialized_channel, user_ids: [user.id]) - end - end - - def self.publish_inaccessible_mentions( - user_id, - chat_message, - cannot_chat_users, - without_membership, - too_many_members, - mentions_disabled - ) - MessageBus.publish( - "/chat/#{chat_message.chat_channel_id}", - { - type: :mention_warning, - chat_message_id: chat_message.id, - cannot_see: cannot_chat_users.map { |u| { username: u.username, id: u.id } }.as_json, - without_membership: - without_membership.map { |u| { username: u.username, id: u.id } }.as_json, - groups_with_too_many_members: too_many_members.map(&:name).as_json, - group_mentions_disabled: mentions_disabled.map(&:name).as_json, - }, - user_ids: [user_id], - ) - end - - CHANNEL_EDITS_MESSAGE_BUS_CHANNEL = "/chat/channel-edits" - - def self.publish_chat_channel_edit(chat_channel, acting_user) - MessageBus.publish( - CHANNEL_EDITS_MESSAGE_BUS_CHANNEL, - { - chat_channel_id: chat_channel.id, - name: chat_channel.title(acting_user), - description: chat_channel.description, - slug: chat_channel.slug, - }, - permissions(chat_channel), - ) - end - - CHANNEL_STATUS_MESSAGE_BUS_CHANNEL = "/chat/channel-status" - - def self.publish_channel_status(chat_channel) - MessageBus.publish( - CHANNEL_STATUS_MESSAGE_BUS_CHANNEL, - { chat_channel_id: chat_channel.id, status: chat_channel.status }, - permissions(chat_channel), - ) - end - - CHANNEL_METADATA_MESSAGE_BUS_CHANNEL = "/chat/channel-metadata" - - def self.publish_chat_channel_metadata(chat_channel) - MessageBus.publish( - CHANNEL_METADATA_MESSAGE_BUS_CHANNEL, - { chat_channel_id: chat_channel.id, memberships_count: chat_channel.user_count }, - permissions(chat_channel), - ) - end - - CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL = "/chat/channel-archive-status" - - def self.publish_archive_status( - chat_channel, - archive_status:, - archived_messages:, - archive_topic_id:, - total_messages: - ) - MessageBus.publish( - CHANNEL_ARCHIVE_STATUS_MESSAGE_BUS_CHANNEL, - { - chat_channel_id: chat_channel.id, - archive_failed: archive_status == :failed, - archive_completed: archive_status == :success, - archived_messages: archived_messages, - total_messages: total_messages, - archive_topic_id: archive_topic_id, - }, - permissions(chat_channel), - ) - end - - private - - def self.permissions(chat_channel) - { user_ids: chat_channel.allowed_user_ids, group_ids: chat_channel.allowed_group_ids } - end - - def self.anonymous_guardian - Guardian.new(nil) - end -end diff --git a/plugins/chat/app/services/lookup_thread.rb b/plugins/chat/app/services/lookup_thread.rb deleted file mode 100644 index 9e4c3ca26c7..00000000000 --- a/plugins/chat/app/services/lookup_thread.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -module Chat - module Service - # Finds a thread within a channel. The thread_id and channel_id must - # match. For now we do not want to allow fetching threads if the - # enable_experimental_chat_threaded_discussions hidden site setting - # is not turned on, and the channel must specifically have threading - # enabled. - # - # @example - # Chat::Service::LookupThread.call(thread_id: 88, channel_id: 2, guardian: guardian) - # - class LookupThread - include Base - - # @!method call(thread_id:, channel_id:, guardian:) - # @param [Integer] thread_id - # @param [Integer] channel_id - # @param [Guardian] guardian - # @return [Chat::Service::Base::Context] - - policy :threaded_discussions_enabled - contract - model :thread, :fetch_thread - policy :invalid_access - policy :threading_enabled_for_channel - - # @!visibility private - class Contract - attribute :thread_id, :integer - attribute :channel_id, :integer - - validates :thread_id, :channel_id, presence: true - end - - private - - def threaded_discussions_enabled - SiteSetting.enable_experimental_chat_threaded_discussions - end - - def fetch_thread(contract:, **) - ChatThread.includes( - :channel, - original_message_user: :user_status, - original_message: :chat_webhook_event, - ).find_by(id: contract.thread_id, channel_id: contract.channel_id) - end - - def invalid_access(guardian:, thread:, **) - guardian.can_preview_chat_channel?(thread.channel) - end - - def threading_enabled_for_channel(thread:, **) - thread.channel.threading_enabled - end - end - end -end diff --git a/plugins/chat/app/services/service.rb b/plugins/chat/app/services/service.rb new file mode 100644 index 00000000000..c45e25eb0da --- /dev/null +++ b/plugins/chat/app/services/service.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Service + # Module to be included to provide steps DSL to any class. This allows to + # create easy to understand services as the whole service cycle is visible + # simply by reading the beginning of its class. + # + # Steps are executed in the order they’re defined. They will use their name + # to execute the corresponding method defined in the service class. + # + # Currently, there are 5 types of steps: + # + # * +contract(name = :default)+: used to validate the input parameters, + # typically provided by a user calling an endpoint. A special embedded + # +Contract+ class has to be defined to holds the validations. If the + # validations fail, the step will fail. Otherwise, the resulting contract + # will be available in +context[:contract]+. When calling +step(name)+ or + # +model(name = :model)+ methods after validating a contract, the contract + # should be used as an argument instead of context attributes. + # * +model(name = :model)+: used to instantiate a model (either by building + # it or fetching it from the DB). If a falsy value is returned, then the + # step will fail. Otherwise the resulting object will be assigned in + # +context[name]+ (+context[:model]+ by default). + # * +policy(name = :default)+: used to perform a check on the state of the + # system. Typically used to run guardians. If a falsy value is returned, + # the step will fail. + # * +step(name)+: used to run small snippets of arbitrary code. The step + # doesn’t care about its return value, so to mark the service as failed, + # {#fail!} has to be called explicitly. + # * +transaction+: used to wrap other steps inside a DB transaction. + # + # The methods defined on the service are automatically provided with + # the whole context passed as keyword arguments. This allows to define in a + # very explicit way what dependencies are used by the method. If for + # whatever reason a key isn’t found in the current context, then Ruby will + # raise an exception when the method is called. + # + # Regarding contract classes, they automatically have {ActiveModel} modules + # included so all the {ActiveModel} API is available. + # + # @example An example from the {TrashChannel} service + # class TrashChannel + # include Base + # + # model :channel, :fetch_channel + # policy :invalid_access + # transaction do + # step :prevents_slug_collision + # step :soft_delete_channel + # step :log_channel_deletion + # end + # step :enqueue_delete_channel_relations_job + # + # private + # + # def fetch_channel(channel_id:, **) + # Chat::Channel.find_by(id: channel_id) + # end + # + # def invalid_access(guardian:, channel:, **) + # guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel? + # end + # + # def prevents_slug_collision(channel:, **) + # … + # end + # + # def soft_delete_channel(guardian:, channel:, **) + # … + # end + # + # def log_channel_deletion(guardian:, channel:, **) + # … + # end + # + # def enqueue_delete_channel_relations_job(channel:, **) + # … + # end + # end + # @example An example from the {UpdateChannelStatus} service which uses a contract + # class UpdateChannelStatus + # include Base + # + # model :channel, :fetch_channel + # contract + # policy :check_channel_permission + # step :change_status + # + # class Contract + # attribute :status + # validates :status, inclusion: { in: Chat::Channel.editable_statuses.keys } + # end + # + # … + # end +end diff --git a/plugins/chat/app/services/service/base.rb b/plugins/chat/app/services/service/base.rb new file mode 100644 index 00000000000..ccd35016cc8 --- /dev/null +++ b/plugins/chat/app/services/service/base.rb @@ -0,0 +1,336 @@ +# frozen_string_literal: true + +module Service + module Base + extend ActiveSupport::Concern + + # The only exception that can be raised by a service. + class Failure < StandardError + # @return [Context] + attr_reader :context + + # @!visibility private + def initialize(context = nil) + @context = context + super + end + end + + # Simple structure to hold the context of the service during its whole lifecycle. + class Context < OpenStruct + # @return [Boolean] returns +true+ if the conext is set as successful (default) + def success? + !failure? + end + + # @return [Boolean] returns +true+ if the context is set as failed + # @see #fail! + # @see #fail + def failure? + @failure || false + end + + # Marks the context as failed. + # @param context [Hash, Context] the context to merge into the current one + # @example + # context.fail!("failure": "something went wrong") + # @return [Context] + def fail!(context = {}) + fail(context) + raise Failure, self + end + + # Marks the context as failed without raising an exception. + # @param context [Hash, Context] the context to merge into the current one + # @example + # context.fail("failure": "something went wrong") + # @return [Context] + def fail(context = {}) + merge(context) + @failure = true + self + end + + # Merges the given context into the current one. + # @!visibility private + def merge(other_context = {}) + other_context.each { |key, value| self[key.to_sym] = value } + self + end + + private + + def self.build(context = {}) + self === context ? context : new(context) + end + end + + # Internal module to define available steps as DSL + # @!visibility private + module StepsHelpers + def model(name = :model, step_name = :"fetch_#{name}") + steps << ModelStep.new(name, step_name) + end + + def contract(name = :default, class_name: self::Contract, default_values_from: nil) + steps << ContractStep.new( + name, + class_name: class_name, + default_values_from: default_values_from, + ) + end + + def policy(name = :default) + steps << PolicyStep.new(name) + end + + def step(name) + steps << Step.new(name) + end + + def transaction(&block) + steps << TransactionStep.new(&block) + end + end + + # @!visibility private + class Step + attr_reader :name, :method_name, :class_name + + def initialize(name, method_name = name, class_name: nil) + @name = name + @method_name = method_name + @class_name = class_name + end + + def call(instance, context) + method = instance.method(method_name) + args = {} + args = context.to_h if method.arity.nonzero? + context[result_key] = Context.build + instance.instance_exec(**args, &method) + end + + private + + def type + self.class.name.split("::").last.downcase.sub(/^(\w+)step$/, "\\1") + end + + def result_key + "result.#{type}.#{name}" + end + end + + # @!visibility private + class ModelStep < Step + def call(instance, context) + context[name] = super + raise ArgumentError, "Model not found" if context[name].blank? + rescue ArgumentError => exception + context[result_key].fail(exception: exception) + context.fail! + end + end + + # @!visibility private + class PolicyStep < Step + def call(instance, context) + if !super + context[result_key].fail + context.fail! + end + end + end + + # @!visibility private + class ContractStep < Step + attr_reader :default_values_from + + def initialize(name, method_name = name, class_name: nil, default_values_from: nil) + super(name, method_name, class_name: class_name) + @default_values_from = default_values_from + end + + def call(instance, context) + attributes = class_name.attribute_names.map(&:to_sym) + default_values = {} + default_values = context[default_values_from].slice(*attributes) if default_values_from + contract = class_name.new(default_values.merge(context.to_h.slice(*attributes))) + context[contract_name] = contract + context[result_key] = Context.build + if contract.invalid? + context[result_key].fail(errors: contract.errors) + context.fail! + end + end + + private + + def contract_name + return :contract if name.to_sym == :default + :"#{name}_contract" + end + end + + # @!visibility private + class TransactionStep < Step + include StepsHelpers + + attr_reader :steps + + def initialize(&block) + @steps = [] + instance_exec(&block) + end + + def call(instance, context) + ActiveRecord::Base.transaction { steps.each { |step| step.call(instance, context) } } + end + end + + included do + # The global context which is available from any step. + attr_reader :context + + # @!visibility private + # Internal class used to setup the base contract of the service. + self::Contract = + Class.new do + include ActiveModel::API + include ActiveModel::Attributes + include ActiveModel::AttributeMethods + include ActiveModel::Validations::Callbacks + end + end + + class_methods do + include StepsHelpers + + def call(context = {}) + new(context).tap(&:run).context + end + + def call!(context = {}) + new(context).tap(&:run!).context + end + + def steps + @steps ||= [] + end + end + + # @!scope class + # @!method model(name = :model, step_name = :"fetch_#{name}") + # @param name [Symbol] name of the model + # @param step_name [Symbol] name of the method to call for this step + # Evaluates arbitrary code to build or fetch a model (typically from the + # DB). If the step returns a falsy value, then the step will fail. + # + # It stores the resulting model in +context[:model]+ by default (can be + # customized by providing the +name+ argument). + # + # @example + # model :channel, :fetch_channel + # + # private + # + # def fetch_channel(channel_id:, **) + # Chat::Channel.find_by(id: channel_id) + # end + + # @!scope class + # @!method policy(name = :default) + # @param name [Symbol] name for this policy + # Performs checks related to the state of the system. If the + # step doesn’t return a truthy value, then the policy will fail. + # + # @example + # policy :no_direct_message_channel + # + # private + # + # def no_direct_message_channel(channel:, **) + # !channel.direct_message_channel? + # end + + # @!scope class + # @!method contract(name = :default, class_name: self::Contract, default_values_from: nil) + # @param name [Symbol] name for this contract + # @param class_name [Class] a class defining the contract + # @param default_values_from [Symbol] name of the model to get default values from + # Checks the validity of the input parameters. + # Implements ActiveModel::Validations and ActiveModel::Attributes. + # + # It stores the resulting contract in +context[:contract]+ by default + # (can be customized by providing the +name+ argument). + # + # @example + # contract + # + # class Contract + # attribute :name + # validates :name, presence: true + # end + + # @!scope class + # @!method step(name) + # @param name [Symbol] the name of this step + # Runs arbitrary code. To mark a step as failed, a call to {#fail!} needs + # to be made explicitly. + # + # @example + # step :update_channel + # + # private + # + # def update_channel(channel:, params_to_edit:, **) + # channel.update!(params_to_edit) + # end + # @example using {#fail!} in a step + # step :save_channel + # + # private + # + # def save_channel(channel:, **) + # fail!("something went wrong") if !channel.save + # end + + # @!scope class + # @!method transaction(&block) + # @param block [Proc] a block containing steps to be run inside a transaction + # Runs steps inside a DB transaction. + # + # @example + # transaction do + # step :prevents_slug_collision + # step :soft_delete_channel + # step :log_channel_deletion + # end + + # @!visibility private + def initialize(initial_context = {}) + @initial_context = initial_context.with_indifferent_access + @context = Context.build(initial_context.merge(__steps__: self.class.steps)) + end + + # @!visibility private + def run + run! + rescue Failure => exception + raise if context.object_id != exception.context.object_id + end + + # @!visibility private + def run! + self.class.steps.each { |step| step.call(self, context) } + end + + # @!visibility private + def fail!(message) + step_name = caller_locations(1, 1)[0].label + context["result.step.#{step_name}"].fail(error: message) + context.fail! + end + end +end diff --git a/plugins/chat/app/services/trash_channel.rb b/plugins/chat/app/services/trash_channel.rb deleted file mode 100644 index 48dde07f17f..00000000000 --- a/plugins/chat/app/services/trash_channel.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -module Chat - module Service - # Service responsible for trashing a chat channel. - # Note the slug is modified to prevent collisions. - # - # @example - # Chat::Service::TrashChannel.call(channel_id: 2, guardian: guardian) - # - class TrashChannel - include Base - - # @!method call(channel_id:, guardian:) - # @param [Integer] channel_id - # @param [Guardian] guardian - # @return [Chat::Service::Base::Context] - - DELETE_CHANNEL_LOG_KEY = "chat_channel_delete" - - model :channel, :fetch_channel - policy :invalid_access - transaction do - step :prevents_slug_collision - step :soft_delete_channel - step :log_channel_deletion - end - step :enqueue_delete_channel_relations_job - - private - - def fetch_channel(channel_id:, **) - ChatChannel.find_by(id: channel_id) - end - - def invalid_access(guardian:, channel:, **) - guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel? - end - - def prevents_slug_collision(channel:, **) - channel.update!( - slug: - "#{Time.current.strftime("%Y%m%d-%H%M")}-#{channel.slug}-deleted".truncate( - SiteSetting.max_topic_title_length, - omission: "", - ), - ) - end - - def soft_delete_channel(guardian:, channel:, **) - channel.trash!(guardian.user) - end - - def log_channel_deletion(guardian:, channel:, **) - StaffActionLogger.new(guardian.user).log_custom( - DELETE_CHANNEL_LOG_KEY, - { chat_channel_id: channel.id, chat_channel_name: channel.title(guardian.user) }, - ) - end - - def enqueue_delete_channel_relations_job(channel:, **) - Jobs.enqueue(:chat_channel_delete, chat_channel_id: channel.id) - end - end - end -end diff --git a/plugins/chat/app/services/update_channel.rb b/plugins/chat/app/services/update_channel.rb deleted file mode 100644 index bcbb59b101e..00000000000 --- a/plugins/chat/app/services/update_channel.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -module Chat - module Service - # Service responsible for updating a chat channel's name, slug, and description. - # - # For a CategoryChannel, the settings for auto_join_users and allow_channel_wide_mentions - # are also editable. - # - # @example - # Chat::Service::UpdateChannel.call( - # channel_id: 2, - # guardian: guardian, - # name: "SuperChannel", - # description: "This is the best channel", - # slug: "super-channel", - # ) - # - class UpdateChannel - include Base - - # @!method call(channel_id:, guardian:, **params_to_edit) - # @param [Integer] channel_id - # @param [Guardian] guardian - # @param [Hash] params_to_edit - # @option params_to_edit [String,nil] name - # @option params_to_edit [String,nil] description - # @option params_to_edit [String,nil] slug - # @option params_to_edit [Boolean] auto_join_users Only valid for {CategoryChannel}. Whether active users - # with permission to see the category should automatically join the channel. - # @option params_to_edit [Boolean] allow_channel_wide_mentions Allow the use of @here and @all in the channel. - # @return [Chat::Service::Base::Context] - - model :channel, :fetch_channel - policy :no_direct_message_channel - policy :check_channel_permission - contract default_values_from: :channel - step :update_channel - step :publish_channel_update - step :auto_join_users_if_needed - - # @!visibility private - class Contract - attribute :name, :string - attribute :description, :string - attribute :slug, :string - attribute :auto_join_users, :boolean, default: false - attribute :allow_channel_wide_mentions, :boolean, default: true - - before_validation do - assign_attributes( - attributes - .symbolize_keys - .slice(:name, :description, :slug) - .transform_values(&:presence), - ) - end - end - - private - - def fetch_channel(channel_id:, **) - ChatChannel.find_by(id: channel_id) - end - - def no_direct_message_channel(channel:, **) - !channel.direct_message_channel? - end - - def check_channel_permission(guardian:, channel:, **) - guardian.can_preview_chat_channel?(channel) && guardian.can_edit_chat_channel? - end - - def update_channel(channel:, contract:, **) - channel.update!(contract.attributes) - end - - def publish_channel_update(channel:, guardian:, **) - ChatPublisher.publish_chat_channel_edit(channel, guardian.user) - end - - def auto_join_users_if_needed(channel:, **) - return unless channel.auto_join_users? - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships - end - end - end -end diff --git a/plugins/chat/app/services/update_channel_status.rb b/plugins/chat/app/services/update_channel_status.rb deleted file mode 100644 index e11e80c6eed..00000000000 --- a/plugins/chat/app/services/update_channel_status.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module Chat - module Service - # Service responsible for updating a chat channel status. - # - # @example - # Chat::Service::UpdateChannelStatus.call(channel_id: 2, guardian: guardian, status: "open") - # - class UpdateChannelStatus - include Base - - # @!method call(channel_id:, guardian:, status:) - # @param [Integer] channel_id - # @param [Guardian] guardian - # @param [String] status - # @return [Chat::Service::Base::Context] - - model :channel, :fetch_channel - contract - policy :check_channel_permission - step :change_status - - # @!visibility private - class Contract - attribute :status - validates :status, inclusion: { in: ChatChannel.editable_statuses.keys } - end - - private - - def fetch_channel(channel_id:, **) - ChatChannel.find_by(id: channel_id) - end - - def check_channel_permission(guardian:, channel:, status:, **) - guardian.can_preview_chat_channel?(channel) && - guardian.can_change_channel_status?(channel, status.to_sym) - end - - def change_status(channel:, status:, guardian:, **) - channel.public_send("#{status}!", guardian.user) - end - end - end -end diff --git a/plugins/chat/app/services/update_user_last_read.rb b/plugins/chat/app/services/update_user_last_read.rb deleted file mode 100644 index ad9e90dc6ca..00000000000 --- a/plugins/chat/app/services/update_user_last_read.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -module Chat - module Service - # Service responsible for updating the last read message id of a membership. - # - # @example - # Chat::Service::UpdateUserLastRead.call(user_id: 1, channel_id: 2, message_id: 3, guardian: guardian) - # - class UpdateUserLastRead - include Base - - # @!method call(user_id:, channel_id:, message_id:, guardian:) - # @param [Integer] user_id - # @param [Integer] channel_id - # @param [Integer] message_id - # @param [Guardian] guardian - # @return [Chat::Service::Base::Context] - - contract - model :membership, :fetch_active_membership - policy :invalid_access - policy :ensure_message_id_recency - policy :ensure_message_exists - step :update_last_read_message_id - step :mark_associated_mentions_as_read - step :publish_new_last_read_to_clients - - # @!visibility private - class Contract - attribute :message_id, :integer - attribute :user_id, :integer - attribute :channel_id, :integer - - validates :message_id, :user_id, :channel_id, presence: true - end - - private - - def fetch_active_membership(user_id:, channel_id:, **) - UserChatChannelMembership.includes(:user, :chat_channel).find_by( - user_id: user_id, - chat_channel_id: channel_id, - following: true, - ) - end - - def invalid_access(guardian:, membership:, **) - guardian.can_join_chat_channel?(membership.chat_channel) - end - - def ensure_message_id_recency(message_id:, membership:, **) - !membership.last_read_message_id || message_id >= membership.last_read_message_id - end - - def ensure_message_exists(channel_id:, message_id:, **) - ChatMessage.with_deleted.exists?(chat_channel_id: channel_id, id: message_id) - end - - def update_last_read_message_id(message_id:, membership:, **) - membership.update!(last_read_message_id: message_id) - end - - def mark_associated_mentions_as_read(membership:, message_id:, **) - Notification - .where(notification_type: Notification.types[:chat_mention]) - .where(user: membership.user) - .where(read: false) - .joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id") - .joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id") - .where("chat_messages.id <= ?", message_id) - .where("chat_messages.chat_channel_id = ?", membership.chat_channel.id) - .update_all(read: true) - end - - def publish_new_last_read_to_clients(guardian:, channel_id:, message_id:, **) - ChatPublisher.publish_user_tracking_state(guardian.user, channel_id, message_id) - end - end - end -end diff --git a/plugins/chat/app/validators/chat/allow_uploads_validator.rb b/plugins/chat/app/validators/chat/allow_uploads_validator.rb new file mode 100644 index 00000000000..df859b53f62 --- /dev/null +++ b/plugins/chat/app/validators/chat/allow_uploads_validator.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Chat + class AllowUploadsValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(value) + return false if value == "t" && prevent_enabling_chat_uploads? + true + end + + def error_message + if prevent_enabling_chat_uploads? + I18n.t("site_settings.errors.chat_upload_not_allowed_secure_uploads") + end + end + + def prevent_enabling_chat_uploads? + SiteSetting.secure_uploads && !GlobalSetting.allow_unsecure_chat_uploads + end + end +end diff --git a/plugins/chat/app/validators/chat/default_channel_validator.rb b/plugins/chat/app/validators/chat/default_channel_validator.rb new file mode 100644 index 00000000000..c8f23893851 --- /dev/null +++ b/plugins/chat/app/validators/chat/default_channel_validator.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Chat + class DefaultChannelValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(value) + !!(value == "" || Chat::Channel.find_by(id: value.to_i)&.public_channel?) + end + + def error_message + I18n.t("site_settings.errors.chat_default_channel") + end + end +end diff --git a/plugins/chat/app/validators/chat/direct_message_enabled_groups_validator.rb b/plugins/chat/app/validators/chat/direct_message_enabled_groups_validator.rb new file mode 100644 index 00000000000..f56fadf5dde --- /dev/null +++ b/plugins/chat/app/validators/chat/direct_message_enabled_groups_validator.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Chat + class DirectMessageEnabledGroupsValidator + def initialize(opts = {}) + @opts = opts + end + + def valid_value?(val) + val.present? && val != "" + end + + def error_message + I18n.t("site_settings.errors.direct_message_enabled_groups_invalid") + end + end +end diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.js b/plugins/chat/assets/javascripts/discourse/components/chat-message.js index 14534d88d83..eb8f13ef4c2 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.js @@ -614,7 +614,7 @@ export default class ChatMessage extends Component { this.args.message.bookmark || Bookmark.createFor( this.currentUser, - "ChatMessage", + "Chat::Message", this.args.message.id ), { diff --git a/plugins/chat/assets/stylesheets/common/common.scss b/plugins/chat/assets/stylesheets/common/base-common.scss similarity index 100% rename from plugins/chat/assets/stylesheets/common/common.scss rename to plugins/chat/assets/stylesheets/common/base-common.scss diff --git a/plugins/chat/assets/stylesheets/common/chat-message.scss b/plugins/chat/assets/stylesheets/common/chat-message.scss index 286fd52a97a..2ee5de37395 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message.scss @@ -14,47 +14,6 @@ } } -@mixin chat-reaction { - align-items: center; - display: inline-flex; - padding: 0.3em 0.6em; - margin: 1px 0.25em 1px 0; - font-size: var(--font-down-2); - border-radius: 4px; - border: 1px solid var(--primary-low); - background: transparent; - cursor: pointer; - user-select: none; - transition: background 0.2s, border-color 0.2s; - - &.reacted { - border-color: var(--tertiary-medium); - background: var(--tertiary-very-low); - color: var(--tertiary-hover); - - &:hover { - background: var(--tertiary-low); - } - } - - &:not(.reacted) { - &:hover { - background: var(--primary-low); - border-color: var(--primary-low-mid); - } - - &:focus { - background: none; - } - } - - .emoji { - height: 15px; - margin-right: 4px; - width: auto; - } -} - .chat-message { align-items: flex-start; padding: 0.25em 0.5em 0.25em 0.75em; diff --git a/plugins/chat/assets/stylesheets/common/index.scss b/plugins/chat/assets/stylesheets/common/index.scss new file mode 100644 index 00000000000..ec8e8c2d6b6 --- /dev/null +++ b/plugins/chat/assets/stylesheets/common/index.scss @@ -0,0 +1,45 @@ +@import "base-common"; +@import "sidebar-extensions"; +@import "chat-browse"; +@import "chat-channel-card"; +@import "chat-channel-info"; +@import "chat-channel-preview-card"; +@import "chat-channel-selector-modal"; +@import "chat-channel-settings-saved-indicator"; +@import "chat-channel-title"; +@import "chat-composer-dropdown"; +@import "chat-composer-inline-button"; +@import "chat-composer-upload"; +@import "chat-composer-uploads"; +@import "chat-composer"; +@import "chat-draft-channel"; +@import "chat-drawer"; +@import "chat-emoji-picker"; +@import "chat-form"; +@import "chat-index"; +@import "chat-mention-warnings"; +@import "chat-message-actions"; +@import "chat-message-collapser"; +@import "chat-message-images"; +@import "chat-message-info"; +@import "chat-message-left-gutter"; +@import "chat-message-separator"; +@import "chat-message"; +@import "chat-onebox"; +@import "chat-reply"; +@import "chat-replying-indicator"; +@import "chat-retention-reminder"; +@import "chat-selection-manager"; +@import "chat-side-panel"; +@import "chat-skeleton"; +@import "chat-tabs"; +@import "chat-thread"; +@import "chat-transcript"; +@import "core-extensions"; +@import "create-channel-modal"; +@import "d-progress-bar"; +@import "dc-filter-input"; +@import "direct-message-creator"; +@import "full-page-chat-header"; +@import "incoming-chat-webhooks"; +@import "reviewable-chat-message"; diff --git a/plugins/chat/assets/stylesheets/sidebar-extensions.scss b/plugins/chat/assets/stylesheets/common/sidebar-extensions.scss similarity index 100% rename from plugins/chat/assets/stylesheets/sidebar-extensions.scss rename to plugins/chat/assets/stylesheets/common/sidebar-extensions.scss diff --git a/plugins/chat/assets/stylesheets/desktop/desktop.scss b/plugins/chat/assets/stylesheets/desktop/base-desktop.scss similarity index 100% rename from plugins/chat/assets/stylesheets/desktop/desktop.scss rename to plugins/chat/assets/stylesheets/desktop/base-desktop.scss diff --git a/plugins/chat/assets/stylesheets/desktop/index.scss b/plugins/chat/assets/stylesheets/desktop/index.scss new file mode 100644 index 00000000000..e08babb76a8 --- /dev/null +++ b/plugins/chat/assets/stylesheets/desktop/index.scss @@ -0,0 +1,9 @@ +@import "base-desktop"; +@import "chat-channel-title"; +@import "chat-composer-uploads"; +@import "chat-composer"; +@import "chat-index-drawer"; +@import "chat-index-full-page"; +@import "chat-message-actions"; +@import "chat-message"; +@import "sidebar-extensions"; diff --git a/plugins/chat/assets/stylesheets/mixins/chat-reaction.scss b/plugins/chat/assets/stylesheets/mixins/chat-reaction.scss new file mode 100644 index 00000000000..233423be031 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mixins/chat-reaction.scss @@ -0,0 +1,40 @@ +@mixin chat-reaction { + align-items: center; + display: inline-flex; + padding: 0.3em 0.6em; + margin: 1px 0.25em 1px 0; + font-size: var(--font-down-2); + border-radius: 4px; + border: 1px solid var(--primary-low); + background: transparent; + cursor: pointer; + user-select: none; + transition: background 0.2s, border-color 0.2s; + + &.reacted { + border-color: var(--tertiary-medium); + background: var(--tertiary-very-low); + color: var(--tertiary-hover); + + &:hover { + background: var(--tertiary-low); + } + } + + &:not(.reacted) { + &:hover { + background: var(--primary-low); + border-color: var(--primary-low-mid); + } + + &:focus { + background: none; + } + } + + .emoji { + height: 15px; + margin-right: 4px; + width: auto; + } +} diff --git a/plugins/chat/assets/stylesheets/mixins/index.scss b/plugins/chat/assets/stylesheets/mixins/index.scss new file mode 100644 index 00000000000..82e3451dc32 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mixins/index.scss @@ -0,0 +1,2 @@ +@import "chat-scrollbar"; +@import "chat-reaction"; diff --git a/plugins/chat/assets/stylesheets/mobile/mobile.scss b/plugins/chat/assets/stylesheets/mobile/base-mobile.scss similarity index 100% rename from plugins/chat/assets/stylesheets/mobile/mobile.scss rename to plugins/chat/assets/stylesheets/mobile/base-mobile.scss diff --git a/plugins/chat/assets/stylesheets/mobile/index.scss b/plugins/chat/assets/stylesheets/mobile/index.scss new file mode 100644 index 00000000000..752b6567d00 --- /dev/null +++ b/plugins/chat/assets/stylesheets/mobile/index.scss @@ -0,0 +1,7 @@ +@import "base-mobile"; +@import "chat-channel-info"; +@import "chat-composer"; +@import "chat-index"; +@import "chat-message-actions"; +@import "chat-message"; +@import "chat-selection-manager"; diff --git a/plugins/chat/config/routes.rb b/plugins/chat/config/routes.rb new file mode 100644 index 00000000000..0c66fac78eb --- /dev/null +++ b/plugins/chat/config/routes.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +Chat::Engine.routes.draw do + namespace :api, defaults: { format: :json } do + get "/chatables" => "chatables#index" + get "/channels" => "channels#index" + get "/channels/me" => "current_user_channels#index" + post "/channels" => "channels#create" + delete "/channels/:channel_id" => "channels#destroy" + put "/channels/:channel_id" => "channels#update" + get "/channels/:channel_id" => "channels#show" + put "/channels/:channel_id/status" => "channels_status#update" + post "/channels/:channel_id/messages/moves" => "channels_messages_moves#create" + post "/channels/:channel_id/archives" => "channels_archives#create" + get "/channels/:channel_id/memberships" => "channels_memberships#index" + delete "/channels/:channel_id/memberships/me" => "channels_current_user_membership#destroy" + post "/channels/:channel_id/memberships/me" => "channels_current_user_membership#create" + put "/channels/:channel_id/notifications-settings/me" => + "channels_current_user_notifications_settings#update" + + # Category chatables controller hints. Only used by staff members, we don't want to leak category permissions. + get "/category-chatables/:id/permissions" => "category_chatables#permissions", + :format => :json, + :constraints => StaffConstraint.new + + # Hints for JIT warnings. + get "/mentions/groups" => "hints#check_group_mentions", :format => :json + + get "/channels/:channel_id/threads/:thread_id" => "channel_threads#show" + end + + # direct_messages_controller routes + get "/direct_messages" => "direct_messages#index" + post "/direct_messages/create" => "direct_messages#create" + + # incoming_webhooks_controller routes + post "/hooks/:key" => "incoming_webhooks#create_message" + + # incoming_webhooks_controller routes + post "/hooks/:key/slack" => "incoming_webhooks#create_message_slack_compatible" + + # chat_controller routes + get "/" => "chat#respond" + get "/browse" => "chat#respond" + get "/browse/all" => "chat#respond" + get "/browse/closed" => "chat#respond" + get "/browse/open" => "chat#respond" + get "/browse/archived" => "chat#respond" + get "/draft-channel" => "chat#respond" + post "/enable" => "chat#enable_chat" + post "/disable" => "chat#disable_chat" + post "/dismiss-retention-reminder" => "chat#dismiss_retention_reminder" + get "/:chat_channel_id/messages" => "chat#messages" + get "/message/:message_id" => "chat#message_link" + put ":chat_channel_id/edit/:message_id" => "chat#edit_message" + put ":chat_channel_id/react/:message_id" => "chat#react" + delete "/:chat_channel_id/:message_id" => "chat#delete" + put "/:chat_channel_id/:message_id/rebake" => "chat#rebake" + post "/:chat_channel_id/:message_id/flag" => "chat#flag" + post "/:chat_channel_id/quote" => "chat#quote_messages" + put "/:chat_channel_id/restore/:message_id" => "chat#restore" + get "/lookup/:message_id" => "chat#lookup_message" + put "/:chat_channel_id/read/:message_id" => "chat#update_user_last_read" + put "/user_chat_enabled/:user_id" => "chat#set_user_chat_status" + put "/:chat_channel_id/invite" => "chat#invite_users" + post "/drafts" => "chat#set_draft" + post "/:chat_channel_id" => "chat#create_message" + put "/flag" => "chat#flag" + get "/emojis" => "emojis#index" + + base_c_route = "/c/:channel_title/:channel_id" + get base_c_route => "chat#respond", :as => "channel" + get "#{base_c_route}/:message_id" => "chat#respond" + + %w[info info/about info/members info/settings].each do |route| + get "#{base_c_route}/#{route}" => "chat#respond" + end + + # /channel -> /c redirects + get "/channel/:channel_id", to: redirect("/chat/c/-/%{channel_id}") + + get "#{base_c_route}/t/:thread_id" => "chat#respond" + + base_channel_route = "/channel/:channel_id/:channel_title" + redirect_base = "/chat/c/%{channel_title}/%{channel_id}" + + get base_channel_route, to: redirect(redirect_base) + + %w[info info/about info/members info/settings].each do |route| + get "#{base_channel_route}/#{route}", to: redirect("#{redirect_base}/#{route}") + end +end diff --git a/plugins/chat/config/settings.yml b/plugins/chat/config/settings.yml index e62299b59cc..a1947fc499e 100644 --- a/plugins/chat/config/settings.yml +++ b/plugins/chat/config/settings.yml @@ -59,7 +59,7 @@ chat: chat_default_channel_id: default: "" client: true - validator: "ChatDefaultChannelValidator" + validator: "Chat::DefaultChannelValidator" chat_duplicate_message_sensitivity: type: float default: 0.5 @@ -85,7 +85,7 @@ chat: chat_allow_uploads: default: true client: true - validator: "ChatAllowUploadsValidator" + validator: "Chat::AllowUploadsValidator" max_chat_auto_joined_users: min: 0 default: 10000 @@ -97,7 +97,7 @@ chat: client: true allow_any: false refresh: true - validator: "DirectMessageEnabledGroupsValidator" + validator: "Chat::DirectMessageEnabledGroupsValidator" chat_message_flag_allowed_groups: default: "11" # @trust_level_1 type: group_list diff --git a/plugins/chat/db/fixtures/600_chat_channels.rb b/plugins/chat/db/fixtures/600_chat_channels.rb index 972398ba7f0..63b55a19cc3 100644 --- a/plugins/chat/db/fixtures/600_chat_channels.rb +++ b/plugins/chat/db/fixtures/600_chat_channels.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -ChatSeeder.new.execute if !Rails.env.test? +Chat::Seeder.new.execute if !Rails.env.test? diff --git a/plugins/chat/lib/chat/bookmark_extension.rb b/plugins/chat/lib/chat/bookmark_extension.rb new file mode 100644 index 00000000000..31bca99444a --- /dev/null +++ b/plugins/chat/lib/chat/bookmark_extension.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Chat + module BookmarkExtension + extend ActiveSupport::Concern + + prepended do + def valid_bookmarkable_type + return true if self.bookmarkable_type == Chat::Message.sti_name + super if defined?(super) + end + + CLASS_MAPPING = { "ChatMessage" => Chat::Message } + + # the model used when loading chatable_type column + def self.polymorphic_class_for(name) + return CLASS_MAPPING[name] if CLASS_MAPPING.key?(name) + super if defined?(super) + end + end + end +end diff --git a/plugins/chat/lib/chat/category_extension.rb b/plugins/chat/lib/chat/category_extension.rb new file mode 100644 index 00000000000..eb7b3fd8496 --- /dev/null +++ b/plugins/chat/lib/chat/category_extension.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Chat + module CategoryExtension + extend ActiveSupport::Concern + + include Chat::Chatable + + def self.polymorphic_name + Chat::Chatable.polymorphic_name_for(self) || super + end + + prepended do + has_one :category_channel, + as: :chatable, + class_name: "Chat::CategoryChannel", + dependent: :destroy + end + + def cannot_delete_reason + return I18n.t("category.cannot_delete.has_chat_channels") if category_channel + super + end + + def deletable_for_chat? + return true if !category_channel + category_channel.chat_messages_empty? + end + end +end diff --git a/plugins/chat/lib/chat/channel_archive_service.rb b/plugins/chat/lib/chat/channel_archive_service.rb new file mode 100644 index 00000000000..d3d7a349588 --- /dev/null +++ b/plugins/chat/lib/chat/channel_archive_service.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true + +## +# From time to time, site admins may choose to sunset a chat channel and archive +# the messages within. It cannot be used for DM channels in its current iteration. +# +# To archive a channel, we mark it read_only first to prevent any further message +# additions or changes, and create a record to track whether the archive topic +# will be new or existing. When we archive the channel, messages are copied into +# posts in batches using the [chat] BBCode to quote the messages. The messages are +# deleted once the batch has its post made. The execute action of this class is +# idempotent, so if we fail halfway through the archive process it can be run again. +# +# Once all of the messages have been copied then we mark the channel as archived. +module Chat + class ChannelArchiveService + ARCHIVED_MESSAGES_PER_POST = 100 + + class ArchiveValidationError < StandardError + attr_reader :errors + + def initialize(errors: []) + super + @errors = errors + end + end + + def self.create_archive_process(chat_channel:, acting_user:, topic_params:) + return if Chat::ChannelArchive.exists?(chat_channel: chat_channel) + + # Only need to validate topic params for a new topic, not an existing one. + if topic_params[:topic_id].blank? + valid, errors = + Chat::ChannelArchiveService.validate_topic_params(Guardian.new(acting_user), topic_params) + + raise ArchiveValidationError.new(errors: errors) if !valid + end + + Chat::ChannelArchive.transaction do + chat_channel.read_only!(acting_user) + + archive = + Chat::ChannelArchive.create!( + chat_channel: chat_channel, + archived_by: acting_user, + total_messages: chat_channel.chat_messages.count, + destination_topic_id: topic_params[:topic_id], + destination_topic_title: topic_params[:topic_title], + destination_category_id: topic_params[:category_id], + destination_tags: topic_params[:tags], + ) + Jobs.enqueue(Jobs::Chat::ChannelArchive, chat_channel_archive_id: archive.id) + + archive + end + end + + def self.retry_archive_process(chat_channel:) + return if !chat_channel.chat_channel_archive&.failed? + Jobs.enqueue( + Jobs::Chat::ChannelArchive, + chat_channel_archive_id: chat_channel.chat_channel_archive.id, + ) + chat_channel.chat_channel_archive + end + + def self.validate_topic_params(guardian, topic_params) + topic_creator = + TopicCreator.new( + Discourse.system_user, + guardian, + { + title: topic_params[:topic_title], + category: topic_params[:category_id], + tags: topic_params[:tags], + import_mode: true, + }, + ) + [topic_creator.valid?, topic_creator.errors.full_messages] + end + + attr_reader :chat_channel_archive, :chat_channel, :chat_channel_title + + def initialize(chat_channel_archive) + @chat_channel_archive = chat_channel_archive + @chat_channel = chat_channel_archive.chat_channel + @chat_channel_title = chat_channel.title(chat_channel_archive.archived_by) + end + + def execute + chat_channel_archive.update(archive_error: nil) + + begin + return if !ensure_destination_topic_exists! + + Rails.logger.info( + "Creating posts from message batches for #{chat_channel_title} archive, #{chat_channel_archive.total_messages} messages to archive (#{chat_channel_archive.total_messages / ARCHIVED_MESSAGES_PER_POST} posts).", + ) + + # A batch should be idempotent, either the post is created and the + # messages are deleted or we roll back the whole thing. + # + # At some point we may want to reconsider disabling post validations, + # and add in things like dynamic resizing of the number of messages per + # post based on post length, but that can be done later. + # + # Another future improvement is to send a MessageBus message for each + # completed batch, so the UI can receive updates and show a progress + # bar or something similar. + chat_channel + .chat_messages + .find_in_batches(batch_size: ARCHIVED_MESSAGES_PER_POST) do |chat_messages| + create_post( + Chat::TranscriptService.new( + chat_channel, + chat_channel_archive.archived_by, + messages_or_ids: chat_messages, + opts: { + no_link: true, + include_reactions: true, + }, + ).generate_markdown, + ) { delete_message_batch(chat_messages.map(&:id)) } + end + + kick_all_users + complete_archive + rescue => err + notify_archiver(:failed, error_message: err.message) + raise err + end + end + + private + + def create_post(raw) + pc = nil + Post.transaction do + pc = + PostCreator.new( + Discourse.system_user, + raw: raw, + # we must skip these because the posts are created in a big transaction, + # we do them all at the end instead + skip_jobs: true, + # we do not want to be sending out notifications etc. from this + # automatic background process + import_mode: true, + # don't want to be stopped by watched word or post length validations + skip_validations: true, + topic_id: chat_channel_archive.destination_topic_id, + ) + + pc.create + + # so we can also delete chat messages in the same transaction + yield if block_given? + end + pc.enqueue_jobs + end + + def ensure_destination_topic_exists! + if !chat_channel_archive.destination_topic.present? + Rails.logger.info("Creating topic for #{chat_channel_title} archive.") + Topic.transaction do + topic_creator = + TopicCreator.new( + Discourse.system_user, + Guardian.new(chat_channel_archive.archived_by), + { + title: chat_channel_archive.destination_topic_title, + category: chat_channel_archive.destination_category_id, + tags: chat_channel_archive.destination_tags, + import_mode: true, + }, + ) + + if topic_creator.valid? + chat_channel_archive.update!(destination_topic: topic_creator.create) + else + Rails.logger.info("Destination topic for #{chat_channel_title} archive was not valid.") + notify_archiver( + :failed_no_topic, + error_message: topic_creator.errors.full_messages.join("\n"), + ) + end + end + + if chat_channel_archive.destination_topic.present? + Rails.logger.info("Creating first post for #{chat_channel_title} archive.") + create_post( + I18n.t( + "chat.channel.archive.first_post_raw", + channel_name: chat_channel_title, + channel_url: chat_channel.url, + ), + ) + end + else + Rails.logger.info("Topic already exists for #{chat_channel_title} archive.") + end + + if chat_channel_archive.destination_topic.present? + update_destination_topic_status + return true + end + + false + end + + def update_destination_topic_status + # We only want to do this when the destination topic is new, not an + # existing topic, because we don't want to update the status unexpectedly + # on an existing topic + if chat_channel_archive.new_topic? + if SiteSetting.chat_archive_destination_topic_status == "archived" + chat_channel_archive.destination_topic.update!(archived: true) + elsif SiteSetting.chat_archive_destination_topic_status == "closed" + chat_channel_archive.destination_topic.update!(closed: true) + end + end + end + + def delete_message_batch(message_ids) + Chat::Message.transaction do + Chat::Message.where(id: message_ids).update_all( + deleted_at: DateTime.now, + deleted_by_id: chat_channel_archive.archived_by.id, + ) + + chat_channel_archive.update!( + archived_messages: chat_channel_archive.archived_messages + message_ids.length, + ) + end + + Rails.logger.info( + "Archived #{chat_channel_archive.archived_messages} messages for #{chat_channel_title} archive.", + ) + end + + def complete_archive + Rails.logger.info("Creating posts completed for #{chat_channel_title} archive.") + chat_channel.archived!(chat_channel_archive.archived_by) + notify_archiver(:success) + end + + def notify_archiver(result, error_message: nil) + base_translation_params = { + channel_hashtag_or_name: channel_hashtag_or_name, + topic_title: chat_channel_archive.destination_topic&.title, + topic_url: chat_channel_archive.destination_topic&.url, + topic_validation_errors: result == :failed_no_topic ? error_message : nil, + } + + if result == :failed || result == :failed_no_topic + Discourse.warn_exception( + error_message, + message: "Error when archiving chat channel #{chat_channel_title}.", + env: { + chat_channel_id: chat_channel.id, + chat_channel_name: chat_channel_title, + }, + ) + error_translation_params = + base_translation_params.merge( + channel_url: chat_channel.url, + messages_archived: chat_channel_archive.archived_messages, + ) + chat_channel_archive.update(archive_error: error_message) + message_translation_key = + case result + when :failed + :chat_channel_archive_failed + when :failed_no_topic + :chat_channel_archive_failed_no_topic + end + SystemMessage.create_from_system_user( + chat_channel_archive.archived_by, + message_translation_key, + error_translation_params, + ) + else + SystemMessage.create_from_system_user( + chat_channel_archive.archived_by, + :chat_channel_archive_complete, + base_translation_params, + ) + end + + Chat::Publisher.publish_archive_status( + chat_channel, + archive_status: result != :success ? :failed : :success, + archived_messages: chat_channel_archive.archived_messages, + archive_topic_id: chat_channel_archive.destination_topic_id, + total_messages: chat_channel_archive.total_messages, + ) + end + + def kick_all_users + Chat::ChannelMembershipManager.new(chat_channel).unfollow_all_users + end + + def channel_hashtag_or_name + if chat_channel.slug.present? && SiteSetting.enable_experimental_hashtag_autocomplete + return "##{chat_channel.slug}::channel" + end + chat_channel_title + end + end +end diff --git a/plugins/chat/lib/chat/channel_fetcher.rb b/plugins/chat/lib/chat/channel_fetcher.rb new file mode 100644 index 00000000000..852185ef4e0 --- /dev/null +++ b/plugins/chat/lib/chat/channel_fetcher.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +module Chat + class ChannelFetcher + MAX_PUBLIC_CHANNEL_RESULTS = 50 + + def self.structured(guardian) + memberships = Chat::ChannelMembershipManager.all_for_user(guardian.user) + { + public_channels: + secured_public_channels(guardian, memberships, status: :open, following: true), + direct_message_channels: + secured_direct_message_channels(guardian.user.id, memberships, guardian), + memberships: memberships, + } + end + + def self.all_secured_channel_ids(guardian, following: true) + allowed_channel_ids_sql = generate_allowed_channel_ids_sql(guardian) + + return DB.query_single(allowed_channel_ids_sql) if !following + + DB.query_single(<<~SQL, user_id: guardian.user.id) + SELECT chat_channel_id + FROM user_chat_channel_memberships + WHERE user_chat_channel_memberships.user_id = :user_id + AND user_chat_channel_memberships.chat_channel_id IN ( + #{allowed_channel_ids_sql} + ) + SQL + end + + def self.generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: false) + category_channel_sql = + Category + .post_create_allowed(guardian) + .joins( + "INNER JOIN chat_channels ON chat_channels.chatable_id = categories.id AND chat_channels.chatable_type = 'Category'", + ) + .select("chat_channels.id") + .to_sql + dm_channel_sql = "" + if !exclude_dm_channels + dm_channel_sql = <<~SQL + UNION + + -- secured direct message chat channels + #{ + Chat::Channel + .select(:id) + .joins( + "INNER JOIN direct_message_channels ON direct_message_channels.id = chat_channels.chatable_id + AND chat_channels.chatable_type = 'DirectMessage' + INNER JOIN direct_message_users ON direct_message_users.direct_message_channel_id = direct_message_channels.id", + ) + .where("direct_message_users.user_id = :user_id", user_id: guardian.user.id) + .to_sql + } + SQL + end + + <<~SQL + -- secured category chat channels + #{category_channel_sql} + #{dm_channel_sql} + SQL + end + + def self.secured_public_channel_slug_lookup(guardian, slugs) + allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) + + Chat::Channel + .joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + .where(chatable_type: Chat::Channel.public_channel_chatable_types) + .where("chat_channels.id IN (#{allowed_channel_ids})") + .where("chat_channels.slug IN (:slugs)", slugs: slugs) + .limit(1) + end + + def self.secured_public_channel_search(guardian, options = {}) + allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) + + channels = Chat::Channel.includes(chatable: [:topic_only_relative_url]) + channels = channels.includes(:chat_channel_archive) if options[:include_archives] + + channels = + channels + .joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + .where(chatable_type: Chat::Channel.public_channel_chatable_types) + .where("chat_channels.id IN (#{allowed_channel_ids})") + + channels = channels.where(status: options[:status]) if options[:status].present? + + if options[:filter].present? + category_filter = + (options[:filter_on_category_name] ? "OR categories.name ILIKE :filter" : "") + + sql = + "chat_channels.name ILIKE :filter OR chat_channels.slug ILIKE :filter #{category_filter}" + if options[:match_filter_on_starts_with] + filter_sql = "#{options[:filter].downcase}%" + else + filter_sql = "%#{options[:filter].downcase}%" + end + + channels = + channels.where(sql, filter: filter_sql).order( + "chat_channels.name ASC, categories.name ASC", + ) + end + + if options.key?(:slugs) + channels = channels.where("chat_channels.slug IN (:slugs)", slugs: options[:slugs]) + end + + if options.key?(:following) + if options[:following] + channels = + channels.joins(:user_chat_channel_memberships).where( + user_chat_channel_memberships: { + user_id: guardian.user.id, + following: true, + }, + ) + else + channels = + channels.where( + "chat_channels.id NOT IN (SELECT chat_channel_id FROM user_chat_channel_memberships uccm WHERE uccm.chat_channel_id = chat_channels.id AND following IS TRUE AND user_id = ?)", + guardian.user.id, + ) + end + end + + options[:limit] = (options[:limit] || MAX_PUBLIC_CHANNEL_RESULTS).to_i.clamp( + 1, + MAX_PUBLIC_CHANNEL_RESULTS, + ) + options[:offset] = [options[:offset].to_i, 0].max + + channels.limit(options[:limit]).offset(options[:offset]) + end + + def self.secured_public_channels(guardian, memberships, options = { following: true }) + channels = + secured_public_channel_search( + guardian, + options.merge(include_archives: true, filter_on_category_name: true), + ) + + decorate_memberships_with_tracking_data(guardian, channels, memberships) + channels = channels.to_a + preload_custom_fields_for(channels) + channels + end + + def self.preload_custom_fields_for(channels) + preload_fields = Category.instance_variable_get(:@custom_field_types).keys + Category.preload_custom_fields( + channels + .select { |c| c.chatable_type == "Category" || c.chatable_type == "category" } + .map(&:chatable), + preload_fields, + ) + end + + def self.secured_direct_message_channels(user_id, memberships, guardian) + query = Chat::Channel.includes(chatable: [{ direct_message_users: :user }, :users]) + query = query.includes(chatable: [{ users: :user_status }]) if SiteSetting.enable_user_status + + channels = + query + .joins(:user_chat_channel_memberships) + .where(user_chat_channel_memberships: { user_id: user_id, following: true }) + .where(chatable_type: Chat::Channel.direct_channel_chatable_types) + .where("chat_channels.id IN (#{generate_allowed_channel_ids_sql(guardian)})") + .order(last_message_sent_at: :desc) + .to_a + + preload_fields = + User.allowed_user_custom_fields(guardian) + + UserField.all.pluck(:id).map { |fid| "#{User::USER_FIELD_PREFIX}#{fid}" } + User.preload_custom_fields(channels.map { |c| c.chatable.users }.flatten, preload_fields) + + decorate_memberships_with_tracking_data(guardian, channels, memberships) + end + + def self.decorate_memberships_with_tracking_data(guardian, channels, memberships) + unread_counts_per_channel = unread_counts(channels, guardian.user.id) + + mention_notifications = + Notification.unread.where( + user_id: guardian.user.id, + notification_type: Notification.types[:chat_mention], + ) + mention_notification_data = mention_notifications.map { |m| JSON.parse(m.data) } + + channels.each do |channel| + membership = memberships.find { |m| m.chat_channel_id == channel.id } + + if membership + membership.unread_mentions = + mention_notification_data.count do |data| + data["chat_channel_id"] == channel.id && + data["chat_message_id"] > (membership.last_read_message_id || 0) + end + + membership.unread_count = unread_counts_per_channel[channel.id] if !membership.muted + end + end + end + + def self.unread_counts(channels, user_id) + unread_counts = DB.query_array(<<~SQL, channel_ids: channels.map(&:id), user_id: user_id).to_h + SELECT cc.id, COUNT(*) as count + FROM chat_messages cm + JOIN chat_channels cc ON cc.id = cm.chat_channel_id + JOIN user_chat_channel_memberships uccm ON uccm.chat_channel_id = cc.id + WHERE cc.id IN (:channel_ids) + AND cm.user_id != :user_id + AND uccm.user_id = :user_id + AND cm.id > COALESCE(uccm.last_read_message_id, 0) + AND cm.deleted_at IS NULL + GROUP BY cc.id + SQL + unread_counts.default = 0 + unread_counts + end + + def self.find_with_access_check(channel_id_or_name, guardian) + begin + channel_id_or_name = Integer(channel_id_or_name) + rescue ArgumentError + end + + base_channel_relation = + Chat::Channel.includes(:chatable).joins( + "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", + ) + + if guardian.user.staff? + base_channel_relation = base_channel_relation.includes(:chat_channel_archive) + end + + if channel_id_or_name.is_a? Integer + chat_channel = base_channel_relation.find_by(id: channel_id_or_name) + else + chat_channel = + base_channel_relation.find_by( + "LOWER(categories.name) = :name OR LOWER(chat_channels.name) = :name", + name: channel_id_or_name.downcase, + ) + end + + raise Discourse::NotFound if chat_channel.blank? + raise Discourse::InvalidAccess if !guardian.can_join_chat_channel?(chat_channel) + chat_channel + end + end +end diff --git a/plugins/chat/lib/chat/channel_hashtag_data_source.rb b/plugins/chat/lib/chat/channel_hashtag_data_source.rb new file mode 100644 index 00000000000..f97b1e7334c --- /dev/null +++ b/plugins/chat/lib/chat/channel_hashtag_data_source.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Chat + class ChannelHashtagDataSource + def self.icon + "comment" + end + + def self.type + "channel" + end + + def self.channel_to_hashtag_item(guardian, channel) + HashtagAutocompleteService::HashtagItem.new.tap do |item| + item.text = channel.title + item.description = channel.description + item.slug = channel.slug + item.icon = icon + item.relative_url = channel.relative_url + item.type = "channel" + end + end + + def self.lookup(guardian, slugs) + if SiteSetting.enable_experimental_hashtag_autocomplete + return [] if !guardian.can_chat? + Chat::ChannelFetcher + .secured_public_channel_slug_lookup(guardian, slugs) + .map { |channel| channel_to_hashtag_item(guardian, channel) } + else + [] + end + end + + def self.search( + guardian, + term, + limit, + condition = HashtagAutocompleteService.search_conditions[:contains] + ) + if SiteSetting.enable_experimental_hashtag_autocomplete + return [] if !guardian.can_chat? + Chat::ChannelFetcher + .secured_public_channel_search( + guardian, + filter: term, + limit: limit, + exclude_dm_channels: true, + match_filter_on_starts_with: + condition == HashtagAutocompleteService.search_conditions[:starts_with], + ) + .map { |channel| channel_to_hashtag_item(guardian, channel) } + else + [] + end + end + + def self.search_sort(search_results, _) + search_results.sort_by { |result| result.text.downcase } + end + + def self.search_without_term(guardian, limit) + if SiteSetting.enable_experimental_hashtag_autocomplete + return [] if !guardian.can_chat? + allowed_channel_ids_sql = + Chat::ChannelFetcher.generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) + Chat::Channel + .joins( + "INNER JOIN user_chat_channel_memberships + ON user_chat_channel_memberships.chat_channel_id = chat_channels.id + AND user_chat_channel_memberships.user_id = #{guardian.user.id} + AND user_chat_channel_memberships.following = true", + ) + .where("chat_channels.id IN (#{allowed_channel_ids_sql})") + .order(messages_count: :desc) + .limit(limit) + .map { |channel| channel_to_hashtag_item(guardian, channel) } + else + [] + end + end + end +end diff --git a/plugins/chat/lib/chat/channel_membership_manager.rb b/plugins/chat/lib/chat/channel_membership_manager.rb new file mode 100644 index 00000000000..d55a11546e6 --- /dev/null +++ b/plugins/chat/lib/chat/channel_membership_manager.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Chat + class ChannelMembershipManager + def self.all_for_user(user) + Chat::UserChatChannelMembership.where(user: user) + end + + attr_reader :channel + + def initialize(channel) + @channel = channel + end + + def find_for_user(user, following: nil) + params = { user_id: user.id, chat_channel_id: channel.id } + params[:following] = following if following.present? + + Chat::UserChatChannelMembership.includes(:user, :chat_channel).find_by(params) + end + + def follow(user) + membership = + find_for_user(user) || + Chat::UserChatChannelMembership.new(user: user, chat_channel: channel, following: true) + + ActiveRecord::Base.transaction do + if membership.new_record? + membership.save! + recalculate_user_count + elsif !membership.following + membership.update!(following: true) + recalculate_user_count + end + end + + membership + end + + def unfollow(user) + membership = find_for_user(user) + + return if membership.blank? + + ActiveRecord::Base.transaction do + if membership.following + membership.update!(following: false) + recalculate_user_count + end + end + + membership + end + + def recalculate_user_count + return if Chat::Channel.exists?(id: channel.id, user_count_stale: true) + channel.update!(user_count_stale: true) + Jobs.enqueue_in(3.seconds, Jobs::Chat::UpdateChannelUserCount, chat_channel_id: channel.id) + end + + def unfollow_all_users + Chat::UserChatChannelMembership.where(chat_channel: channel).update_all( + following: false, + last_read_message_id: channel.chat_messages.last&.id, + ) + end + + def enforce_automatic_channel_memberships + Jobs.enqueue(Jobs::Chat::AutoManageChannelMemberships, chat_channel_id: channel.id) + end + + def enforce_automatic_user_membership(user) + Jobs.enqueue( + Jobs::Chat::AutoJoinChannelBatch, + chat_channel_id: channel.id, + starts_at: user.id, + ends_at: user.id, + ) + end + end +end diff --git a/plugins/chat/lib/chat/direct_message_channel_creator.rb b/plugins/chat/lib/chat/direct_message_channel_creator.rb new file mode 100644 index 00000000000..baad5d355d5 --- /dev/null +++ b/plugins/chat/lib/chat/direct_message_channel_creator.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module Chat + class DirectMessageChannelCreator + class NotAllowed < StandardError + end + + def self.create!(acting_user:, target_users:) + Guardian.new(acting_user).ensure_can_create_direct_message! + target_users.uniq! + direct_message = Chat::DirectMessage.for_user_ids(target_users.map(&:id)) + + if direct_message + chat_channel = Chat::Channel.find_by!(chatable: direct_message) + else + enforce_max_direct_message_users!(acting_user, target_users) + ensure_actor_can_communicate!(acting_user, target_users) + direct_message = Chat::DirectMessage.create!(user_ids: target_users.map(&:id)) + chat_channel = direct_message.create_chat_channel! + end + + update_memberships(acting_user, target_users, chat_channel.id) + Chat::Publisher.publish_new_channel(chat_channel, target_users) + + chat_channel + end + + private + + def self.enforce_max_direct_message_users!(acting_user, target_users) + # We never want to prevent the actor from communicating with themself. + target_users = target_users.reject { |user| user.id == acting_user.id } + + if !acting_user.staff? && target_users.size > SiteSetting.chat_max_direct_message_users + if SiteSetting.chat_max_direct_message_users == 0 + raise NotAllowed.new(I18n.t("chat.errors.over_chat_max_direct_message_users_allow_self")) + else + raise NotAllowed.new( + I18n.t( + "chat.errors.over_chat_max_direct_message_users", + count: SiteSetting.chat_max_direct_message_users + 1, # +1 for the acting_user + ), + ) + end + end + end + + def self.update_memberships(acting_user, target_users, chat_channel_id) + sql_params = { + acting_user_id: acting_user.id, + user_ids: target_users.map(&:id), + chat_channel_id: chat_channel_id, + always_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + } + + DB.exec(<<~SQL, sql_params) + INSERT INTO user_chat_channel_memberships( + user_id, + chat_channel_id, + muted, + following, + desktop_notification_level, + mobile_notification_level, + created_at, + updated_at + ) + VALUES( + unnest(array[:user_ids]), + :chat_channel_id, + false, + false, + :always_notification_level, + :always_notification_level, + NOW(), + NOW() + ) + ON CONFLICT (user_id, chat_channel_id) DO NOTHING; + + UPDATE user_chat_channel_memberships + SET following = true + WHERE user_id = :acting_user_id AND chat_channel_id = :chat_channel_id; + SQL + end + + def self.ensure_actor_can_communicate!(acting_user, target_users) + # We never want to prevent the actor from communicating with themself. + target_users = target_users.reject { |user| user.id == acting_user.id } + + screener = + UserCommScreener.new(acting_user: acting_user, target_user_ids: target_users.map(&:id)) + + # People blocking the actor. + screener.preventing_actor_communication.each do |user_id| + raise NotAllowed.new( + I18n.t( + "chat.errors.not_accepting_dms", + username: target_users.find { |user| user.id == user_id }.username, + ), + ) + end + + # The actor cannot start DMs with people if they are not allowing anyone + # to start DMs with them, that's no fair! + if screener.actor_disallowing_all_pms? + raise NotAllowed.new(I18n.t("chat.errors.actor_disallowed_dms")) + end + + # People the actor is blocking. + target_users.each do |target_user| + if screener.actor_disallowing_pms?(target_user.id) + raise NotAllowed.new( + I18n.t( + "chat.errors.actor_preventing_target_user_from_dm", + username: target_user.username, + ), + ) + end + + if screener.actor_ignoring?(target_user.id) + raise NotAllowed.new( + I18n.t("chat.errors.actor_ignoring_target_user", username: target_user.username), + ) + end + + if screener.actor_muting?(target_user.id) + raise NotAllowed.new( + I18n.t("chat.errors.actor_muting_target_user", username: target_user.username), + ) + end + end + end + end +end diff --git a/plugins/chat/lib/chat/duplicate_message_validator.rb b/plugins/chat/lib/chat/duplicate_message_validator.rb new file mode 100644 index 00000000000..fa9175b8ed7 --- /dev/null +++ b/plugins/chat/lib/chat/duplicate_message_validator.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Chat + class DuplicateMessageValidator + attr_reader :chat_message + + def initialize(chat_message) + @chat_message = chat_message + end + + def validate + return if SiteSetting.chat_duplicate_message_sensitivity.zero? + matrix = + DuplicateMessageValidator.sensitivity_matrix(SiteSetting.chat_duplicate_message_sensitivity) + + # Check if the length of the message is too short to check for a duplicate message + return if chat_message.message.length < matrix[:min_message_length] + + # Check if there are enough users in the channel to check for a duplicate message + return if (chat_message.chat_channel.user_count || 0) < matrix[:min_user_count] + + # Check if the same duplicate message has been posted in the last N seconds by any user + if !chat_message + .chat_channel + .chat_messages + .where("created_at > ?", matrix[:min_past_seconds].seconds.ago) + .where(message: chat_message.message) + .exists? + return + end + + chat_message.errors.add(:base, I18n.t("chat.errors.duplicate_message")) + end + + def self.sensitivity_matrix(sensitivity) + { + # 0.1 sensitivity = 100 users and 1.0 sensitivity = 5 users. + min_user_count: (-1.0 * 105.5 * sensitivity + 110.55).to_i, + # 0.1 sensitivity = 30 chars and 1.0 sensitivity = 10 chars. + min_message_length: (-1.0 * 22.2 * sensitivity + 32.22).to_i, + # 0.1 sensitivity = 10 seconds and 1.0 sensitivity = 60 seconds. + min_past_seconds: (55.55 * sensitivity + 4.5).to_i, + } + end + end +end diff --git a/plugins/chat/lib/chat/engine.rb b/plugins/chat/lib/chat/engine.rb new file mode 100644 index 00000000000..38cca8ab7ba --- /dev/null +++ b/plugins/chat/lib/chat/engine.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ::Chat + HAS_CHAT_ENABLED = "has_chat_enabled" + + class Engine < ::Rails::Engine + engine_name PLUGIN_NAME + isolate_namespace Chat + config.autoload_paths << File.join(config.root, "lib") + end + + def self.allowed_group_ids + SiteSetting.chat_allowed_groups_map + end + + def self.onebox_template + @onebox_template ||= + begin + path = "#{Rails.root}/plugins/chat/lib/onebox/templates/discourse_chat.mustache" + File.read(path) + end + end +end diff --git a/plugins/chat/lib/chat/guardian_extensions.rb b/plugins/chat/lib/chat/guardian_extensions.rb new file mode 100644 index 00000000000..f5a067e295a --- /dev/null +++ b/plugins/chat/lib/chat/guardian_extensions.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +module Chat + module GuardianExtensions + def can_moderate_chat?(chatable) + case chatable.class.name + when "Category" + is_staff? || is_category_group_moderator?(chatable) + else + is_staff? + end + end + + def can_chat? + return false if anonymous? + @user.staff? || @user.in_any_groups?(Chat.allowed_group_ids) + end + + def can_create_chat_message? + !SpamRule::AutoSilence.prevent_posting?(@user) + end + + def can_create_direct_message? + is_staff? || @user.in_any_groups?(SiteSetting.direct_message_enabled_groups_map) + end + + def hidden_tag_names + @hidden_tag_names ||= DiscourseTagging.hidden_tag_names(self) + end + + def can_create_chat_channel? + is_staff? + end + + def can_delete_chat_channel? + is_staff? + end + + # Channel status intentionally has no bearing on whether the channel + # name and description can be edited. + def can_edit_chat_channel? + is_staff? + end + + def can_move_chat_messages?(channel) + can_moderate_chat?(channel.chatable) + end + + def can_create_channel_message?(chat_channel) + valid_statuses = is_staff? ? %w[open closed] : ["open"] + valid_statuses.include?(chat_channel.status) + end + + # This is intentionally identical to can_create_channel_message, we + # may want to have different conditions here in future. + def can_modify_channel_message?(chat_channel) + return chat_channel.open? || chat_channel.closed? if is_staff? + chat_channel.open? + end + + def can_change_channel_status?(chat_channel, target_status) + return false if chat_channel.status.to_sym == target_status.to_sym + return false if !is_staff? + + # FIXME: This logic shouldn't be handled in guardian + case target_status + when :closed + chat_channel.open? + when :open + chat_channel.closed? + when :archived + chat_channel.read_only? + when :read_only + chat_channel.closed? || chat_channel.open? + else + false + end + end + + def can_rebake_chat_message?(message) + return false if !can_modify_channel_message?(message.chat_channel) + is_staff? || @user.has_trust_level?(TrustLevel[4]) + end + + def can_preview_chat_channel?(chat_channel) + return false unless chat_channel.chatable + + if chat_channel.direct_message_channel? + chat_channel.chatable.user_can_access?(@user) + elsif chat_channel.category_channel? + can_see_category?(chat_channel.chatable) + else + true + end + end + + def can_join_chat_channel?(chat_channel) + return false if anonymous? + can_preview_chat_channel?(chat_channel) && + (chat_channel.direct_message_channel? || can_post_in_category?(chat_channel.chatable)) + end + + def can_flag_chat_messages? + return false if @user.silenced? + return true if @user.staff? + + @user.in_any_groups?(SiteSetting.chat_message_flag_allowed_groups_map) + end + + def can_flag_in_chat_channel?(chat_channel) + return false if !can_modify_channel_message?(chat_channel) + + can_join_chat_channel?(chat_channel) + end + + def can_flag_chat_message?(chat_message) + if !authenticated? || !chat_message || chat_message.trashed? || !chat_message.user + return false + end + return false if chat_message.user.staff? && !SiteSetting.allow_flagging_staff + return false if chat_message.user_id == @user.id + + can_flag_chat_messages? && can_flag_in_chat_channel?(chat_message.chat_channel) + end + + def can_flag_message_as?(chat_message, flag_type_id, opts) + return false if !is_staff? && (opts[:take_action] || opts[:queue_for_review]) + + if flag_type_id == ReviewableScore.types[:notify_user] + is_warning = ActiveRecord::Type::Boolean.new.deserialize(opts[:is_warning]) + + return false if is_warning && !is_staff? + end + + true + end + + def can_delete_chat?(message, chatable) + return false if @user.silenced? + return false if !can_modify_channel_message?(message.chat_channel) + + if message.user_id == current_user.id + can_delete_own_chats?(chatable) + else + can_delete_other_chats?(chatable) + end + end + + def can_delete_own_chats?(chatable) + return false if (SiteSetting.max_post_deletions_per_day < 1) + return true if can_moderate_chat?(chatable) + + true + end + + def can_delete_other_chats?(chatable) + return true if can_moderate_chat?(chatable) + + false + end + + def can_restore_chat?(message, chatable) + return false if !can_modify_channel_message?(message.chat_channel) + + if message.user_id == current_user.id + case chatable + when Category + return can_see_category?(chatable) + when Chat::DirectMessage + return true + end + end + + can_delete_other_chats?(chatable) + end + + def can_restore_other_chats?(chatable) + can_moderate_chat?(chatable) + end + + def can_edit_chat?(message) + message.user_id == @user.id && !@user.silenced? + end + + def can_react? + can_create_chat_message? + end + + def can_delete_category?(category) + super && category.deletable_for_chat? + end + end +end diff --git a/plugins/chat/lib/chat/mailer.rb b/plugins/chat/lib/chat/mailer.rb new file mode 100644 index 00000000000..f6a2579af1e --- /dev/null +++ b/plugins/chat/lib/chat/mailer.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Chat + class Mailer + def self.send_unread_mentions_summary + return unless SiteSetting.chat_enabled + + users_with_unprocessed_unread_mentions.find_each do |user| + # user#memberships_with_unread_messages is a nested array that looks like [[membership_id, unread_message_id]] + # Find the max unread id per membership. + membership_and_max_unread_mention_ids = + user + .memberships_with_unread_messages + .group_by { |memberships| memberships[0] } + .transform_values do |membership_and_msg_ids| + membership_and_msg_ids.max_by { |membership, msg| msg } + end + .values + + Jobs.enqueue( + :user_email, + type: "chat_summary", + user_id: user.id, + force_respect_seen_recently: true, + memberships_to_update_data: membership_and_max_unread_mention_ids, + ) + end + end + + private + + def self.users_with_unprocessed_unread_mentions + when_away_frequency = UserOption.chat_email_frequencies[:when_away] + allowed_group_ids = Chat.allowed_group_ids + + users = + User + .joins(:user_option) + .where(user_options: { chat_enabled: true, chat_email_frequency: when_away_frequency }) + .where("users.last_seen_at < ?", 15.minutes.ago) + + if !allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone]) + users = users.joins(:groups).where(groups: { id: allowed_group_ids }) + end + + users + .select( + "users.id", + "ARRAY_AGG(ARRAY[uccm.id, c_msg.id]) AS memberships_with_unread_messages", + ) + .joins("INNER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id") + .joins("INNER JOIN chat_channels cc ON cc.id = uccm.chat_channel_id") + .joins("INNER JOIN chat_messages c_msg ON c_msg.chat_channel_id = uccm.chat_channel_id") + .joins("LEFT OUTER JOIN chat_mentions c_mentions ON c_mentions.chat_message_id = c_msg.id") + .where("c_msg.deleted_at IS NULL AND c_msg.user_id <> users.id") + .where("c_msg.created_at > ?", 1.week.ago) + .where(<<~SQL) + (uccm.last_read_message_id IS NULL OR c_msg.id > uccm.last_read_message_id) AND + (uccm.last_unread_mention_when_emailed_id IS NULL OR c_msg.id > uccm.last_unread_mention_when_emailed_id) AND + ( + (uccm.user_id = c_mentions.user_id AND uccm.following IS true AND cc.chatable_type = 'Category') OR + (cc.chatable_type = 'DirectMessage') + ) + SQL + .group("users.id, uccm.user_id") + end + end +end diff --git a/plugins/chat/lib/chat/message_bookmarkable.rb b/plugins/chat/lib/chat/message_bookmarkable.rb new file mode 100644 index 00000000000..41c286cbf37 --- /dev/null +++ b/plugins/chat/lib/chat/message_bookmarkable.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Chat + class MessageBookmarkable < BaseBookmarkable + def self.model + Chat::Message + end + + def self.serializer + Chat::UserMessageBookmarkSerializer + end + + def self.preload_associations + [:chat_channel] + end + + def self.list_query(user, guardian) + accessible_channel_ids = Chat::ChannelFetcher.all_secured_channel_ids(guardian) + return if accessible_channel_ids.empty? + + joins = + ActiveRecord::Base.public_send( + :sanitize_sql_array, + [ + "INNER JOIN chat_messages ON chat_messages.id = bookmarks.bookmarkable_id AND chat_messages.deleted_at IS NULL AND bookmarks.bookmarkable_type = ?", + Chat::Message.sti_name, + ], + ) + + user + .bookmarks_of_type(Chat::Message.sti_name) + .joins(joins) + .where("chat_messages.chat_channel_id IN (?)", accessible_channel_ids) + end + + def self.search_query(bookmarks, query, ts_query, &bookmarkable_search) + bookmarkable_search.call(bookmarks, "chat_messages.message ILIKE :q") + end + + def self.validate_before_create(guardian, bookmarkable) + if bookmarkable.blank? || !guardian.can_join_chat_channel?(bookmarkable.chat_channel) + raise Discourse::InvalidAccess + end + end + + def self.reminder_handler(bookmark) + send_reminder_notification( + bookmark, + data: { + title: + I18n.t( + "chat.bookmarkable.notification_title", + channel_name: bookmark.bookmarkable.chat_channel.title(bookmark.user), + ), + bookmarkable_url: bookmark.bookmarkable.url, + }, + ) + end + + def self.reminder_conditions(bookmark) + bookmark.bookmarkable.present? && bookmark.bookmarkable.chat_channel.present? + end + + def self.can_see?(guardian, bookmark) + guardian.can_join_chat_channel?(bookmark.bookmarkable.chat_channel) + end + + def self.cleanup_deleted + DB.query(<<~SQL, grace_time: 3.days.ago, bookmarkable_type: Chat::Message.sti_name) + DELETE FROM bookmarks b + USING chat_messages cm + WHERE b.bookmarkable_id = cm.id + AND b.bookmarkable_type = :bookmarkable_type + AND (cm.deleted_at < :grace_time) + SQL + end + end +end diff --git a/plugins/chat/lib/chat/message_creator.rb b/plugins/chat/lib/chat/message_creator.rb new file mode 100644 index 00000000000..7eec88b7a76 --- /dev/null +++ b/plugins/chat/lib/chat/message_creator.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true +module Chat + class MessageCreator + attr_reader :error, :chat_message + + def self.create(opts) + instance = new(**opts) + instance.create + instance + end + + def initialize( + chat_channel:, + in_reply_to_id: nil, + thread_id: nil, + user:, + content:, + staged_id: nil, + incoming_chat_webhook: nil, + upload_ids: nil + ) + @chat_channel = chat_channel + @user = user + @guardian = Guardian.new(user) + + # NOTE: We confirm this exists and the user can access it in the ChatController, + # but in future the checks should be here + @in_reply_to_id = in_reply_to_id + @content = content + @staged_id = staged_id + @incoming_chat_webhook = incoming_chat_webhook + @upload_ids = upload_ids || [] + @thread_id = thread_id + @error = nil + + @chat_message = + Chat::Message.new( + chat_channel: @chat_channel, + user_id: @user.id, + last_editor_id: @user.id, + in_reply_to_id: @in_reply_to_id, + message: @content, + ) + end + + def create + begin + validate_channel_status! + uploads = get_uploads + validate_message!(has_uploads: uploads.any?) + validate_reply_chain! + validate_existing_thread! + @chat_message.thread_id = @existing_thread&.id + @chat_message.cook + @chat_message.save! + create_chat_webhook_event + create_thread + @chat_message.attach_uploads(uploads) + Chat::Draft.where(user_id: @user.id, chat_channel_id: @chat_channel.id).destroy_all + Chat::Publisher.publish_new!(@chat_channel, @chat_message, @staged_id) + Jobs.enqueue(Jobs::Chat::ProcessMessage, { chat_message_id: @chat_message.id }) + Chat::Notifier.notify_new(chat_message: @chat_message, timestamp: @chat_message.created_at) + @chat_channel.touch(:last_message_sent_at) + DiscourseEvent.trigger(:chat_message_created, @chat_message, @chat_channel, @user) + rescue => error + @error = error + end + end + + def failed? + @error.present? + end + + private + + def validate_channel_status! + return if @guardian.can_create_channel_message?(@chat_channel) + + if @chat_channel.direct_message_channel? && !@guardian.can_create_direct_message? + raise StandardError.new(I18n.t("chat.errors.user_cannot_send_direct_messages")) + else + raise StandardError.new( + I18n.t("chat.errors.channel_new_message_disallowed.#{@chat_channel.status}"), + ) + end + end + + def validate_reply_chain! + return if @in_reply_to_id.blank? + + @original_message_id = DB.query_single(<<~SQL).last + WITH RECURSIVE original_message_finder( id, in_reply_to_id ) + AS ( + -- start with the message id we want to find the parents of + SELECT id, in_reply_to_id + FROM chat_messages + WHERE id = #{@in_reply_to_id} + + UNION ALL + + -- get the chain of direct parents of the message + -- following in_reply_to_id + SELECT cm.id, cm.in_reply_to_id + FROM original_message_finder rm + JOIN chat_messages cm ON rm.in_reply_to_id = cm.id + ) + SELECT id FROM original_message_finder + + -- this makes it so only the root parent ID is returned, we can + -- exclude this to return all parents in the chain + WHERE in_reply_to_id IS NULL; + SQL + + if @original_message_id.blank? + raise StandardError.new(I18n.t("chat.errors.original_message_not_found")) + end + + @original_message = Chat::Message.with_deleted.find_by(id: @original_message_id) + if @original_message&.trashed? + raise StandardError.new(I18n.t("chat.errors.original_message_not_found")) + end + end + + def validate_existing_thread! + return if @thread_id.blank? + @existing_thread = Chat::Thread.find(@thread_id) + + if @existing_thread.channel_id != @chat_channel.id + raise StandardError.new(I18n.t("chat.errors.thread_invalid_for_channel")) + end + + reply_to_thread_mismatch = + @chat_message.in_reply_to&.thread_id && + @chat_message.in_reply_to.thread_id != @existing_thread.id + original_message_has_no_thread = @original_message && @original_message.thread_id.blank? + original_message_thread_mismatch = + @original_message && @original_message.thread_id != @existing_thread.id + if reply_to_thread_mismatch || original_message_has_no_thread || + original_message_thread_mismatch + raise StandardError.new(I18n.t("chat.errors.thread_does_not_match_parent")) + end + end + + def validate_message!(has_uploads:) + @chat_message.validate_message(has_uploads: has_uploads) + if @chat_message.errors.present? + raise StandardError.new(@chat_message.errors.map(&:full_message).join(", ")) + end + end + + def create_chat_webhook_event + return if @incoming_chat_webhook.blank? + Chat::WebhookEvent.create( + chat_message: @chat_message, + incoming_chat_webhook: @incoming_chat_webhook, + ) + end + + def get_uploads + return [] if @upload_ids.blank? || !SiteSetting.chat_allow_uploads + + ::Upload.where(id: @upload_ids, user_id: @user.id) + end + + def create_thread + return if @in_reply_to_id.blank? + return if @chat_message.thread_id.present? + + thread = + @original_message.thread || + Chat::Thread.create!( + original_message: @chat_message.in_reply_to, + original_message_user: @chat_message.in_reply_to.user, + channel: @chat_message.chat_channel, + ) + + # NOTE: We intentionally do not try to correct thread IDs within the chain + # if they are incorrect, and only set the thread ID of messages where the + # thread ID is NULL. In future we may want some sync/background job to correct + # any inconsistencies. + DB.exec(<<~SQL) + WITH RECURSIVE thread_updater AS ( + SELECT cm.id, cm.in_reply_to_id + FROM chat_messages cm + WHERE cm.in_reply_to_id IS NULL AND cm.id = #{@original_message_id} + + UNION ALL + + SELECT cm.id, cm.in_reply_to_id + FROM chat_messages cm + JOIN thread_updater ON cm.in_reply_to_id = thread_updater.id + ) + UPDATE chat_messages + SET thread_id = #{thread.id} + FROM thread_updater + WHERE thread_id IS NULL AND chat_messages.id = thread_updater.id + SQL + + @chat_message.thread_id = thread.id + end + end +end diff --git a/plugins/chat/lib/chat/message_mentions.rb b/plugins/chat/lib/chat/message_mentions.rb new file mode 100644 index 00000000000..613b8aead1f --- /dev/null +++ b/plugins/chat/lib/chat/message_mentions.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Chat + class MessageMentions + def initialize(message) + @message = message + + mentions = parse_mentions(message) + group_mentions = parse_group_mentions(message) + + @has_global_mention = mentions.include?("@all") + @has_here_mention = mentions.include?("@here") + @parsed_direct_mentions = normalize(mentions) + @parsed_group_mentions = normalize(group_mentions) + end + + attr_accessor :has_global_mention, + :has_here_mention, + :parsed_direct_mentions, + :parsed_group_mentions + + def all_mentioned_users_ids + @all_mentioned_users_ids ||= + begin + user_ids = global_mentions.pluck(:id) + user_ids.concat(direct_mentions.pluck(:id)) + user_ids.concat(group_mentions.pluck(:id)) + user_ids.concat(here_mentions.pluck(:id)) + user_ids.uniq! + user_ids + end + end + + def global_mentions + return User.none unless @has_global_mention + channel_members.where.not(username_lower: @parsed_direct_mentions) + end + + def direct_mentions + chat_users.where(username_lower: @parsed_direct_mentions) + end + + def group_mentions + chat_users.includes(:groups).joins(:groups).where(groups: mentionable_groups) + end + + def here_mentions + return User.none unless @has_here_mention + + channel_members + .where("last_seen_at > ?", 5.minutes.ago) + .where.not(username_lower: @parsed_direct_mentions) + end + + def mentionable_groups + @mentionable_groups ||= + Group.mentionable(@message.user, include_public: false).where(id: visible_groups.map(&:id)) + end + + def visible_groups + @visible_groups ||= + Group.where("LOWER(name) IN (?)", @parsed_group_mentions).visible_groups(@message.user) + end + + private + + def channel_members + chat_users.where( + user_chat_channel_memberships: { + following: true, + chat_channel_id: @message.chat_channel.id, + }, + ) + end + + def chat_users + User + .includes(:user_chat_channel_memberships, :group_users) + .distinct + .joins("LEFT OUTER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id") + .joins(:user_option) + .real + .where(user_options: { chat_enabled: true }) + .where.not(username_lower: @message.user.username.downcase) + end + + def parse_mentions(message) + Nokogiri::HTML5.fragment(message.cooked).css(".mention").map(&:text) + end + + def parse_group_mentions(message) + Nokogiri::HTML5.fragment(message.cooked).css(".mention-group").map(&:text) + end + + def normalize(mentions) + mentions.reduce([]) do |memo, mention| + %w[@here @all].include?(mention.downcase) ? memo : (memo << mention[1..-1].downcase) + end + end + end +end diff --git a/plugins/chat/lib/chat/message_mover.rb b/plugins/chat/lib/chat/message_mover.rb new file mode 100644 index 00000000000..3b8184e7596 --- /dev/null +++ b/plugins/chat/lib/chat/message_mover.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +## +# Used to move chat messages from a chat channel to some other +# location. +# +# Channel -> Channel: +# ------------------- +# +# Messages are sometimes misplaced and must be moved to another channel. For +# now we only support moving messages between public channels, handling the +# permissions and membership around moving things in and out of DMs is a little +# much for V1. +# +# The original messages will be deleted, and then similar to PostMover in core, +# all of the references associated to a chat message (e.g. reactions, bookmarks, +# notifications, revisions, mentions, uploads) will be updated to the new +# message IDs via a moved_chat_messages temporary table. +# +# Reply chains are a little complex. No reply chains are preserved when moving +# messages into a new channel. Remaining messages that referenced moved ones +# have their in_reply_to_id cleared so the data makes sense. +# +# Threads are even more complex. No threads are preserved when moving messages +# into a new channel, they end up as just a flat series of messages that are +# not in a chain. If the original message of a thread and N other messages +# in that thread, then any messages left behind just get placed into a new +# thread. Message moving will be disabled in the thread UI while +# enable_experimental_chat_threaded_discussions is present, its too complicated +# to have end users reason about for now, and we may want a standalone +# "Move Thread" UI later on. +module Chat + class MessageMover + class NoMessagesFound < StandardError + end + class InvalidChannel < StandardError + end + + def initialize(acting_user:, source_channel:, message_ids:) + @source_channel = source_channel + @acting_user = acting_user + @source_message_ids = message_ids + @source_messages = find_messages(@source_message_ids, source_channel) + @ordered_source_message_ids = @source_messages.map(&:id) + end + + def move_to_channel(destination_channel) + if !@source_channel.public_channel? || !destination_channel.public_channel? + raise InvalidChannel.new(I18n.t("chat.errors.message_move_invalid_channel")) + end + + if @ordered_source_message_ids.empty? + raise NoMessagesFound.new(I18n.t("chat.errors.message_move_no_messages_found")) + end + + moved_messages = nil + + Chat::Message.transaction do + create_temp_table + moved_messages = + find_messages( + create_destination_messages_in_channel(destination_channel), + destination_channel, + ) + bulk_insert_movement_metadata + update_references + delete_source_messages + update_reply_references + update_thread_references + end + + add_moved_placeholder(destination_channel, moved_messages.first) + moved_messages + end + + private + + def find_messages(message_ids, channel) + Chat::Message + .includes(thread: %i[original_message original_message_user]) + .where(id: message_ids, chat_channel_id: channel.id) + .order("created_at ASC, id ASC") + end + + def create_temp_table + DB.exec("DROP TABLE IF EXISTS moved_chat_messages") if Rails.env.test? + + DB.exec <<~SQL + CREATE TEMPORARY TABLE moved_chat_messages ( + old_chat_message_id INTEGER, + new_chat_message_id INTEGER + ) ON COMMIT DROP; + + CREATE INDEX moved_chat_messages_old_chat_message_id ON moved_chat_messages(old_chat_message_id); + SQL + end + + def bulk_insert_movement_metadata + values_sql = @movement_metadata.map { |mm| "(#{mm[:old_id]}, #{mm[:new_id]})" }.join(",\n") + DB.exec( + "INSERT INTO moved_chat_messages(old_chat_message_id, new_chat_message_id) VALUES #{values_sql}", + ) + end + + ## + # We purposefully omit in_reply_to_id when creating the messages in the + # new channel, because it could be pointing to a message that has not + # been moved. + def create_destination_messages_in_channel(destination_channel) + query_args = { + message_ids: @ordered_source_message_ids, + destination_channel_id: destination_channel.id, + } + moved_message_ids = DB.query_single(<<~SQL, query_args) + INSERT INTO chat_messages( + chat_channel_id, user_id, last_editor_id, message, cooked, cooked_version, created_at, updated_at + ) + SELECT :destination_channel_id, + user_id, + last_editor_id, + message, + cooked, + cooked_version, + CLOCK_TIMESTAMP(), + CLOCK_TIMESTAMP() + FROM chat_messages + WHERE id IN (:message_ids) + RETURNING id + SQL + + @movement_metadata = + moved_message_ids.map.with_index do |chat_message_id, idx| + { old_id: @ordered_source_message_ids[idx], new_id: chat_message_id } + end + moved_message_ids + end + + def update_references + DB.exec(<<~SQL) + UPDATE chat_message_reactions cmr + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE cmr.chat_message_id = mm.old_chat_message_id + SQL + + DB.exec(<<~SQL, target_type: Chat::Message.sti_name) + UPDATE upload_references uref + SET target_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE uref.target_id = mm.old_chat_message_id AND uref.target_type = :target_type + SQL + + DB.exec(<<~SQL) + UPDATE chat_mentions cment + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE cment.chat_message_id = mm.old_chat_message_id + SQL + + DB.exec(<<~SQL) + UPDATE chat_message_revisions crev + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE crev.chat_message_id = mm.old_chat_message_id + SQL + + DB.exec(<<~SQL) + UPDATE chat_webhook_events cweb + SET chat_message_id = mm.new_chat_message_id + FROM moved_chat_messages mm + WHERE cweb.chat_message_id = mm.old_chat_message_id + SQL + end + + def delete_source_messages + # We do this so @source_messages is not nulled out, which is the + # case when using update_all here. + DB.exec(<<~SQL, source_message_ids: @source_message_ids, deleted_by_id: @acting_user.id) + UPDATE chat_messages + SET deleted_at = NOW(), deleted_by_id = :deleted_by_id + WHERE id IN (:source_message_ids) + SQL + Chat::Publisher.publish_bulk_delete!(@source_channel, @source_message_ids) + end + + def add_moved_placeholder(destination_channel, first_moved_message) + Chat::MessageCreator.create( + chat_channel: @source_channel, + user: Discourse.system_user, + content: + I18n.t( + "chat.channel.messages_moved", + count: @source_message_ids.length, + acting_username: @acting_user.username, + channel_name: destination_channel.title(@acting_user), + first_moved_message_url: first_moved_message.url, + ), + ) + end + + def update_reply_references + DB.exec(<<~SQL, deleted_reply_to_ids: @source_message_ids) + UPDATE chat_messages + SET in_reply_to_id = NULL + WHERE in_reply_to_id IN (:deleted_reply_to_ids) + SQL + end + + def update_thread_references + threads_to_update = [] + @source_messages + .select { |message| message.thread_id.present? } + .each do |message_with_thread| + # If one of the messages we are moving is the original message in a thread, + # then all the remaining messages for that thread must be moved to a new one, + # otherwise they will be pointing to a thread in a different channel. + if message_with_thread.thread.original_message_id == message_with_thread.id + threads_to_update << message_with_thread.thread + end + end + + threads_to_update.each do |thread| + # NOTE: We may want to do something different with the old empty thread at some + # point when we add an explicit thread move UI, for now we can just delete it, + # since it will not contain any important data. + if thread.chat_messages.empty? + thread.destroy! + next + end + + Chat::Thread.transaction do + original_message = thread.chat_messages.first + new_thread = + Chat::Thread.create!( + original_message: original_message, + original_message_user: original_message.user, + channel: @source_channel, + ) + thread.chat_messages.update_all(thread_id: new_thread.id) + end + end + end + end +end diff --git a/plugins/chat/lib/chat/message_processor.rb b/plugins/chat/lib/chat/message_processor.rb new file mode 100644 index 00000000000..bc9ec2acee8 --- /dev/null +++ b/plugins/chat/lib/chat/message_processor.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Chat + class MessageProcessor + include ::CookedProcessorMixin + + def initialize(chat_message) + @model = chat_message + @previous_cooked = (chat_message.cooked || "").dup + @with_secure_uploads = false + @size_cache = {} + @opts = {} + + cooked = Chat::Message.cook(chat_message.message, user_id: chat_message.last_editor_id) + @doc = Loofah.fragment(cooked) + end + + def run! + post_process_oneboxes + DiscourseEvent.trigger(:chat_message_processed, @doc, @model) + end + + def large_images + [] + end + + def broken_images + [] + end + + def downloaded_images + {} + end + end +end diff --git a/plugins/chat/lib/chat/message_rate_limiter.rb b/plugins/chat/lib/chat/message_rate_limiter.rb new file mode 100644 index 00000000000..8d1f3b83f00 --- /dev/null +++ b/plugins/chat/lib/chat/message_rate_limiter.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Chat + class MessageRateLimiter + def self.run!(user) + instance = self.new(user) + instance.run! + end + + def initialize(user) + @user = user + end + + def run! + return if @user.staff? + + allowed_message_count = + ( + if @user.trust_level == TrustLevel[0] + SiteSetting.chat_allowed_messages_for_trust_level_0 + else + SiteSetting.chat_allowed_messages_for_other_trust_levels + end + ) + return if allowed_message_count.zero? + + @rate_limiter = + RateLimiter.new(@user, "create_chat_message", allowed_message_count, 30.seconds) + silence_user if @rate_limiter.remaining.zero? + @rate_limiter.performed! + end + + def clear! + # Used only for testing. Need to clear the rate limiter between tests. + @rate_limiter.clear! if defined?(@rate_limiter) + end + + private + + def silence_user + silenced_for_minutes = SiteSetting.chat_auto_silence_duration + return if silenced_for_minutes.zero? + + UserSilencer.silence( + @user, + Discourse.system_user, + silenced_till: silenced_for_minutes.minutes.from_now, + reason: I18n.t("chat.errors.rate_limit_exceeded"), + ) + end + end +end diff --git a/plugins/chat/lib/chat/message_reactor.rb b/plugins/chat/lib/chat/message_reactor.rb new file mode 100644 index 00000000000..74dcdc2c8c5 --- /dev/null +++ b/plugins/chat/lib/chat/message_reactor.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Chat + class MessageReactor + ADD_REACTION = :add + REMOVE_REACTION = :remove + MAX_REACTIONS_LIMIT = 30 + + def initialize(user, chat_channel) + @user = user + @chat_channel = chat_channel + @guardian = Guardian.new(user) + end + + def react!(message_id:, react_action:, emoji:) + @guardian.ensure_can_join_chat_channel!(@chat_channel) + @guardian.ensure_can_react! + validate_channel_status! + validate_reaction!(react_action, emoji) + message = ensure_chat_message!(message_id) + validate_max_reactions!(message, react_action, emoji) + + reaction = nil + ActiveRecord::Base.transaction do + enforce_channel_membership! + reaction = create_reaction(message, react_action, emoji) + end + + publish_reaction(message, react_action, emoji) + + reaction + end + + private + + def ensure_chat_message!(message_id) + message = Chat::Message.find_by(id: message_id, chat_channel: @chat_channel) + raise Discourse::NotFound unless message + message + end + + def validate_reaction!(react_action, emoji) + if ![ADD_REACTION, REMOVE_REACTION].include?(react_action) || !Emoji.exists?(emoji) + raise Discourse::InvalidParameters + end + end + + def enforce_channel_membership! + Chat::ChannelMembershipManager.new(@chat_channel).follow(@user) + end + + def validate_channel_status! + return if @guardian.can_create_channel_message?(@chat_channel) + raise Discourse::InvalidAccess.new( + nil, + nil, + custom_message: + "chat.errors.channel_modify_message_disallowed.#{@chat_channel.status}", + ) + end + + def validate_max_reactions!(message, react_action, emoji) + if react_action == ADD_REACTION && + message.reactions.count("DISTINCT emoji") >= MAX_REACTIONS_LIMIT && + !message.reactions.exists?(emoji: emoji) + raise Discourse::InvalidAccess.new( + nil, + nil, + custom_message: "chat.errors.max_reactions_limit_reached", + ) + end + end + + def create_reaction(message, react_action, emoji) + if react_action == ADD_REACTION + message.reactions.find_or_create_by!(user: @user, emoji: emoji) + else + message.reactions.where(user: @user, emoji: emoji).destroy_all + end + end + + def publish_reaction(message, react_action, emoji) + Chat::Publisher.publish_reaction!(@chat_channel, message, react_action, @user, emoji) + end + end +end diff --git a/plugins/chat/lib/chat/message_updater.rb b/plugins/chat/lib/chat/message_updater.rb new file mode 100644 index 00000000000..13eea6cf18b --- /dev/null +++ b/plugins/chat/lib/chat/message_updater.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module Chat + class MessageUpdater + attr_reader :error + + def self.update(opts) + instance = new(**opts) + instance.update + instance + end + + def initialize(guardian:, chat_message:, new_content:, upload_ids: nil) + @guardian = guardian + @user = guardian.user + @chat_message = chat_message + @old_message_content = chat_message.message + @chat_channel = @chat_message.chat_channel + @new_content = new_content + @upload_ids = upload_ids + @error = nil + end + + def update + begin + validate_channel_status! + @guardian.ensure_can_edit_chat!(@chat_message) + @chat_message.message = @new_content + @chat_message.last_editor_id = @user.id + upload_info = get_upload_info + validate_message!(has_uploads: upload_info[:uploads].any?) + @chat_message.cook + @chat_message.save! + update_uploads(upload_info) + revision = save_revision! + @chat_message.reload + Chat::Publisher.publish_edit!(@chat_channel, @chat_message) + Jobs.enqueue(Jobs::Chat::ProcessMessage, { chat_message_id: @chat_message.id }) + Chat::Notifier.notify_edit(chat_message: @chat_message, timestamp: revision.created_at) + DiscourseEvent.trigger(:chat_message_edited, @chat_message, @chat_channel, @user) + rescue => error + @error = error + end + end + + def failed? + @error.present? + end + + private + + def validate_channel_status! + return if @guardian.can_modify_channel_message?(@chat_channel) + raise StandardError.new( + I18n.t("chat.errors.channel_modify_message_disallowed.#{@chat_channel.status}"), + ) + end + + def validate_message!(has_uploads:) + @chat_message.validate_message(has_uploads: has_uploads) + if @chat_message.errors.present? + raise StandardError.new(@chat_message.errors.map(&:full_message).join(", ")) + end + end + + def get_upload_info + return { uploads: [] } if @upload_ids.nil? || !SiteSetting.chat_allow_uploads + + uploads = ::Upload.where(id: @upload_ids, user_id: @user.id) + if uploads.count != @upload_ids.count + # User is passing upload_ids for uploads that they don't own. Don't change anything. + return { uploads: @chat_message.uploads, changed: false } + end + + new_upload_ids = uploads.map(&:id) + existing_upload_ids = @chat_message.upload_ids + difference = (existing_upload_ids + new_upload_ids) - (existing_upload_ids & new_upload_ids) + { uploads: uploads, changed: difference.any? } + end + + def update_uploads(upload_info) + return unless upload_info[:changed] + + DB.exec("DELETE FROM chat_uploads WHERE chat_message_id = #{@chat_message.id}") + UploadReference.where(target: @chat_message).destroy_all + @chat_message.attach_uploads(upload_info[:uploads]) + end + + def save_revision! + @chat_message.revisions.create!( + old_message: @old_message_content, + new_message: @chat_message.message, + user_id: @user.id, + ) + end + end +end diff --git a/plugins/chat/lib/chat/notifier.rb b/plugins/chat/lib/chat/notifier.rb new file mode 100644 index 00000000000..e0805b5b274 --- /dev/null +++ b/plugins/chat/lib/chat/notifier.rb @@ -0,0 +1,317 @@ +# frozen_string_literal: true + +## +# When we are attempting to notify users based on a message we have to take +# into account the following: +# +# * Individual user mentions like @alfred +# * Group mentions that include N users such as @support +# * Global @here and @all mentions +# * Users watching the channel via UserChatChannelMembership +# +# For various reasons a mention may not notify a user: +# +# * The target user of the mention is ignoring or muting the user who created the message +# * The target user either cannot chat or cannot see the chat channel, in which case +# they are defined as `unreachable` +# * The target user is not a member of the channel, in which case they are defined +# as `welcome_to_join` +# * In the case of global @here and @all mentions users with the preference +# `ignore_channel_wide_mention` set to true will not be notified +# +# For any users that fall under the `unreachable` or `welcome_to_join` umbrellas +# we send a MessageBus message to the UI and to inform the creating user. The +# creating user can invite any `welcome_to_join` users to the channel. Target +# users who are ignoring or muting the creating user _do not_ fall into this bucket. +# +# The ignore/mute filtering is also applied via the Jobs::Chat::NotifyWatching job, +# which prevents desktop / push notifications being sent. +module Chat + class Notifier + class << self + def user_has_seen_message?(membership, chat_message_id) + (membership.last_read_message_id || 0) >= chat_message_id + end + + def push_notification_tag(type, chat_channel_id) + "#{Discourse.current_hostname}-chat-#{type}-#{chat_channel_id}" + end + + def notify_edit(chat_message:, timestamp:) + Jobs.enqueue( + Jobs::Chat::SendMessageNotifications, + chat_message_id: chat_message.id, + timestamp: timestamp.iso8601(6), + reason: "edit", + ) + end + + def notify_new(chat_message:, timestamp:) + Jobs.enqueue( + Jobs::Chat::SendMessageNotifications, + chat_message_id: chat_message.id, + timestamp: timestamp.iso8601(6), + reason: "new", + ) + end + end + + def initialize(chat_message, timestamp) + @chat_message = chat_message + @timestamp = timestamp + @chat_channel = @chat_message.chat_channel + @user = @chat_message.user + @mentions = Chat::MessageMentions.new(chat_message) + end + + ### Public API + + def notify_new + if @mentions.all_mentioned_users_ids.present? + @chat_message.create_mentions(@mentions.all_mentioned_users_ids) + end + + to_notify = list_users_to_notify + mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids] + + mentioned_user_ids.each do |member_id| + Chat::Publisher.publish_new_mention(member_id, @chat_channel.id, @chat_message.id) + end + + notify_creator_of_inaccessible_mentions(to_notify) + + notify_mentioned_users(to_notify) + notify_watching_users(except: mentioned_user_ids << @user.id) + + to_notify + end + + def notify_edit + @chat_message.update_mentions(@mentions.all_mentioned_users_ids) + + existing_notifications = + Chat::Mention.includes(:user, :notification).where(chat_message: @chat_message) + already_notified_user_ids = existing_notifications.map(&:user_id) + + to_notify = list_users_to_notify + mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids] + + needs_deletion = already_notified_user_ids - mentioned_user_ids + needs_deletion.each do |user_id| + chat_mention = existing_notifications.detect { |n| n.user_id == user_id } + chat_mention.notification.destroy! + chat_mention.destroy! + end + + needs_notification_ids = mentioned_user_ids - already_notified_user_ids + return if needs_notification_ids.blank? + + notify_creator_of_inaccessible_mentions(to_notify) + + notify_mentioned_users(to_notify, already_notified_user_ids: already_notified_user_ids) + + to_notify + end + + private + + def list_users_to_notify + mentions_count = + @mentions.parsed_direct_mentions.length + @mentions.parsed_group_mentions.length + mentions_count += 1 if @mentions.has_global_mention + mentions_count += 1 if @mentions.has_here_mention + + skip_notifications = mentions_count > SiteSetting.max_mentions_per_chat_message + + {}.tap do |to_notify| + # The order of these methods is the precedence + # between different mention types. + + already_covered_ids = [] + + expand_direct_mentions(to_notify, already_covered_ids, skip_notifications) + expand_group_mentions(to_notify, already_covered_ids, skip_notifications) + expand_here_mention(to_notify, already_covered_ids, skip_notifications) + expand_global_mention(to_notify, already_covered_ids, skip_notifications) + + filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) + + to_notify[:all_mentioned_user_ids] = already_covered_ids + end + end + + def expand_global_mention(to_notify, already_covered_ids, skip) + has_all_mention = @mentions.has_global_mention + + if has_all_mention && @chat_channel.allow_channel_wide_mentions && !skip + to_notify[:global_mentions] = @mentions + .global_mentions + .not_suspended + .where(user_options: { ignore_channel_wide_mention: [false, nil] }) + .where.not(id: already_covered_ids) + .pluck(:id) + + already_covered_ids.concat(to_notify[:global_mentions]) + else + to_notify[:global_mentions] = [] + end + end + + def expand_here_mention(to_notify, already_covered_ids, skip) + has_here_mention = @mentions.has_here_mention + + if has_here_mention && @chat_channel.allow_channel_wide_mentions && !skip + to_notify[:here_mentions] = @mentions + .here_mentions + .not_suspended + .where(user_options: { ignore_channel_wide_mention: [false, nil] }) + .where.not(id: already_covered_ids) + .pluck(:id) + + already_covered_ids.concat(to_notify[:here_mentions]) + else + to_notify[:here_mentions] = [] + end + end + + def group_users_to_notify(users) + potential_participants, unreachable = + users.partition do |user| + guardian = Guardian.new(user) + guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel) + end + + participants, welcome_to_join = + potential_participants.partition do |participant| + participant.user_chat_channel_memberships.any? do |m| + predicate = m.chat_channel_id == @chat_channel.id + predicate = predicate && m.following == true if @chat_channel.public_channel? + predicate + end + end + + { + already_participating: participants || [], + welcome_to_join: welcome_to_join || [], + unreachable: unreachable || [], + } + end + + def expand_direct_mentions(to_notify, already_covered_ids, skip) + if skip + direct_mentions = [] + else + direct_mentions = @mentions.direct_mentions.not_suspended.where.not(id: already_covered_ids) + end + + grouped = group_users_to_notify(direct_mentions) + + to_notify[:direct_mentions] = grouped[:already_participating].map(&:id) + to_notify[:welcome_to_join] = grouped[:welcome_to_join] + to_notify[:unreachable] = grouped[:unreachable] + already_covered_ids.concat(to_notify[:direct_mentions]) + end + + def expand_group_mentions(to_notify, already_covered_ids, skip) + return [] if skip || @mentions.visible_groups.empty? + + reached_by_group = + @mentions + .group_mentions + .not_suspended + .where("user_count <= ?", SiteSetting.max_users_notified_per_group_mention) + .where.not(id: already_covered_ids) + + too_many_members, mentionable = + @mentions.mentionable_groups.partition do |group| + group.user_count > SiteSetting.max_users_notified_per_group_mention + end + + mentions_disabled = @mentions.visible_groups - @mentions.mentionable_groups + to_notify[:group_mentions_disabled] = mentions_disabled + to_notify[:too_many_members] = too_many_members + mentionable.each { |g| to_notify[g.name.downcase] = [] } + + grouped = group_users_to_notify(reached_by_group) + grouped[:already_participating].each do |user| + # When a user is a member of multiple mentioned groups, + # the most far to the left should take precedence. + ordered_group_names = + @mentions.parsed_group_mentions & mentionable.map { |mg| mg.name.downcase } + user_group_names = user.groups.map { |ug| ug.name.downcase } + group_name = ordered_group_names.detect { |gn| user_group_names.include?(gn) } + + to_notify[group_name] << user.id + already_covered_ids << user.id + end + + to_notify[:welcome_to_join] = to_notify[:welcome_to_join].concat(grouped[:welcome_to_join]) + to_notify[:unreachable] = to_notify[:unreachable].concat(grouped[:unreachable]) + end + + def notify_creator_of_inaccessible_mentions(to_notify) + inaccessible = + to_notify.extract!( + :unreachable, + :welcome_to_join, + :too_many_members, + :group_mentions_disabled, + ) + return if inaccessible.values.all?(&:blank?) + + Chat::Publisher.publish_inaccessible_mentions( + @user.id, + @chat_message, + inaccessible[:unreachable].to_a, + inaccessible[:welcome_to_join].to_a, + inaccessible[:too_many_members].to_a, + inaccessible[:group_mentions_disabled].to_a, + ) + end + + # Filters out users from global, here, group, and direct mentions that are + # ignoring or muting the creator of the message, so they will not receive + # a notification via the Jobs::Chat::NotifyMentioned job and are not prompted for + # invitation by the creator. + def filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) + screen_targets = already_covered_ids.concat(to_notify[:welcome_to_join].map(&:id)) + + return if screen_targets.blank? + + screener = UserCommScreener.new(acting_user: @user, target_user_ids: screen_targets) + to_notify + .except(:unreachable, :welcome_to_join) + .each do |key, user_ids| + to_notify[key] = user_ids.reject { |user_id| screener.ignoring_or_muting_actor?(user_id) } + end + + # :welcome_to_join contains users because it's serialized by MB. + to_notify[:welcome_to_join] = to_notify[:welcome_to_join].reject do |user| + screener.ignoring_or_muting_actor?(user.id) + end + + already_covered_ids.reject! do |already_covered| + screener.ignoring_or_muting_actor?(already_covered) + end + end + + def notify_mentioned_users(to_notify, already_notified_user_ids: []) + Jobs.enqueue( + Jobs::Chat::NotifyMentioned, + { + chat_message_id: @chat_message.id, + to_notify_ids_map: to_notify.as_json, + already_notified_user_ids: already_notified_user_ids, + timestamp: @timestamp, + }, + ) + end + + def notify_watching_users(except: []) + Jobs.enqueue( + Jobs::Chat::NotifyWatching, + { chat_message_id: @chat_message.id, except_user_ids: except, timestamp: @timestamp }, + ) + end + end +end diff --git a/plugins/chat/lib/chat/plugin_instance_extension.rb b/plugins/chat/lib/chat/plugin_instance_extension.rb new file mode 100644 index 00000000000..58c5ebc308f --- /dev/null +++ b/plugins/chat/lib/chat/plugin_instance_extension.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Chat + module PluginInstanceExtension + def self.prepended(base) + DiscoursePluginRegistry.define_register(:chat_markdown_features, Set) + end + + def chat + ChatPluginApiExtensions + end + + module ChatPluginApiExtensions + def self.enable_markdown_feature(name) + DiscoursePluginRegistry.chat_markdown_features << name + end + end + end +end diff --git a/plugins/chat/lib/chat/post_notification_handler.rb b/plugins/chat/lib/chat/post_notification_handler.rb new file mode 100644 index 00000000000..449d6c27a22 --- /dev/null +++ b/plugins/chat/lib/chat/post_notification_handler.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +## +# Handles :post_alerter_after_save_post events from +# core. Used for notifying users that their chat message +# has been quoted in a post. +module Chat + class PostNotificationHandler + attr_reader :post + + def initialize(post, notified_users) + @post = post + @notified_users = notified_users + end + + def handle + return false if post.post_type == Post.types[:whisper] + return false if post.topic.blank? + return false if post.topic.private_message? + + quoted_users = extract_quoted_users(post) + if @notified_users.present? + quoted_users = quoted_users.where("users.id NOT IN (?)", @notified_users) + end + + opts = { user_id: post.user.id, display_username: post.user.username } + quoted_users.each do |user| + # PostAlerter.create_notification handles many edge cases, such as + # muting, ignoring, double notifications etc. + PostAlerter.new.create_notification(user, Notification.types[:chat_quoted], post, opts) + end + end + + private + + def extract_quoted_users(post) + usernames = + post.raw.scan(/\[chat quote=\"([^;]+);.+\"\]/).uniq.map { |q| q.first.strip.downcase } + User.where.not(id: post.user_id).where(username_lower: usernames) + end + end +end diff --git a/plugins/chat/lib/chat/review_queue.rb b/plugins/chat/lib/chat/review_queue.rb new file mode 100644 index 00000000000..c5dabebe06d --- /dev/null +++ b/plugins/chat/lib/chat/review_queue.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +# Acceptable options: +# - message: Used when the flag type is notify_user or notify_moderators and we have to create +# a separate PM. +# - is_warning: Staff can send warnings when using the notify_user flag. +# - take_action: Automatically approves the created reviewable and deletes the chat message. +# - queue_for_review: Adds a special reason to the reviwable score and creates the reviewable using +# the force_review option. + +module Chat + class ReviewQueue + def flag_message(chat_message, guardian, flag_type_id, opts = {}) + result = { success: false, errors: [] } + + is_notify_type = + ReviewableScore.types.slice(:notify_user, :notify_moderators).values.include?(flag_type_id) + is_dm = chat_message.chat_channel.direct_message_channel? + + raise Discourse::InvalidParameters.new(:flag_type) if is_dm && is_notify_type + + guardian.ensure_can_flag_chat_message!(chat_message) + guardian.ensure_can_flag_message_as!(chat_message, flag_type_id, opts) + + existing_reviewable = Reviewable.includes(:reviewable_scores).find_by(target: chat_message) + + if !can_flag_again?(existing_reviewable, chat_message, guardian.user, flag_type_id) + result[:errors] << I18n.t("chat.reviewables.message_already_handled") + return result + end + + payload = { message_cooked: chat_message.cooked } + + if opts[:message].present? && !is_dm && is_notify_type + creator = companion_pm_creator(chat_message, guardian.user, flag_type_id, opts) + post = creator.create + + if creator.errors.present? + creator.errors.full_messages.each { |msg| result[:errors] << msg } + return result + end + elsif is_dm + transcript = find_or_create_transcript(chat_message, guardian.user, existing_reviewable) + payload[:transcript_topic_id] = transcript.topic_id if transcript + end + + queued_for_review = !!ActiveRecord::Type::Boolean.new.deserialize(opts[:queue_for_review]) + + reviewable = + Chat::ReviewableMessage.needs_review!( + created_by: guardian.user, + target: chat_message, + reviewable_by_moderator: true, + potential_spam: flag_type_id == ReviewableScore.types[:spam], + payload: payload, + ) + reviewable.update(target_created_by: chat_message.user) + score = + reviewable.add_score( + guardian.user, + flag_type_id, + meta_topic_id: post&.topic_id, + take_action: opts[:take_action], + reason: queued_for_review ? "chat_message_queued_by_staff" : nil, + force_review: queued_for_review, + ) + + if opts[:take_action] + reviewable.perform(guardian.user, :agree_and_delete) + Chat::Publisher.publish_delete!(chat_message.chat_channel, chat_message) + else + enforce_auto_silence_threshold(reviewable) + Chat::Publisher.publish_flag!(chat_message, guardian.user, reviewable, score) + end + + result.tap do |r| + r[:success] = true + r[:reviewable] = reviewable + end + end + + private + + def enforce_auto_silence_threshold(reviewable) + auto_silence_duration = SiteSetting.chat_auto_silence_from_flags_duration + return if auto_silence_duration.zero? + return if reviewable.score <= Chat::ReviewableMessage.score_to_silence_user + + user = reviewable.target_created_by + return unless user + return if user.silenced? + + UserSilencer.silence( + user, + Discourse.system_user, + silenced_till: auto_silence_duration.minutes.from_now, + reason: I18n.t("chat.errors.auto_silence_from_flags"), + ) + end + + def companion_pm_creator(chat_message, flagger, flag_type_id, opts) + notifying_user = flag_type_id == ReviewableScore.types[:notify_user] + + i18n_key = notifying_user ? "notify_user" : "notify_moderators" + + title = + I18n.t( + "reviewable_score_types.#{i18n_key}.chat_pm_title", + channel_name: chat_message.chat_channel.title(flagger), + locale: SiteSetting.default_locale, + ) + + body = + I18n.t( + "reviewable_score_types.#{i18n_key}.chat_pm_body", + message: opts[:message], + link: chat_message.full_url, + locale: SiteSetting.default_locale, + ) + + create_args = { + archetype: Archetype.private_message, + title: title.truncate(SiteSetting.max_topic_title_length, separator: /\s/), + raw: body, + } + + if notifying_user + create_args[:subtype] = TopicSubtype.notify_user + create_args[:target_usernames] = chat_message.user.username + + create_args[:is_warning] = opts[:is_warning] if flagger.staff? + else + create_args[:subtype] = TopicSubtype.notify_moderators + create_args[:target_group_names] = [Group[:moderators].name] + end + + PostCreator.new(flagger, create_args) + end + + def find_or_create_transcript(chat_message, flagger, existing_reviewable) + previous_message_ids = + Chat::Message + .where(chat_channel: chat_message.chat_channel) + .where("id < ?", chat_message.id) + .order("created_at DESC") + .limit(10) + .pluck(:id) + .reverse + + return if previous_message_ids.empty? + + service = + Chat::TranscriptService.new( + chat_message.chat_channel, + Discourse.system_user, + messages_or_ids: previous_message_ids, + ) + + title = + I18n.t( + "chat.reviewables.direct_messages.transcript_title", + channel_name: chat_message.chat_channel.title(flagger), + locale: SiteSetting.default_locale, + ) + + body = + I18n.t( + "chat.reviewables.direct_messages.transcript_body", + transcript: service.generate_markdown, + locale: SiteSetting.default_locale, + ) + + create_args = { + archetype: Archetype.private_message, + title: title.truncate(SiteSetting.max_topic_title_length, separator: /\s/), + raw: body, + subtype: TopicSubtype.notify_moderators, + target_group_names: [Group[:moderators].name], + } + + PostCreator.new(Discourse.system_user, create_args).create + end + + def can_flag_again?(reviewable, message, flagger, flag_type_id) + return true if reviewable.blank? + + flagger_has_pending_flags = + reviewable.reviewable_scores.any? { |rs| rs.user == flagger && rs.pending? } + + if !flagger_has_pending_flags && flag_type_id == ReviewableScore.types[:notify_moderators] + return true + end + + flag_used = + reviewable.reviewable_scores.any? do |rs| + rs.reviewable_score_type == flag_type_id && rs.pending? + end + handled_recently = + !( + reviewable.pending? || + reviewable.updated_at < SiteSetting.cooldown_hours_until_reflag.to_i.hours.ago + ) + + latest_revision = message.revisions.last + edited_since_last_review = + latest_revision && latest_revision.updated_at > reviewable.updated_at + + !flag_used && !flagger_has_pending_flags && (!handled_recently || edited_since_last_review) + end + end +end diff --git a/plugins/chat/lib/chat/reviewable_extension.rb b/plugins/chat/lib/chat/reviewable_extension.rb new file mode 100644 index 00000000000..e2218d4fe19 --- /dev/null +++ b/plugins/chat/lib/chat/reviewable_extension.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Chat + module ReviewableExtension + extend ActiveSupport::Concern + + prepended do + # the model used when loading type column + def self.sti_class_for(name) + return Chat::ReviewableMessage if name == "ReviewableChatMessage" + super(name) + end + + # the model used when loading target_type column + def self.polymorphic_class_for(name) + return Chat::Message if name == Chat::Message.sti_name + super(name) + end + + # the type column value when saving a Chat::ReviewableMessage + def self.sti_name + return "ReviewableChatMessage" if self.to_s == "Chat::ReviewableMessage" + super + end + end + end +end diff --git a/plugins/chat/lib/chat/secure_uploads_compatibility.rb b/plugins/chat/lib/chat/secure_uploads_compatibility.rb new file mode 100644 index 00000000000..1a63f0ff743 --- /dev/null +++ b/plugins/chat/lib/chat/secure_uploads_compatibility.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Chat + class SecureUploadsCompatibility + ## + # At this point in time, secure uploads is not compatible with chat, + # so if it is enabled then chat uploads must be disabled to avoid undesirable + # behaviour. + # + # The env var DISCOURSE_ALLOW_UNSECURE_CHAT_UPLOADS can be set to keep + # it enabled, but this is strongly advised against. + def self.update_settings + if SiteSetting.secure_uploads && SiteSetting.chat_allow_uploads && + !GlobalSetting.allow_unsecure_chat_uploads + SiteSetting.chat_allow_uploads = false + StaffActionLogger.new(Discourse.system_user).log_site_setting_change( + "chat_allow_uploads", + true, + false, + context: "Disabled because secure_uploads is enabled", + ) + end + end + end +end diff --git a/plugins/chat/lib/chat/seeder.rb b/plugins/chat/lib/chat/seeder.rb new file mode 100644 index 00000000000..b6853be92bb --- /dev/null +++ b/plugins/chat/lib/chat/seeder.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Chat + class Seeder + def execute(args = {}) + return if !SiteSetting.needs_chat_seeded + + begin + create_category_channel_from(SiteSetting.staff_category_id) + create_category_channel_from(SiteSetting.general_category_id) + rescue => error + Rails.logger.warn("Error seeding chat category - #{error.inspect}") + ensure + SiteSetting.needs_chat_seeded = false + end + end + + def create_category_channel_from(category_id) + category = Category.find_by(id: category_id) + return if category.nil? + + chat_channel = category.create_chat_channel!(auto_join_users: true, name: category.name) + category.custom_fields[Chat::HAS_CHAT_ENABLED] = true + category.save! + + Chat::ChannelMembershipManager.new(chat_channel).enforce_automatic_channel_memberships + chat_channel + end + end +end diff --git a/plugins/chat/lib/chat/slack_compatibility.rb b/plugins/chat/lib/chat/slack_compatibility.rb new file mode 100644 index 00000000000..1c8b628668c --- /dev/null +++ b/plugins/chat/lib/chat/slack_compatibility.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +## +# Processes slack-formatted text messages, as Mattermost does with +# Slack incoming webhook interoperability, for example links in the +# format and , and mentions. +# +# See https://api.slack.com/reference/surfaces/formatting for all of +# the different formatting slack supports with mrkdwn which is mostly +# identical to Markdown. +# +# Mattermost docs for translating the slack format: +# +# https://docs.mattermost.com/developer/webhooks-incoming.html?highlight=translate%20slack%20data%20format%20mattermost#translate-slack-s-data-format-to-mattermost +# +# We may want to process attachments and blocks from slack in future, and +# convert user IDs into user mentions. +module Chat + class SlackCompatibility + MRKDWN_LINK_REGEX = Regexp.new(/(<[^\n<\|>]+>|<[^\n<\>]+>)/).freeze + + class << self + def process_text(text) + text = text.gsub("", "@here") + text = text.gsub("", "@all") + + text.scan(MRKDWN_LINK_REGEX) do |match| + match = match.first + + if match.include?("|") + link, title = match.split("|")[0..1] + else + link = match + end + + title = title&.gsub(/<|>/, "") + link = link&.gsub(/<|>/, "") + + if title + text = text.gsub(match, "[#{title}](#{link})") + else + text = text.gsub(match, "#{link}") + end + end + + text + end + + # TODO: This is quite hacky and is only here to support a single + # attachment for our OpsGenie integration. In future we would + # want to iterate through this attachments array and extract + # things properly. + # + # See https://api.slack.com/reference/messaging/attachments for + # more details on what fields are here. + def process_legacy_attachments(attachments) + text = CGI.unescape(attachments[0][:fallback]) + process_text(text) + end + end + end +end diff --git a/plugins/chat/lib/chat/statistics.rb b/plugins/chat/lib/chat/statistics.rb new file mode 100644 index 00000000000..cc9a6b3f313 --- /dev/null +++ b/plugins/chat/lib/chat/statistics.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Chat + class Statistics + def self.about_messages + { + :last_day => Chat::Message.where("created_at > ?", 1.days.ago).count, + "7_days" => Chat::Message.where("created_at > ?", 7.days.ago).count, + "30_days" => Chat::Message.where("created_at > ?", 30.days.ago).count, + :previous_30_days => + Chat::Message.where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago).count, + :count => Chat::Message.count, + } + end + + def self.about_channels + { + :last_day => Chat::Channel.where(status: :open).where("created_at > ?", 1.days.ago).count, + "7_days" => Chat::Channel.where(status: :open).where("created_at > ?", 7.days.ago).count, + "30_days" => Chat::Channel.where(status: :open).where("created_at > ?", 30.days.ago).count, + :previous_30_days => + Chat::Channel + .where(status: :open) + .where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago) + .count, + :count => Chat::Channel.where(status: :open).count, + } + end + + def self.about_users + { + :last_day => Chat::Message.where("created_at > ?", 1.days.ago).distinct.count(:user_id), + "7_days" => Chat::Message.where("created_at > ?", 7.days.ago).distinct.count(:user_id), + "30_days" => Chat::Message.where("created_at > ?", 30.days.ago).distinct.count(:user_id), + :previous_30_days => + Chat::Message + .where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago) + .distinct + .count(:user_id), + :count => Chat::Message.distinct.count(:user_id), + } + end + + def self.monthly + start_of_month = Time.zone.now.beginning_of_month + { + messages: Chat::Message.where("created_at > ?", start_of_month).count, + channels: Chat::Channel.where(status: :open).where("created_at > ?", start_of_month).count, + users: Chat::Message.where("created_at > ?", start_of_month).distinct.count(:user_id), + } + end + end +end diff --git a/plugins/chat/lib/steps_inspector.rb b/plugins/chat/lib/chat/steps_inspector.rb similarity index 97% rename from plugins/chat/lib/steps_inspector.rb rename to plugins/chat/lib/chat/steps_inspector.rb index 001e7a9f773..965a0f7612c 100644 --- a/plugins/chat/lib/steps_inspector.rb +++ b/plugins/chat/lib/chat/steps_inspector.rb @@ -38,7 +38,7 @@ module Chat end def inspect - "#{" " * nesting_level}[#{type}] '#{name}' #{emoji}" + "#{" " * nesting_level}[#{type}] '#{name}' #{emoji}".rstrip end private diff --git a/plugins/chat/lib/chat/transcript_service.rb b/plugins/chat/lib/chat/transcript_service.rb new file mode 100644 index 00000000000..82f27dca428 --- /dev/null +++ b/plugins/chat/lib/chat/transcript_service.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +## +# Used to generate BBCode [chat] tags for the message IDs provided. +# +# If there is > 1 message then the channel name will be shown at +# the top of the first message, and subsequent messages will have +# the chained attribute, which will affect how they are displayed +# in the UI. +# +# Subsequent messages from the same user will be put into the same +# tag. Each new user in the chain of messages will have a new [chat] +# tag created. +# +# A single message will have the channel name displayed to the right +# of the username and datetime of the message. +module Chat + class TranscriptService + CHAINED_ATTR = "chained=\"true\"" + MULTIQUOTE_ATTR = "multiQuote=\"true\"" + NO_LINK_ATTR = "noLink=\"true\"" + + class TranscriptBBCode + attr_reader :channel, :multiquote, :chained, :no_link, :include_reactions + + def initialize( + channel: nil, + acting_user: nil, + multiquote: false, + chained: false, + no_link: false, + include_reactions: false + ) + @channel = channel + @acting_user = acting_user + @multiquote = multiquote + @chained = chained + @no_link = no_link + @include_reactions = include_reactions + @message_data = [] + end + + def add(message:, reactions: nil) + @message_data << { message: message, reactions: reactions } + end + + def render + attrs = [quote_attr(@message_data.first[:message])] + + if channel + attrs << channel_attr + attrs << channel_id_attr + end + + attrs << MULTIQUOTE_ATTR if multiquote + attrs << CHAINED_ATTR if chained + attrs << NO_LINK_ATTR if no_link + attrs << reactions_attr if include_reactions + + <<~MARKDOWN + [chat #{attrs.compact.join(" ")}] + #{@message_data.map { |msg| msg[:message].to_markdown }.join("\n\n")} + [/chat] + MARKDOWN + end + + private + + def reactions_attr + reaction_data = + @message_data.reduce([]) do |array, msg_data| + if msg_data[:reactions].any? + array << msg_data[:reactions].map { |react| "#{react.emoji}:#{react.usernames}" } + end + array + end + return if reaction_data.empty? + "reactions=\"#{reaction_data.join(";")}\"" + end + + def quote_attr(message) + "quote=\"#{message.user.username};#{message.id};#{message.created_at.iso8601}\"" + end + + def channel_attr + "channel=\"#{channel.title(@acting_user)}\"" + end + + def channel_id_attr + "channelId=\"#{channel.id}\"" + end + end + + def initialize(channel, acting_user, messages_or_ids: [], opts: {}) + @channel = channel + @acting_user = acting_user + + if messages_or_ids.all? { |m| m.is_a?(Numeric) } + @message_ids = messages_or_ids + else + @messages = messages_or_ids + end + @opts = opts + end + + def generate_markdown + previous_message = nil + rendered_markdown = [] + all_messages_same_user = messages.count(:user_id) == 1 + open_bbcode_tag = + TranscriptBBCode.new( + channel: @channel, + acting_user: @acting_user, + multiquote: messages.length > 1, + chained: !all_messages_same_user, + no_link: @opts[:no_link], + include_reactions: @opts[:include_reactions], + ) + + messages.each.with_index do |message, idx| + if previous_message.present? && previous_message.user_id != message.user_id + rendered_markdown << open_bbcode_tag.render + + open_bbcode_tag = + TranscriptBBCode.new( + acting_user: @acting_user, + chained: !all_messages_same_user, + no_link: @opts[:no_link], + include_reactions: @opts[:include_reactions], + ) + end + + if @opts[:include_reactions] + open_bbcode_tag.add(message: message, reactions: reactions_for_message(message)) + else + open_bbcode_tag.add(message: message) + end + previous_message = message + end + + # tie off the last open bbcode + render + rendered_markdown << open_bbcode_tag.render + rendered_markdown.join("\n") + end + + private + + def messages + @messages ||= + Chat::Message + .includes(:user, upload_references: :upload) + .where(id: @message_ids, chat_channel_id: @channel.id) + .order(:created_at) + end + + ## + # Queries reactions and returns them in this format + # + # emoji | usernames | chat_message_id + # ---------------------------------------- + # +1 | foo,bar,baz | 102 + # heart | foo | 102 + # sob | bar,baz | 103 + def reactions + @reactions ||= DB.query(<<~SQL, @messages.map(&:id)) + SELECT emoji, STRING_AGG(DISTINCT users.username, ',') AS usernames, chat_message_id + FROM chat_message_reactions + INNER JOIN users on users.id = chat_message_reactions.user_id + WHERE chat_message_id IN (?) + GROUP BY emoji, chat_message_id + ORDER BY chat_message_id, emoji + SQL + end + + def reactions_for_message(message) + reactions.select { |react| react.chat_message_id == message.id } + end + end +end diff --git a/plugins/chat/lib/chat/user_email_extension.rb b/plugins/chat/lib/chat/user_email_extension.rb new file mode 100644 index 00000000000..366fc41bb32 --- /dev/null +++ b/plugins/chat/lib/chat/user_email_extension.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Chat + module UserEmailExtension + def execute(args) + super(args) + + if args[:type] == "chat_summary" && args[:memberships_to_update_data].present? + args[:memberships_to_update_data].to_a.each do |membership_id, max_unread_mention_id| + Chat::UserChatChannelMembership.find_by( + user: args[:user_id], + id: membership_id.to_i, + )&.update(last_unread_mention_when_emailed_id: max_unread_mention_id.to_i) + end + end + end + end +end diff --git a/plugins/chat/lib/chat/user_extension.rb b/plugins/chat/lib/chat/user_extension.rb new file mode 100644 index 00000000000..f4861d24a37 --- /dev/null +++ b/plugins/chat/lib/chat/user_extension.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Chat + module UserExtension + extend ActiveSupport::Concern + + prepended do + has_many :user_chat_channel_memberships, + class_name: "Chat::UserChatChannelMembership", + dependent: :destroy + has_many :chat_message_reactions, class_name: "Chat::MessageReaction", dependent: :destroy + has_many :chat_mentions, class_name: "Chat::Mention" + end + end +end diff --git a/plugins/chat/lib/chat/user_notifications_extension.rb b/plugins/chat/lib/chat/user_notifications_extension.rb new file mode 100644 index 00000000000..c0c87a06df2 --- /dev/null +++ b/plugins/chat/lib/chat/user_notifications_extension.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +module Chat + module UserNotificationsExtension + def chat_summary(user, opts) + guardian = Guardian.new(user) + return unless guardian.can_chat? + + @messages = + Chat::Message + .joins(:user, :chat_channel) + .where.not(user: user) + .where("chat_messages.created_at > ?", 1.week.ago) + .joins( + "LEFT OUTER JOIN chat_mentions cm ON cm.chat_message_id = chat_messages.id AND cm.notification_id IS NOT NULL", + ) + .joins( + "INNER JOIN user_chat_channel_memberships uccm ON uccm.chat_channel_id = chat_channels.id", + ) + .where(<<~SQL, user_id: user.id) + uccm.user_id = :user_id AND + (uccm.last_read_message_id IS NULL OR chat_messages.id > uccm.last_read_message_id) AND + (uccm.last_unread_mention_when_emailed_id IS NULL OR chat_messages.id > uccm.last_unread_mention_when_emailed_id) AND + ( + (cm.user_id = :user_id AND uccm.following IS true AND chat_channels.chatable_type = 'Category') OR + (chat_channels.chatable_type = 'DirectMessage') + ) + SQL + .to_a + + return if @messages.empty? + @grouped_messages = @messages.group_by { |message| message.chat_channel } + @grouped_messages = + @grouped_messages.select { |channel, _| guardian.can_join_chat_channel?(channel) } + return if @grouped_messages.empty? + + @grouped_messages.each do |chat_channel, messages| + @grouped_messages[chat_channel] = messages.sort_by(&:created_at) + end + @user = user + @user_tz = UserOption.user_tzinfo(user.id) + @display_usernames = SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names + + build_summary_for(user) + @preferences_path = "#{Discourse.base_url}/my/preferences/chat" + + # TODO(roman): Remove after the 2.9 release + add_unsubscribe_link = UnsubscribeKey.respond_to?(:get_unsubscribe_strategy_for) + + if add_unsubscribe_link + unsubscribe_key = UnsubscribeKey.create_key_for(@user, "chat_summary") + @unsubscribe_link = "#{Discourse.base_url}/email/unsubscribe/#{unsubscribe_key}" + opts[:unsubscribe_url] = @unsubscribe_link + end + + opts = { + from_alias: I18n.t("user_notifications.chat_summary.from", site_name: Email.site_title), + subject: summary_subject(user, @grouped_messages), + add_unsubscribe_link: add_unsubscribe_link, + } + + build_email(user.email, opts) + end + + def summary_subject(user, grouped_messages) + all_channels = grouped_messages.keys + grouped_channels = all_channels.partition { |c| !c.direct_message_channel? } + channels = grouped_channels.first + + dm_messages = grouped_channels.last.flat_map { |c| grouped_messages[c] } + dm_users = dm_messages.sort_by(&:created_at).uniq { |m| m.user_id }.map(&:user) + + # Prioritize messages from regular channels over direct messages + if channels.any? + channel_notification_text( + channels.sort_by { |channel| [channel.last_message_sent_at, channel.created_at] }, + dm_users, + ) + else + direct_message_notification_text(dm_users) + end + end + + private + + def channel_notification_text(channels, dm_users) + total_count = channels.size + dm_users.size + + if total_count > 2 + I18n.t( + "user_notifications.chat_summary.subject.chat_channel_more", + email_prefix: @email_prefix, + channel: channels.first.title, + count: total_count - 1, + ) + elsif channels.size == 1 && dm_users.size == 0 + I18n.t( + "user_notifications.chat_summary.subject.chat_channel_1", + email_prefix: @email_prefix, + channel: channels.first.title, + ) + elsif channels.size == 1 && dm_users.size == 1 + I18n.t( + "user_notifications.chat_summary.subject.chat_channel_and_direct_message", + email_prefix: @email_prefix, + channel: channels.first.title, + username: dm_users.first.username, + ) + elsif channels.size == 2 + I18n.t( + "user_notifications.chat_summary.subject.chat_channel_2", + email_prefix: @email_prefix, + channel1: channels.first.title, + channel2: channels.second.title, + ) + end + end + + def direct_message_notification_text(dm_users) + case dm_users.size + when 1 + I18n.t( + "user_notifications.chat_summary.subject.direct_message_from_1", + email_prefix: @email_prefix, + username: dm_users.first.username, + ) + when 2 + I18n.t( + "user_notifications.chat_summary.subject.direct_message_from_2", + email_prefix: @email_prefix, + username1: dm_users.first.username, + username2: dm_users.second.username, + ) + else + I18n.t( + "user_notifications.chat_summary.subject.direct_message_from_more", + email_prefix: @email_prefix, + username: dm_users.first.username, + count: dm_users.size - 1, + ) + end + end + end +end diff --git a/plugins/chat/lib/chat/user_option_extension.rb b/plugins/chat/lib/chat/user_option_extension.rb new file mode 100644 index 00000000000..71e860ebb25 --- /dev/null +++ b/plugins/chat/lib/chat/user_option_extension.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Chat + module UserOptionExtension + # TODO: remove last_emailed_for_chat and chat_isolated in 2023 + def self.prepended(base) + if base.ignored_columns + base.ignored_columns = base.ignored_columns + %i[last_emailed_for_chat chat_isolated] + else + base.ignored_columns = %i[last_emailed_for_chat chat_isolated] + end + + def base.chat_email_frequencies + @chat_email_frequencies ||= { never: 0, when_away: 1 } + end + + def base.chat_header_indicator_preferences + @chat_header_indicator_preferences ||= { all_new: 0, dm_and_mentions: 1, never: 2 } + end + + base.enum :chat_email_frequency, base.chat_email_frequencies, prefix: "send_chat_email" + base.enum :chat_header_indicator_preference, base.chat_header_indicator_preferences + end + end +end diff --git a/plugins/chat/lib/chat_channel_archive_service.rb b/plugins/chat/lib/chat_channel_archive_service.rb deleted file mode 100644 index cf656ef4cac..00000000000 --- a/plugins/chat/lib/chat_channel_archive_service.rb +++ /dev/null @@ -1,311 +0,0 @@ -# frozen_string_literal: true - -## -# From time to time, site admins may choose to sunset a chat channel and archive -# the messages within. It cannot be used for DM channels in its current iteration. -# -# To archive a channel, we mark it read_only first to prevent any further message -# additions or changes, and create a record to track whether the archive topic -# will be new or existing. When we archive the channel, messages are copied into -# posts in batches using the [chat] BBCode to quote the messages. The messages are -# deleted once the batch has its post made. The execute action of this class is -# idempotent, so if we fail halfway through the archive process it can be run again. -# -# Once all of the messages have been copied then we mark the channel as archived. -class Chat::ChatChannelArchiveService - ARCHIVED_MESSAGES_PER_POST = 100 - - class ArchiveValidationError < StandardError - attr_reader :errors - - def initialize(errors: []) - super - @errors = errors - end - end - - def self.create_archive_process(chat_channel:, acting_user:, topic_params:) - return if ChatChannelArchive.exists?(chat_channel: chat_channel) - - # Only need to validate topic params for a new topic, not an existing one. - if topic_params[:topic_id].blank? - valid, errors = - Chat::ChatChannelArchiveService.validate_topic_params( - Guardian.new(acting_user), - topic_params, - ) - - raise ArchiveValidationError.new(errors: errors) if !valid - end - - ChatChannelArchive.transaction do - chat_channel.read_only!(acting_user) - - archive = - ChatChannelArchive.create!( - chat_channel: chat_channel, - archived_by: acting_user, - total_messages: chat_channel.chat_messages.count, - destination_topic_id: topic_params[:topic_id], - destination_topic_title: topic_params[:topic_title], - destination_category_id: topic_params[:category_id], - destination_tags: topic_params[:tags], - ) - Jobs.enqueue(:chat_channel_archive, chat_channel_archive_id: archive.id) - - archive - end - end - - def self.retry_archive_process(chat_channel:) - return if !chat_channel.chat_channel_archive&.failed? - Jobs.enqueue( - :chat_channel_archive, - chat_channel_archive_id: chat_channel.chat_channel_archive.id, - ) - chat_channel.chat_channel_archive - end - - def self.validate_topic_params(guardian, topic_params) - topic_creator = - TopicCreator.new( - Discourse.system_user, - guardian, - { - title: topic_params[:topic_title], - category: topic_params[:category_id], - tags: topic_params[:tags], - import_mode: true, - }, - ) - [topic_creator.valid?, topic_creator.errors.full_messages] - end - - attr_reader :chat_channel_archive, :chat_channel, :chat_channel_title - - def initialize(chat_channel_archive) - @chat_channel_archive = chat_channel_archive - @chat_channel = chat_channel_archive.chat_channel - @chat_channel_title = chat_channel.title(chat_channel_archive.archived_by) - end - - def execute - chat_channel_archive.update(archive_error: nil) - - begin - return if !ensure_destination_topic_exists! - - Rails.logger.info( - "Creating posts from message batches for #{chat_channel_title} archive, #{chat_channel_archive.total_messages} messages to archive (#{chat_channel_archive.total_messages / ARCHIVED_MESSAGES_PER_POST} posts).", - ) - - # A batch should be idempotent, either the post is created and the - # messages are deleted or we roll back the whole thing. - # - # At some point we may want to reconsider disabling post validations, - # and add in things like dynamic resizing of the number of messages per - # post based on post length, but that can be done later. - # - # Another future improvement is to send a MessageBus message for each - # completed batch, so the UI can receive updates and show a progress - # bar or something similar. - chat_channel - .chat_messages - .find_in_batches(batch_size: ARCHIVED_MESSAGES_PER_POST) do |chat_messages| - create_post( - ChatTranscriptService.new( - chat_channel, - chat_channel_archive.archived_by, - messages_or_ids: chat_messages, - opts: { - no_link: true, - include_reactions: true, - }, - ).generate_markdown, - ) { delete_message_batch(chat_messages.map(&:id)) } - end - - kick_all_users - complete_archive - rescue => err - notify_archiver(:failed, error_message: err.message) - raise err - end - end - - private - - def create_post(raw) - pc = nil - Post.transaction do - pc = - PostCreator.new( - Discourse.system_user, - raw: raw, - # we must skip these because the posts are created in a big transaction, - # we do them all at the end instead - skip_jobs: true, - # we do not want to be sending out notifications etc. from this - # automatic background process - import_mode: true, - # don't want to be stopped by watched word or post length validations - skip_validations: true, - topic_id: chat_channel_archive.destination_topic_id, - ) - - pc.create - - # so we can also delete chat messages in the same transaction - yield if block_given? - end - pc.enqueue_jobs - end - - def ensure_destination_topic_exists! - if !chat_channel_archive.destination_topic.present? - Rails.logger.info("Creating topic for #{chat_channel_title} archive.") - Topic.transaction do - topic_creator = - TopicCreator.new( - Discourse.system_user, - Guardian.new(chat_channel_archive.archived_by), - { - title: chat_channel_archive.destination_topic_title, - category: chat_channel_archive.destination_category_id, - tags: chat_channel_archive.destination_tags, - import_mode: true, - }, - ) - - if topic_creator.valid? - chat_channel_archive.update!(destination_topic: topic_creator.create) - else - Rails.logger.info("Destination topic for #{chat_channel_title} archive was not valid.") - notify_archiver( - :failed_no_topic, - error_message: topic_creator.errors.full_messages.join("\n"), - ) - end - end - - if chat_channel_archive.destination_topic.present? - Rails.logger.info("Creating first post for #{chat_channel_title} archive.") - create_post( - I18n.t( - "chat.channel.archive.first_post_raw", - channel_name: chat_channel_title, - channel_url: chat_channel.url, - ), - ) - end - else - Rails.logger.info("Topic already exists for #{chat_channel_title} archive.") - end - - if chat_channel_archive.destination_topic.present? - update_destination_topic_status - return true - end - - false - end - - def update_destination_topic_status - # We only want to do this when the destination topic is new, not an - # existing topic, because we don't want to update the status unexpectedly - # on an existing topic - if chat_channel_archive.new_topic? - if SiteSetting.chat_archive_destination_topic_status == "archived" - chat_channel_archive.destination_topic.update!(archived: true) - elsif SiteSetting.chat_archive_destination_topic_status == "closed" - chat_channel_archive.destination_topic.update!(closed: true) - end - end - end - - def delete_message_batch(message_ids) - ChatMessage.transaction do - ChatMessage.where(id: message_ids).update_all( - deleted_at: DateTime.now, - deleted_by_id: chat_channel_archive.archived_by.id, - ) - - chat_channel_archive.update!( - archived_messages: chat_channel_archive.archived_messages + message_ids.length, - ) - end - - Rails.logger.info( - "Archived #{chat_channel_archive.archived_messages} messages for #{chat_channel_title} archive.", - ) - end - - def complete_archive - Rails.logger.info("Creating posts completed for #{chat_channel_title} archive.") - chat_channel.archived!(chat_channel_archive.archived_by) - notify_archiver(:success) - end - - def notify_archiver(result, error_message: nil) - base_translation_params = { - channel_hashtag_or_name: channel_hashtag_or_name, - topic_title: chat_channel_archive.destination_topic&.title, - topic_url: chat_channel_archive.destination_topic&.url, - topic_validation_errors: result == :failed_no_topic ? error_message : nil, - } - - if result == :failed || result == :failed_no_topic - Discourse.warn_exception( - error_message, - message: "Error when archiving chat channel #{chat_channel_title}.", - env: { - chat_channel_id: chat_channel.id, - chat_channel_name: chat_channel_title, - }, - ) - error_translation_params = - base_translation_params.merge( - channel_url: chat_channel.url, - messages_archived: chat_channel_archive.archived_messages, - ) - chat_channel_archive.update(archive_error: error_message) - message_translation_key = - case result - when :failed - :chat_channel_archive_failed - when :failed_no_topic - :chat_channel_archive_failed_no_topic - end - SystemMessage.create_from_system_user( - chat_channel_archive.archived_by, - message_translation_key, - error_translation_params, - ) - else - SystemMessage.create_from_system_user( - chat_channel_archive.archived_by, - :chat_channel_archive_complete, - base_translation_params, - ) - end - - ChatPublisher.publish_archive_status( - chat_channel, - archive_status: result != :success ? :failed : :success, - archived_messages: chat_channel_archive.archived_messages, - archive_topic_id: chat_channel_archive.destination_topic_id, - total_messages: chat_channel_archive.total_messages, - ) - end - - def kick_all_users - Chat::ChatChannelMembershipManager.new(chat_channel).unfollow_all_users - end - - def channel_hashtag_or_name - if chat_channel.slug.present? && SiteSetting.enable_experimental_hashtag_autocomplete - return "##{chat_channel.slug}::channel" - end - chat_channel_title - end -end diff --git a/plugins/chat/lib/chat_channel_fetcher.rb b/plugins/chat/lib/chat_channel_fetcher.rb deleted file mode 100644 index 028e0f3643e..00000000000 --- a/plugins/chat/lib/chat_channel_fetcher.rb +++ /dev/null @@ -1,257 +0,0 @@ -# frozen_string_literal: true - -module Chat::ChatChannelFetcher - MAX_PUBLIC_CHANNEL_RESULTS = 50 - - def self.structured(guardian) - memberships = Chat::ChatChannelMembershipManager.all_for_user(guardian.user) - { - public_channels: - secured_public_channels(guardian, memberships, status: :open, following: true), - direct_message_channels: - secured_direct_message_channels(guardian.user.id, memberships, guardian), - memberships: memberships, - } - end - - def self.all_secured_channel_ids(guardian, following: true) - allowed_channel_ids_sql = generate_allowed_channel_ids_sql(guardian) - - return DB.query_single(allowed_channel_ids_sql) if !following - - DB.query_single(<<~SQL, user_id: guardian.user.id) - SELECT chat_channel_id - FROM user_chat_channel_memberships - WHERE user_chat_channel_memberships.user_id = :user_id - AND user_chat_channel_memberships.chat_channel_id IN ( - #{allowed_channel_ids_sql} - ) - SQL - end - - def self.generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: false) - category_channel_sql = - Category - .post_create_allowed(guardian) - .joins( - "INNER JOIN chat_channels ON chat_channels.chatable_id = categories.id AND chat_channels.chatable_type = 'Category'", - ) - .select("chat_channels.id") - .to_sql - dm_channel_sql = "" - if !exclude_dm_channels - dm_channel_sql = <<~SQL - UNION - - -- secured direct message chat channels - #{ - ChatChannel - .select(:id) - .joins( - "INNER JOIN direct_message_channels ON direct_message_channels.id = chat_channels.chatable_id - AND chat_channels.chatable_type = 'DirectMessage' - INNER JOIN direct_message_users ON direct_message_users.direct_message_channel_id = direct_message_channels.id", - ) - .where("direct_message_users.user_id = :user_id", user_id: guardian.user.id) - .to_sql - } - SQL - end - - <<~SQL - -- secured category chat channels - #{category_channel_sql} - #{dm_channel_sql} - SQL - end - - def self.secured_public_channel_slug_lookup(guardian, slugs) - allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) - - ChatChannel - .joins( - "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", - ) - .where(chatable_type: ChatChannel.public_channel_chatable_types) - .where("chat_channels.id IN (#{allowed_channel_ids})") - .where("chat_channels.slug IN (:slugs)", slugs: slugs) - .limit(1) - end - - def self.secured_public_channel_search(guardian, options = {}) - allowed_channel_ids = generate_allowed_channel_ids_sql(guardian, exclude_dm_channels: true) - - channels = ChatChannel.includes(chatable: [:topic_only_relative_url]) - channels = channels.includes(:chat_channel_archive) if options[:include_archives] - - channels = - channels - .joins( - "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", - ) - .where(chatable_type: ChatChannel.public_channel_chatable_types) - .where("chat_channels.id IN (#{allowed_channel_ids})") - - channels = channels.where(status: options[:status]) if options[:status].present? - - if options[:filter].present? - category_filter = - (options[:filter_on_category_name] ? "OR categories.name ILIKE :filter" : "") - - sql = - "chat_channels.name ILIKE :filter OR chat_channels.slug ILIKE :filter #{category_filter}" - if options[:match_filter_on_starts_with] - filter_sql = "#{options[:filter].downcase}%" - else - filter_sql = "%#{options[:filter].downcase}%" - end - - channels = - channels.where(sql, filter: filter_sql).order("chat_channels.name ASC, categories.name ASC") - end - - if options.key?(:slugs) - channels = channels.where("chat_channels.slug IN (:slugs)", slugs: options[:slugs]) - end - - if options.key?(:following) - if options[:following] - channels = - channels.joins(:user_chat_channel_memberships).where( - user_chat_channel_memberships: { - user_id: guardian.user.id, - following: true, - }, - ) - else - channels = - channels.where( - "chat_channels.id NOT IN (SELECT chat_channel_id FROM user_chat_channel_memberships uccm WHERE uccm.chat_channel_id = chat_channels.id AND following IS TRUE AND user_id = ?)", - guardian.user.id, - ) - end - end - - options[:limit] = (options[:limit] || MAX_PUBLIC_CHANNEL_RESULTS).to_i.clamp( - 1, - MAX_PUBLIC_CHANNEL_RESULTS, - ) - options[:offset] = [options[:offset].to_i, 0].max - - channels.limit(options[:limit]).offset(options[:offset]) - end - - def self.secured_public_channels(guardian, memberships, options = { following: true }) - channels = - secured_public_channel_search( - guardian, - options.merge(include_archives: true, filter_on_category_name: true), - ) - - decorate_memberships_with_tracking_data(guardian, channels, memberships) - channels = channels.to_a - preload_custom_fields_for(channels) - channels - end - - def self.preload_custom_fields_for(channels) - preload_fields = Category.instance_variable_get(:@custom_field_types).keys - Category.preload_custom_fields( - channels.select { |c| c.chatable_type == "Category" }.map(&:chatable), - preload_fields, - ) - end - - def self.secured_direct_message_channels(user_id, memberships, guardian) - query = ChatChannel.includes(chatable: [{ direct_message_users: :user }, :users]) - query = query.includes(chatable: [{ users: :user_status }]) if SiteSetting.enable_user_status - - channels = - query - .joins(:user_chat_channel_memberships) - .where(user_chat_channel_memberships: { user_id: user_id, following: true }) - .where(chatable_type: "DirectMessage") - .where("chat_channels.id IN (#{generate_allowed_channel_ids_sql(guardian)})") - .order(last_message_sent_at: :desc) - .to_a - - preload_fields = - User.allowed_user_custom_fields(guardian) + - UserField.all.pluck(:id).map { |fid| "#{User::USER_FIELD_PREFIX}#{fid}" } - User.preload_custom_fields(channels.map { |c| c.chatable.users }.flatten, preload_fields) - - decorate_memberships_with_tracking_data(guardian, channels, memberships) - end - - def self.decorate_memberships_with_tracking_data(guardian, channels, memberships) - unread_counts_per_channel = unread_counts(channels, guardian.user.id) - - mention_notifications = - Notification.unread.where( - user_id: guardian.user.id, - notification_type: Notification.types[:chat_mention], - ) - mention_notification_data = mention_notifications.map { |m| JSON.parse(m.data) } - - channels.each do |channel| - membership = memberships.find { |m| m.chat_channel_id == channel.id } - - if membership - membership.unread_mentions = - mention_notification_data.count do |data| - data["chat_channel_id"] == channel.id && - data["chat_message_id"] > (membership.last_read_message_id || 0) - end - - membership.unread_count = unread_counts_per_channel[channel.id] if !membership.muted - end - end - end - - def self.unread_counts(channels, user_id) - unread_counts = DB.query_array(<<~SQL, channel_ids: channels.map(&:id), user_id: user_id).to_h - SELECT cc.id, COUNT(*) as count - FROM chat_messages cm - JOIN chat_channels cc ON cc.id = cm.chat_channel_id - JOIN user_chat_channel_memberships uccm ON uccm.chat_channel_id = cc.id - WHERE cc.id IN (:channel_ids) - AND cm.user_id != :user_id - AND uccm.user_id = :user_id - AND cm.id > COALESCE(uccm.last_read_message_id, 0) - AND cm.deleted_at IS NULL - GROUP BY cc.id - SQL - unread_counts.default = 0 - unread_counts - end - - def self.find_with_access_check(channel_id_or_name, guardian) - begin - channel_id_or_name = Integer(channel_id_or_name) - rescue ArgumentError - end - - base_channel_relation = - ChatChannel.includes(:chatable).joins( - "LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'", - ) - - if guardian.user.staff? - base_channel_relation = base_channel_relation.includes(:chat_channel_archive) - end - - if channel_id_or_name.is_a? Integer - chat_channel = base_channel_relation.find_by(id: channel_id_or_name) - else - chat_channel = - base_channel_relation.find_by( - "LOWER(categories.name) = :name OR LOWER(chat_channels.name) = :name", - name: channel_id_or_name.downcase, - ) - end - - raise Discourse::NotFound if chat_channel.blank? - raise Discourse::InvalidAccess if !guardian.can_join_chat_channel?(chat_channel) - chat_channel - end -end diff --git a/plugins/chat/lib/chat_channel_hashtag_data_source.rb b/plugins/chat/lib/chat_channel_hashtag_data_source.rb deleted file mode 100644 index 5c4e31cd867..00000000000 --- a/plugins/chat/lib/chat_channel_hashtag_data_source.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatChannelHashtagDataSource - def self.icon - "comment" - end - - def self.type - "channel" - end - - def self.channel_to_hashtag_item(guardian, channel) - HashtagAutocompleteService::HashtagItem.new.tap do |item| - item.text = channel.title - item.description = channel.description - item.slug = channel.slug - item.icon = icon - item.relative_url = channel.relative_url - item.type = "channel" - end - end - - def self.lookup(guardian, slugs) - if SiteSetting.enable_experimental_hashtag_autocomplete - return [] if !guardian.can_chat? - Chat::ChatChannelFetcher - .secured_public_channel_slug_lookup(guardian, slugs) - .map { |channel| channel_to_hashtag_item(guardian, channel) } - else - [] - end - end - - def self.search( - guardian, - term, - limit, - condition = HashtagAutocompleteService.search_conditions[:contains] - ) - if SiteSetting.enable_experimental_hashtag_autocomplete - return [] if !guardian.can_chat? - Chat::ChatChannelFetcher - .secured_public_channel_search( - guardian, - filter: term, - limit: limit, - exclude_dm_channels: true, - match_filter_on_starts_with: - condition == HashtagAutocompleteService.search_conditions[:starts_with], - ) - .map { |channel| channel_to_hashtag_item(guardian, channel) } - else - [] - end - end - - def self.search_sort(search_results, _) - search_results.sort_by { |result| result.text.downcase } - end - - def self.search_without_term(guardian, limit) - if SiteSetting.enable_experimental_hashtag_autocomplete - return [] if !guardian.can_chat? - allowed_channel_ids_sql = - Chat::ChatChannelFetcher.generate_allowed_channel_ids_sql( - guardian, - exclude_dm_channels: true, - ) - ChatChannel - .joins( - "INNER JOIN user_chat_channel_memberships - ON user_chat_channel_memberships.chat_channel_id = chat_channels.id - AND user_chat_channel_memberships.user_id = #{guardian.user.id} - AND user_chat_channel_memberships.following = true", - ) - .where("chat_channels.id IN (#{allowed_channel_ids_sql})") - .order(messages_count: :desc) - .limit(limit) - .map { |channel| channel_to_hashtag_item(guardian, channel) } - else - [] - end - end -end diff --git a/plugins/chat/lib/chat_channel_membership_manager.rb b/plugins/chat/lib/chat_channel_membership_manager.rb deleted file mode 100644 index 5947f23d7a4..00000000000 --- a/plugins/chat/lib/chat_channel_membership_manager.rb +++ /dev/null @@ -1,79 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatChannelMembershipManager - def self.all_for_user(user) - UserChatChannelMembership.where(user: user) - end - - attr_reader :channel - - def initialize(channel) - @channel = channel - end - - def find_for_user(user, following: nil) - params = { user_id: user.id, chat_channel_id: channel.id } - params[:following] = following if following.present? - - UserChatChannelMembership.includes(:user, :chat_channel).find_by(params) - end - - def follow(user) - membership = - find_for_user(user) || - UserChatChannelMembership.new(user: user, chat_channel: channel, following: true) - - ActiveRecord::Base.transaction do - if membership.new_record? - membership.save! - recalculate_user_count - elsif !membership.following - membership.update!(following: true) - recalculate_user_count - end - end - - membership - end - - def unfollow(user) - membership = find_for_user(user) - - return if membership.blank? - - ActiveRecord::Base.transaction do - if membership.following - membership.update!(following: false) - recalculate_user_count - end - end - - membership - end - - def recalculate_user_count - return if ChatChannel.exists?(id: channel.id, user_count_stale: true) - channel.update!(user_count_stale: true) - Jobs.enqueue_in(3.seconds, :update_channel_user_count, chat_channel_id: channel.id) - end - - def unfollow_all_users - UserChatChannelMembership.where(chat_channel: channel).update_all( - following: false, - last_read_message_id: channel.chat_messages.last&.id, - ) - end - - def enforce_automatic_channel_memberships - Jobs.enqueue(:auto_manage_channel_memberships, chat_channel_id: channel.id) - end - - def enforce_automatic_user_membership(user) - Jobs.enqueue( - :auto_join_channel_batch, - chat_channel_id: channel.id, - starts_at: user.id, - ends_at: user.id, - ) - end -end diff --git a/plugins/chat/lib/chat_mailer.rb b/plugins/chat/lib/chat_mailer.rb deleted file mode 100644 index 7600a25fe4c..00000000000 --- a/plugins/chat/lib/chat_mailer.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatMailer - def self.send_unread_mentions_summary - return unless SiteSetting.chat_enabled - - users_with_unprocessed_unread_mentions.find_each do |user| - # user#memberships_with_unread_messages is a nested array that looks like [[membership_id, unread_message_id]] - # Find the max unread id per membership. - membership_and_max_unread_mention_ids = - user - .memberships_with_unread_messages - .group_by { |memberships| memberships[0] } - .transform_values do |membership_and_msg_ids| - membership_and_msg_ids.max_by { |membership, msg| msg } - end - .values - - Jobs.enqueue( - :user_email, - type: "chat_summary", - user_id: user.id, - force_respect_seen_recently: true, - memberships_to_update_data: membership_and_max_unread_mention_ids, - ) - end - end - - private - - def self.users_with_unprocessed_unread_mentions - when_away_frequency = UserOption.chat_email_frequencies[:when_away] - allowed_group_ids = Chat.allowed_group_ids - - users = - User - .joins(:user_option) - .where(user_options: { chat_enabled: true, chat_email_frequency: when_away_frequency }) - .where("users.last_seen_at < ?", 15.minutes.ago) - - if !allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone]) - users = users.joins(:groups).where(groups: { id: allowed_group_ids }) - end - - users - .select("users.id", "ARRAY_AGG(ARRAY[uccm.id, c_msg.id]) AS memberships_with_unread_messages") - .joins("INNER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id") - .joins("INNER JOIN chat_channels cc ON cc.id = uccm.chat_channel_id") - .joins("INNER JOIN chat_messages c_msg ON c_msg.chat_channel_id = uccm.chat_channel_id") - .joins("LEFT OUTER JOIN chat_mentions c_mentions ON c_mentions.chat_message_id = c_msg.id") - .where("c_msg.deleted_at IS NULL AND c_msg.user_id <> users.id") - .where("c_msg.created_at > ?", 1.week.ago) - .where(<<~SQL) - (uccm.last_read_message_id IS NULL OR c_msg.id > uccm.last_read_message_id) AND - (uccm.last_unread_mention_when_emailed_id IS NULL OR c_msg.id > uccm.last_unread_mention_when_emailed_id) AND - ( - (uccm.user_id = c_mentions.user_id AND uccm.following IS true AND cc.chatable_type = 'Category') OR - (cc.chatable_type = 'DirectMessage') - ) - SQL - .group("users.id, uccm.user_id") - end -end diff --git a/plugins/chat/lib/chat_message_bookmarkable.rb b/plugins/chat/lib/chat_message_bookmarkable.rb deleted file mode 100644 index 09353508701..00000000000 --- a/plugins/chat/lib/chat_message_bookmarkable.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -class ChatMessageBookmarkable < BaseBookmarkable - def self.model - ChatMessage - end - - def self.serializer - UserChatMessageBookmarkSerializer - end - - def self.preload_associations - [:chat_channel] - end - - def self.list_query(user, guardian) - accessible_channel_ids = Chat::ChatChannelFetcher.all_secured_channel_ids(guardian) - return if accessible_channel_ids.empty? - user - .bookmarks_of_type("ChatMessage") - .joins( - "INNER JOIN chat_messages ON chat_messages.id = bookmarks.bookmarkable_id - AND chat_messages.deleted_at IS NULL - AND bookmarks.bookmarkable_type = 'ChatMessage'", - ) - .where("chat_messages.chat_channel_id IN (?)", accessible_channel_ids) - end - - def self.search_query(bookmarks, query, ts_query, &bookmarkable_search) - bookmarkable_search.call(bookmarks, "chat_messages.message ILIKE :q") - end - - def self.validate_before_create(guardian, bookmarkable) - if bookmarkable.blank? || !guardian.can_join_chat_channel?(bookmarkable.chat_channel) - raise Discourse::InvalidAccess - end - end - - def self.reminder_handler(bookmark) - send_reminder_notification( - bookmark, - data: { - title: - I18n.t( - "chat.bookmarkable.notification_title", - channel_name: bookmark.bookmarkable.chat_channel.title(bookmark.user), - ), - bookmarkable_url: bookmark.bookmarkable.url, - }, - ) - end - - def self.reminder_conditions(bookmark) - bookmark.bookmarkable.present? && bookmark.bookmarkable.chat_channel.present? - end - - def self.can_see?(guardian, bookmark) - guardian.can_join_chat_channel?(bookmark.bookmarkable.chat_channel) - end - - def self.cleanup_deleted - DB.query(<<~SQL, grace_time: 3.days.ago) - DELETE FROM bookmarks b - USING chat_messages cm - WHERE b.bookmarkable_id = cm.id AND b.bookmarkable_type = 'ChatMessage' - AND (cm.deleted_at < :grace_time) - SQL - end -end diff --git a/plugins/chat/lib/chat_message_creator.rb b/plugins/chat/lib/chat_message_creator.rb deleted file mode 100644 index 6cd55f2fedb..00000000000 --- a/plugins/chat/lib/chat_message_creator.rb +++ /dev/null @@ -1,203 +0,0 @@ -# frozen_string_literal: true -class Chat::ChatMessageCreator - attr_reader :error, :chat_message - - def self.create(opts) - instance = new(**opts) - instance.create - instance - end - - def initialize( - chat_channel:, - in_reply_to_id: nil, - thread_id: nil, - user:, - content:, - staged_id: nil, - incoming_chat_webhook: nil, - upload_ids: nil - ) - @chat_channel = chat_channel - @user = user - @guardian = Guardian.new(user) - - # NOTE: We confirm this exists and the user can access it in the ChatController, - # but in future the checks should be here - @in_reply_to_id = in_reply_to_id - @content = content - @staged_id = staged_id - @incoming_chat_webhook = incoming_chat_webhook - @upload_ids = upload_ids || [] - @thread_id = thread_id - @error = nil - - @chat_message = - ChatMessage.new( - chat_channel: @chat_channel, - user_id: @user.id, - last_editor_id: @user.id, - in_reply_to_id: @in_reply_to_id, - message: @content, - ) - end - - def create - begin - validate_channel_status! - uploads = get_uploads - validate_message!(has_uploads: uploads.any?) - validate_reply_chain! - validate_existing_thread! - @chat_message.thread_id = @existing_thread&.id - @chat_message.cook - @chat_message.save! - create_chat_webhook_event - create_thread - @chat_message.attach_uploads(uploads) - ChatDraft.where(user_id: @user.id, chat_channel_id: @chat_channel.id).destroy_all - ChatPublisher.publish_new!(@chat_channel, @chat_message, @staged_id) - Jobs.enqueue(:process_chat_message, { chat_message_id: @chat_message.id }) - Chat::ChatNotifier.notify_new( - chat_message: @chat_message, - timestamp: @chat_message.created_at, - ) - @chat_channel.touch(:last_message_sent_at) - DiscourseEvent.trigger(:chat_message_created, @chat_message, @chat_channel, @user) - rescue => error - @error = error - end - end - - def failed? - @error.present? - end - - private - - def validate_channel_status! - return if @guardian.can_create_channel_message?(@chat_channel) - - if @chat_channel.direct_message_channel? && !@guardian.can_create_direct_message? - raise StandardError.new(I18n.t("chat.errors.user_cannot_send_direct_messages")) - else - raise StandardError.new( - I18n.t("chat.errors.channel_new_message_disallowed.#{@chat_channel.status}"), - ) - end - end - - def validate_reply_chain! - return if @in_reply_to_id.blank? - - @original_message_id = DB.query_single(<<~SQL).last - WITH RECURSIVE original_message_finder( id, in_reply_to_id ) - AS ( - -- start with the message id we want to find the parents of - SELECT id, in_reply_to_id - FROM chat_messages - WHERE id = #{@in_reply_to_id} - - UNION ALL - - -- get the chain of direct parents of the message - -- following in_reply_to_id - SELECT cm.id, cm.in_reply_to_id - FROM original_message_finder rm - JOIN chat_messages cm ON rm.in_reply_to_id = cm.id - ) - SELECT id FROM original_message_finder - - -- this makes it so only the root parent ID is returned, we can - -- exclude this to return all parents in the chain - WHERE in_reply_to_id IS NULL; - SQL - - if @original_message_id.blank? - raise StandardError.new(I18n.t("chat.errors.original_message_not_found")) - end - - @original_message = ChatMessage.with_deleted.find_by(id: @original_message_id) - if @original_message&.trashed? - raise StandardError.new(I18n.t("chat.errors.original_message_not_found")) - end - end - - def validate_existing_thread! - return if @thread_id.blank? - @existing_thread = ChatThread.find(@thread_id) - - if @existing_thread.channel_id != @chat_channel.id - raise StandardError.new(I18n.t("chat.errors.thread_invalid_for_channel")) - end - - reply_to_thread_mismatch = - @chat_message.in_reply_to&.thread_id && - @chat_message.in_reply_to.thread_id != @existing_thread.id - original_message_has_no_thread = @original_message && @original_message.thread_id.blank? - original_message_thread_mismatch = - @original_message && @original_message.thread_id != @existing_thread.id - if reply_to_thread_mismatch || original_message_has_no_thread || - original_message_thread_mismatch - raise StandardError.new(I18n.t("chat.errors.thread_does_not_match_parent")) - end - end - - def validate_message!(has_uploads:) - @chat_message.validate_message(has_uploads: has_uploads) - if @chat_message.errors.present? - raise StandardError.new(@chat_message.errors.map(&:full_message).join(", ")) - end - end - - def create_chat_webhook_event - return if @incoming_chat_webhook.blank? - ChatWebhookEvent.create( - chat_message: @chat_message, - incoming_chat_webhook: @incoming_chat_webhook, - ) - end - - def get_uploads - return [] if @upload_ids.blank? || !SiteSetting.chat_allow_uploads - - Upload.where(id: @upload_ids, user_id: @user.id) - end - - def create_thread - return if @in_reply_to_id.blank? - return if @chat_message.thread_id.present? - - thread = - @original_message.thread || - ChatThread.create!( - original_message: @chat_message.in_reply_to, - original_message_user: @chat_message.in_reply_to.user, - channel: @chat_message.chat_channel, - ) - - # NOTE: We intentionally do not try to correct thread IDs within the chain - # if they are incorrect, and only set the thread ID of messages where the - # thread ID is NULL. In future we may want some sync/background job to correct - # any inconsistencies. - DB.exec(<<~SQL) - WITH RECURSIVE thread_updater AS ( - SELECT cm.id, cm.in_reply_to_id - FROM chat_messages cm - WHERE cm.in_reply_to_id IS NULL AND cm.id = #{@original_message_id} - - UNION ALL - - SELECT cm.id, cm.in_reply_to_id - FROM chat_messages cm - JOIN thread_updater ON cm.in_reply_to_id = thread_updater.id - ) - UPDATE chat_messages - SET thread_id = #{thread.id} - FROM thread_updater - WHERE thread_id IS NULL AND chat_messages.id = thread_updater.id - SQL - - @chat_message.thread_id = thread.id - end -end diff --git a/plugins/chat/lib/chat_message_mentions.rb b/plugins/chat/lib/chat_message_mentions.rb deleted file mode 100644 index fd032301f89..00000000000 --- a/plugins/chat/lib/chat_message_mentions.rb +++ /dev/null @@ -1,99 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatMessageMentions - def initialize(message) - @message = message - - mentions = parse_mentions(message) - group_mentions = parse_group_mentions(message) - - @has_global_mention = mentions.include?("@all") - @has_here_mention = mentions.include?("@here") - @parsed_direct_mentions = normalize(mentions) - @parsed_group_mentions = normalize(group_mentions) - end - - attr_accessor :has_global_mention, - :has_here_mention, - :parsed_direct_mentions, - :parsed_group_mentions - - def all_mentioned_users_ids - @all_mentioned_users_ids ||= - begin - user_ids = global_mentions.pluck(:id) - user_ids.concat(direct_mentions.pluck(:id)) - user_ids.concat(group_mentions.pluck(:id)) - user_ids.concat(here_mentions.pluck(:id)) - user_ids.uniq! - user_ids - end - end - - def global_mentions - return User.none unless @has_global_mention - channel_members.where.not(username_lower: @parsed_direct_mentions) - end - - def direct_mentions - chat_users.where(username_lower: @parsed_direct_mentions) - end - - def group_mentions - chat_users.includes(:groups).joins(:groups).where(groups: mentionable_groups) - end - - def here_mentions - return User.none unless @has_here_mention - - channel_members - .where("last_seen_at > ?", 5.minutes.ago) - .where.not(username_lower: @parsed_direct_mentions) - end - - def mentionable_groups - @mentionable_groups ||= - Group.mentionable(@message.user, include_public: false).where(id: visible_groups.map(&:id)) - end - - def visible_groups - @visible_groups ||= - Group.where("LOWER(name) IN (?)", @parsed_group_mentions).visible_groups(@message.user) - end - - private - - def channel_members - chat_users.where( - user_chat_channel_memberships: { - following: true, - chat_channel_id: @message.chat_channel.id, - }, - ) - end - - def chat_users - User - .includes(:user_chat_channel_memberships, :group_users) - .distinct - .joins("LEFT OUTER JOIN user_chat_channel_memberships uccm ON uccm.user_id = users.id") - .joins(:user_option) - .real - .where(user_options: { chat_enabled: true }) - .where.not(username_lower: @message.user.username.downcase) - end - - def parse_mentions(message) - Nokogiri::HTML5.fragment(message.cooked).css(".mention").map(&:text) - end - - def parse_group_mentions(message) - Nokogiri::HTML5.fragment(message.cooked).css(".mention-group").map(&:text) - end - - def normalize(mentions) - mentions.reduce([]) do |memo, mention| - %w[@here @all].include?(mention.downcase) ? memo : (memo << mention[1..-1].downcase) - end - end -end diff --git a/plugins/chat/lib/chat_message_processor.rb b/plugins/chat/lib/chat_message_processor.rb deleted file mode 100644 index bf2a621d920..00000000000 --- a/plugins/chat/lib/chat_message_processor.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatMessageProcessor - include ::CookedProcessorMixin - - def initialize(chat_message) - @model = chat_message - @previous_cooked = (chat_message.cooked || "").dup - @with_secure_uploads = false - @size_cache = {} - @opts = {} - - cooked = ChatMessage.cook(chat_message.message, user_id: chat_message.last_editor_id) - @doc = Loofah.fragment(cooked) - end - - def run! - post_process_oneboxes - DiscourseEvent.trigger(:chat_message_processed, @doc, @model) - end - - def large_images - [] - end - - def broken_images - [] - end - - def downloaded_images - {} - end -end diff --git a/plugins/chat/lib/chat_message_rate_limiter.rb b/plugins/chat/lib/chat_message_rate_limiter.rb deleted file mode 100644 index 2706e4e5f64..00000000000 --- a/plugins/chat/lib/chat_message_rate_limiter.rb +++ /dev/null @@ -1,49 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatMessageRateLimiter - def self.run!(user) - instance = self.new(user) - instance.run! - end - - def initialize(user) - @user = user - end - - def run! - return if @user.staff? - - allowed_message_count = - ( - if @user.trust_level == TrustLevel[0] - SiteSetting.chat_allowed_messages_for_trust_level_0 - else - SiteSetting.chat_allowed_messages_for_other_trust_levels - end - ) - return if allowed_message_count.zero? - - @rate_limiter = RateLimiter.new(@user, "create_chat_message", allowed_message_count, 30.seconds) - silence_user if @rate_limiter.remaining.zero? - @rate_limiter.performed! - end - - def clear! - # Used only for testing. Need to clear the rate limiter between tests. - @rate_limiter.clear! if defined?(@rate_limiter) - end - - private - - def silence_user - silenced_for_minutes = SiteSetting.chat_auto_silence_duration - return if silenced_for_minutes.zero? - - UserSilencer.silence( - @user, - Discourse.system_user, - silenced_till: silenced_for_minutes.minutes.from_now, - reason: I18n.t("chat.errors.rate_limit_exceeded"), - ) - end -end diff --git a/plugins/chat/lib/chat_message_reactor.rb b/plugins/chat/lib/chat_message_reactor.rb deleted file mode 100644 index 8815fa135e3..00000000000 --- a/plugins/chat/lib/chat_message_reactor.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatMessageReactor - ADD_REACTION = :add - REMOVE_REACTION = :remove - MAX_REACTIONS_LIMIT = 30 - - def initialize(user, chat_channel) - @user = user - @chat_channel = chat_channel - @guardian = Guardian.new(user) - end - - def react!(message_id:, react_action:, emoji:) - @guardian.ensure_can_join_chat_channel!(@chat_channel) - @guardian.ensure_can_react! - validate_channel_status! - validate_reaction!(react_action, emoji) - message = ensure_chat_message!(message_id) - validate_max_reactions!(message, react_action, emoji) - - reaction = nil - ActiveRecord::Base.transaction do - enforce_channel_membership! - reaction = create_reaction(message, react_action, emoji) - end - - publish_reaction(message, react_action, emoji) - - reaction - end - - private - - def ensure_chat_message!(message_id) - message = ChatMessage.find_by(id: message_id, chat_channel: @chat_channel) - raise Discourse::NotFound unless message - message - end - - def validate_reaction!(react_action, emoji) - if ![ADD_REACTION, REMOVE_REACTION].include?(react_action) || !Emoji.exists?(emoji) - raise Discourse::InvalidParameters - end - end - - def enforce_channel_membership! - Chat::ChatChannelMembershipManager.new(@chat_channel).follow(@user) - end - - def validate_channel_status! - return if @guardian.can_create_channel_message?(@chat_channel) - raise Discourse::InvalidAccess.new( - nil, - nil, - custom_message: "chat.errors.channel_modify_message_disallowed.#{@chat_channel.status}", - ) - end - - def validate_max_reactions!(message, react_action, emoji) - if react_action == ADD_REACTION && - message.reactions.count("DISTINCT emoji") >= MAX_REACTIONS_LIMIT && - !message.reactions.exists?(emoji: emoji) - raise Discourse::InvalidAccess.new( - nil, - nil, - custom_message: "chat.errors.max_reactions_limit_reached", - ) - end - end - - def create_reaction(message, react_action, emoji) - if react_action == ADD_REACTION - message.reactions.find_or_create_by!(user: @user, emoji: emoji) - else - message.reactions.where(user: @user, emoji: emoji).destroy_all - end - end - - def publish_reaction(message, react_action, emoji) - ChatPublisher.publish_reaction!(@chat_channel, message, react_action, @user, emoji) - end -end diff --git a/plugins/chat/lib/chat_message_updater.rb b/plugins/chat/lib/chat_message_updater.rb deleted file mode 100644 index 43bb028c40d..00000000000 --- a/plugins/chat/lib/chat_message_updater.rb +++ /dev/null @@ -1,95 +0,0 @@ -# frozen_string_literal: true - -class Chat::ChatMessageUpdater - attr_reader :error - - def self.update(opts) - instance = new(**opts) - instance.update - instance - end - - def initialize(guardian:, chat_message:, new_content:, upload_ids: nil) - @guardian = guardian - @user = guardian.user - @chat_message = chat_message - @old_message_content = chat_message.message - @chat_channel = @chat_message.chat_channel - @new_content = new_content - @upload_ids = upload_ids - @error = nil - end - - def update - begin - validate_channel_status! - @guardian.ensure_can_edit_chat!(@chat_message) - @chat_message.message = @new_content - @chat_message.last_editor_id = @user.id - upload_info = get_upload_info - validate_message!(has_uploads: upload_info[:uploads].any?) - @chat_message.cook - @chat_message.save! - update_uploads(upload_info) - revision = save_revision! - @chat_message.reload - ChatPublisher.publish_edit!(@chat_channel, @chat_message) - Jobs.enqueue(:process_chat_message, { chat_message_id: @chat_message.id }) - Chat::ChatNotifier.notify_edit(chat_message: @chat_message, timestamp: revision.created_at) - DiscourseEvent.trigger(:chat_message_edited, @chat_message, @chat_channel, @user) - rescue => error - @error = error - end - end - - def failed? - @error.present? - end - - private - - def validate_channel_status! - return if @guardian.can_modify_channel_message?(@chat_channel) - raise StandardError.new( - I18n.t("chat.errors.channel_modify_message_disallowed.#{@chat_channel.status}"), - ) - end - - def validate_message!(has_uploads:) - @chat_message.validate_message(has_uploads: has_uploads) - if @chat_message.errors.present? - raise StandardError.new(@chat_message.errors.map(&:full_message).join(", ")) - end - end - - def get_upload_info - return { uploads: [] } if @upload_ids.nil? || !SiteSetting.chat_allow_uploads - - uploads = Upload.where(id: @upload_ids, user_id: @user.id) - if uploads.count != @upload_ids.count - # User is passing upload_ids for uploads that they don't own. Don't change anything. - return { uploads: @chat_message.uploads, changed: false } - end - - new_upload_ids = uploads.map(&:id) - existing_upload_ids = @chat_message.upload_ids - difference = (existing_upload_ids + new_upload_ids) - (existing_upload_ids & new_upload_ids) - { uploads: uploads, changed: difference.any? } - end - - def update_uploads(upload_info) - return unless upload_info[:changed] - - DB.exec("DELETE FROM chat_uploads WHERE chat_message_id = #{@chat_message.id}") - UploadReference.where(target: @chat_message).destroy_all - @chat_message.attach_uploads(upload_info[:uploads]) - end - - def save_revision! - @chat_message.revisions.create!( - old_message: @old_message_content, - new_message: @chat_message.message, - user_id: @user.id, - ) - end -end diff --git a/plugins/chat/lib/chat_notifier.rb b/plugins/chat/lib/chat_notifier.rb deleted file mode 100644 index 0f8ffea4e11..00000000000 --- a/plugins/chat/lib/chat_notifier.rb +++ /dev/null @@ -1,315 +0,0 @@ -# frozen_string_literal: true - -## -# When we are attempting to notify users based on a message we have to take -# into account the following: -# -# * Individual user mentions like @alfred -# * Group mentions that include N users such as @support -# * Global @here and @all mentions -# * Users watching the channel via UserChatChannelMembership -# -# For various reasons a mention may not notify a user: -# -# * The target user of the mention is ignoring or muting the user who created the message -# * The target user either cannot chat or cannot see the chat channel, in which case -# they are defined as `unreachable` -# * The target user is not a member of the channel, in which case they are defined -# as `welcome_to_join` -# * In the case of global @here and @all mentions users with the preference -# `ignore_channel_wide_mention` set to true will not be notified -# -# For any users that fall under the `unreachable` or `welcome_to_join` umbrellas -# we send a MessageBus message to the UI and to inform the creating user. The -# creating user can invite any `welcome_to_join` users to the channel. Target -# users who are ignoring or muting the creating user _do not_ fall into this bucket. -# -# The ignore/mute filtering is also applied via the ChatNotifyWatching job, -# which prevents desktop / push notifications being sent. -class Chat::ChatNotifier - class << self - def user_has_seen_message?(membership, chat_message_id) - (membership.last_read_message_id || 0) >= chat_message_id - end - - def push_notification_tag(type, chat_channel_id) - "#{Discourse.current_hostname}-chat-#{type}-#{chat_channel_id}" - end - - def notify_edit(chat_message:, timestamp:) - Jobs.enqueue( - :send_message_notifications, - chat_message_id: chat_message.id, - timestamp: timestamp.iso8601(6), - reason: "edit", - ) - end - - def notify_new(chat_message:, timestamp:) - Jobs.enqueue( - :send_message_notifications, - chat_message_id: chat_message.id, - timestamp: timestamp.iso8601(6), - reason: "new", - ) - end - end - - def initialize(chat_message, timestamp) - @chat_message = chat_message - @timestamp = timestamp - @chat_channel = @chat_message.chat_channel - @user = @chat_message.user - @mentions = Chat::ChatMessageMentions.new(chat_message) - end - - ### Public API - - def notify_new - if @mentions.all_mentioned_users_ids.present? - @chat_message.create_mentions(@mentions.all_mentioned_users_ids) - end - - to_notify = list_users_to_notify - mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids] - - mentioned_user_ids.each do |member_id| - ChatPublisher.publish_new_mention(member_id, @chat_channel.id, @chat_message.id) - end - - notify_creator_of_inaccessible_mentions(to_notify) - - notify_mentioned_users(to_notify) - notify_watching_users(except: mentioned_user_ids << @user.id) - - to_notify - end - - def notify_edit - @chat_message.update_mentions(@mentions.all_mentioned_users_ids) - - existing_notifications = - ChatMention.includes(:user, :notification).where(chat_message: @chat_message) - already_notified_user_ids = existing_notifications.map(&:user_id) - - to_notify = list_users_to_notify - mentioned_user_ids = to_notify.extract!(:all_mentioned_user_ids)[:all_mentioned_user_ids] - - needs_deletion = already_notified_user_ids - mentioned_user_ids - needs_deletion.each do |user_id| - chat_mention = existing_notifications.detect { |n| n.user_id == user_id } - chat_mention.notification.destroy! - chat_mention.destroy! - end - - needs_notification_ids = mentioned_user_ids - already_notified_user_ids - return if needs_notification_ids.blank? - - notify_creator_of_inaccessible_mentions(to_notify) - - notify_mentioned_users(to_notify, already_notified_user_ids: already_notified_user_ids) - - to_notify - end - - private - - def list_users_to_notify - mentions_count = - @mentions.parsed_direct_mentions.length + @mentions.parsed_group_mentions.length - mentions_count += 1 if @mentions.has_global_mention - mentions_count += 1 if @mentions.has_here_mention - - skip_notifications = mentions_count > SiteSetting.max_mentions_per_chat_message - - {}.tap do |to_notify| - # The order of these methods is the precedence - # between different mention types. - - already_covered_ids = [] - - expand_direct_mentions(to_notify, already_covered_ids, skip_notifications) - expand_group_mentions(to_notify, already_covered_ids, skip_notifications) - expand_here_mention(to_notify, already_covered_ids, skip_notifications) - expand_global_mention(to_notify, already_covered_ids, skip_notifications) - - filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) - - to_notify[:all_mentioned_user_ids] = already_covered_ids - end - end - - def expand_global_mention(to_notify, already_covered_ids, skip) - has_all_mention = @mentions.has_global_mention - - if has_all_mention && @chat_channel.allow_channel_wide_mentions && !skip - to_notify[:global_mentions] = @mentions - .global_mentions - .not_suspended - .where(user_options: { ignore_channel_wide_mention: [false, nil] }) - .where.not(id: already_covered_ids) - .pluck(:id) - - already_covered_ids.concat(to_notify[:global_mentions]) - else - to_notify[:global_mentions] = [] - end - end - - def expand_here_mention(to_notify, already_covered_ids, skip) - has_here_mention = @mentions.has_here_mention - - if has_here_mention && @chat_channel.allow_channel_wide_mentions && !skip - to_notify[:here_mentions] = @mentions - .here_mentions - .not_suspended - .where(user_options: { ignore_channel_wide_mention: [false, nil] }) - .where.not(id: already_covered_ids) - .pluck(:id) - - already_covered_ids.concat(to_notify[:here_mentions]) - else - to_notify[:here_mentions] = [] - end - end - - def group_users_to_notify(users) - potential_participants, unreachable = - users.partition do |user| - guardian = Guardian.new(user) - guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel) - end - - participants, welcome_to_join = - potential_participants.partition do |participant| - participant.user_chat_channel_memberships.any? do |m| - predicate = m.chat_channel_id == @chat_channel.id - predicate = predicate && m.following == true if @chat_channel.public_channel? - predicate - end - end - - { - already_participating: participants || [], - welcome_to_join: welcome_to_join || [], - unreachable: unreachable || [], - } - end - - def expand_direct_mentions(to_notify, already_covered_ids, skip) - if skip - direct_mentions = [] - else - direct_mentions = @mentions.direct_mentions.not_suspended.where.not(id: already_covered_ids) - end - - grouped = group_users_to_notify(direct_mentions) - - to_notify[:direct_mentions] = grouped[:already_participating].map(&:id) - to_notify[:welcome_to_join] = grouped[:welcome_to_join] - to_notify[:unreachable] = grouped[:unreachable] - already_covered_ids.concat(to_notify[:direct_mentions]) - end - - def expand_group_mentions(to_notify, already_covered_ids, skip) - return [] if skip || @mentions.visible_groups.empty? - - reached_by_group = - @mentions - .group_mentions - .not_suspended - .where("user_count <= ?", SiteSetting.max_users_notified_per_group_mention) - .where.not(id: already_covered_ids) - - too_many_members, mentionable = - @mentions.mentionable_groups.partition do |group| - group.user_count > SiteSetting.max_users_notified_per_group_mention - end - - mentions_disabled = @mentions.visible_groups - @mentions.mentionable_groups - to_notify[:group_mentions_disabled] = mentions_disabled - to_notify[:too_many_members] = too_many_members - mentionable.each { |g| to_notify[g.name.downcase] = [] } - - grouped = group_users_to_notify(reached_by_group) - grouped[:already_participating].each do |user| - # When a user is a member of multiple mentioned groups, - # the most far to the left should take precedence. - ordered_group_names = - @mentions.parsed_group_mentions & mentionable.map { |mg| mg.name.downcase } - user_group_names = user.groups.map { |ug| ug.name.downcase } - group_name = ordered_group_names.detect { |gn| user_group_names.include?(gn) } - - to_notify[group_name] << user.id - already_covered_ids << user.id - end - - to_notify[:welcome_to_join] = to_notify[:welcome_to_join].concat(grouped[:welcome_to_join]) - to_notify[:unreachable] = to_notify[:unreachable].concat(grouped[:unreachable]) - end - - def notify_creator_of_inaccessible_mentions(to_notify) - inaccessible = - to_notify.extract!( - :unreachable, - :welcome_to_join, - :too_many_members, - :group_mentions_disabled, - ) - return if inaccessible.values.all?(&:blank?) - - ChatPublisher.publish_inaccessible_mentions( - @user.id, - @chat_message, - inaccessible[:unreachable].to_a, - inaccessible[:welcome_to_join].to_a, - inaccessible[:too_many_members].to_a, - inaccessible[:group_mentions_disabled].to_a, - ) - end - - # Filters out users from global, here, group, and direct mentions that are - # ignoring or muting the creator of the message, so they will not receive - # a notification via the ChatNotifyMentioned job and are not prompted for - # invitation by the creator. - def filter_users_ignoring_or_muting_creator(to_notify, already_covered_ids) - screen_targets = already_covered_ids.concat(to_notify[:welcome_to_join].map(&:id)) - - return if screen_targets.blank? - - screener = UserCommScreener.new(acting_user: @user, target_user_ids: screen_targets) - to_notify - .except(:unreachable, :welcome_to_join) - .each do |key, user_ids| - to_notify[key] = user_ids.reject { |user_id| screener.ignoring_or_muting_actor?(user_id) } - end - - # :welcome_to_join contains users because it's serialized by MB. - to_notify[:welcome_to_join] = to_notify[:welcome_to_join].reject do |user| - screener.ignoring_or_muting_actor?(user.id) - end - - already_covered_ids.reject! do |already_covered| - screener.ignoring_or_muting_actor?(already_covered) - end - end - - def notify_mentioned_users(to_notify, already_notified_user_ids: []) - Jobs.enqueue( - :chat_notify_mentioned, - { - chat_message_id: @chat_message.id, - to_notify_ids_map: to_notify.as_json, - already_notified_user_ids: already_notified_user_ids, - timestamp: @timestamp, - }, - ) - end - - def notify_watching_users(except: []) - Jobs.enqueue( - :chat_notify_watching, - { chat_message_id: @chat_message.id, except_user_ids: except, timestamp: @timestamp }, - ) - end -end diff --git a/plugins/chat/lib/chat_review_queue.rb b/plugins/chat/lib/chat_review_queue.rb deleted file mode 100644 index 4b0392e1511..00000000000 --- a/plugins/chat/lib/chat_review_queue.rb +++ /dev/null @@ -1,208 +0,0 @@ -# frozen_string_literal: true - -# Acceptable options: -# - message: Used when the flag type is notify_user or notify_moderators and we have to create -# a separate PM. -# - is_warning: Staff can send warnings when using the notify_user flag. -# - take_action: Automatically approves the created reviewable and deletes the chat message. -# - queue_for_review: Adds a special reason to the reviwable score and creates the reviewable using -# the force_review option. - -class Chat::ChatReviewQueue - def flag_message(chat_message, guardian, flag_type_id, opts = {}) - result = { success: false, errors: [] } - - is_notify_type = - ReviewableScore.types.slice(:notify_user, :notify_moderators).values.include?(flag_type_id) - is_dm = chat_message.chat_channel.direct_message_channel? - - raise Discourse::InvalidParameters.new(:flag_type) if is_dm && is_notify_type - - guardian.ensure_can_flag_chat_message!(chat_message) - guardian.ensure_can_flag_message_as!(chat_message, flag_type_id, opts) - - existing_reviewable = Reviewable.includes(:reviewable_scores).find_by(target: chat_message) - - if !can_flag_again?(existing_reviewable, chat_message, guardian.user, flag_type_id) - result[:errors] << I18n.t("chat.reviewables.message_already_handled") - return result - end - - payload = { message_cooked: chat_message.cooked } - - if opts[:message].present? && !is_dm && is_notify_type - creator = companion_pm_creator(chat_message, guardian.user, flag_type_id, opts) - post = creator.create - - if creator.errors.present? - creator.errors.full_messages.each { |msg| result[:errors] << msg } - return result - end - elsif is_dm - transcript = find_or_create_transcript(chat_message, guardian.user, existing_reviewable) - payload[:transcript_topic_id] = transcript.topic_id if transcript - end - - queued_for_review = !!ActiveRecord::Type::Boolean.new.deserialize(opts[:queue_for_review]) - - reviewable = - ReviewableChatMessage.needs_review!( - created_by: guardian.user, - target: chat_message, - reviewable_by_moderator: true, - potential_spam: flag_type_id == ReviewableScore.types[:spam], - payload: payload, - ) - reviewable.update(target_created_by: chat_message.user) - score = - reviewable.add_score( - guardian.user, - flag_type_id, - meta_topic_id: post&.topic_id, - take_action: opts[:take_action], - reason: queued_for_review ? "chat_message_queued_by_staff" : nil, - force_review: queued_for_review, - ) - - if opts[:take_action] - reviewable.perform(guardian.user, :agree_and_delete) - ChatPublisher.publish_delete!(chat_message.chat_channel, chat_message) - else - enforce_auto_silence_threshold(reviewable) - ChatPublisher.publish_flag!(chat_message, guardian.user, reviewable, score) - end - - result.tap do |r| - r[:success] = true - r[:reviewable] = reviewable - end - end - - private - - def enforce_auto_silence_threshold(reviewable) - auto_silence_duration = SiteSetting.chat_auto_silence_from_flags_duration - return if auto_silence_duration.zero? - return if reviewable.score <= ReviewableChatMessage.score_to_silence_user - - user = reviewable.target_created_by - return unless user - return if user.silenced? - - UserSilencer.silence( - user, - Discourse.system_user, - silenced_till: auto_silence_duration.minutes.from_now, - reason: I18n.t("chat.errors.auto_silence_from_flags"), - ) - end - - def companion_pm_creator(chat_message, flagger, flag_type_id, opts) - notifying_user = flag_type_id == ReviewableScore.types[:notify_user] - - i18n_key = notifying_user ? "notify_user" : "notify_moderators" - - title = - I18n.t( - "reviewable_score_types.#{i18n_key}.chat_pm_title", - channel_name: chat_message.chat_channel.title(flagger), - locale: SiteSetting.default_locale, - ) - - body = - I18n.t( - "reviewable_score_types.#{i18n_key}.chat_pm_body", - message: opts[:message], - link: chat_message.full_url, - locale: SiteSetting.default_locale, - ) - - create_args = { - archetype: Archetype.private_message, - title: title.truncate(SiteSetting.max_topic_title_length, separator: /\s/), - raw: body, - } - - if notifying_user - create_args[:subtype] = TopicSubtype.notify_user - create_args[:target_usernames] = chat_message.user.username - - create_args[:is_warning] = opts[:is_warning] if flagger.staff? - else - create_args[:subtype] = TopicSubtype.notify_moderators - create_args[:target_group_names] = [Group[:moderators].name] - end - - PostCreator.new(flagger, create_args) - end - - def find_or_create_transcript(chat_message, flagger, existing_reviewable) - previous_message_ids = - ChatMessage - .where(chat_channel: chat_message.chat_channel) - .where("id < ?", chat_message.id) - .order("created_at DESC") - .limit(10) - .pluck(:id) - .reverse - - return if previous_message_ids.empty? - - service = - ChatTranscriptService.new( - chat_message.chat_channel, - Discourse.system_user, - messages_or_ids: previous_message_ids, - ) - - title = - I18n.t( - "chat.reviewables.direct_messages.transcript_title", - channel_name: chat_message.chat_channel.title(flagger), - locale: SiteSetting.default_locale, - ) - - body = - I18n.t( - "chat.reviewables.direct_messages.transcript_body", - transcript: service.generate_markdown, - locale: SiteSetting.default_locale, - ) - - create_args = { - archetype: Archetype.private_message, - title: title.truncate(SiteSetting.max_topic_title_length, separator: /\s/), - raw: body, - subtype: TopicSubtype.notify_moderators, - target_group_names: [Group[:moderators].name], - } - - PostCreator.new(Discourse.system_user, create_args).create - end - - def can_flag_again?(reviewable, message, flagger, flag_type_id) - return true if reviewable.blank? - - flagger_has_pending_flags = - reviewable.reviewable_scores.any? { |rs| rs.user == flagger && rs.pending? } - - if !flagger_has_pending_flags && flag_type_id == ReviewableScore.types[:notify_moderators] - return true - end - - flag_used = - reviewable.reviewable_scores.any? do |rs| - rs.reviewable_score_type == flag_type_id && rs.pending? - end - handled_recently = - !( - reviewable.pending? || - reviewable.updated_at < SiteSetting.cooldown_hours_until_reflag.to_i.hours.ago - ) - - latest_revision = message.revisions.last - edited_since_last_review = latest_revision && latest_revision.updated_at > reviewable.updated_at - - !flag_used && !flagger_has_pending_flags && (!handled_recently || edited_since_last_review) - end -end diff --git a/plugins/chat/lib/chat_seeder.rb b/plugins/chat/lib/chat_seeder.rb deleted file mode 100644 index 79d8dc23bda..00000000000 --- a/plugins/chat/lib/chat_seeder.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -class ChatSeeder - def execute(args = {}) - return if !SiteSetting.needs_chat_seeded - - begin - create_category_channel_from(SiteSetting.staff_category_id) - create_category_channel_from(SiteSetting.general_category_id) - rescue => error - Rails.logger.warn("Error seeding chat category - #{error.inspect}") - ensure - SiteSetting.needs_chat_seeded = false - end - end - - def create_category_channel_from(category_id) - category = Category.find_by(id: category_id) - return if category.nil? - - chat_channel = category.create_chat_channel!(auto_join_users: true, name: category.name) - category.custom_fields[Chat::HAS_CHAT_ENABLED] = true - category.save! - - Chat::ChatChannelMembershipManager.new(chat_channel).enforce_automatic_channel_memberships - chat_channel - end -end diff --git a/plugins/chat/lib/chat_statistics.rb b/plugins/chat/lib/chat_statistics.rb deleted file mode 100644 index ab79fcf1110..00000000000 --- a/plugins/chat/lib/chat_statistics.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -class Chat::Statistics - def self.about_messages - { - :last_day => ChatMessage.where("created_at > ?", 1.days.ago).count, - "7_days" => ChatMessage.where("created_at > ?", 7.days.ago).count, - "30_days" => ChatMessage.where("created_at > ?", 30.days.ago).count, - :previous_30_days => - ChatMessage.where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago).count, - :count => ChatMessage.count, - } - end - - def self.about_channels - { - :last_day => ChatChannel.where(status: :open).where("created_at > ?", 1.days.ago).count, - "7_days" => ChatChannel.where(status: :open).where("created_at > ?", 7.days.ago).count, - "30_days" => ChatChannel.where(status: :open).where("created_at > ?", 30.days.ago).count, - :previous_30_days => - ChatChannel - .where(status: :open) - .where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago) - .count, - :count => ChatChannel.where(status: :open).count, - } - end - - def self.about_users - { - :last_day => ChatMessage.where("created_at > ?", 1.days.ago).distinct.count(:user_id), - "7_days" => ChatMessage.where("created_at > ?", 7.days.ago).distinct.count(:user_id), - "30_days" => ChatMessage.where("created_at > ?", 30.days.ago).distinct.count(:user_id), - :previous_30_days => - ChatMessage - .where("created_at BETWEEN ? AND ?", 60.days.ago, 30.days.ago) - .distinct - .count(:user_id), - :count => ChatMessage.distinct.count(:user_id), - } - end - - def self.monthly - start_of_month = Time.zone.now.beginning_of_month - { - messages: ChatMessage.where("created_at > ?", start_of_month).count, - channels: ChatChannel.where(status: :open).where("created_at > ?", start_of_month).count, - users: ChatMessage.where("created_at > ?", start_of_month).distinct.count(:user_id), - } - end -end diff --git a/plugins/chat/lib/chat_transcript_service.rb b/plugins/chat/lib/chat_transcript_service.rb deleted file mode 100644 index f61421d35f6..00000000000 --- a/plugins/chat/lib/chat_transcript_service.rb +++ /dev/null @@ -1,177 +0,0 @@ -# frozen_string_literal: true - -## -# Used to generate BBCode [chat] tags for the message IDs provided. -# -# If there is > 1 message then the channel name will be shown at -# the top of the first message, and subsequent messages will have -# the chained attribute, which will affect how they are displayed -# in the UI. -# -# Subsequent messages from the same user will be put into the same -# tag. Each new user in the chain of messages will have a new [chat] -# tag created. -# -# A single message will have the channel name displayed to the right -# of the username and datetime of the message. -class ChatTranscriptService - CHAINED_ATTR = "chained=\"true\"" - MULTIQUOTE_ATTR = "multiQuote=\"true\"" - NO_LINK_ATTR = "noLink=\"true\"" - - class ChatTranscriptBBCode - attr_reader :channel, :multiquote, :chained, :no_link, :include_reactions - - def initialize( - channel: nil, - acting_user: nil, - multiquote: false, - chained: false, - no_link: false, - include_reactions: false - ) - @channel = channel - @acting_user = acting_user - @multiquote = multiquote - @chained = chained - @no_link = no_link - @include_reactions = include_reactions - @message_data = [] - end - - def add(message:, reactions: nil) - @message_data << { message: message, reactions: reactions } - end - - def render - attrs = [quote_attr(@message_data.first[:message])] - - if channel - attrs << channel_attr - attrs << channel_id_attr - end - - attrs << MULTIQUOTE_ATTR if multiquote - attrs << CHAINED_ATTR if chained - attrs << NO_LINK_ATTR if no_link - attrs << reactions_attr if include_reactions - - <<~MARKDOWN - [chat #{attrs.compact.join(" ")}] - #{@message_data.map { |msg| msg[:message].to_markdown }.join("\n\n")} - [/chat] - MARKDOWN - end - - private - - def reactions_attr - reaction_data = - @message_data.reduce([]) do |array, msg_data| - if msg_data[:reactions].any? - array << msg_data[:reactions].map { |react| "#{react.emoji}:#{react.usernames}" } - end - array - end - return if reaction_data.empty? - "reactions=\"#{reaction_data.join(";")}\"" - end - - def quote_attr(message) - "quote=\"#{message.user.username};#{message.id};#{message.created_at.iso8601}\"" - end - - def channel_attr - "channel=\"#{channel.title(@acting_user)}\"" - end - - def channel_id_attr - "channelId=\"#{channel.id}\"" - end - end - - def initialize(channel, acting_user, messages_or_ids: [], opts: {}) - @channel = channel - @acting_user = acting_user - - if messages_or_ids.all? { |m| m.is_a?(Numeric) } - @message_ids = messages_or_ids - else - @messages = messages_or_ids - end - @opts = opts - end - - def generate_markdown - previous_message = nil - rendered_markdown = [] - all_messages_same_user = messages.count(:user_id) == 1 - open_bbcode_tag = - ChatTranscriptBBCode.new( - channel: @channel, - acting_user: @acting_user, - multiquote: messages.length > 1, - chained: !all_messages_same_user, - no_link: @opts[:no_link], - include_reactions: @opts[:include_reactions], - ) - - messages.each.with_index do |message, idx| - if previous_message.present? && previous_message.user_id != message.user_id - rendered_markdown << open_bbcode_tag.render - - open_bbcode_tag = - ChatTranscriptBBCode.new( - acting_user: @acting_user, - chained: !all_messages_same_user, - no_link: @opts[:no_link], - include_reactions: @opts[:include_reactions], - ) - end - - if @opts[:include_reactions] - open_bbcode_tag.add(message: message, reactions: reactions_for_message(message)) - else - open_bbcode_tag.add(message: message) - end - previous_message = message - end - - # tie off the last open bbcode + render - rendered_markdown << open_bbcode_tag.render - rendered_markdown.join("\n") - end - - private - - def messages - @messages ||= - ChatMessage - .includes(:user, upload_references: :upload) - .where(id: @message_ids, chat_channel_id: @channel.id) - .order(:created_at) - end - - ## - # Queries reactions and returns them in this format - # - # emoji | usernames | chat_message_id - # ---------------------------------------- - # +1 | foo,bar,baz | 102 - # heart | foo | 102 - # sob | bar,baz | 103 - def reactions - @reactions ||= DB.query(<<~SQL, @messages.map(&:id)) - SELECT emoji, STRING_AGG(DISTINCT users.username, ',') AS usernames, chat_message_id - FROM chat_message_reactions - INNER JOIN users on users.id = chat_message_reactions.user_id - WHERE chat_message_id IN (?) - GROUP BY emoji, chat_message_id - ORDER BY chat_message_id, emoji - SQL - end - - def reactions_for_message(message) - reactions.select { |react| react.chat_message_id == message.id } - end -end diff --git a/plugins/chat/lib/direct_message_channel_creator.rb b/plugins/chat/lib/direct_message_channel_creator.rb deleted file mode 100644 index 505801d42fe..00000000000 --- a/plugins/chat/lib/direct_message_channel_creator.rb +++ /dev/null @@ -1,130 +0,0 @@ -# frozen_string_literal: true - -module Chat::DirectMessageChannelCreator - class NotAllowed < StandardError - end - - def self.create!(acting_user:, target_users:) - Guardian.new(acting_user).ensure_can_create_direct_message! - target_users.uniq! - direct_message = DirectMessage.for_user_ids(target_users.map(&:id)) - if direct_message - chat_channel = ChatChannel.find_by!(chatable: direct_message) - else - enforce_max_direct_message_users!(acting_user, target_users) - ensure_actor_can_communicate!(acting_user, target_users) - direct_message = DirectMessage.create!(user_ids: target_users.map(&:id)) - chat_channel = direct_message.create_chat_channel! - end - - update_memberships(acting_user, target_users, chat_channel.id) - ChatPublisher.publish_new_channel(chat_channel, target_users) - - chat_channel - end - - private - - def self.enforce_max_direct_message_users!(acting_user, target_users) - # We never want to prevent the actor from communicating with themself. - target_users = target_users.reject { |user| user.id == acting_user.id } - - if !acting_user.staff? && target_users.size > SiteSetting.chat_max_direct_message_users - if SiteSetting.chat_max_direct_message_users == 0 - raise NotAllowed.new(I18n.t("chat.errors.over_chat_max_direct_message_users_allow_self")) - else - raise NotAllowed.new( - I18n.t( - "chat.errors.over_chat_max_direct_message_users", - count: SiteSetting.chat_max_direct_message_users + 1, # +1 for the acting_user - ), - ) - end - end - end - - def self.update_memberships(acting_user, target_users, chat_channel_id) - sql_params = { - acting_user_id: acting_user.id, - user_ids: target_users.map(&:id), - chat_channel_id: chat_channel_id, - always_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], - } - - DB.exec(<<~SQL, sql_params) - INSERT INTO user_chat_channel_memberships( - user_id, - chat_channel_id, - muted, - following, - desktop_notification_level, - mobile_notification_level, - created_at, - updated_at - ) - VALUES( - unnest(array[:user_ids]), - :chat_channel_id, - false, - false, - :always_notification_level, - :always_notification_level, - NOW(), - NOW() - ) - ON CONFLICT (user_id, chat_channel_id) DO NOTHING; - - UPDATE user_chat_channel_memberships - SET following = true - WHERE user_id = :acting_user_id AND chat_channel_id = :chat_channel_id; - SQL - end - - def self.ensure_actor_can_communicate!(acting_user, target_users) - # We never want to prevent the actor from communicating with themself. - target_users = target_users.reject { |user| user.id == acting_user.id } - - screener = - UserCommScreener.new(acting_user: acting_user, target_user_ids: target_users.map(&:id)) - - # People blocking the actor. - screener.preventing_actor_communication.each do |user_id| - raise NotAllowed.new( - I18n.t( - "chat.errors.not_accepting_dms", - username: target_users.find { |user| user.id == user_id }.username, - ), - ) - end - - # The actor cannot start DMs with people if they are not allowing anyone - # to start DMs with them, that's no fair! - if screener.actor_disallowing_all_pms? - raise NotAllowed.new(I18n.t("chat.errors.actor_disallowed_dms")) - end - - # People the actor is blocking. - target_users.each do |target_user| - if screener.actor_disallowing_pms?(target_user.id) - raise NotAllowed.new( - I18n.t( - "chat.errors.actor_preventing_target_user_from_dm", - username: target_user.username, - ), - ) - end - - if screener.actor_ignoring?(target_user.id) - raise NotAllowed.new( - I18n.t("chat.errors.actor_ignoring_target_user", username: target_user.username), - ) - end - - if screener.actor_muting?(target_user.id) - raise NotAllowed.new( - I18n.t("chat.errors.actor_muting_target_user", username: target_user.username), - ) - end - end - end -end diff --git a/plugins/chat/lib/discourse_dev/direct_channel.rb b/plugins/chat/lib/discourse_dev/direct_channel.rb deleted file mode 100644 index 4ee6e835fe2..00000000000 --- a/plugins/chat/lib/discourse_dev/direct_channel.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -require "discourse_dev/record" -require "faker" - -module DiscourseDev - class DirectChannel < Record - def initialize - super(::DirectMessage, 5) - end - - def data - if Faker::Boolean.boolean(true_ratio: 0.5) - admin_username = - begin - DiscourseDev::Config.new.config[:admin][:username] - rescue StandardError - nil - end - admin_user = ::User.find_by(username: admin_username) if admin_username - end - - [User.new.create!, admin_user || User.new.create!] - end - - def create! - users = data - Chat::DirectMessageChannelCreator.create!(acting_user: users[0], target_users: users) - end - end -end diff --git a/plugins/chat/lib/discourse_dev/message.rb b/plugins/chat/lib/discourse_dev/message.rb deleted file mode 100644 index 6cd72225a11..00000000000 --- a/plugins/chat/lib/discourse_dev/message.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -require "discourse_dev/record" -require "faker" - -module DiscourseDev - class Message < Record - def initialize - super(::ChatMessage, 200) - end - - def data - if Faker::Boolean.boolean(true_ratio: 0.5) - channel = ::ChatChannel.where(chatable_type: "DirectMessage").order("RANDOM()").first - channel.user_chat_channel_memberships.update_all(following: true) - user = channel.chatable.users.order("RANDOM()").first - else - membership = ::UserChatChannelMembership.order("RANDOM()").first - channel = membership.chat_channel - user = membership.user - end - - { user: user, content: Faker::Lorem.paragraph, chat_channel: channel } - end - - def create! - Chat::ChatMessageCreator.create(data) - end - end -end diff --git a/plugins/chat/lib/discourse_dev/public_channel.rb b/plugins/chat/lib/discourse_dev/public_channel.rb deleted file mode 100644 index cb9c672caa9..00000000000 --- a/plugins/chat/lib/discourse_dev/public_channel.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require "discourse_dev/record" -require "faker" - -module DiscourseDev - class PublicChannel < Record - def initialize - super(::CategoryChannel, 5) - end - - def data - chatable = Category.random - - { - chatable: chatable, - description: Faker::Lorem.paragraph, - user_count: 1, - name: Faker::Company.name, - created_at: Faker::Time.between(from: DiscourseDev.config.start_date, to: DateTime.now), - } - end - - def create! - super do |channel| - Faker::Number - .between(from: 5, to: 10) - .times do - if Faker::Boolean.boolean(true_ratio: 0.5) - admin_username = - begin - DiscourseDev::Config.new.config[:admin][:username] - rescue StandardError - nil - end - admin_user = ::User.find_by(username: admin_username) if admin_username - end - - Chat::ChatChannelMembershipManager.new(channel).follow(admin_user || User.new.create!) - end - end - end - end -end diff --git a/plugins/chat/lib/duplicate_message_validator.rb b/plugins/chat/lib/duplicate_message_validator.rb deleted file mode 100644 index 7b094692ff4..00000000000 --- a/plugins/chat/lib/duplicate_message_validator.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -class Chat::DuplicateMessageValidator - attr_reader :chat_message - - def initialize(chat_message) - @chat_message = chat_message - end - - def validate - return if SiteSetting.chat_duplicate_message_sensitivity.zero? - matrix = - Chat::DuplicateMessageValidator.sensitivity_matrix( - SiteSetting.chat_duplicate_message_sensitivity, - ) - - # Check if the length of the message is too short to check for a duplicate message - return if chat_message.message.length < matrix[:min_message_length] - - # Check if there are enough users in the channel to check for a duplicate message - return if (chat_message.chat_channel.user_count || 0) < matrix[:min_user_count] - - # Check if the same duplicate message has been posted in the last N seconds by any user - if !chat_message - .chat_channel - .chat_messages - .where("created_at > ?", matrix[:min_past_seconds].seconds.ago) - .where(message: chat_message.message) - .exists? - return - end - - chat_message.errors.add(:base, I18n.t("chat.errors.duplicate_message")) - end - - def self.sensitivity_matrix(sensitivity) - { - # 0.1 sensitivity = 100 users and 1.0 sensitivity = 5 users. - min_user_count: (-1.0 * 105.5 * sensitivity + 110.55).to_i, - # 0.1 sensitivity = 30 chars and 1.0 sensitivity = 10 chars. - min_message_length: (-1.0 * 22.2 * sensitivity + 32.22).to_i, - # 0.1 sensitivity = 10 seconds and 1.0 sensitivity = 60 seconds. - min_past_seconds: (55.55 * sensitivity + 4.5).to_i, - } - end -end diff --git a/plugins/chat/lib/extensions/category_extension.rb b/plugins/chat/lib/extensions/category_extension.rb deleted file mode 100644 index d12ce387645..00000000000 --- a/plugins/chat/lib/extensions/category_extension.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Chat::CategoryExtension - extend ActiveSupport::Concern - - include Chatable - - prepended { has_one :category_channel, as: :chatable, dependent: :destroy } - - def cannot_delete_reason - return I18n.t("category.cannot_delete.has_chat_channels") if category_channel - super - end - - def deletable_for_chat? - return true if !category_channel - category_channel.chat_messages_empty? - end -end diff --git a/plugins/chat/lib/extensions/user_email_extension.rb b/plugins/chat/lib/extensions/user_email_extension.rb deleted file mode 100644 index 6742dccbe37..00000000000 --- a/plugins/chat/lib/extensions/user_email_extension.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Chat::UserEmailExtension - def execute(args) - super(args) - - if args[:type] == "chat_summary" && args[:memberships_to_update_data].present? - args[:memberships_to_update_data].to_a.each do |membership_id, max_unread_mention_id| - UserChatChannelMembership.find_by(user: args[:user_id], id: membership_id.to_i)&.update( - last_unread_mention_when_emailed_id: max_unread_mention_id.to_i, - ) - end - end - end -end diff --git a/plugins/chat/lib/extensions/user_extension.rb b/plugins/chat/lib/extensions/user_extension.rb deleted file mode 100644 index b4c041d4d8b..00000000000 --- a/plugins/chat/lib/extensions/user_extension.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Chat::UserExtension - extend ActiveSupport::Concern - - prepended do - has_many :user_chat_channel_memberships, dependent: :destroy - has_many :chat_message_reactions, dependent: :destroy - has_many :chat_mentions - end -end diff --git a/plugins/chat/lib/extensions/user_notifications_extension.rb b/plugins/chat/lib/extensions/user_notifications_extension.rb deleted file mode 100644 index 93d3039d705..00000000000 --- a/plugins/chat/lib/extensions/user_notifications_extension.rb +++ /dev/null @@ -1,142 +0,0 @@ -# frozen_string_literal: true - -module Chat::UserNotificationsExtension - def chat_summary(user, opts) - guardian = Guardian.new(user) - return unless guardian.can_chat? - - @messages = - ChatMessage - .joins(:user, :chat_channel) - .where.not(user: user) - .where("chat_messages.created_at > ?", 1.week.ago) - .joins( - "LEFT OUTER JOIN chat_mentions cm ON cm.chat_message_id = chat_messages.id AND cm.notification_id IS NOT NULL", - ) - .joins( - "INNER JOIN user_chat_channel_memberships uccm ON uccm.chat_channel_id = chat_channels.id", - ) - .where(<<~SQL, user_id: user.id) - uccm.user_id = :user_id AND - (uccm.last_read_message_id IS NULL OR chat_messages.id > uccm.last_read_message_id) AND - (uccm.last_unread_mention_when_emailed_id IS NULL OR chat_messages.id > uccm.last_unread_mention_when_emailed_id) AND - ( - (cm.user_id = :user_id AND uccm.following IS true AND chat_channels.chatable_type = 'Category') OR - (chat_channels.chatable_type = 'DirectMessage') - ) - SQL - .to_a - - return if @messages.empty? - @grouped_messages = @messages.group_by { |message| message.chat_channel } - @grouped_messages = - @grouped_messages.select { |channel, _| guardian.can_join_chat_channel?(channel) } - return if @grouped_messages.empty? - - @grouped_messages.each do |chat_channel, messages| - @grouped_messages[chat_channel] = messages.sort_by(&:created_at) - end - @user = user - @user_tz = UserOption.user_tzinfo(user.id) - @display_usernames = SiteSetting.prioritize_username_in_ux || !SiteSetting.enable_names - - build_summary_for(user) - @preferences_path = "#{Discourse.base_url}/my/preferences/chat" - - # TODO(roman): Remove after the 2.9 release - add_unsubscribe_link = UnsubscribeKey.respond_to?(:get_unsubscribe_strategy_for) - - if add_unsubscribe_link - unsubscribe_key = UnsubscribeKey.create_key_for(@user, "chat_summary") - @unsubscribe_link = "#{Discourse.base_url}/email/unsubscribe/#{unsubscribe_key}" - opts[:unsubscribe_url] = @unsubscribe_link - end - - opts = { - from_alias: I18n.t("user_notifications.chat_summary.from", site_name: Email.site_title), - subject: summary_subject(user, @grouped_messages), - add_unsubscribe_link: add_unsubscribe_link, - } - - build_email(user.email, opts) - end - - def summary_subject(user, grouped_messages) - all_channels = grouped_messages.keys - grouped_channels = all_channels.partition { |c| !c.direct_message_channel? } - channels = grouped_channels.first - - dm_messages = grouped_channels.last.flat_map { |c| grouped_messages[c] } - dm_users = dm_messages.sort_by(&:created_at).uniq { |m| m.user_id }.map(&:user) - - # Prioritize messages from regular channels over direct messages - if channels.any? - channel_notification_text( - channels.sort_by { |channel| [channel.last_message_sent_at, channel.created_at] }, - dm_users, - ) - else - direct_message_notification_text(dm_users) - end - end - - private - - def channel_notification_text(channels, dm_users) - total_count = channels.size + dm_users.size - - if total_count > 2 - I18n.t( - "user_notifications.chat_summary.subject.chat_channel_more", - email_prefix: @email_prefix, - channel: channels.first.title, - count: total_count - 1, - ) - elsif channels.size == 1 && dm_users.size == 0 - I18n.t( - "user_notifications.chat_summary.subject.chat_channel_1", - email_prefix: @email_prefix, - channel: channels.first.title, - ) - elsif channels.size == 1 && dm_users.size == 1 - I18n.t( - "user_notifications.chat_summary.subject.chat_channel_and_direct_message", - email_prefix: @email_prefix, - channel: channels.first.title, - username: dm_users.first.username, - ) - elsif channels.size == 2 - I18n.t( - "user_notifications.chat_summary.subject.chat_channel_2", - email_prefix: @email_prefix, - channel1: channels.first.title, - channel2: channels.second.title, - ) - end - end - - def direct_message_notification_text(dm_users) - case dm_users.size - when 1 - I18n.t( - "user_notifications.chat_summary.subject.direct_message_from_1", - email_prefix: @email_prefix, - username: dm_users.first.username, - ) - when 2 - I18n.t( - "user_notifications.chat_summary.subject.direct_message_from_2", - email_prefix: @email_prefix, - username1: dm_users.first.username, - username2: dm_users.second.username, - ) - else - I18n.t( - "user_notifications.chat_summary.subject.direct_message_from_more", - email_prefix: @email_prefix, - username: dm_users.first.username, - count: dm_users.size - 1, - ) - end - end -end diff --git a/plugins/chat/lib/extensions/user_option_extension.rb b/plugins/chat/lib/extensions/user_option_extension.rb deleted file mode 100644 index 467f1a84a67..00000000000 --- a/plugins/chat/lib/extensions/user_option_extension.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Chat::UserOptionExtension - # TODO: remove last_emailed_for_chat and chat_isolated in 2023 - def self.prepended(base) - if base.ignored_columns - base.ignored_columns = base.ignored_columns + %i[last_emailed_for_chat chat_isolated] - else - base.ignored_columns = %i[last_emailed_for_chat chat_isolated] - end - - def base.chat_email_frequencies - @chat_email_frequencies ||= { never: 0, when_away: 1 } - end - - def base.chat_header_indicator_preferences - @chat_header_indicator_preferences ||= { all_new: 0, dm_and_mentions: 1, never: 2 } - end - - base.enum :chat_email_frequency, base.chat_email_frequencies, prefix: "send_chat_email" - base.enum :chat_header_indicator_preference, base.chat_header_indicator_preferences - end -end diff --git a/plugins/chat/lib/guardian_extensions.rb b/plugins/chat/lib/guardian_extensions.rb deleted file mode 100644 index e57f7c5927b..00000000000 --- a/plugins/chat/lib/guardian_extensions.rb +++ /dev/null @@ -1,189 +0,0 @@ -# frozen_string_literal: true - -module Chat::GuardianExtensions - def can_moderate_chat?(chatable) - case chatable.class.name - when "Category" - is_staff? || is_category_group_moderator?(chatable) - else - is_staff? - end - end - - def can_chat? - return false if anonymous? - @user.staff? || @user.in_any_groups?(Chat.allowed_group_ids) - end - - def can_create_chat_message? - !SpamRule::AutoSilence.prevent_posting?(@user) - end - - def can_create_direct_message? - is_staff? || @user.in_any_groups?(SiteSetting.direct_message_enabled_groups_map) - end - - def hidden_tag_names - @hidden_tag_names ||= DiscourseTagging.hidden_tag_names(self) - end - - def can_create_chat_channel? - is_staff? - end - - def can_delete_chat_channel? - is_staff? - end - - # Channel status intentionally has no bearing on whether the channel - # name and description can be edited. - def can_edit_chat_channel? - is_staff? - end - - def can_move_chat_messages?(channel) - can_moderate_chat?(channel.chatable) - end - - def can_create_channel_message?(chat_channel) - valid_statuses = is_staff? ? %w[open closed] : ["open"] - valid_statuses.include?(chat_channel.status) - end - - # This is intentionally identical to can_create_channel_message, we - # may want to have different conditions here in future. - def can_modify_channel_message?(chat_channel) - return chat_channel.open? || chat_channel.closed? if is_staff? - chat_channel.open? - end - - def can_change_channel_status?(chat_channel, target_status) - return false if chat_channel.status.to_sym == target_status.to_sym - return false if !is_staff? - - # FIXME: This logic shouldn't be handled in guardian - case target_status - when :closed - chat_channel.open? - when :open - chat_channel.closed? - when :archived - chat_channel.read_only? - when :read_only - chat_channel.closed? || chat_channel.open? - else - false - end - end - - def can_rebake_chat_message?(message) - return false if !can_modify_channel_message?(message.chat_channel) - is_staff? || @user.has_trust_level?(TrustLevel[4]) - end - - def can_preview_chat_channel?(chat_channel) - return false unless chat_channel.chatable - - if chat_channel.direct_message_channel? - chat_channel.chatable.user_can_access?(@user) - elsif chat_channel.category_channel? - can_see_category?(chat_channel.chatable) - else - true - end - end - - def can_join_chat_channel?(chat_channel) - return false if anonymous? - can_preview_chat_channel?(chat_channel) && - (chat_channel.direct_message_channel? || can_post_in_category?(chat_channel.chatable)) - end - - def can_flag_chat_messages? - return false if @user.silenced? - return true if @user.staff? - - @user.in_any_groups?(SiteSetting.chat_message_flag_allowed_groups_map) - end - - def can_flag_in_chat_channel?(chat_channel) - return false if !can_modify_channel_message?(chat_channel) - - can_join_chat_channel?(chat_channel) - end - - def can_flag_chat_message?(chat_message) - return false if !authenticated? || !chat_message || chat_message.trashed? || !chat_message.user - return false if chat_message.user.staff? && !SiteSetting.allow_flagging_staff - return false if chat_message.user_id == @user.id - - can_flag_chat_messages? && can_flag_in_chat_channel?(chat_message.chat_channel) - end - - def can_flag_message_as?(chat_message, flag_type_id, opts) - return false if !is_staff? && (opts[:take_action] || opts[:queue_for_review]) - - if flag_type_id == ReviewableScore.types[:notify_user] - is_warning = ActiveRecord::Type::Boolean.new.deserialize(opts[:is_warning]) - - return false if is_warning && !is_staff? - end - - true - end - - def can_delete_chat?(message, chatable) - return false if @user.silenced? - return false if !can_modify_channel_message?(message.chat_channel) - - if message.user_id == current_user.id - can_delete_own_chats?(chatable) - else - can_delete_other_chats?(chatable) - end - end - - def can_delete_own_chats?(chatable) - return false if (SiteSetting.max_post_deletions_per_day < 1) - return true if can_moderate_chat?(chatable) - - true - end - - def can_delete_other_chats?(chatable) - return true if can_moderate_chat?(chatable) - - false - end - - def can_restore_chat?(message, chatable) - return false if !can_modify_channel_message?(message.chat_channel) - - if message.user_id == current_user.id - case chatable - when Category - return can_see_category?(chatable) - when DirectMessage - return true - end - end - - can_delete_other_chats?(chatable) - end - - def can_restore_other_chats?(chatable) - can_moderate_chat?(chatable) - end - - def can_edit_chat?(message) - message.user_id == @user.id && !@user.silenced? - end - - def can_react? - can_create_chat_message? - end - - def can_delete_category?(category) - super && category.deletable_for_chat? - end -end diff --git a/plugins/chat/lib/message_mover.rb b/plugins/chat/lib/message_mover.rb deleted file mode 100644 index 767d5175a47..00000000000 --- a/plugins/chat/lib/message_mover.rb +++ /dev/null @@ -1,242 +0,0 @@ -# frozen_string_literal: true - -## -# Used to move chat messages from a chat channel to some other -# location. -# -# Channel -> Channel: -# ------------------- -# -# Messages are sometimes misplaced and must be moved to another channel. For -# now we only support moving messages between public channels, handling the -# permissions and membership around moving things in and out of DMs is a little -# much for V1. -# -# The original messages will be deleted, and then similar to PostMover in core, -# all of the references associated to a chat message (e.g. reactions, bookmarks, -# notifications, revisions, mentions, uploads) will be updated to the new -# message IDs via a moved_chat_messages temporary table. -# -# Reply chains are a little complex. No reply chains are preserved when moving -# messages into a new channel. Remaining messages that referenced moved ones -# have their in_reply_to_id cleared so the data makes sense. -# -# Threads are even more complex. No threads are preserved when moving messages -# into a new channel, they end up as just a flat series of messages that are -# not in a chain. If the original message of a thread and N other messages -# in that thread, then any messages left behind just get placed into a new -# thread. Message moving will be disabled in the thread UI while -# enable_experimental_chat_threaded_discussions is present, its too complicated -# to have end users reason about for now, and we may want a standalone -# "Move Thread" UI later on. -class Chat::MessageMover - class NoMessagesFound < StandardError - end - class InvalidChannel < StandardError - end - - def initialize(acting_user:, source_channel:, message_ids:) - @source_channel = source_channel - @acting_user = acting_user - @source_message_ids = message_ids - @source_messages = find_messages(@source_message_ids, source_channel) - @ordered_source_message_ids = @source_messages.map(&:id) - end - - def move_to_channel(destination_channel) - if !@source_channel.public_channel? || !destination_channel.public_channel? - raise InvalidChannel.new(I18n.t("chat.errors.message_move_invalid_channel")) - end - - if @ordered_source_message_ids.empty? - raise NoMessagesFound.new(I18n.t("chat.errors.message_move_no_messages_found")) - end - - moved_messages = nil - - ChatMessage.transaction do - create_temp_table - moved_messages = - find_messages( - create_destination_messages_in_channel(destination_channel), - destination_channel, - ) - bulk_insert_movement_metadata - update_references - delete_source_messages - update_reply_references - update_thread_references - end - - add_moved_placeholder(destination_channel, moved_messages.first) - moved_messages - end - - private - - def find_messages(message_ids, channel) - ChatMessage - .includes(thread: %i[original_message original_message_user]) - .where(id: message_ids, chat_channel_id: channel.id) - .order("created_at ASC, id ASC") - end - - def create_temp_table - DB.exec("DROP TABLE IF EXISTS moved_chat_messages") if Rails.env.test? - - DB.exec <<~SQL - CREATE TEMPORARY TABLE moved_chat_messages ( - old_chat_message_id INTEGER, - new_chat_message_id INTEGER - ) ON COMMIT DROP; - - CREATE INDEX moved_chat_messages_old_chat_message_id ON moved_chat_messages(old_chat_message_id); - SQL - end - - def bulk_insert_movement_metadata - values_sql = @movement_metadata.map { |mm| "(#{mm[:old_id]}, #{mm[:new_id]})" }.join(",\n") - DB.exec( - "INSERT INTO moved_chat_messages(old_chat_message_id, new_chat_message_id) VALUES #{values_sql}", - ) - end - - ## - # We purposefully omit in_reply_to_id when creating the messages in the - # new channel, because it could be pointing to a message that has not - # been moved. - def create_destination_messages_in_channel(destination_channel) - query_args = { - message_ids: @ordered_source_message_ids, - destination_channel_id: destination_channel.id, - } - moved_message_ids = DB.query_single(<<~SQL, query_args) - INSERT INTO chat_messages( - chat_channel_id, user_id, last_editor_id, message, cooked, cooked_version, created_at, updated_at - ) - SELECT :destination_channel_id, - user_id, - last_editor_id, - message, - cooked, - cooked_version, - CLOCK_TIMESTAMP(), - CLOCK_TIMESTAMP() - FROM chat_messages - WHERE id IN (:message_ids) - RETURNING id - SQL - - @movement_metadata = - moved_message_ids.map.with_index do |chat_message_id, idx| - { old_id: @ordered_source_message_ids[idx], new_id: chat_message_id } - end - moved_message_ids - end - - def update_references - DB.exec(<<~SQL) - UPDATE chat_message_reactions cmr - SET chat_message_id = mm.new_chat_message_id - FROM moved_chat_messages mm - WHERE cmr.chat_message_id = mm.old_chat_message_id - SQL - - DB.exec(<<~SQL) - UPDATE upload_references uref - SET target_id = mm.new_chat_message_id - FROM moved_chat_messages mm - WHERE uref.target_id = mm.old_chat_message_id AND uref.target_type = 'ChatMessage' - SQL - - DB.exec(<<~SQL) - UPDATE chat_mentions cment - SET chat_message_id = mm.new_chat_message_id - FROM moved_chat_messages mm - WHERE cment.chat_message_id = mm.old_chat_message_id - SQL - - DB.exec(<<~SQL) - UPDATE chat_message_revisions crev - SET chat_message_id = mm.new_chat_message_id - FROM moved_chat_messages mm - WHERE crev.chat_message_id = mm.old_chat_message_id - SQL - - DB.exec(<<~SQL) - UPDATE chat_webhook_events cweb - SET chat_message_id = mm.new_chat_message_id - FROM moved_chat_messages mm - WHERE cweb.chat_message_id = mm.old_chat_message_id - SQL - end - - def delete_source_messages - # We do this so @source_messages is not nulled out, which is the - # case when using update_all here. - DB.exec(<<~SQL, source_message_ids: @source_message_ids, deleted_by_id: @acting_user.id) - UPDATE chat_messages - SET deleted_at = NOW(), deleted_by_id = :deleted_by_id - WHERE id IN (:source_message_ids) - SQL - ChatPublisher.publish_bulk_delete!(@source_channel, @source_message_ids) - end - - def add_moved_placeholder(destination_channel, first_moved_message) - Chat::ChatMessageCreator.create( - chat_channel: @source_channel, - user: Discourse.system_user, - content: - I18n.t( - "chat.channel.messages_moved", - count: @source_message_ids.length, - acting_username: @acting_user.username, - channel_name: destination_channel.title(@acting_user), - first_moved_message_url: first_moved_message.url, - ), - ) - end - - def update_reply_references - DB.exec(<<~SQL, deleted_reply_to_ids: @source_message_ids) - UPDATE chat_messages - SET in_reply_to_id = NULL - WHERE in_reply_to_id IN (:deleted_reply_to_ids) - SQL - end - - def update_thread_references - threads_to_update = [] - @source_messages - .select { |message| message.thread_id.present? } - .each do |message_with_thread| - # If one of the messages we are moving is the original message in a thread, - # then all the remaining messages for that thread must be moved to a new one, - # otherwise they will be pointing to a thread in a different channel. - if message_with_thread.thread.original_message_id == message_with_thread.id - threads_to_update << message_with_thread.thread - end - end - - threads_to_update.each do |thread| - # NOTE: We may want to do something different with the old empty thread at some - # point when we add an explicit thread move UI, for now we can just delete it, - # since it will not contain any important data. - if thread.chat_messages.empty? - thread.destroy! - next - end - - ChatThread.transaction do - original_message = thread.chat_messages.first - new_thread = - ChatThread.create!( - original_message: original_message, - original_message_user: original_message.user, - channel: @source_channel, - ) - thread.chat_messages.update_all(thread_id: new_thread.id) - end - end - end -end diff --git a/plugins/chat/lib/post_notification_handler.rb b/plugins/chat/lib/post_notification_handler.rb deleted file mode 100644 index beefe24ab73..00000000000 --- a/plugins/chat/lib/post_notification_handler.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -## -# Handles :post_alerter_after_save_post events from -# core. Used for notifying users that their chat message -# has been quoted in a post. -class Chat::PostNotificationHandler - attr_reader :post - - def initialize(post, notified_users) - @post = post - @notified_users = notified_users - end - - def handle - return false if post.post_type == Post.types[:whisper] - return false if post.topic.blank? - return false if post.topic.private_message? - - quoted_users = extract_quoted_users(post) - if @notified_users.present? - quoted_users = quoted_users.where("users.id NOT IN (?)", @notified_users) - end - - opts = { user_id: post.user.id, display_username: post.user.username } - quoted_users.each do |user| - # PostAlerter.create_notification handles many edge cases, such as - # muting, ignoring, double notifications etc. - PostAlerter.new.create_notification(user, Notification.types[:chat_quoted], post, opts) - end - end - - private - - def extract_quoted_users(post) - usernames = - post.raw.scan(/\[chat quote=\"([^;]+);.+\"\]/).uniq.map { |q| q.first.strip.downcase } - User.where.not(id: post.user_id).where(username_lower: usernames) - end -end diff --git a/plugins/chat/lib/secure_uploads_compatibility.rb b/plugins/chat/lib/secure_uploads_compatibility.rb deleted file mode 100644 index 6fd898f10bc..00000000000 --- a/plugins/chat/lib/secure_uploads_compatibility.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -class Chat::SecureUploadsCompatibility - ## - # At this point in time, secure uploads is not compatible with chat, - # so if it is enabled then chat uploads must be disabled to avoid undesirable - # behaviour. - # - # The env var DISCOURSE_ALLOW_UNSECURE_CHAT_UPLOADS can be set to keep - # it enabled, but this is strongly advised against. - def self.update_settings - if SiteSetting.secure_uploads && SiteSetting.chat_allow_uploads && - !GlobalSetting.allow_unsecure_chat_uploads - SiteSetting.chat_allow_uploads = false - StaffActionLogger.new(Discourse.system_user).log_site_setting_change( - "chat_allow_uploads", - true, - false, - context: "Disabled because secure_uploads is enabled", - ) - end - end -end diff --git a/plugins/chat/lib/service_runner.rb b/plugins/chat/lib/service_runner.rb index 21084cedd42..b82ff5c9dcf 100644 --- a/plugins/chat/lib/service_runner.rb +++ b/plugins/chat/lib/service_runner.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true # -# = Chat::ServiceRunner +# = ServiceRunner # # This class is to be used via its helper +with_service+ in any class. Its # main purpose is to ease how actions can be run upon a service completion. @@ -45,7 +45,8 @@ # The only exception to this being +on_failure+ as it will always be executed # last. # -class Chat::ServiceRunner + +class ServiceRunner # @!visibility private NULL_RESULT = OpenStruct.new(failure?: false) # @!visibility private @@ -70,7 +71,7 @@ class Chat::ServiceRunner @actions = {} end - # @param service [Class] a class including {Chat::Service::Base} + # @param service [Class] a class including {Service::Base} # @param block [Proc] a block containing the steps to match on # @return [void] def self.call(service, object, **dependencies, &block) diff --git a/plugins/chat/lib/slack_compatibility.rb b/plugins/chat/lib/slack_compatibility.rb deleted file mode 100644 index 106af32caf3..00000000000 --- a/plugins/chat/lib/slack_compatibility.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -## -# Processes slack-formatted text messages, as Mattermost does with -# Slack incoming webhook interoperability, for example links in the -# format and , and mentions. -# -# See https://api.slack.com/reference/surfaces/formatting for all of -# the different formatting slack supports with mrkdwn which is mostly -# identical to Markdown. -# -# Mattermost docs for translating the slack format: -# -# https://docs.mattermost.com/developer/webhooks-incoming.html?highlight=translate%20slack%20data%20format%20mattermost#translate-slack-s-data-format-to-mattermost -# -# We may want to process attachments and blocks from slack in future, and -# convert user IDs into user mentions. -class Chat::SlackCompatibility - MRKDWN_LINK_REGEX = Regexp.new(/(<[^\n<\|>]+>|<[^\n<\>]+>)/).freeze - - class << self - def process_text(text) - text = text.gsub("", "@here") - text = text.gsub("", "@all") - - text.scan(MRKDWN_LINK_REGEX) do |match| - match = match.first - - if match.include?("|") - link, title = match.split("|")[0..1] - else - link = match - end - - title = title&.gsub(/<|>/, "") - link = link&.gsub(/<|>/, "") - - if title - text = text.gsub(match, "[#{title}](#{link})") - else - text = text.gsub(match, "#{link}") - end - end - - text - end - - # TODO: This is quite hacky and is only here to support a single - # attachment for our OpsGenie integration. In future we would - # want to iterate through this attachments array and extract - # things properly. - # - # See https://api.slack.com/reference/messaging/attachments for - # more details on what fields are here. - def process_legacy_attachments(attachments) - text = CGI.unescape(attachments[0][:fallback]) - process_text(text) - end - end -end diff --git a/plugins/chat/lib/tasks/chat.rake b/plugins/chat/lib/tasks/chat.rake deleted file mode 100644 index a53e1b319cc..00000000000 --- a/plugins/chat/lib/tasks/chat.rake +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -if Discourse.allow_dev_populate? - chat_task = Rake::Task["dev:populate"] - chat_task.enhance do - SiteSetting.chat_enabled = true - DiscourseDev::PublicChannel.populate! - DiscourseDev::DirectChannel.populate! - DiscourseDev::Message.populate! - end - - desc "Generates sample content for chat" - task "chat:populate" => ["db:load_config"] do |_, args| - DiscourseDev::PublicChannel.new.populate!(ignore_current_count: true) - DiscourseDev::DirectChannel.new.populate!(ignore_current_count: true) - DiscourseDev::Message.new.populate!(ignore_current_count: true) - end - - desc "Generates sample messages in channels" - task "chat:message:populate" => ["db:load_config"] do |_, args| - DiscourseDev::Message.new.populate!(ignore_current_count: true) - end -end diff --git a/plugins/chat/lib/tasks/chat_message.rake b/plugins/chat/lib/tasks/chat_message.rake index 603722e4ba4..316033fcd21 100644 --- a/plugins/chat/lib/tasks/chat_message.rake +++ b/plugins/chat/lib/tasks/chat_message.rake @@ -15,7 +15,7 @@ end def rebake_uncooked_chat_messages puts "Rebaking uncooked chat messages on #{RailsMultisite::ConnectionManagement.current_db}" - uncooked = ChatMessage.uncooked + uncooked = Chat::Message.uncooked rebaked = 0 total = uncooked.count @@ -100,7 +100,7 @@ task "chat:make_channel_to_test_archiving", [:user_for_membership] => :environme raw: "This is some cool first post for archive stuff", ) chat_channel = - ChatChannel.create( + Chat::Channel.create( chatable: topic, chatable_type: "Topic", name: "testing channel for archiving #{SecureRandom.hex(4)}", @@ -112,12 +112,13 @@ task "chat:make_channel_to_test_archiving", [:user_for_membership] => :environme users = [make_test_user, make_test_user, make_test_user] - ChatChannel.transaction do + Chat::Channel.transaction do start_time = Time.now puts "creating 1039 messages for the channel" 1039.times do - cm = ChatMessage.new(message: messages.sample, user: users.sample, chat_channel: chat_channel) + cm = + Chat::Message.new(message: messages.sample, user: users.sample, chat_channel: chat_channel) cm.cook cm.save! end @@ -125,7 +126,7 @@ task "chat:make_channel_to_test_archiving", [:user_for_membership] => :environme puts "message creation done" puts "took #{Time.now - start_time} seconds" - UserChatChannelMembership.create( + Chat::UserChatChannelMembership.create( chat_channel: chat_channel, last_read_message_id: 0, user: User.find_by(username: user_for_membership), diff --git a/plugins/chat/lib/validators/chat_allow_uploads_validator.rb b/plugins/chat/lib/validators/chat_allow_uploads_validator.rb deleted file mode 100644 index bd7bbd4b020..00000000000 --- a/plugins/chat/lib/validators/chat_allow_uploads_validator.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -class ChatAllowUploadsValidator - def initialize(opts = {}) - @opts = opts - end - - def valid_value?(value) - return false if value == "t" && prevent_enabling_chat_uploads? - true - end - - def error_message - if prevent_enabling_chat_uploads? - I18n.t("site_settings.errors.chat_upload_not_allowed_secure_uploads") - end - end - - def prevent_enabling_chat_uploads? - SiteSetting.secure_uploads && !GlobalSetting.allow_unsecure_chat_uploads - end -end diff --git a/plugins/chat/lib/validators/chat_default_channel_validator.rb b/plugins/chat/lib/validators/chat_default_channel_validator.rb deleted file mode 100644 index 917663fcfea..00000000000 --- a/plugins/chat/lib/validators/chat_default_channel_validator.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class ChatDefaultChannelValidator - def initialize(opts = {}) - @opts = opts - end - - def valid_value?(value) - !!(value == "" || ChatChannel.find_by(id: value.to_i)&.public_channel?) - end - - def error_message - I18n.t("site_settings.errors.chat_default_channel") - end -end diff --git a/plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb b/plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb deleted file mode 100644 index bcd54905124..00000000000 --- a/plugins/chat/lib/validators/direct_message_enabled_groups_validator.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -class DirectMessageEnabledGroupsValidator - def initialize(opts = {}) - @opts = opts - end - - def valid_value?(val) - val.present? && val != "" - end - - def error_message - I18n.t("site_settings.errors.direct_message_enabled_groups_invalid") - end -end diff --git a/plugins/chat/plugin.rb b/plugins/chat/plugin.rb index 29f7347f35c..1f2f5b81d97 100644 --- a/plugins/chat/plugin.rb +++ b/plugins/chat/plugin.rb @@ -9,74 +9,16 @@ enabled_site_setting :chat_enabled -register_asset "stylesheets/mixins/chat-scrollbar.scss" -register_asset "stylesheets/common/core-extensions.scss" -register_asset "stylesheets/common/chat-emoji-picker.scss" -register_asset "stylesheets/common/chat-channel-card.scss" -register_asset "stylesheets/common/create-channel-modal.scss" -register_asset "stylesheets/common/dc-filter-input.scss" -register_asset "stylesheets/common/common.scss" -register_asset "stylesheets/common/chat-browse.scss" -register_asset "stylesheets/common/chat-drawer.scss" -register_asset "stylesheets/common/chat-index.scss" -register_asset "stylesheets/mobile/chat-index.scss", :mobile -register_asset "stylesheets/desktop/chat-index-full-page.scss", :desktop -register_asset "stylesheets/desktop/chat-index-drawer.scss", :desktop -register_asset "stylesheets/common/chat-channel-preview-card.scss" -register_asset "stylesheets/common/chat-channel-info.scss" -register_asset "stylesheets/common/chat-draft-channel.scss" -register_asset "stylesheets/common/chat-tabs.scss" -register_asset "stylesheets/common/chat-form.scss" -register_asset "stylesheets/common/d-progress-bar.scss" -register_asset "stylesheets/common/incoming-chat-webhooks.scss" -register_asset "stylesheets/mobile/chat-message.scss", :mobile -register_asset "stylesheets/desktop/chat-message.scss", :desktop -register_asset "stylesheets/common/chat-channel-title.scss" -register_asset "stylesheets/desktop/chat-channel-title.scss", :desktop -register_asset "stylesheets/common/full-page-chat-header.scss" -register_asset "stylesheets/common/chat-reply.scss" -register_asset "stylesheets/common/chat-message.scss" -register_asset "stylesheets/common/chat-message-left-gutter.scss" -register_asset "stylesheets/common/chat-message-info.scss" -register_asset "stylesheets/common/chat-composer-inline-button.scss" -register_asset "stylesheets/common/chat-replying-indicator.scss" -register_asset "stylesheets/common/chat-composer.scss" -register_asset "stylesheets/desktop/chat-composer.scss", :desktop -register_asset "stylesheets/mobile/chat-composer.scss", :mobile -register_asset "stylesheets/common/direct-message-creator.scss" -register_asset "stylesheets/common/chat-message-collapser.scss" -register_asset "stylesheets/common/chat-message-images.scss" -register_asset "stylesheets/common/chat-transcript.scss" -register_asset "stylesheets/common/chat-composer-dropdown.scss" -register_asset "stylesheets/common/chat-retention-reminder.scss" -register_asset "stylesheets/common/chat-composer-uploads.scss" -register_asset "stylesheets/desktop/chat-composer-uploads.scss", :desktop -register_asset "stylesheets/common/chat-composer-upload.scss" -register_asset "stylesheets/common/chat-selection-manager.scss" -register_asset "stylesheets/mobile/chat-selection-manager.scss", :mobile -register_asset "stylesheets/common/chat-channel-selector-modal.scss" -register_asset "stylesheets/mobile/mobile.scss", :mobile -register_asset "stylesheets/desktop/desktop.scss", :desktop -register_asset "stylesheets/sidebar-extensions.scss" -register_asset "stylesheets/desktop/sidebar-extensions.scss", :desktop -register_asset "stylesheets/common/chat-message-actions.scss" -register_asset "stylesheets/desktop/chat-message-actions.scss", :desktop -register_asset "stylesheets/mobile/chat-message-actions.scss", :mobile -register_asset "stylesheets/common/chat-message-separator.scss" -register_asset "stylesheets/common/chat-onebox.scss" -register_asset "stylesheets/common/chat-skeleton.scss" register_asset "stylesheets/colors.scss", :color_definitions -register_asset "stylesheets/common/reviewable-chat-message.scss" -register_asset "stylesheets/common/chat-mention-warnings.scss" -register_asset "stylesheets/common/chat-channel-settings-saved-indicator.scss" -register_asset "stylesheets/common/chat-thread.scss" -register_asset "stylesheets/common/chat-side-panel.scss" +register_asset "stylesheets/mixins/index.scss" +register_asset "stylesheets/common/index.scss" +register_asset "stylesheets/desktop/index.scss", :desktop +register_asset "stylesheets/mobile/index.scss", :mobile register_svg_icon "comments" register_svg_icon "comment-slash" register_svg_icon "hashtag" register_svg_icon "lock" - register_svg_icon "file-audio" register_svg_icon "file-video" register_svg_icon "file-image" @@ -84,178 +26,17 @@ register_svg_icon "file-image" # route: /admin/plugins/chat add_admin_route "chat.admin.title", "chat" -# Site setting validators must be loaded before initialize -require_relative "lib/validators/chat_default_channel_validator.rb" -require_relative "lib/validators/chat_allow_uploads_validator.rb" -require_relative "lib/validators/direct_message_enabled_groups_validator.rb" -require_relative "app/core_ext/plugin_instance.rb" - GlobalSetting.add_default(:allow_unsecure_chat_uploads, false) +module ::Chat + PLUGIN_NAME = "chat" +end + +require_relative "lib/chat/engine" + after_initialize do - # Namespace for classes and modules parts of chat plugin - module ::Chat - PLUGIN_NAME = "chat" - HAS_CHAT_ENABLED = "has_chat_enabled" - - class Engine < ::Rails::Engine - engine_name PLUGIN_NAME - isolate_namespace Chat - end - - def self.allowed_group_ids - SiteSetting.chat_allowed_groups_map - end - - def self.onebox_template - @onebox_template ||= - begin - path = "#{Rails.root}/plugins/chat/lib/onebox/templates/discourse_chat.mustache" - File.read(path) - end - end - end - register_seedfu_fixtures(Rails.root.join("plugins", "chat", "db", "fixtures")) - load File.expand_path( - "../app/controllers/admin/admin_incoming_chat_webhooks_controller.rb", - __FILE__, - ) - load File.expand_path("../app/helpers/with_service_helper.rb", __FILE__) - load File.expand_path("../app/controllers/chat_base_controller.rb", __FILE__) - load File.expand_path("../app/controllers/chat_controller.rb", __FILE__) - load File.expand_path("../app/controllers/emojis_controller.rb", __FILE__) - load File.expand_path("../app/controllers/direct_messages_controller.rb", __FILE__) - load File.expand_path("../app/controllers/incoming_chat_webhooks_controller.rb", __FILE__) - load File.expand_path("../app/models/concerns/chatable.rb", __FILE__) - load File.expand_path("../app/models/deleted_chat_user.rb", __FILE__) - load File.expand_path("../app/models/user_chat_channel_membership.rb", __FILE__) - load File.expand_path("../app/models/chat_channel.rb", __FILE__) - load File.expand_path("../app/models/chat_channel_archive.rb", __FILE__) - load File.expand_path("../app/models/chat_draft.rb", __FILE__) - load File.expand_path("../app/models/chat_message.rb", __FILE__) - load File.expand_path("../app/models/chat_message_reaction.rb", __FILE__) - load File.expand_path("../app/models/chat_message_revision.rb", __FILE__) - load File.expand_path("../app/models/chat_mention.rb", __FILE__) - load File.expand_path("../app/models/chat_thread.rb", __FILE__) - load File.expand_path("../app/models/chat_upload.rb", __FILE__) - load File.expand_path("../app/models/chat_webhook_event.rb", __FILE__) - load File.expand_path("../app/models/direct_message_channel.rb", __FILE__) - load File.expand_path("../app/models/direct_message.rb", __FILE__) - load File.expand_path("../app/models/direct_message_user.rb", __FILE__) - load File.expand_path("../app/models/incoming_chat_webhook.rb", __FILE__) - load File.expand_path("../app/models/reviewable_chat_message.rb", __FILE__) - load File.expand_path("../app/models/chat_view.rb", __FILE__) - load File.expand_path("../app/models/category_channel.rb", __FILE__) - load File.expand_path("../app/serializers/chat_message_user_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/structured_channel_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_webhook_event_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_in_reply_to_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/base_chat_channel_membership_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/user_chat_channel_membership_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_message_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_channel_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_channel_index_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_channel_search_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_thread_original_message_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_thread_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/chat_view_serializer.rb", __FILE__) - load File.expand_path( - "../app/serializers/user_with_custom_fields_and_status_serializer.rb", - __FILE__, - ) - load File.expand_path("../app/serializers/direct_message_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/incoming_chat_webhook_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/admin_chat_index_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/user_chat_message_bookmark_serializer.rb", __FILE__) - load File.expand_path("../app/serializers/reviewable_chat_message_serializer.rb", __FILE__) - load File.expand_path("../app/services/base.rb", __FILE__) - load File.expand_path("../lib/chat_channel_fetcher.rb", __FILE__) - load File.expand_path("../lib/chat_channel_hashtag_data_source.rb", __FILE__) - load File.expand_path("../lib/chat_mailer.rb", __FILE__) - load File.expand_path("../lib/chat_message_creator.rb", __FILE__) - load File.expand_path("../lib/chat_message_processor.rb", __FILE__) - load File.expand_path("../lib/chat_message_updater.rb", __FILE__) - load File.expand_path("../lib/chat_message_rate_limiter.rb", __FILE__) - load File.expand_path("../lib/chat_message_reactor.rb", __FILE__) - load File.expand_path("../lib/chat_message_mentions.rb", __FILE__) - load File.expand_path("../lib/chat_notifier.rb", __FILE__) - load File.expand_path("../lib/chat_seeder.rb", __FILE__) - load File.expand_path("../lib/chat_statistics.rb", __FILE__) - load File.expand_path("../lib/chat_transcript_service.rb", __FILE__) - load File.expand_path("../lib/duplicate_message_validator.rb", __FILE__) - load File.expand_path("../lib/message_mover.rb", __FILE__) - load File.expand_path("../lib/chat_channel_membership_manager.rb", __FILE__) - load File.expand_path("../lib/chat_message_bookmarkable.rb", __FILE__) - load File.expand_path("../lib/chat_channel_archive_service.rb", __FILE__) - load File.expand_path("../lib/chat_review_queue.rb", __FILE__) - load File.expand_path("../lib/direct_message_channel_creator.rb", __FILE__) - load File.expand_path("../lib/guardian_extensions.rb", __FILE__) - load File.expand_path("../lib/extensions/user_option_extension.rb", __FILE__) - load File.expand_path("../lib/extensions/user_notifications_extension.rb", __FILE__) - load File.expand_path("../lib/extensions/user_email_extension.rb", __FILE__) - load File.expand_path("../lib/extensions/category_extension.rb", __FILE__) - load File.expand_path("../lib/extensions/user_extension.rb", __FILE__) - load File.expand_path("../lib/slack_compatibility.rb", __FILE__) - load File.expand_path("../lib/post_notification_handler.rb", __FILE__) - load File.expand_path("../lib/secure_uploads_compatibility.rb", __FILE__) - load File.expand_path("../lib/service_runner.rb", __FILE__) - load File.expand_path("../lib/steps_inspector.rb", __FILE__) - load File.expand_path("../app/jobs/regular/auto_manage_channel_memberships.rb", __FILE__) - load File.expand_path("../app/jobs/regular/auto_join_channel_batch.rb", __FILE__) - load File.expand_path("../app/jobs/regular/process_chat_message.rb", __FILE__) - load File.expand_path("../app/jobs/regular/chat_channel_archive.rb", __FILE__) - load File.expand_path("../app/jobs/regular/chat_channel_delete.rb", __FILE__) - load File.expand_path("../app/jobs/regular/chat_notify_mentioned.rb", __FILE__) - load File.expand_path("../app/jobs/regular/chat_notify_watching.rb", __FILE__) - load File.expand_path("../app/jobs/regular/update_channel_user_count.rb", __FILE__) - load File.expand_path("../app/jobs/regular/delete_user_messages.rb", __FILE__) - load File.expand_path("../app/jobs/regular/send_message_notifications.rb", __FILE__) - load File.expand_path("../app/jobs/scheduled/delete_old_chat_messages.rb", __FILE__) - load File.expand_path("../app/jobs/scheduled/update_user_counts_for_chat_channels.rb", __FILE__) - load File.expand_path("../app/jobs/scheduled/email_chat_notifications.rb", __FILE__) - load File.expand_path("../app/jobs/scheduled/auto_join_users.rb", __FILE__) - load File.expand_path("../app/jobs/scheduled/chat_periodical_updates.rb", __FILE__) - load File.expand_path("../app/jobs/service_job.rb", __FILE__) - load File.expand_path("../app/services/chat_publisher.rb", __FILE__) - load File.expand_path("../app/services/trash_channel.rb", __FILE__) - load File.expand_path("../app/services/update_channel.rb", __FILE__) - load File.expand_path("../app/services/update_channel_status.rb", __FILE__) - load File.expand_path("../app/services/chat_message_destroyer.rb", __FILE__) - load File.expand_path("../app/services/update_user_last_read.rb", __FILE__) - load File.expand_path("../app/services/lookup_thread.rb", __FILE__) - load File.expand_path("../app/controllers/api_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/chat_channels_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/chat_current_user_channels_controller.rb", __FILE__) - load File.expand_path( - "../app/controllers/api/chat_channels_current_user_membership_controller.rb", - __FILE__, - ) - load File.expand_path("../app/controllers/api/chat_channels_memberships_controller.rb", __FILE__) - load File.expand_path( - "../app/controllers/api/chat_channels_messages_moves_controller.rb", - __FILE__, - ) - load File.expand_path("../app/controllers/api/chat_channels_archives_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/chat_channels_status_controller.rb", __FILE__) - load File.expand_path( - "../app/controllers/api/chat_channels_current_user_notifications_settings_controller.rb", - __FILE__, - ) - load File.expand_path("../app/controllers/api/category_chatables_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/hints_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/chat_channel_threads_controller.rb", __FILE__) - load File.expand_path("../app/controllers/api/chat_chatables_controller.rb", __FILE__) - load File.expand_path("../app/queries/chat_channel_unreads_query.rb", __FILE__) - load File.expand_path("../app/queries/chat_channel_memberships_query.rb", __FILE__) - - if Discourse.allow_dev_populate? - load File.expand_path("../lib/discourse_dev/public_channel.rb", __FILE__) - load File.expand_path("../lib/discourse_dev/direct_channel.rb", __FILE__) - load File.expand_path("../lib/discourse_dev/message.rb", __FILE__) - end - UserNotifications.append_view_path(File.expand_path("../app/views", __FILE__)) register_category_custom_field_type(Chat::HAS_CHAT_ENABLED, :boolean) @@ -267,7 +48,7 @@ after_initialize do UserUpdater::OPTION_ATTR.push(:chat_email_frequency) UserUpdater::OPTION_ATTR.push(:chat_header_indicator_preference) - register_reviewable_type ReviewableChatMessage + register_reviewable_type Chat::ReviewableMessage reloadable_patch do |plugin| ReviewableScore.add_new_types([:needs_review]) @@ -278,21 +59,24 @@ after_initialize do UserNotifications.prepend Chat::UserNotificationsExtension UserOption.prepend Chat::UserOptionExtension Category.prepend Chat::CategoryExtension + Reviewable.prepend Chat::ReviewableExtension + Bookmark.prepend Chat::BookmarkExtension User.prepend Chat::UserExtension Jobs::UserEmail.prepend Chat::UserEmailExtension + Plugin::Instance.prepend Chat::PluginInstanceExtension end if Oneboxer.respond_to?(:register_local_handler) Oneboxer.register_local_handler("chat/chat") do |url, route| if route[:message_id].present? - message = ChatMessage.find_by(id: route[:message_id]) + message = Chat::Message.find_by(id: route[:message_id]) next if !message chat_channel = message.chat_channel user = message.user next if !chat_channel || !user else - chat_channel = ChatChannel.find_by(id: route[:channel_id]) + chat_channel = Chat::Channel.find_by(id: route[:channel_id]) next if !chat_channel end @@ -346,7 +130,7 @@ after_initialize do if InlineOneboxer.respond_to?(:register_local_handler) InlineOneboxer.register_local_handler("chat/chat") do |url, route| if route[:message_id].present? - message = ChatMessage.find_by(id: route[:message_id]) + message = Chat::Message.find_by(id: route[:message_id]) next if !message chat_channel = message.chat_channel @@ -361,7 +145,7 @@ after_initialize do username: user.username, ) else - chat_channel = ChatChannel.find_by(id: route[:channel_id]) + chat_channel = Chat::Channel.find_by(id: route[:channel_id]) next if !chat_channel title = @@ -378,12 +162,12 @@ after_initialize do if respond_to?(:register_upload_in_use) register_upload_in_use do |upload| - ChatMessage.where( + Chat::Message.where( "message LIKE ? OR message LIKE ?", "%#{upload.sha1}%", "%#{upload.base62_sha1}%", ).exists? || - ChatDraft.where( + Chat::Draft.where( "data LIKE ? OR data LIKE ?", "%#{upload.sha1}%", "%#{upload.base62_sha1}%", @@ -425,7 +209,7 @@ after_initialize do add_to_serializer(:current_user, :needs_dm_retention_reminder) { true } add_to_serializer(:current_user, :has_joinable_public_channels) do - Chat::ChatChannelFetcher.secured_public_channel_search( + Chat::ChannelFetcher.secured_public_channel_search( self.scope, following: false, limit: 1, @@ -434,8 +218,8 @@ after_initialize do end add_to_serializer(:current_user, :chat_channels) do - structured = Chat::ChatChannelFetcher.structured(self.scope) - ChatChannelIndexSerializer.new(structured, scope: self.scope, root: false).as_json + structured = Chat::ChannelFetcher.structured(self.scope) + Chat::ChannelIndexSerializer.new(structured, scope: self.scope, root: false).as_json end add_to_serializer(:current_user, :include_needs_channel_retention_reminder?) do @@ -450,7 +234,7 @@ after_initialize do end add_to_serializer(:current_user, :chat_drafts) do - ChatDraft + Chat::Draft .where(user_id: object.id) .order(updated_at: :desc) .limit(20) @@ -519,7 +303,7 @@ after_initialize do register_presence_channel_prefix("chat-reply") do |channel_name| if chat_channel_id = channel_name[%r{/chat-reply/(\d+)}, 1] - chat_channel = ChatChannel.find(chat_channel_id) + chat_channel = Chat::Channel.find(chat_channel_id) PresenceChannel::Config.new.tap do |config| config.allowed_group_ids = chat_channel.allowed_group_ids @@ -553,27 +337,27 @@ after_initialize do on(:user_seen) do |user| if user.last_seen_at == user.first_seen_at - ChatChannel + Chat::Channel .where(auto_join_users: true) .each do |channel| - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + Chat::ChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) end end end on(:user_confirmed_email) do |user| if user.active? - ChatChannel + Chat::Channel .where(auto_join_users: true) .each do |channel| - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + Chat::ChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) end end end on(:user_added_to_group) do |user, group| channels_to_add = - ChatChannel + Chat::Channel .distinct .where(auto_join_users: true, chatable_type: "Category") .joins( @@ -582,7 +366,7 @@ after_initialize do .where(category_groups: { group_id: group.id }) channels_to_add.each do |channel| - Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) + Chat::ChannelMembershipManager.new(channel).enforce_automatic_user_membership(user) end end @@ -590,116 +374,25 @@ after_initialize do # TODO(roman): remove early return after 2.9 release. # There's a bug on core where this event is triggered with an `#update` result (true/false) return if !category.is_a?(Category) - category_channel = ChatChannel.find_by(auto_join_users: true, chatable: category) + category_channel = Chat::Channel.find_by(auto_join_users: true, chatable: category) if category_channel - Chat::ChatChannelMembershipManager.new(category_channel).enforce_automatic_channel_memberships - end - end - - Chat::Engine.routes.draw do - namespace :api, defaults: { format: :json } do - get "/chatables" => "chat_chatables#index" - get "/channels" => "chat_channels#index" - get "/channels/me" => "chat_current_user_channels#index" - post "/channels" => "chat_channels#create" - delete "/channels/:channel_id" => "chat_channels#destroy" - put "/channels/:channel_id" => "chat_channels#update" - get "/channels/:channel_id" => "chat_channels#show" - put "/channels/:channel_id/status" => "chat_channels_status#update" - post "/channels/:channel_id/messages/moves" => "chat_channels_messages_moves#create" - post "/channels/:channel_id/archives" => "chat_channels_archives#create" - get "/channels/:channel_id/memberships" => "chat_channels_memberships#index" - delete "/channels/:channel_id/memberships/me" => - "chat_channels_current_user_membership#destroy" - post "/channels/:channel_id/memberships/me" => "chat_channels_current_user_membership#create" - put "/channels/:channel_id/notifications-settings/me" => - "chat_channels_current_user_notifications_settings#update" - - # Category chatables controller hints. Only used by staff members, we don't want to leak category permissions. - get "/category-chatables/:id/permissions" => "category_chatables#permissions", - :format => :json, - :constraints => StaffConstraint.new - - # Hints for JIT warnings. - get "/mentions/groups" => "hints#check_group_mentions", :format => :json - - get "/channels/:channel_id/threads/:thread_id" => "chat_channel_threads#show" - end - - # direct_messages_controller routes - get "/direct_messages" => "direct_messages#index" - post "/direct_messages/create" => "direct_messages#create" - - # incoming_webhooks_controller routes - post "/hooks/:key" => "incoming_chat_webhooks#create_message" - - # incoming_webhooks_controller routes - post "/hooks/:key/slack" => "incoming_chat_webhooks#create_message_slack_compatible" - - # chat_controller routes - get "/" => "chat#respond" - get "/browse" => "chat#respond" - get "/browse/all" => "chat#respond" - get "/browse/closed" => "chat#respond" - get "/browse/open" => "chat#respond" - get "/browse/archived" => "chat#respond" - get "/draft-channel" => "chat#respond" - post "/enable" => "chat#enable_chat" - post "/disable" => "chat#disable_chat" - post "/dismiss-retention-reminder" => "chat#dismiss_retention_reminder" - get "/:chat_channel_id/messages" => "chat#messages" - get "/message/:message_id" => "chat#message_link" - put ":chat_channel_id/edit/:message_id" => "chat#edit_message" - put ":chat_channel_id/react/:message_id" => "chat#react" - delete "/:chat_channel_id/:message_id" => "chat#delete" - put "/:chat_channel_id/:message_id/rebake" => "chat#rebake" - post "/:chat_channel_id/:message_id/flag" => "chat#flag" - post "/:chat_channel_id/quote" => "chat#quote_messages" - put "/:chat_channel_id/restore/:message_id" => "chat#restore" - get "/lookup/:message_id" => "chat#lookup_message" - put "/:chat_channel_id/read/:message_id" => "chat#update_user_last_read" - put "/user_chat_enabled/:user_id" => "chat#set_user_chat_status" - put "/:chat_channel_id/invite" => "chat#invite_users" - post "/drafts" => "chat#set_draft" - post "/:chat_channel_id" => "chat#create_message" - put "/flag" => "chat#flag" - get "/emojis" => "emojis#index" - - base_c_route = "/c/:channel_title/:channel_id" - get base_c_route => "chat#respond", :as => "channel" - get "#{base_c_route}/:message_id" => "chat#respond" - - %w[info info/about info/members info/settings].each do |route| - get "#{base_c_route}/#{route}" => "chat#respond" - end - - # /channel -> /c redirects - get "/channel/:channel_id", to: redirect("/chat/c/-/%{channel_id}") - - get "#{base_c_route}/t/:thread_id" => "chat#respond" - - base_channel_route = "/channel/:channel_id/:channel_title" - redirect_base = "/chat/c/%{channel_title}/%{channel_id}" - - get base_channel_route, to: redirect(redirect_base) - - %w[info info/about info/members info/settings].each do |route| - get "#{base_channel_route}/#{route}", to: redirect("#{redirect_base}/#{route}") + Chat::ChannelMembershipManager.new(category_channel).enforce_automatic_channel_memberships end end Discourse::Application.routes.append do mount ::Chat::Engine, at: "/chat" - get "/admin/plugins/chat" => "chat/admin_incoming_chat_webhooks#index", + + get "/admin/plugins/chat" => "chat/admin/incoming_webhooks#index", :constraints => StaffConstraint.new - post "/admin/plugins/chat/hooks" => "chat/admin_incoming_chat_webhooks#create", + post "/admin/plugins/chat/hooks" => "chat/admin/incoming_webhooks#create", :constraints => StaffConstraint.new put "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" => - "chat/admin_incoming_chat_webhooks#update", + "chat/admin/incoming_webhooks#update", :constraints => StaffConstraint.new delete "/admin/plugins/chat/hooks/:incoming_chat_webhook_id" => - "chat/admin_incoming_chat_webhooks#destroy", + "chat/admin/incoming_webhooks#destroy", :constraints => StaffConstraint.new get "u/:username/preferences/chat" => "users#preferences", :constraints => { @@ -719,12 +412,12 @@ after_initialize do script do |context, fields, automation| sender = User.find_by(username: fields.dig("sender", "value")) || Discourse.system_user - channel = ChatChannel.find_by(id: fields.dig("chat_channel_id", "value")) + channel = Chat::Channel.find_by(id: fields.dig("chat_channel_id", "value")) placeholders = { channel_name: channel.title(sender) }.merge(context["placeholders"] || {}) creator = - Chat::ChatMessageCreator.create( + Chat::MessageCreator.create( chat_channel: channel, user: sender, content: utils.apply_placeholders(fields.dig("message", "value"), placeholders), @@ -748,11 +441,7 @@ after_initialize do fragment.css(".chat-summary-content").each { |element| element[:dm] = "body" } end - # TODO(roman): Remove `respond_to?` after 2.9 release - if respond_to?(:register_email_unsubscriber) - load File.expand_path("../lib/email_controller_helper/chat_summary_unsubscriber.rb", __FILE__) - register_email_unsubscriber("chat_summary", EmailControllerHelper::ChatSummaryUnsubscriber) - end + register_email_unsubscriber("chat_summary", EmailControllerHelper::ChatSummaryUnsubscriber) register_about_stat_group("chat_messages", show_in_ui: true) { Chat::Statistics.about_messages } @@ -761,23 +450,23 @@ after_initialize do register_about_stat_group("chat_users") { Chat::Statistics.about_users } # Make sure to update spec/system/hashtag_autocomplete_spec.rb when changing this. - register_hashtag_data_source(Chat::ChatChannelHashtagDataSource) + register_hashtag_data_source(Chat::ChannelHashtagDataSource) register_hashtag_type_priority_for_context("channel", "chat-composer", 200) register_hashtag_type_priority_for_context("category", "chat-composer", 100) register_hashtag_type_priority_for_context("tag", "chat-composer", 50) register_hashtag_type_priority_for_context("channel", "topic-composer", 10) Site.markdown_additional_options["chat"] = { - limited_pretty_text_features: ChatMessage::MARKDOWN_FEATURES, - limited_pretty_text_markdown_rules: ChatMessage::MARKDOWN_IT_RULES, + limited_pretty_text_features: Chat::Message::MARKDOWN_FEATURES, + limited_pretty_text_markdown_rules: Chat::Message::MARKDOWN_IT_RULES, hashtag_configurations: HashtagAutocompleteService.contexts_with_ordered_types, } register_user_destroyer_on_content_deletion_callback( - Proc.new { |user| Jobs.enqueue(:delete_user_messages, user_id: user.id) }, + Proc.new { |user| Jobs.enqueue(Jobs::Chat::DeleteUserMessages, user_id: user.id) }, ) - register_bookmarkable(ChatMessageBookmarkable) + register_bookmarkable(Chat::MessageBookmarkable) end if Rails.env == "test" diff --git a/plugins/chat/spec/components/chat_mailer_spec.rb b/plugins/chat/spec/components/chat/mailer_spec.rb similarity index 99% rename from plugins/chat/spec/components/chat_mailer_spec.rb rename to plugins/chat/spec/components/chat/mailer_spec.rb index 10a0669f3a1..cfc06500e2d 100644 --- a/plugins/chat/spec/components/chat_mailer_spec.rb +++ b/plugins/chat/spec/components/chat/mailer_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatMailer do +describe Chat::Mailer do fab!(:chatters_group) { Fabricate(:group) } fab!(:sender) { Fabricate(:user, group_ids: [chatters_group.id]) } fab!(:user_1) { Fabricate(:user, group_ids: [chatters_group.id], last_seen_at: 15.minutes.ago) } diff --git a/plugins/chat/spec/components/chat_message_creator_spec.rb b/plugins/chat/spec/components/chat/message_creator_spec.rb similarity index 86% rename from plugins/chat/spec/components/chat_message_creator_spec.rb rename to plugins/chat/spec/components/chat/message_creator_spec.rb index 2c9e01de7e4..703bdc9a493 100644 --- a/plugins/chat/spec/components/chat_message_creator_spec.rb +++ b/plugins/chat/spec/components/chat/message_creator_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatMessageCreator do +describe Chat::MessageCreator do fab!(:admin1) { Fabricate(:admin) } fab!(:admin2) { Fabricate(:admin) } fab!(:user1) { Fabricate(:user, group_ids: [Group::AUTO_GROUPS[:everyone]]) } @@ -55,11 +55,7 @@ describe Chat::ChatMessageCreator do it "errors when length is less than `chat_minimum_message_length`" do SiteSetting.chat_minimum_message_length = 10 creator = - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user1, - content: "2 short", - ) + described_class.create(chat_channel: public_chat_channel, user: user1, content: "2 short") expect(creator.failed?).to eq(true) expect(creator.error.message).to match( I18n.t( @@ -72,7 +68,7 @@ describe Chat::ChatMessageCreator do it "errors when length is greater than `chat_maximum_message_length`" do SiteSetting.chat_maximum_message_length = 100 creator = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "a really long and in depth message that is just too detailed" * 100, @@ -87,29 +83,29 @@ describe Chat::ChatMessageCreator do upload = Fabricate(:upload, user: user1) SiteSetting.chat_minimum_message_length = 10 expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "2 short", upload_ids: [upload.id], ) - }.to change { ChatMessage.count }.by(1) + }.to change { Chat::Message.count }.by(1) end it "creates messages for users who can see the channel" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", ) - }.to change { ChatMessage.count }.by(1) + }.to change { Chat::Message.count }.by(1) end it "updates the channel’s last message date" do previous_last_message_sent_at = public_chat_channel.last_message_sent_at - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -120,7 +116,7 @@ describe Chat::ChatMessageCreator do it "sets the last_editor_id to the user who created the message" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -131,7 +127,7 @@ describe Chat::ChatMessageCreator do it "publishes a DiscourseEvent for new messages" do events = DiscourseEvent.track_events do - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -142,7 +138,7 @@ describe Chat::ChatMessageCreator do it "creates mentions and mention notifications for public chat" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: @@ -167,7 +163,7 @@ describe Chat::ChatMessageCreator do it "mentions are case insensitive" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "Hey @#{user2.username.upcase}", @@ -177,23 +173,20 @@ describe Chat::ChatMessageCreator do it "notifies @all properly" do expect { - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user1, - content: "@all", - ) - }.to change { ChatMention.count }.by(4) + described_class.create(chat_channel: public_chat_channel, user: user1, content: "@all") + }.to change { Chat::Mention.count }.by(4) - UserChatChannelMembership.where(user: user2, chat_channel: public_chat_channel).update_all( - following: false, - ) + Chat::UserChatChannelMembership.where( + user: user2, + chat_channel: public_chat_channel, + ).update_all(following: false) expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "again! @all", ) - }.to change { ChatMention.count }.by(3) + }.to change { Chat::Mention.count }.by(3) end it "notifies @here properly" do @@ -203,18 +196,14 @@ describe Chat::ChatMessageCreator do user2.update(last_seen_at: Time.now) user3.update(last_seen_at: Time.now) expect { - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user1, - content: "@here", - ) - }.to change { ChatMention.count }.by(2) + described_class.create(chat_channel: public_chat_channel, user: user1, content: "@here") + }.to change { Chat::Mention.count }.by(2) end it "doesn't sent double notifications when '@here' is mentioned" do user2.update(last_seen_at: Time.now) expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "@here @#{user2.username}", @@ -229,7 +218,7 @@ describe Chat::ChatMessageCreator do user2.update(last_seen_at: 1.year.ago) user3.update(last_seen_at: 1.year.ago) expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "@here plus @#{user3.username}", @@ -239,7 +228,7 @@ describe Chat::ChatMessageCreator do it "doesn't create mention notifications for users without a membership record" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hello @#{user_without_memberships.username}", @@ -254,7 +243,7 @@ describe Chat::ChatMessageCreator do SiteSetting.chat_allowed_groups = new_group.id message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hi @#{user2.username} @#{user3.username}", @@ -271,7 +260,7 @@ describe Chat::ChatMessageCreator do user2.user_option.update(chat_enabled: false) message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hi @#{user2.username}", @@ -283,7 +272,7 @@ describe Chat::ChatMessageCreator do it "creates only mention notifications for users with access in private chat" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: direct_message_channel, user: user1, content: "hello there @#{user2.username} and @#{user3.username}", @@ -299,7 +288,7 @@ describe Chat::ChatMessageCreator do it "creates a mention for group users even if they're not participating in private chat" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: direct_message_channel, user: user1, content: "hello there @#{user_group.name}", @@ -309,7 +298,7 @@ describe Chat::ChatMessageCreator do it "creates a mention notifications only for group users that are participating in private chat" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: direct_message_channel, user: user1, content: "hello there @#{user_group.name}", @@ -323,8 +312,8 @@ describe Chat::ChatMessageCreator do end it "publishes inaccessible mentions when user isn't aren't a part of the channel" do - ChatPublisher.expects(:publish_inaccessible_mentions).once - Chat::ChatMessageCreator.create( + Chat::Publisher.expects(:publish_inaccessible_mentions).once + described_class.create( chat_channel: public_chat_channel, user: admin1, content: "hello @#{user4.username}", @@ -333,8 +322,8 @@ describe Chat::ChatMessageCreator do it "publishes inaccessible mentions when user doesn't have chat access" do SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff] - ChatPublisher.expects(:publish_inaccessible_mentions).once - Chat::ChatMessageCreator.create( + Chat::Publisher.expects(:publish_inaccessible_mentions).once + described_class.create( chat_channel: public_chat_channel, user: admin1, content: "hello @#{user3.username}", @@ -342,8 +331,8 @@ describe Chat::ChatMessageCreator do end it "doesn't publish inaccessible mentions when user is following channel" do - ChatPublisher.expects(:publish_inaccessible_mentions).never - Chat::ChatMessageCreator.create( + Chat::Publisher.expects(:publish_inaccessible_mentions).never + described_class.create( chat_channel: public_chat_channel, user: admin1, content: "hello @#{admin2.username}", @@ -354,7 +343,7 @@ describe Chat::ChatMessageCreator do user2.update(suspended_till: Time.now + 10.years) message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: direct_message_channel, user: user1, content: "hello @#{user2.username}", @@ -368,7 +357,7 @@ describe Chat::ChatMessageCreator do user2.update(suspended_till: Time.now + 10.years) message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: direct_message_channel, user: user1, content: "hello @#{user2.username}", @@ -383,7 +372,7 @@ describe Chat::ChatMessageCreator do it "when mentioning @all creates a mention without notification" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hi! @all", @@ -398,7 +387,7 @@ describe Chat::ChatMessageCreator do user2.update(last_seen_at: Time.now) message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "@here", @@ -419,7 +408,7 @@ describe Chat::ChatMessageCreator do it "links the message that the user is replying to" do message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -433,13 +422,13 @@ describe Chat::ChatMessageCreator do message = nil expect { message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", in_reply_to_id: reply_message.id, ).chat_message - }.to change { ChatThread.count }.by(1) + }.to change { Chat::Thread.count }.by(1) expect(message.reload.thread).not_to eq(nil) expect(message.in_reply_to.thread).to eq(message.thread) @@ -454,13 +443,13 @@ describe Chat::ChatMessageCreator do message = nil expect { message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", thread_id: existing_thread.id, ).chat_message - }.not_to change { ChatThread.count } + }.not_to change { Chat::Thread.count } expect(message.reload.thread).to eq(existing_thread) end @@ -468,7 +457,7 @@ describe Chat::ChatMessageCreator do it "errors when the thread ID is for a different channel" do other_channel_thread = Fabricate(:chat_thread, channel: Fabricate(:chat_channel)) result = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -480,7 +469,7 @@ describe Chat::ChatMessageCreator do it "errors when the thread does not match the in_reply_to thread" do reply_message.update!(thread: existing_thread) result = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -493,7 +482,7 @@ describe Chat::ChatMessageCreator do it "errors when the root message does not have a thread ID" do reply_message.update!(thread: nil) result = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -519,7 +508,7 @@ describe Chat::ChatMessageCreator do it "raises an error when the root message has been trashed" do original_message.trash! result = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -530,7 +519,7 @@ describe Chat::ChatMessageCreator do it "uses the next message in the chain as the root when the root is deleted" do original_message.destroy! - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -576,16 +565,16 @@ describe Chat::ChatMessageCreator do end it "creates a thread and updates all the messages in the chain" do - thread_count = ChatThread.count + thread_count = Chat::Thread.count message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", in_reply_to_id: reply_message.id, ).chat_message - expect(ChatThread.count).to eq(thread_count + 1) + expect(Chat::Thread.count).to eq(thread_count + 1) expect(message.reload.thread).not_to eq(nil) expect(message.reload.in_reply_to.thread).to eq(message.thread) expect(old_message_1.reload.thread).to eq(message.thread) @@ -593,7 +582,7 @@ describe Chat::ChatMessageCreator do expect(old_message_3.reload.thread).to eq(message.thread) expect(message.thread.chat_messages.count).to eq(5) message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -603,7 +592,7 @@ describe Chat::ChatMessageCreator do context "when a thread already exists and the thread_id is passed in" do let!(:last_message) do - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -613,10 +602,10 @@ describe Chat::ChatMessageCreator do let!(:existing_thread) { last_message.reload.thread } it "does not create a new thread" do - thread_count = ChatThread.count + thread_count = Chat::Thread.count message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message again", @@ -624,7 +613,7 @@ describe Chat::ChatMessageCreator do thread_id: existing_thread.id, ).chat_message - expect(ChatThread.count).to eq(thread_count) + expect(Chat::Thread.count).to eq(thread_count) expect(message.reload.thread).to eq(existing_thread) expect(message.reload.in_reply_to.thread).to eq(existing_thread) expect(message.thread.chat_messages.count).to eq(6) @@ -633,7 +622,7 @@ describe Chat::ChatMessageCreator do it "errors when the thread does not match the root thread" do old_message_1.update!(thread: Fabricate(:chat_thread, channel: public_chat_channel)) result = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -646,7 +635,7 @@ describe Chat::ChatMessageCreator do it "errors when the root message does not have a thread ID" do old_message_1.update!(thread: nil) result = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -674,12 +663,12 @@ describe Chat::ChatMessageCreator do end xit "works" do - thread_count = ChatThread.count + thread_count = Chat::Thread.count message = nil puts Benchmark.measure { message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", @@ -687,7 +676,7 @@ describe Chat::ChatMessageCreator do ).chat_message } - expect(ChatThread.count).to eq(thread_count + 1) + expect(Chat::Thread.count).to eq(thread_count + 1) expect(message.reload.thread).not_to eq(nil) expect(message.reload.in_reply_to.thread).to eq(message.thread) expect(message.thread.chat_messages.count).to eq(1001) @@ -704,16 +693,16 @@ describe Chat::ChatMessageCreator do end it "does not change any messages in the chain, assumes they have the correct thread ID" do - thread_count = ChatThread.count + thread_count = Chat::Thread.count message = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "this is a message", in_reply_to_id: reply_message.id, ).chat_message - expect(ChatThread.count).to eq(thread_count) + expect(Chat::Thread.count).to eq(thread_count) expect(message.reload.thread).to eq(old_thread) expect(message.reload.in_reply_to.thread).to eq(old_thread) expect(old_message_1.reload.thread).to eq(old_thread) @@ -728,7 +717,7 @@ describe Chat::ChatMessageCreator do describe "group mentions" do it "creates chat mentions for group mentions where the group is mentionable" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hello @#{admin_group.name}", @@ -740,7 +729,7 @@ describe Chat::ChatMessageCreator do it "doesn't mention users twice if they are direct mentioned and group mentioned" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hello @#{admin_group.name} @#{admin1.username} and @#{admin2.username}", @@ -752,7 +741,7 @@ describe Chat::ChatMessageCreator do it "creates chat mentions for group mentions and direct mentions" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hello @#{admin_group.name} @#{user2.username}", @@ -764,7 +753,7 @@ describe Chat::ChatMessageCreator do it "creates chat mentions for group mentions and direct mentions" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hello @#{admin_group.name} @#{user_group.name}", @@ -779,40 +768,35 @@ describe Chat::ChatMessageCreator do it "doesn't create chat mentions for group mentions where the group is un-mentionable" do admin_group.update(mentionable_level: Group::ALIAS_LEVELS[:nobody]) expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "hello @#{admin_group.name}", ) - }.not_to change { ChatMention.count } + }.not_to change { Chat::Mention.count } end end describe "push notifications" do before do - UserChatChannelMembership.where(user: user1, chat_channel: public_chat_channel).update( - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + Chat::UserChatChannelMembership.where( + user: user1, + chat_channel: public_chat_channel, + ).update( + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) PresenceChannel.clear_all! end it "sends a push notification to watching users who are not in chat" do PostAlerter.expects(:push_notification).once - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user2, - content: "Beep boop", - ) + described_class.create(chat_channel: public_chat_channel, user: user2, content: "Beep boop") end it "does not send a push notification to watching users who are in chat" do PresenceChannel.new("/chat/online").present(user_id: user1.id, client_id: 1) PostAlerter.expects(:push_notification).never - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user2, - content: "Beep boop", - ) + described_class.create(chat_channel: public_chat_channel, user: user2, content: "Beep boop") end end @@ -823,7 +807,7 @@ describe Chat::ChatMessageCreator do it "can attach 1 upload to a new message" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "Beep boop", @@ -836,7 +820,7 @@ describe Chat::ChatMessageCreator do it "can attach multiple uploads to a new message" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "Beep boop", @@ -849,7 +833,7 @@ describe Chat::ChatMessageCreator do it "filters out uploads that weren't uploaded by the user" do expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "Beep boop", @@ -861,7 +845,7 @@ describe Chat::ChatMessageCreator do it "doesn't attach uploads when `chat_allow_uploads` is false" do SiteSetting.chat_allow_uploads = false expect { - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "Beep boop", @@ -875,15 +859,15 @@ describe Chat::ChatMessageCreator do end it "destroys draft after message was created" do - ChatDraft.create!(user: user1, chat_channel: public_chat_channel, data: "{}") + Chat::Draft.create!(user: user1, chat_channel: public_chat_channel, data: "{}") expect do - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "Hi @#{user2.username}", ) - end.to change { ChatDraft.count }.by(-1) + end.to change { Chat::Draft.count }.by(-1) end describe "watched words" do @@ -891,7 +875,7 @@ describe Chat::ChatMessageCreator do it "errors when a blocked word is present" do creator = - Chat::ChatMessageCreator.create( + described_class.create( chat_channel: public_chat_channel, user: user1, content: "bad word - #{watched_word.word}", @@ -905,11 +889,7 @@ describe Chat::ChatMessageCreator do describe "channel statuses" do def create_message(user) - Chat::ChatMessageCreator.create( - chat_channel: public_chat_channel, - user: user, - content: "test message", - ) + described_class.create(chat_channel: public_chat_channel, user: user, content: "test message") end context "when channel is closed" do @@ -924,7 +904,7 @@ describe Chat::ChatMessageCreator do end it "does not error when trying to create a message for staff" do - expect { create_message(admin1) }.to change { ChatMessage.count }.by(1) + expect { create_message(admin1) }.to change { Chat::Message.count }.by(1) end end diff --git a/plugins/chat/spec/components/chat_message_rate_limiter_spec.rb b/plugins/chat/spec/components/chat/message_rate_limiter_spec.rb similarity index 98% rename from plugins/chat/spec/components/chat_message_rate_limiter_spec.rb rename to plugins/chat/spec/components/chat/message_rate_limiter_spec.rb index fa73e927819..50d394ffa33 100644 --- a/plugins/chat/spec/components/chat_message_rate_limiter_spec.rb +++ b/plugins/chat/spec/components/chat/message_rate_limiter_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatMessageRateLimiter do +describe Chat::MessageRateLimiter do fab!(:user) { Fabricate(:user, trust_level: 3) } let(:limiter) { described_class.new(user) } diff --git a/plugins/chat/spec/components/chat_message_updater_spec.rb b/plugins/chat/spec/components/chat/message_updater_spec.rb similarity index 93% rename from plugins/chat/spec/components/chat_message_updater_spec.rb rename to plugins/chat/spec/components/chat/message_updater_spec.rb index edb101a3afa..356b24076d8 100644 --- a/plugins/chat/spec/components/chat_message_updater_spec.rb +++ b/plugins/chat/spec/components/chat/message_updater_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatMessageUpdater do +describe Chat::MessageUpdater do let(:guardian) { Guardian.new(user1) } fab!(:admin1) { Fabricate(:admin) } fab!(:admin2) { Fabricate(:admin) } @@ -36,7 +36,7 @@ describe Chat::ChatMessageUpdater do def create_chat_message(user, message, channel, upload_ids: nil) creator = - Chat::ChatMessageCreator.create( + Chat::MessageCreator.create( chat_channel: channel, user: user, in_reply_to_id: nil, @@ -53,7 +53,7 @@ describe Chat::ChatMessageUpdater do new_message = "2 short" updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -75,7 +75,7 @@ describe Chat::ChatMessageUpdater do new_message = "2 long" * 100 updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -92,7 +92,7 @@ describe Chat::ChatMessageUpdater do chat_message = create_chat_message(user1, og_message, public_chat_channel) new_message = "2 short" updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: Guardian.new(Fabricate(:user)), chat_message: chat_message, new_content: new_message, @@ -105,7 +105,7 @@ describe Chat::ChatMessageUpdater do chat_message = create_chat_message(user1, "This will be changed", public_chat_channel) new_message = "Change to this!" - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -117,7 +117,7 @@ describe Chat::ChatMessageUpdater do chat_message = create_chat_message(user1, "This will be changed", public_chat_channel) events = DiscourseEvent.track_events do - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "Change to this!", @@ -129,7 +129,7 @@ describe Chat::ChatMessageUpdater do it "creates mention notifications for unmentioned users" do chat_message = create_chat_message(user1, "This will be changed", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: @@ -142,19 +142,19 @@ describe Chat::ChatMessageUpdater do message = "ping @#{user2.username} @#{user3.username}" chat_message = create_chat_message(user1, message, public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: message + " editedddd", ) - }.not_to change { ChatMention.count } + }.not_to change { Chat::Mention.count } end it "doesn't create mention notification for users without access" do message = "ping" chat_message = create_chat_message(user1, message, public_chat_channel) - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: message + " @#{user_without_memberships.username}", @@ -168,7 +168,7 @@ describe Chat::ChatMessageUpdater do chat_message = create_chat_message(user1, "ping @#{user2.username} @#{user3.username}", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "ping @#{user3.username}", @@ -179,7 +179,7 @@ describe Chat::ChatMessageUpdater do it "creates new, leaves existing, and removes old mentions all at once" do chat_message = create_chat_message(user1, "ping @#{user2.username} @#{user3.username}", public_chat_channel) - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "ping @#{user3.username} @#{user4.username}", @@ -193,7 +193,7 @@ describe Chat::ChatMessageUpdater do it "doesn't create mention notification in direct message for users without access" do message = create_chat_message(user1, "ping nobody", @direct_message_channel) - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: message, new_content: "ping @#{admin1.username}", @@ -207,12 +207,12 @@ describe Chat::ChatMessageUpdater do it "creates group mentions on update" do chat_message = create_chat_message(user1, "ping nobody", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "ping @#{admin_group.name}", ) - }.to change { ChatMention.where(chat_message: chat_message).count }.by(2) + }.to change { Chat::Mention.where(chat_message: chat_message).count }.by(2) expect(admin1.chat_mentions.where(chat_message: chat_message)).to be_present expect(admin2.chat_mentions.where(chat_message: chat_message)).to be_present @@ -221,7 +221,7 @@ describe Chat::ChatMessageUpdater do it "doesn't duplicate mentions when the user is already direct mentioned and then group mentioned" do chat_message = create_chat_message(user1, "ping @#{admin2.username}", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "ping @#{admin_group.name} @#{admin2.username}", @@ -232,12 +232,12 @@ describe Chat::ChatMessageUpdater do it "deletes old mentions when group mention is removed" do chat_message = create_chat_message(user1, "ping @#{admin_group.name}", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "ping nobody anymore!", ) - }.to change { ChatMention.where(chat_message: chat_message).count }.by(-2) + }.to change { Chat::Mention.where(chat_message: chat_message).count }.by(-2) expect(admin1.chat_mentions.where(chat_message: chat_message)).not_to be_present expect(admin2.chat_mentions.where(chat_message: chat_message)).not_to be_present @@ -248,7 +248,7 @@ describe Chat::ChatMessageUpdater do old_message = "It's a thrsday!" new_message = "It's a thursday!" chat_message = create_chat_message(user1, old_message, public_chat_channel) - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -282,7 +282,7 @@ describe Chat::ChatMessageUpdater do chat_message_2.update!(created_at: 20.seconds.ago) updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message_1, new_content: "another different chat message here", @@ -302,7 +302,7 @@ describe Chat::ChatMessageUpdater do chat_message.update!(created_at: 30.seconds.ago) updater = - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "this is some chat message", @@ -326,7 +326,7 @@ describe Chat::ChatMessageUpdater do upload_ids: [upload1.id, upload2.id], ) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -354,7 +354,7 @@ describe Chat::ChatMessageUpdater do VALUES(#{upload2.id}, #{chat_message.id}, NOW(), NOW()) SQL expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -384,7 +384,7 @@ describe Chat::ChatMessageUpdater do VALUES(#{upload2.id}, #{chat_message.id}, NOW(), NOW()) SQL expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -398,7 +398,7 @@ describe Chat::ChatMessageUpdater do it "adds one upload if none exist" do chat_message = create_chat_message(user1, "something", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -412,7 +412,7 @@ describe Chat::ChatMessageUpdater do it "adds multiple uploads if none exist" do chat_message = create_chat_message(user1, "something", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -427,7 +427,7 @@ describe Chat::ChatMessageUpdater do chat_message = create_chat_message(user1, "something", public_chat_channel, upload_ids: [upload1.id]) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -442,7 +442,7 @@ describe Chat::ChatMessageUpdater do SiteSetting.chat_allow_uploads = false chat_message = create_chat_message(user1, "something", public_chat_channel) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -463,7 +463,7 @@ describe Chat::ChatMessageUpdater do upload_ids: [upload1.id, upload2.id], ) expect { - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: "I guess this is different", @@ -484,7 +484,7 @@ describe Chat::ChatMessageUpdater do ) SiteSetting.chat_minimum_message_length = 10 new_message = "hi :)" - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: guardian, chat_message: chat_message, new_content: new_message, @@ -500,7 +500,7 @@ describe Chat::ChatMessageUpdater do it "errors when a blocked word is present" do chat_message = create_chat_message(user1, "something", public_chat_channel) creator = - Chat::ChatMessageCreator.create( + Chat::MessageCreator.create( chat_channel: public_chat_channel, user: user1, content: "bad word - #{watched_word.word}", @@ -517,7 +517,7 @@ describe Chat::ChatMessageUpdater do def update_message(user) message.update(user: user) - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: Guardian.new(user), chat_message: message, new_content: "I guess this is different", diff --git a/plugins/chat/spec/components/chat_seeder_spec.rb b/plugins/chat/spec/components/chat/seeder_spec.rb similarity index 76% rename from plugins/chat/spec/components/chat_seeder_spec.rb rename to plugins/chat/spec/components/chat/seeder_spec.rb index e0a7c5222a6..f0ee8d1faac 100644 --- a/plugins/chat/spec/components/chat_seeder_spec.rb +++ b/plugins/chat/spec/components/chat/seeder_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe ChatSeeder do +describe Chat::Seeder do fab!(:staff_category) { Fabricate(:private_category, name: "Staff", group: Group[:staff]) } fab!(:general_category) { Fabricate(:category, name: "General") } @@ -27,16 +27,16 @@ describe ChatSeeder do expected_members_count = GroupUser.where(group: group).count memberships_count = - UserChatChannelMembership.automatic.where(chat_channel: channel, following: true).count + Chat::UserChatChannelMembership.automatic.where(chat_channel: channel, following: true).count expect(memberships_count).to eq(expected_members_count) end it "seeds default channels" do - ChatSeeder.new.execute + Chat::Seeder.new.execute - staff_channel = ChatChannel.find_by(chatable: staff_category) - general_channel = ChatChannel.find_by(chatable: general_category) + staff_channel = Chat::Channel.find_by(chatable_id: staff_category) + general_channel = Chat::Channel.find_by(chatable_id: general_category) assert_channel_was_correctly_seeded(staff_channel, Group[:staff]) assert_channel_was_correctly_seeded(general_channel, Group[:everyone]) @@ -49,24 +49,24 @@ describe ChatSeeder do it "applies a name to the general category channel" do expected_name = general_category.name - ChatSeeder.new.execute + Chat::Seeder.new.execute - general_channel = ChatChannel.find_by(chatable: general_category) + general_channel = Chat::Channel.find_by(chatable_id: general_category) expect(general_channel.name).to eq(expected_name) end it "applies a name to the staff category channel" do expected_name = staff_category.name - ChatSeeder.new.execute + Chat::Seeder.new.execute - staff_channel = ChatChannel.find_by(chatable: staff_category) + staff_channel = Chat::Channel.find_by(chatable_id: staff_category) expect(staff_channel.name).to eq(expected_name) end it "does nothing when 'SiteSetting.needs_chat_seeded' is false" do SiteSetting.needs_chat_seeded = false - expect { ChatSeeder.new.execute }.not_to change { ChatChannel.count } + expect { Chat::Seeder.new.execute }.not_to change { Chat::Channel.count } end end diff --git a/plugins/chat/spec/fabricators/chat_fabricator.rb b/plugins/chat/spec/fabricators/chat_fabricator.rb index c0b4480325a..d642326abfa 100644 --- a/plugins/chat/spec/fabricators/chat_fabricator.rb +++ b/plugins/chat/spec/fabricators/chat_fabricator.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -Fabricator(:chat_channel) do +Fabricator(:chat_channel, class_name: "Chat::Channel") do name do sequence(:name) do |n| random_name = [ @@ -25,14 +25,14 @@ Fabricator(:chat_channel) do status { :open } end -Fabricator(:category_channel, from: :chat_channel, class_name: :category_channel) {} +Fabricator(:category_channel, from: :chat_channel) {} -Fabricator(:private_category_channel, from: :category_channel, class_name: :category_channel) do +Fabricator(:private_category_channel, from: :category_channel) do transient :group chatable { |attrs| Fabricate(:private_category, group: attrs[:group] || Group[:staff]) } end -Fabricator(:direct_message_channel, from: :chat_channel, class_name: :direct_message_channel) do +Fabricator(:direct_message_channel, from: :chat_channel) do transient :users, following: true, with_membership: true chatable do |attrs| Fabricate(:direct_message, users: attrs[:users] || [Fabricate(:user), Fabricate(:user)]) @@ -49,16 +49,16 @@ Fabricator(:direct_message_channel, from: :chat_channel, class_name: :direct_mes end end -Fabricator(:chat_message) do +Fabricator(:chat_message, class_name: "Chat::Message") do chat_channel user message "Beep boop" - cooked { |attrs| ChatMessage.cook(attrs[:message]) } - cooked_version ChatMessage::BAKED_VERSION + cooked { |attrs| Chat::Message.cook(attrs[:message]) } + cooked_version Chat::Message::BAKED_VERSION in_reply_to nil end -Fabricator(:chat_mention) do +Fabricator(:chat_mention, class_name: "Chat::Mention") do transient read: false transient high_priority: true transient identifier: :direct_mentions @@ -67,7 +67,7 @@ Fabricator(:chat_mention) do chat_message { Fabricate(:chat_message) } end -Fabricator(:chat_message_reaction) do +Fabricator(:chat_message_reaction, class_name: "Chat::MessageReaction") do chat_message { Fabricate(:chat_message) } user { Fabricate(:user) } emoji { %w[+1 tada heart joffrey_facepalm].sample } @@ -76,7 +76,7 @@ Fabricator(:chat_message_reaction) do end end -Fabricator(:chat_upload) do +Fabricator(:chat_upload, class_name: "Chat::Upload") do transient :user user { Fabricate(:user) } @@ -85,38 +85,40 @@ Fabricator(:chat_upload) do upload { |attrs| Fabricate(:upload, user: attrs[:user]) } end -Fabricator(:chat_message_revision) do +Fabricator(:chat_message_revision, class_name: "Chat::MessageRevision") do chat_message { Fabricate(:chat_message) } old_message { "something old" } new_message { "something new" } user { |attrs| attrs[:chat_message].user } end -Fabricator(:reviewable_chat_message) do +Fabricator(:chat_reviewable_message, class_name: "Chat::ReviewableMessage") do reviewable_by_moderator true type "ReviewableChatMessage" created_by { Fabricate(:user) } - target_type "ChatMessage" + target_type Chat::Message.sti_name target { Fabricate(:chat_message) } reviewable_scores { |p| [Fabricate.build(:reviewable_score, reviewable_id: p[:id])] } end -Fabricator(:direct_message) { users { [Fabricate(:user), Fabricate(:user)] } } +Fabricator(:direct_message, class_name: "Chat::DirectMessage") do + users { [Fabricate(:user), Fabricate(:user)] } +end -Fabricator(:chat_webhook_event) do +Fabricator(:chat_webhook_event, class_name: "Chat::WebhookEvent") do chat_message { Fabricate(:chat_message) } incoming_chat_webhook do |attrs| Fabricate(:incoming_chat_webhook, chat_channel: attrs[:chat_message].chat_channel) end end -Fabricator(:incoming_chat_webhook) do +Fabricator(:incoming_chat_webhook, class_name: "Chat::IncomingWebhook") do name { sequence(:name) { |i| "#{i + 1}" } } key { sequence(:key) { |i| "#{i + 1}" } } chat_channel { Fabricate(:chat_channel, chatable: Fabricate(:category)) } end -Fabricator(:user_chat_channel_membership) do +Fabricator(:user_chat_channel_membership, class_name: "Chat::UserChatChannelMembership") do user chat_channel following true @@ -130,7 +132,7 @@ Fabricator(:user_chat_channel_membership_for_dm, from: :user_chat_channel_member mobile_notification_level 2 end -Fabricator(:chat_draft) do +Fabricator(:chat_draft, class_name: "Chat::Draft") do user chat_channel @@ -143,7 +145,7 @@ Fabricator(:chat_draft) do end end -Fabricator(:chat_thread) do +Fabricator(:chat_thread, class_name: "Chat::Thread") do before_create do |thread, transients| thread.original_message_user = original_message.user thread.channel = original_message.chat_channel diff --git a/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb b/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb index 6fa39be8848..b981161ebc2 100644 --- a/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb +++ b/plugins/chat/spec/integration/custom_api_key_scopes_spec.rb @@ -44,7 +44,7 @@ describe "API keys scoped to chat#create_message" do end it "can create chat messages" do - UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) + Chat::UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) expect { post "/chat/#{chat_channel.id}.json", headers: { @@ -54,12 +54,12 @@ describe "API keys scoped to chat#create_message" do params: { message: "asdfasdf asdfasdf", } - }.to change { ChatMessage.where(chat_channel: chat_channel).count }.by(1) + }.to change { Chat::Message.where(chat_channel: chat_channel).count }.by(1) expect(response.status).to eq(200) end it "cannot post in a channel it is not scoped for" do - UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) + Chat::UserChatChannelMembership.create(user: admin, chat_channel: chat_channel, following: true) expect { post "/chat/#{chat_channel.id}.json", headers: { @@ -69,12 +69,16 @@ describe "API keys scoped to chat#create_message" do params: { message: "asdfasdf asdfasdf", } - }.not_to change { ChatMessage.where(chat_channel: chat_channel).count } + }.not_to change { Chat::Message.where(chat_channel: chat_channel).count } expect(response.status).to eq(403) end it "can only post in scoped channels" do - UserChatChannelMembership.create(user: admin, chat_channel: chat_channel_2, following: true) + Chat::UserChatChannelMembership.create( + user: admin, + chat_channel: chat_channel_2, + following: true, + ) expect { post "/chat/#{chat_channel_2.id}.json", headers: { @@ -84,7 +88,7 @@ describe "API keys scoped to chat#create_message" do params: { message: "asdfasdf asdfasdf", } - }.to change { ChatMessage.where(chat_channel: chat_channel_2).count }.by(1) + }.to change { Chat::Message.where(chat_channel: chat_channel_2).count }.by(1) expect(response.status).to eq(200) end end diff --git a/plugins/chat/spec/integration/post_chat_quote_spec.rb b/plugins/chat/spec/integration/post_chat_quote_spec.rb index 71457ccde1b..2ffe97df0b5 100644 --- a/plugins/chat/spec/integration/post_chat_quote_spec.rb +++ b/plugins/chat/spec/integration/post_chat_quote_spec.rb @@ -220,14 +220,14 @@ martin message1 = Fabricate(:chat_message, chat_channel: channel, user: post.user) message2 = Fabricate(:chat_message, chat_channel: channel, user: post.user) md = - ChatTranscriptService.new( + Chat::TranscriptService.new( channel, message2.user, messages_or_ids: [message2.id], ).generate_markdown message1.update!(message: md) md_for_post = - ChatTranscriptService.new( + Chat::TranscriptService.new( channel, message1.user, messages_or_ids: [message1.id], diff --git a/plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb b/plugins/chat/spec/jobs/regular/chat/auto_join_channel_batch_spec.rb similarity index 92% rename from plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb rename to plugins/chat/spec/jobs/regular/chat/auto_join_channel_batch_spec.rb index e97776b10fe..08b0ba6ad48 100644 --- a/plugins/chat/spec/jobs/regular/auto_join_channel_batch_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/auto_join_channel_batch_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::AutoJoinChannelBatch do +describe Jobs::Chat::AutoJoinChannelBatch do describe "#execute" do fab!(:category) { Fabricate(:category) } let!(:user) { Fabricate(:user, last_seen_at: 15.minutes.ago) } @@ -64,7 +64,12 @@ describe Jobs::AutoJoinChannelBatch do it "enqueues the user count update job and marks the channel user count as stale" do subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) - expect_job_enqueued(job: :update_channel_user_count, args: { chat_channel_id: channel.id }) + expect_job_enqueued( + job: Jobs::Chat::UpdateChannelUserCount, + args: { + chat_channel_id: channel.id, + }, + ) expect(channel.reload.user_count_stale).to eq(true) end @@ -72,7 +77,7 @@ describe Jobs::AutoJoinChannelBatch do it "does not enqueue the user count update job or mark the channel user count as stale when there is more than use user" do user_2 = Fabricate(:user) expect_not_enqueued_with( - job: :update_channel_user_count, + job: Jobs::Chat::UpdateChannelUserCount, args: { chat_channel_id: channel.id, }, @@ -92,7 +97,7 @@ describe Jobs::AutoJoinChannelBatch do it "sets the join reason to automatic" do subject.execute(chat_channel_id: channel.id, starts_at: user.id, ends_at: user.id) - new_membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + new_membership = Chat::UserChatChannelMembership.find_by(user: user, chat_channel: channel) expect(new_membership.automatic?).to eq(true) end @@ -179,12 +184,12 @@ describe Jobs::AutoJoinChannelBatch do end def assert_users_follows_channel(channel, users) - new_memberships = UserChatChannelMembership.where(user: users, chat_channel: channel) + new_memberships = Chat::UserChatChannelMembership.where(user: users, chat_channel: channel) expect(new_memberships.all?(&:following)).to eq(true) end def assert_user_skipped(channel, user) - new_membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + new_membership = Chat::UserChatChannelMembership.find_by(user: user, chat_channel: channel) expect(new_membership).to be_nil end end diff --git a/plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb b/plugins/chat/spec/jobs/regular/chat/auto_manage_channel_memberships_spec.rb similarity index 87% rename from plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb rename to plugins/chat/spec/jobs/regular/chat/auto_manage_channel_memberships_spec.rb index 1ea5470c7f9..cedec7d7e80 100644 --- a/plugins/chat/spec/jobs/regular/auto_manage_channel_memberships_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/auto_manage_channel_memberships_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::AutoManageChannelMemberships do +describe Jobs::Chat::AutoManageChannelMemberships do let(:user) { Fabricate(:user, last_seen_at: 15.minutes.ago) } let(:category) { Fabricate(:category, user: user) } let(:channel) { Fabricate(:category_channel, auto_join_users: true, chatable: category) } @@ -13,7 +13,7 @@ describe Jobs::AutoManageChannelMemberships do end it "does nothing when the channel doesn't exist" do - assert_batches_enqueued(ChatChannel.new(id: -1), 0) + assert_batches_enqueued(Chat::Channel.new(id: -1), 0) end it "does nothing when the chatable is not a category" do @@ -44,24 +44,24 @@ describe Jobs::AutoManageChannelMemberships do it "does nothing when we already reached the max_chat_auto_joined_users limit" do SiteSetting.max_chat_auto_joined_users = 1 user_2 = Fabricate(:user, last_seen_at: 2.minutes.ago) - UserChatChannelMembership.create!( + Chat::UserChatChannelMembership.create!( user: user_2, chat_channel: channel, following: true, - join_mode: UserChatChannelMembership.join_modes[:automatic], + join_mode: Chat::UserChatChannelMembership.join_modes[:automatic], ) assert_batches_enqueued(channel, 0) end it "ignores users that are already channel members" do - UserChatChannelMembership.create!(user: user, chat_channel: channel, following: true) + Chat::UserChatChannelMembership.create!(user: user, chat_channel: channel, following: true) assert_batches_enqueued(channel, 0) end it "doesn't queue a batch when the user doesn't follow the channel" do - UserChatChannelMembership.create!(user: user, chat_channel: channel, following: false) + Chat::UserChatChannelMembership.create!(user: user, chat_channel: channel, following: false) assert_batches_enqueued(channel, 0) end @@ -120,7 +120,7 @@ describe Jobs::AutoManageChannelMemberships do def assert_batches_enqueued(channel, expected) expect { subject.execute(chat_channel_id: channel.id) }.to change( - Jobs::AutoJoinChannelBatch.jobs, + Jobs::Chat::AutoJoinChannelBatch.jobs, :size, ).by(expected) end diff --git a/plugins/chat/spec/jobs/chat_channel_archive_spec.rb b/plugins/chat/spec/jobs/regular/chat/channel_archive_spec.rb similarity index 88% rename from plugins/chat/spec/jobs/chat_channel_archive_spec.rb rename to plugins/chat/spec/jobs/regular/chat/channel_archive_spec.rb index a60c8de55d2..02b43d1749a 100644 --- a/plugins/chat/spec/jobs/chat_channel_archive_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/channel_archive_spec.rb @@ -2,12 +2,12 @@ require "rails_helper" -describe Jobs::ChatChannelArchive do +describe Jobs::Chat::ChannelArchive do fab!(:chat_channel) { Fabricate(:category_channel) } fab!(:user) { Fabricate(:user, admin: true) } fab!(:category) { Fabricate(:category) } fab!(:chat_archive) do - ChatChannelArchive.create!( + Chat::ChannelArchive.create!( chat_channel: chat_channel, archived_by: user, destination_topic_title: "This will be the archive topic", @@ -34,7 +34,7 @@ describe Jobs::ChatChannelArchive do end it "processes the archive" do - Chat::ChatChannelArchiveService.any_instance.expects(:execute) + Chat::ChannelArchiveService.any_instance.expects(:execute) run_job end end diff --git a/plugins/chat/spec/jobs/chat_channel_delete_spec.rb b/plugins/chat/spec/jobs/regular/chat/channel_delete_spec.rb similarity index 76% rename from plugins/chat/spec/jobs/chat_channel_delete_spec.rb rename to plugins/chat/spec/jobs/regular/chat/channel_delete_spec.rb index 3ab19ae6f3b..4d9f5128faf 100644 --- a/plugins/chat/spec/jobs/chat_channel_delete_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/channel_delete_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe Jobs::ChatChannelDelete do +describe Jobs::Chat::ChannelDelete do fab!(:chat_channel) { Fabricate(:chat_channel) } fab!(:user1) { Fabricate(:user) } fab!(:user2) { Fabricate(:user) } @@ -14,7 +14,7 @@ describe Jobs::ChatChannelDelete do end @message_ids = messages.map(&:id) - 10.times { ChatMessageReaction.create(chat_message: messages.sample, user: users.sample) } + 10.times { Chat::MessageReaction.create(chat_message: messages.sample, user: users.sample) } 10.times do upload = Fabricate(:upload, user: users.sample) @@ -28,14 +28,14 @@ describe Jobs::ChatChannelDelete do UploadReference.create(target: message, upload: upload) end - ChatMention.create( + Chat::Mention.create( user: user2, chat_message: messages.sample, notification: Fabricate(:notification), ) @incoming_chat_webhook_id = Fabricate(:incoming_chat_webhook, chat_channel: chat_channel) - ChatWebhookEvent.create( + Chat::WebhookEvent.create( incoming_chat_webhook: @incoming_chat_webhook_id, chat_message: messages.sample, ) @@ -48,7 +48,7 @@ describe Jobs::ChatChannelDelete do new_message: revision_message.message, ) - ChatDraft.create(chat_channel: chat_channel, user: users.sample, data: "wow some draft") + Chat::Draft.create(chat_channel: chat_channel, user: users.sample, data: "wow some draft") Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user1) Fabricate(:user_chat_channel_membership, chat_channel: chat_channel, user: user2) @@ -59,21 +59,21 @@ describe Jobs::ChatChannelDelete do def counts { - incoming_webhooks: IncomingChatWebhook.where(chat_channel_id: chat_channel.id).count, + incoming_webhooks: Chat::IncomingWebhook.where(chat_channel_id: chat_channel.id).count, webhook_events: - ChatWebhookEvent.where(incoming_chat_webhook_id: @incoming_chat_webhook_id).count, - drafts: ChatDraft.where(chat_channel: chat_channel).count, - channel_memberships: UserChatChannelMembership.where(chat_channel: chat_channel).count, - revisions: ChatMessageRevision.where(chat_message_id: @message_ids).count, - mentions: ChatMention.where(chat_message_id: @message_ids).count, + Chat::WebhookEvent.where(incoming_chat_webhook_id: @incoming_chat_webhook_id).count, + drafts: Chat::Draft.where(chat_channel: chat_channel).count, + channel_memberships: Chat::UserChatChannelMembership.where(chat_channel: chat_channel).count, + revisions: Chat::MessageRevision.where(chat_message_id: @message_ids).count, + mentions: Chat::Mention.where(chat_message_id: @message_ids).count, chat_uploads: DB.query_single( "SELECT COUNT(*) FROM chat_uploads WHERE chat_message_id IN (#{@message_ids.join(",")})", ).first, upload_references: - UploadReference.where(target_id: @message_ids, target_type: "ChatMessage").count, - messages: ChatMessage.where(id: @message_ids).count, - reactions: ChatMessageReaction.where(chat_message_id: @message_ids).count, + UploadReference.where(target_id: @message_ids, target_type: Chat::Message.sti_name).count, + messages: Chat::Message.where(id: @message_ids).count, + reactions: Chat::MessageReaction.where(chat_message_id: @message_ids).count, } end diff --git a/plugins/chat/spec/jobs/regular/delete_user_messages_spec.rb b/plugins/chat/spec/jobs/regular/chat/delete_user_messages_spec.rb similarity index 86% rename from plugins/chat/spec/jobs/regular/delete_user_messages_spec.rb rename to plugins/chat/spec/jobs/regular/chat/delete_user_messages_spec.rb index 26242bae9c7..224d382a3c5 100644 --- a/plugins/chat/spec/jobs/regular/delete_user_messages_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/delete_user_messages_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Jobs::DeleteUserMessages do +RSpec.describe Jobs::Chat::DeleteUserMessages do describe "#execute" do fab!(:user_1) { Fabricate(:user) } fab!(:channel) { Fabricate(:chat_channel) } @@ -26,7 +26,7 @@ RSpec.describe Jobs::DeleteUserMessages do subject.execute(user_id: user_1) - expect(ChatMessage.with_deleted.where(id: chat_message.id)).to be_empty + expect(Chat::Message.with_deleted.where(id: chat_message.id)).to be_empty end end end diff --git a/plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb b/plugins/chat/spec/jobs/regular/chat/notify_mentioned_spec.rb similarity index 92% rename from plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb rename to plugins/chat/spec/jobs/regular/chat/notify_mentioned_spec.rb index e411d5b052a..a72aa02b4bd 100644 --- a/plugins/chat/spec/jobs/regular/chat_notify_mentioned_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/notify_mentioned_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::ChatNotifyMentioned do +describe Jobs::Chat::NotifyMentioned do fab!(:user_1) { Fabricate(:user) } fab!(:user_2) { Fabricate(:user) } fab!(:public_channel) { Fabricate(:category_channel) } @@ -77,7 +77,7 @@ describe Jobs::ChatNotifyMentioned do it "does nothing when user is not following the channel" do message = create_chat_message - UserChatChannelMembership.where(chat_channel: public_channel, user: user_2).update!( + Chat::UserChatChannelMembership.where(chat_channel: public_channel, user: user_2).update!( following: false, ) @@ -95,7 +95,7 @@ describe Jobs::ChatNotifyMentioned do it "does nothing when user doesn't have a membership record" do message = create_chat_message - UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).destroy! + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).destroy! PostAlerter.expects(:push_notification).never @@ -146,8 +146,8 @@ describe Jobs::ChatNotifyMentioned do it "skips desktop notifications based on user preferences" do message = create_chat_message - UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], ) desktop_notification = @@ -158,8 +158,8 @@ describe Jobs::ChatNotifyMentioned do it "skips push notifications based on user preferences" do message = create_chat_message - UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], ) PostAlerter.expects(:push_notification).never @@ -173,8 +173,8 @@ describe Jobs::ChatNotifyMentioned do it "skips desktop notifications based on user muting preferences" do message = create_chat_message - UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], muted: true, ) @@ -186,8 +186,8 @@ describe Jobs::ChatNotifyMentioned do it "skips push notifications based on user muting preferences" do message = create_chat_message - UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + Chat::UserChatChannelMembership.find_by(chat_channel: public_channel, user: user_2).update!( + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], muted: true, ) @@ -214,7 +214,7 @@ describe Jobs::ChatNotifyMentioned do expect(desktop_notification.data[:notification_type]).to eq(Notification.types[:chat_mention]) expect(desktop_notification.data[:username]).to eq(user_1.username) expect(desktop_notification.data[:tag]).to eq( - Chat::ChatNotifier.push_notification_tag(:mention, public_channel.id), + Chat::Notifier.push_notification_tag(:mention, public_channel.id), ) expect(desktop_notification.data[:excerpt]).to eq(message.push_notification_excerpt) expect(desktop_notification.data[:post_url]).to eq( @@ -230,7 +230,7 @@ describe Jobs::ChatNotifyMentioned do { notification_type: Notification.types[:chat_mention], username: user_1.username, - tag: Chat::ChatNotifier.push_notification_tag(:mention, public_channel.id), + tag: Chat::Notifier.push_notification_tag(:mention, public_channel.id), excerpt: message.push_notification_excerpt, post_url: "/chat/c/#{public_channel.slug}/#{public_channel.id}/#{message.id}", translated_title: payload_translated_title, @@ -264,7 +264,7 @@ describe Jobs::ChatNotifyMentioned do expect(data_hash[:chat_channel_slug]).to eq(public_channel.slug) chat_mention = - ChatMention.where(notification: created_notification, user: user_2, chat_message: message) + Chat::Mention.where(notification: created_notification, user: user_2, chat_message: message) expect(chat_mention).to be_present end end diff --git a/plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb b/plugins/chat/spec/jobs/regular/chat/notify_watching_spec.rb similarity index 91% rename from plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb rename to plugins/chat/spec/jobs/regular/chat/notify_watching_spec.rb index 72a09d58bf8..0d6f6e1d63e 100644 --- a/plugins/chat/spec/jobs/regular/chat_notify_watching_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/notify_watching_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Jobs::ChatNotifyWatching do +RSpec.describe Jobs::Chat::NotifyWatching do fab!(:user1) { Fabricate(:user) } fab!(:user2) { Fabricate(:user) } fab!(:user3) { Fabricate(:user) } @@ -39,7 +39,7 @@ RSpec.describe Jobs::ChatNotifyWatching do before do membership2.update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end @@ -56,7 +56,7 @@ RSpec.describe Jobs::ChatNotifyWatching do "discourse_push_notifications.popup.new_chat_message", { username: user1.username, channel: channel.title(user2) }, ), - tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + tag: Chat::Notifier.push_notification_tag(:message, channel.id), excerpt: message.message, }, ) @@ -75,8 +75,8 @@ RSpec.describe Jobs::ChatNotifyWatching do context "when mobile_notification_level is always and desktop_notification_level is none" do before do membership2.update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end @@ -93,7 +93,7 @@ RSpec.describe Jobs::ChatNotifyWatching do "discourse_push_notifications.popup.new_chat_message", { username: user1.username, channel: channel.title(user2) }, ), - tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + tag: Chat::Notifier.push_notification_tag(:message, channel.id), excerpt: message.message, }, ), @@ -179,7 +179,7 @@ RSpec.describe Jobs::ChatNotifyWatching do before do membership2.update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end @@ -196,7 +196,7 @@ RSpec.describe Jobs::ChatNotifyWatching do "discourse_push_notifications.popup.new_direct_chat_message", { username: user1.username, channel: channel.title(user2) }, ), - tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + tag: Chat::Notifier.push_notification_tag(:message, channel.id), excerpt: message.message, }, ) @@ -215,8 +215,8 @@ RSpec.describe Jobs::ChatNotifyWatching do context "when mobile_notification_level is always and desktop_notification_level is none" do before do membership2.update!( - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end @@ -233,7 +233,7 @@ RSpec.describe Jobs::ChatNotifyWatching do "discourse_push_notifications.popup.new_direct_chat_message", { username: user1.username, channel: channel.title(user2) }, ), - tag: Chat::ChatNotifier.push_notification_tag(:message, channel.id), + tag: Chat::Notifier.push_notification_tag(:message, channel.id), excerpt: message.message, }, ), diff --git a/plugins/chat/spec/jobs/process_chat_message_spec.rb b/plugins/chat/spec/jobs/regular/chat/process_message_spec.rb similarity index 88% rename from plugins/chat/spec/jobs/process_chat_message_spec.rb rename to plugins/chat/spec/jobs/regular/chat/process_message_spec.rb index cb98c286afc..658bb2e3d1c 100644 --- a/plugins/chat/spec/jobs/process_chat_message_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/process_message_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::ProcessChatMessage do +describe Jobs::Chat::ProcessMessage do fab!(:chat_message) { Fabricate(:chat_message, message: "https://discourse.org/team") } it "updates cooked with oneboxes" do @@ -23,7 +23,7 @@ describe Jobs::ProcessChatMessage do fab!(:chat_message) { Fabricate(:chat_message, message: "a very lovely cat") } it "publishes the update" do - ChatPublisher.expects(:publish_processed!).once + Chat::Publisher.expects(:publish_processed!).once described_class.new.execute(chat_message_id: chat_message.id, is_dirty: true) end end @@ -32,14 +32,14 @@ describe Jobs::ProcessChatMessage do fab!(:chat_message) { Fabricate(:chat_message, message: "a very lovely cat") } it "doesn’t publish the update" do - ChatPublisher.expects(:publish_processed!).never + Chat::Publisher.expects(:publish_processed!).never described_class.new.execute(chat_message_id: chat_message.id) end context "when the cooked message changed" do it "publishes the update" do chat_message.update!(cooked: "another lovely cat") - ChatPublisher.expects(:publish_processed!).once + Chat::Publisher.expects(:publish_processed!).once described_class.new.execute(chat_message_id: chat_message.id) end end diff --git a/plugins/chat/spec/jobs/regular/send_message_notifications_spec.rb b/plugins/chat/spec/jobs/regular/chat/send_message_notifications_spec.rb similarity index 59% rename from plugins/chat/spec/jobs/regular/send_message_notifications_spec.rb rename to plugins/chat/spec/jobs/regular/chat/send_message_notifications_spec.rb index e00bad83f5c..2739339fc35 100644 --- a/plugins/chat/spec/jobs/regular/send_message_notifications_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/send_message_notifications_spec.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -RSpec.describe Jobs::SendMessageNotifications do +RSpec.describe Jobs::Chat::SendMessageNotifications do describe "#execute" do context "when the message doesn't exist" do it "does nothing" do - Chat::ChatNotifier.any_instance.expects(:notify_new).never - Chat::ChatNotifier.any_instance.expects(:notify_edit).never + Chat::Notifier.any_instance.expects(:notify_new).never + Chat::Notifier.any_instance.expects(:notify_edit).never subject.execute(eason: "new", timestamp: 1.minute.ago) end @@ -15,8 +15,8 @@ RSpec.describe Jobs::SendMessageNotifications do fab!(:chat_message) { Fabricate(:chat_message) } it "does nothing when the reason is invalid" do - Chat::ChatNotifier.expects(:notify_new).never - Chat::ChatNotifier.expects(:notify_edit).never + Chat::Notifier.expects(:notify_new).never + Chat::Notifier.expects(:notify_edit).never subject.execute( chat_message_id: chat_message.id, @@ -26,22 +26,22 @@ RSpec.describe Jobs::SendMessageNotifications do end it "does nothing if there is no timestamp" do - Chat::ChatNotifier.any_instance.expects(:notify_new).never - Chat::ChatNotifier.any_instance.expects(:notify_edit).never + Chat::Notifier.any_instance.expects(:notify_new).never + Chat::Notifier.any_instance.expects(:notify_edit).never subject.execute(chat_message_id: chat_message.id, reason: "new") end it "calls notify_new when the reason is 'new'" do - Chat::ChatNotifier.any_instance.expects(:notify_new).once - Chat::ChatNotifier.any_instance.expects(:notify_edit).never + Chat::Notifier.any_instance.expects(:notify_new).once + Chat::Notifier.any_instance.expects(:notify_edit).never subject.execute(chat_message_id: chat_message.id, reason: "new", timestamp: 1.minute.ago) end it "calls notify_edit when the reason is 'edit'" do - Chat::ChatNotifier.any_instance.expects(:notify_new).never - Chat::ChatNotifier.any_instance.expects(:notify_edit).once + Chat::Notifier.any_instance.expects(:notify_new).never + Chat::Notifier.any_instance.expects(:notify_edit).once subject.execute(chat_message_id: chat_message.id, reason: "edit", timestamp: 1.minute.ago) end diff --git a/plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb b/plugins/chat/spec/jobs/regular/chat/update_channel_user_count_spec.rb similarity index 82% rename from plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb rename to plugins/chat/spec/jobs/regular/chat/update_channel_user_count_spec.rb index 6674e53b9e7..a6d2bffbe27 100644 --- a/plugins/chat/spec/jobs/regular/update_channel_user_count_spec.rb +++ b/plugins/chat/spec/jobs/regular/chat/update_channel_user_count_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Jobs::UpdateChannelUserCount do +RSpec.describe Jobs::Chat::UpdateChannelUserCount do fab!(:channel) { Fabricate(:category_channel, user_count: 0, user_count_stale: true) } fab!(:user1) { Fabricate(:user) } fab!(:user2) { Fabricate(:user) } @@ -18,18 +18,18 @@ RSpec.describe Jobs::UpdateChannelUserCount do it "does nothing if the channel does not exist" do channel.destroy - ChatPublisher.expects(:publish_chat_channel_metadata).never + Chat::Publisher.expects(:publish_chat_channel_metadata).never expect(described_class.new.execute(chat_channel_id: channel.id)).to eq(nil) end it "does nothing if the user count has not been marked stale" do channel.update!(user_count_stale: false) - ChatPublisher.expects(:publish_chat_channel_metadata).never + Chat::Publisher.expects(:publish_chat_channel_metadata).never expect(described_class.new.execute(chat_channel_id: channel.id)).to eq(nil) end it "updates the channel user_count and sets user_count_stale back to false" do - ChatPublisher.expects(:publish_chat_channel_metadata).with(channel) + Chat::Publisher.expects(:publish_chat_channel_metadata).with(channel) described_class.new.execute(chat_channel_id: channel.id) channel.reload expect(channel.user_count).to eq(3) diff --git a/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb b/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb index e420b0a8a58..1807f0e74d5 100644 --- a/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/auto_join_users_spec.rb @@ -2,18 +2,18 @@ require "rails_helper" -describe Jobs::AutoJoinUsers do +describe Jobs::Chat::AutoJoinUsers do it "works" do Jobs.run_immediately! channel = Fabricate(:category_channel, auto_join_users: true) user = Fabricate(:user, last_seen_at: 1.minute.ago, active: true) - membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + membership = Chat::UserChatChannelMembership.find_by(user: user, chat_channel: channel) expect(membership).to be_nil subject.execute({}) - membership = UserChatChannelMembership.find_by(user: user, chat_channel: channel) + membership = Chat::UserChatChannelMembership.find_by(user: user, chat_channel: channel) expect(membership.following).to eq(true) end end diff --git a/plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb b/plugins/chat/spec/jobs/scheduled/delete_old_chat_messages_spec.rb similarity index 95% rename from plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb rename to plugins/chat/spec/jobs/scheduled/delete_old_chat_messages_spec.rb index 79b0ea94c21..2fa3edb18cd 100644 --- a/plugins/chat/spec/jobs/delete_old_chat_messages_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/delete_old_chat_messages_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::DeleteOldChatMessages do +describe Jobs::Chat::DeleteOldMessages do base_date = DateTime.parse("2020-12-01 00:00 UTC") fab!(:public_channel) { Fabricate(:category_channel) } @@ -85,7 +85,7 @@ describe Jobs::DeleteOldChatMessages do SiteSetting.chat_channel_retention_days = 0 SiteSetting.chat_dm_retention_days = 0 - expect { described_class.new.execute }.not_to change { ChatMessage.count } + expect { described_class.new.execute }.not_to change { Chat::Message.count } end describe "public channels" do @@ -107,7 +107,7 @@ describe Jobs::DeleteOldChatMessages do it "does nothing when no messages fall in the time range" do SiteSetting.chat_channel_retention_days = 800 - expect { described_class.new.execute }.not_to change { ChatMessage.in_public_channel.count } + expect { described_class.new.execute }.not_to change { Chat::Message.in_public_channel.count } end end @@ -130,7 +130,7 @@ describe Jobs::DeleteOldChatMessages do it "does nothing when no messages fall in the time range" do SiteSetting.chat_dm_retention_days = 800 - expect { described_class.new.execute }.not_to change { ChatMessage.in_dm_channel.count } + expect { described_class.new.execute }.not_to change { Chat::Message.in_dm_channel.count } end end end diff --git a/plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb b/plugins/chat/spec/jobs/scheduled/email_notifications_spec.rb similarity index 55% rename from plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb rename to plugins/chat/spec/jobs/scheduled/email_notifications_spec.rb index c061288aabd..a0c2975e68e 100644 --- a/plugins/chat/spec/jobs/scheduled/email_chat_notifications_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/email_notifications_spec.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true -describe Jobs::EmailChatNotifications do +describe Jobs::Chat::EmailNotifications do before { Jobs.run_immediately! } context "when chat is enabled" do before { SiteSetting.chat_enabled = true } it "starts the mailer" do - Chat::ChatMailer.expects(:send_unread_mentions_summary) + Chat::Mailer.expects(:send_unread_mentions_summary) - Jobs.enqueue(:email_chat_notifications) + Jobs.enqueue(Jobs::Chat::EmailNotifications) end end @@ -17,9 +17,9 @@ describe Jobs::EmailChatNotifications do before { SiteSetting.chat_enabled = false } it "does nothing" do - Chat::ChatMailer.expects(:send_unread_mentions_summary).never + Chat::Mailer.expects(:send_unread_mentions_summary).never - Jobs.enqueue(:email_chat_notifications) + Jobs.enqueue(Jobs::Chat::EmailNotifications) end end end diff --git a/plugins/chat/spec/jobs/chat_periodical_updates_spec.rb b/plugins/chat/spec/jobs/scheduled/periodical_updates_spec.rb similarity index 53% rename from plugins/chat/spec/jobs/chat_periodical_updates_spec.rb rename to plugins/chat/spec/jobs/scheduled/periodical_updates_spec.rb index 113a58229ce..5fa751779a3 100644 --- a/plugins/chat/spec/jobs/chat_periodical_updates_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/periodical_updates_spec.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -RSpec.describe Jobs::ChatPeriodicalUpdates do +RSpec.describe Jobs::Chat::PeriodicalUpdates do it "works" do # does not blow up, no mocks, everything is called - Jobs::ChatPeriodicalUpdates.new.execute(nil) + Jobs::Chat::PeriodicalUpdates.new.execute(nil) end end diff --git a/plugins/chat/spec/jobs/update_user_counts_for_chat_channels_spec.rb b/plugins/chat/spec/jobs/scheduled/update_user_counts_for_channels_spec.rb similarity index 88% rename from plugins/chat/spec/jobs/update_user_counts_for_chat_channels_spec.rb rename to plugins/chat/spec/jobs/scheduled/update_user_counts_for_channels_spec.rb index 753b7b7bdfa..67cdbe6228e 100644 --- a/plugins/chat/spec/jobs/update_user_counts_for_chat_channels_spec.rb +++ b/plugins/chat/spec/jobs/scheduled/update_user_counts_for_channels_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Jobs::UpdateUserCountsForChatChannels do +describe Jobs::Chat::UpdateUserCountsForChannels do fab!(:chat_channel_1) { Fabricate(:category_channel, user_count: 0) } fab!(:chat_channel_2) { Fabricate(:category_channel, user_count: 0) } fab!(:user_1) { Fabricate(:user) } @@ -24,7 +24,7 @@ describe Jobs::UpdateUserCountsForChatChannels do it "sets the user_count correctly for each chat channel" do create_memberships - Jobs::UpdateUserCountsForChatChannels.new.execute + Jobs::Chat::UpdateUserCountsForChannels.new.execute expect(chat_channel_1.reload.user_count).to eq(2) expect(chat_channel_2.reload.user_count).to eq(3) @@ -39,7 +39,7 @@ describe Jobs::UpdateUserCountsForChatChannels do user_3.update(staged: true) user_4.update(active: false) - Jobs::UpdateUserCountsForChatChannels.new.execute + Jobs::Chat::UpdateUserCountsForChannels.new.execute expect(chat_channel_1.reload.user_count).to eq(1) expect(chat_channel_2.reload.user_count).to eq(0) @@ -49,11 +49,11 @@ describe Jobs::UpdateUserCountsForChatChannels do create_memberships chat_channel_1.update!(status: :archived) - Jobs::UpdateUserCountsForChatChannels.new.execute + Jobs::Chat::UpdateUserCountsForChannels.new.execute expect(chat_channel_1.reload.user_count).to eq(0) chat_channel_1.update!(status: :read_only) - Jobs::UpdateUserCountsForChatChannels.new.execute + Jobs::Chat::UpdateUserCountsForChannels.new.execute expect(chat_channel_1.reload.user_count).to eq(0) end end diff --git a/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb b/plugins/chat/spec/lib/chat/channel_archive_service_spec.rb similarity index 89% rename from plugins/chat/spec/lib/chat_channel_archive_service_spec.rb rename to plugins/chat/spec/lib/chat/channel_archive_service_spec.rb index 144b1032257..44a6d4c4db4 100644 --- a/plugins/chat/spec/lib/chat_channel_archive_service_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_archive_service_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatChannelArchiveService do +describe Chat::ChannelArchiveService do class FakeArchiveError < StandardError end @@ -10,7 +10,7 @@ describe Chat::ChatChannelArchiveService do fab!(:user) { Fabricate(:user, admin: true) } fab!(:category) { Fabricate(:category) } let(:topic_params) { { topic_title: "This will be a new topic", category_id: category.id } } - subject { Chat::ChatChannelArchiveService } + subject { Chat::ChannelArchiveService } before { SiteSetting.chat_enabled = true } @@ -32,7 +32,7 @@ describe Chat::ChatChannelArchiveService do acting_user: user, topic_params: topic_params, ) - channel_archive = ChatChannelArchive.find_by(chat_channel: channel) + channel_archive = Chat::ChannelArchive.find_by(chat_channel: channel) expect(channel_archive.archived_by).to eq(user) expect(channel_archive.destination_topic_title).to eq("This will be a new topic") expect(channel_archive.destination_category_id).to eq(category.id) @@ -49,7 +49,7 @@ describe Chat::ChatChannelArchiveService do ) expect( job_enqueued?( - job: :chat_channel_archive, + job: Jobs::Chat::ChannelArchive, args: { chat_channel_archive_id: channel_archive.id, }, @@ -69,7 +69,7 @@ describe Chat::ChatChannelArchiveService do acting_user: user, topic_params: topic_params, ) - }.not_to change { ChatChannelArchive.count } + }.not_to change { Chat::ChannelArchive.count } end it "does not count already deleted messages toward the archive total" do @@ -106,13 +106,13 @@ describe Chat::ChatChannelArchiveService do it "makes a topic, deletes all the messages, creates posts for batches of messages, and changes the channel to archived" do create_messages(50) && start_archive - reaction_message = ChatMessage.last - ChatMessageReaction.create!( + reaction_message = Chat::Message.last + Chat::MessageReaction.create!( chat_message: reaction_message, user: Fabricate(:user), emoji: "+1", ) - stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + stub_const(Chat::ChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do subject.new(@channel_archive).execute end @@ -152,7 +152,7 @@ describe Chat::ChatChannelArchiveService do it "successfully links uploads from messages to the post" do create_messages(3) && start_archive - UploadReference.create(target: ChatMessage.last, upload: Fabricate(:upload)) + UploadReference.create!(target: Chat::Message.last, upload: Fabricate(:upload)) subject.new(@channel_archive).execute expect(@channel_archive.reload.complete?).to eq(true) expect(@channel_archive.destination_topic.posts.last.upload_references.count).to eq(1) @@ -207,30 +207,34 @@ describe Chat::ChatChannelArchiveService do .chat_messages .map(&:user) .each do |user| - UserChatChannelMembership.create!(chat_channel: channel, user: user, following: true) + Chat::UserChatChannelMembership.create!( + chat_channel: channel, + user: user, + following: true, + ) end end it "unfollows (leaves) the channel for all users" do expect( - UserChatChannelMembership.where(chat_channel: channel, following: true).count, + Chat::UserChatChannelMembership.where(chat_channel: channel, following: true).count, ).to eq(3) start_archive subject.new(@channel_archive).execute expect(@channel_archive.reload.complete?).to eq(true) expect( - UserChatChannelMembership.where(chat_channel: channel, following: true).count, + Chat::UserChatChannelMembership.where(chat_channel: channel, following: true).count, ).to eq(0) end it "resets unread state for all users" do - UserChatChannelMembership.last.update!( + Chat::UserChatChannelMembership.last.update!( last_read_message_id: channel.chat_messages.first.id, ) start_archive subject.new(@channel_archive).execute expect(@channel_archive.reload.complete?).to eq(true) - expect(UserChatChannelMembership.last.last_read_message_id).to eq( + expect(Chat::UserChatChannelMembership.last.last_read_message_id).to eq( channel.chat_messages.last.id, ) end @@ -300,13 +304,13 @@ describe Chat::ChatChannelArchiveService do it "deletes all the messages, creates posts for batches of messages, and changes the channel to archived" do create_messages(50) && start_archive - reaction_message = ChatMessage.last - ChatMessageReaction.create!( + reaction_message = Chat::Message.last + Chat::MessageReaction.create!( chat_message: reaction_message, user: Fabricate(:user), emoji: "+1", ) - stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + stub_const(Chat::ChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do subject.new(@channel_archive).execute end @@ -342,12 +346,12 @@ describe Chat::ChatChannelArchiveService do Rails.logger = @fake_logger = FakeLogger.new create_messages(35) && start_archive - Chat::ChatChannelArchiveService + Chat::ChannelArchiveService .any_instance .stubs(:create_post) .raises(FakeArchiveError.new("this is a test error")) - stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + stub_const(Chat::ChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do expect { subject.new(@channel_archive).execute }.to raise_error(FakeArchiveError) end @@ -359,8 +363,8 @@ describe Chat::ChatChannelArchiveService do I18n.t("system_messages.chat_channel_archive_failed.subject_template"), ) - Chat::ChatChannelArchiveService.any_instance.unstub(:create_post) - stub_const(Chat::ChatChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + Chat::ChannelArchiveService.any_instance.unstub(:create_post) + stub_const(Chat::ChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do subject.new(@channel_archive).execute end diff --git a/plugins/chat/spec/lib/chat_channel_fetcher_spec.rb b/plugins/chat/spec/lib/chat/channel_fetcher_spec.rb similarity index 66% rename from plugins/chat/spec/lib/chat_channel_fetcher_spec.rb rename to plugins/chat/spec/lib/chat/channel_fetcher_spec.rb index 46a1f394197..d556aa43e84 100644 --- a/plugins/chat/spec/lib/chat_channel_fetcher_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_fetcher_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe Chat::ChatChannelFetcher do +describe Chat::ChannelFetcher do fab!(:category) { Fabricate(:category, name: "support") } fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) } fab!(:category_channel) { Fabricate(:category_channel, chatable: category, slug: "support") } @@ -16,30 +16,30 @@ describe Chat::ChatChannelFetcher do end def memberships - UserChatChannelMembership.where(user: user1) + Chat::UserChatChannelMembership.where(user: user1) end describe ".structured" do it "returns open channel only" do category_channel.user_chat_channel_memberships.create!(user: user1, following: true) - channels = subject.structured(guardian)[:public_channels] + channels = described_class.structured(guardian)[:public_channels] expect(channels).to contain_exactly(category_channel) category_channel.closed!(Discourse.system_user) - channels = subject.structured(guardian)[:public_channels] + channels = described_class.structured(guardian)[:public_channels] expect(channels).to be_blank end it "returns followed channel only" do - channels = subject.structured(guardian)[:public_channels] + channels = described_class.structured(guardian)[:public_channels] expect(channels).to be_blank category_channel.user_chat_channel_memberships.create!(user: user1, following: true) - channels = subject.structured(guardian)[:public_channels] + channels = described_class.structured(guardian)[:public_channels] expect(channels).to contain_exactly(category_channel) end @@ -58,14 +58,14 @@ describe Chat::ChatChannelFetcher do end it "returns the correct count" do - unread_counts = subject.unread_counts([category_channel], user1) + unread_counts = described_class.unread_counts([category_channel], user1) expect(unread_counts[category_channel.id]).to eq(2) end end context "with no unread messages" do it "returns the correct count" do - unread_counts = subject.unread_counts([category_channel], user1) + unread_counts = described_class.unread_counts([category_channel], user1) expect(unread_counts[category_channel.id]).to eq(0) end end @@ -78,7 +78,7 @@ describe Chat::ChatChannelFetcher do before { last_unread.update!(deleted_at: Time.zone.now) } it "returns the correct count" do - unread_counts = subject.unread_counts([category_channel], user1) + unread_counts = described_class.unread_counts([category_channel], user1) expect(unread_counts[category_channel.id]).to eq(0) end end @@ -91,7 +91,7 @@ describe Chat::ChatChannelFetcher do end it "returns the correct count" do - unread_counts = subject.unread_counts([category_channel], user1) + unread_counts = described_class.unread_counts([category_channel], user1) expect(unread_counts[category_channel.id]).to eq(0) end end @@ -100,41 +100,45 @@ describe Chat::ChatChannelFetcher do describe ".all_secured_channel_ids" do it "returns nothing by default if the user has no memberships" do - expect(subject.all_secured_channel_ids(guardian)).to eq([]) + expect(described_class.all_secured_channel_ids(guardian)).to eq([]) end context "when the user has memberships to all the channels" do before do - UserChatChannelMembership.create!( + Chat::UserChatChannelMembership.create!( user: user1, chat_channel: category_channel, following: true, ) - UserChatChannelMembership.create!( + Chat::UserChatChannelMembership.create!( user: user1, chat_channel: direct_message_channel1, following: true, - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always], ) end it "returns category channel because they are public by default" do - expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + expect(described_class.all_secured_channel_ids(guardian)).to match_array( + [category_channel.id], + ) end it "returns all the channels if the user is a member of the DM channel also" do - DirectMessageUser.create!(user: user1, direct_message: dm_channel1) - expect(subject.all_secured_channel_ids(guardian)).to match_array( + Chat::DirectMessageUser.create!(user: user1, direct_message: dm_channel1) + expect(described_class.all_secured_channel_ids(guardian)).to match_array( [category_channel.id, direct_message_channel1.id], ) end it "does not include the category channel if the category is a private category the user cannot see" do category_channel.update!(chatable: private_category) - expect(subject.all_secured_channel_ids(guardian)).to be_empty + expect(described_class.all_secured_channel_ids(guardian)).to be_empty GroupUser.create!(group: private_category.groups.last, user: user1) - expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + expect(described_class.all_secured_channel_ids(guardian)).to match_array( + [category_channel.id], + ) end context "when restricted category" do @@ -150,7 +154,7 @@ describe Chat::ChatChannelFetcher do permission_type: CategoryGroup.permission_types[:readonly], ), ) - expect(subject.all_secured_channel_ids(guardian)).to be_empty + expect(described_class.all_secured_channel_ids(guardian)).to be_empty end it "includes the category channel for member of group with create_post access" do @@ -162,7 +166,9 @@ describe Chat::ChatChannelFetcher do permission_type: CategoryGroup.permission_types[:create_post], ), ) - expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + expect(described_class.all_secured_channel_ids(guardian)).to match_array( + [category_channel.id], + ) end it "includes the category channel for member of group with full access" do @@ -174,7 +180,9 @@ describe Chat::ChatChannelFetcher do permission_type: CategoryGroup.permission_types[:full], ), ) - expect(subject.all_secured_channel_ids(guardian)).to match_array([category_channel.id]) + expect(described_class.all_secured_channel_ids(guardian)).to match_array( + [category_channel.id], + ) end end end @@ -185,13 +193,15 @@ describe Chat::ChatChannelFetcher do it "does not include DM channels" do expect( - subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + described_class.secured_public_channels(guardian, memberships, following: following).map( + &:id + ), ).to match_array([category_channel.id]) end it "can filter by channel name, or category name" do expect( - subject.secured_public_channels( + described_class.secured_public_channels( guardian, memberships, following: following, @@ -202,7 +212,7 @@ describe Chat::ChatChannelFetcher do category_channel.update!(name: "cool stuff") expect( - subject.secured_public_channels( + described_class.secured_public_channels( guardian, memberships, following: following, @@ -213,29 +223,33 @@ describe Chat::ChatChannelFetcher do it "can filter by an array of slugs" do expect( - subject.secured_public_channels(guardian, memberships, slugs: ["support"]).map(&:id), + described_class.secured_public_channels(guardian, memberships, slugs: ["support"]).map( + &:id + ), ).to match_array([category_channel.id]) end it "returns nothing if the array of slugs is empty" do - expect(subject.secured_public_channels(guardian, memberships, slugs: []).map(&:id)).to eq([]) + expect( + described_class.secured_public_channels(guardian, memberships, slugs: []).map(&:id), + ).to eq([]) end it "can filter by status" do expect( - subject.secured_public_channels(guardian, memberships, status: "closed").map(&:id), + described_class.secured_public_channels(guardian, memberships, status: "closed").map(&:id), ).to match_array([]) category_channel.closed!(Discourse.system_user) expect( - subject.secured_public_channels(guardian, memberships, status: "closed").map(&:id), + described_class.secured_public_channels(guardian, memberships, status: "closed").map(&:id), ).to match_array([category_channel.id]) end it "can filter by following" do expect( - subject.secured_public_channels(guardian, memberships, following: true).map(&:id), + described_class.secured_public_channels(guardian, memberships, following: true).map(&:id), ).to be_blank end @@ -244,35 +258,39 @@ describe Chat::ChatChannelFetcher do another_channel = Fabricate(:category_channel) expect( - subject.secured_public_channels(guardian, memberships, following: false).map(&:id), + described_class.secured_public_channels(guardian, memberships, following: false).map(&:id), ).to match_array([category_channel.id, another_channel.id]) end it "ensures offset is >= 0" do expect( - subject.secured_public_channels(guardian, memberships, offset: -235).map(&:id), + described_class.secured_public_channels(guardian, memberships, offset: -235).map(&:id), ).to match_array([category_channel.id]) end it "ensures limit is > 0" do expect( - subject.secured_public_channels(guardian, memberships, limit: -1, offset: 0).map(&:id), + described_class.secured_public_channels(guardian, memberships, limit: -1, offset: 0).map( + &:id + ), ).to match_array([category_channel.id]) end it "ensures limit has a max value" do - over_limit = Chat::ChatChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS + 1 + over_limit = Chat::ChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS + 1 over_limit.times { Fabricate(:category_channel) } expect( - subject.secured_public_channels(guardian, memberships, limit: over_limit).length, - ).to eq(Chat::ChatChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS) + described_class.secured_public_channels(guardian, memberships, limit: over_limit).length, + ).to eq(Chat::ChannelFetcher::MAX_PUBLIC_CHANNEL_RESULTS) end it "does not show the user category channels they cannot access" do category_channel.update!(chatable: private_category) expect( - subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + described_class.secured_public_channels(guardian, memberships, following: following).map( + &:id + ), ).to be_empty end @@ -281,22 +299,26 @@ describe Chat::ChatChannelFetcher do it "only returns channels where the user is a member and is following the channel" do expect( - subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + described_class.secured_public_channels(guardian, memberships, following: following).map( + &:id + ), ).to be_empty - UserChatChannelMembership.create!( + Chat::UserChatChannelMembership.create!( user: user1, chat_channel: category_channel, following: true, ) expect( - subject.secured_public_channels(guardian, memberships, following: following).map(&:id), + described_class.secured_public_channels(guardian, memberships, following: following).map( + &:id + ), ).to match_array([category_channel.id]) end it "includes the unread count based on mute settings" do - UserChatChannelMembership.create!( + Chat::UserChatChannelMembership.create!( user: user1, chat_channel: category_channel, following: true, @@ -306,7 +328,11 @@ describe Chat::ChatChannelFetcher do Fabricate(:chat_message, user: user2, chat_channel: category_channel) resolved_memberships = memberships - subject.secured_public_channels(guardian, resolved_memberships, following: following) + described_class.secured_public_channels( + guardian, + resolved_memberships, + following: following, + ) expect( resolved_memberships @@ -317,7 +343,11 @@ describe Chat::ChatChannelFetcher do resolved_memberships.last.update!(muted: true) resolved_memberships = memberships - subject.secured_public_channels(guardian, resolved_memberships, following: following) + described_class.secured_public_channels( + guardian, + resolved_memberships, + following: following, + ) expect( resolved_memberships @@ -328,7 +358,7 @@ describe Chat::ChatChannelFetcher do end end - describe "#secured_direct_message_channels" do + describe ".secured_direct_message_channels" do it "includes direct message channels the user is a member of ordered by last_message_sent_at" do Fabricate( :user_chat_channel_membership_for_dm, @@ -336,22 +366,22 @@ describe Chat::ChatChannelFetcher do user: user1, following: true, ) - DirectMessageUser.create!(direct_message: dm_channel1, user: user1) - DirectMessageUser.create!(direct_message: dm_channel1, user: user2) + Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user1) + Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user2) Fabricate( :user_chat_channel_membership_for_dm, chat_channel: direct_message_channel2, user: user1, following: true, ) - DirectMessageUser.create!(direct_message: dm_channel2, user: user1) - DirectMessageUser.create!(direct_message: dm_channel2, user: user2) + Chat::DirectMessageUser.create!(direct_message: dm_channel2, user: user1) + Chat::DirectMessageUser.create!(direct_message: dm_channel2, user: user2) direct_message_channel1.update!(last_message_sent_at: 1.day.ago) direct_message_channel2.update!(last_message_sent_at: 1.hour.ago) expect( - subject.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), + described_class.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), ).to eq([direct_message_channel2.id, direct_message_channel1.id]) end @@ -362,10 +392,10 @@ describe Chat::ChatChannelFetcher do user: user1, following: true, ) - DirectMessageUser.create!(direct_message: dm_channel1, user: user2) + Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user2) expect( - subject.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), + described_class.secured_direct_message_channels(user1.id, memberships, guardian).map(&:id), ).not_to include(direct_message_channel1.id) end @@ -377,14 +407,14 @@ describe Chat::ChatChannelFetcher do user: user1, following: true, ) - DirectMessageUser.create!(direct_message: dm_channel1, user: user1) - DirectMessageUser.create!(direct_message: dm_channel1, user: user2) + Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user1) + Chat::DirectMessageUser.create!(direct_message: dm_channel1, user: user2) Fabricate(:chat_message, user: user2, chat_channel: direct_message_channel1) Fabricate(:chat_message, user: user2, chat_channel: direct_message_channel1) resolved_memberships = memberships - subject.secured_direct_message_channels(user1.id, resolved_memberships, guardian) + described_class.secured_direct_message_channels(user1.id, resolved_memberships, guardian) target_membership = resolved_memberships.find { |mem| mem.chat_channel_id == direct_message_channel1.id } expect(target_membership.unread_count).to eq(2) @@ -393,7 +423,7 @@ describe Chat::ChatChannelFetcher do target_membership = resolved_memberships.find { |mem| mem.chat_channel_id == direct_message_channel1.id } target_membership.update!(muted: true) - subject.secured_direct_message_channels(user1.id, resolved_memberships, guardian) + described_class.secured_direct_message_channels(user1.id, resolved_memberships, guardian) expect(target_membership.unread_count).to eq(0) end end @@ -401,20 +431,22 @@ describe Chat::ChatChannelFetcher do describe ".find_with_access_check" do it "raises NotFound if the channel does not exist" do category_channel.destroy! - expect { subject.find_with_access_check(category_channel.id, guardian) }.to raise_error( - Discourse::NotFound, - ) + expect { + described_class.find_with_access_check(category_channel.id, guardian) + }.to raise_error(Discourse::NotFound) end it "raises InvalidAccess if the user cannot see the channel" do category_channel.update!(chatable: private_category) - expect { subject.find_with_access_check(category_channel.id, guardian) }.to raise_error( - Discourse::InvalidAccess, - ) + expect { + described_class.find_with_access_check(category_channel.id, guardian) + }.to raise_error(Discourse::InvalidAccess) end it "returns the chat channel if it is found and accessible" do - expect(subject.find_with_access_check(category_channel.id, guardian)).to eq(category_channel) + expect(described_class.find_with_access_check(category_channel.id, guardian)).to eq( + category_channel, + ) end end end diff --git a/plugins/chat/spec/lib/chat_channel_hashtag_data_source_spec.rb b/plugins/chat/spec/lib/chat/channel_hashtag_data_source_spec.rb similarity index 99% rename from plugins/chat/spec/lib/chat_channel_hashtag_data_source_spec.rb rename to plugins/chat/spec/lib/chat/channel_hashtag_data_source_spec.rb index cf7de252b76..0a93c2b5e0a 100644 --- a/plugins/chat/spec/lib/chat_channel_hashtag_data_source_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_hashtag_data_source_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Chat::ChatChannelHashtagDataSource do +RSpec.describe Chat::ChannelHashtagDataSource do fab!(:user) { Fabricate(:user) } fab!(:category) { Fabricate(:category) } fab!(:group) { Fabricate(:group) } diff --git a/plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb b/plugins/chat/spec/lib/chat/channel_membership_manager_spec.rb similarity index 86% rename from plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb rename to plugins/chat/spec/lib/chat/channel_membership_manager_spec.rb index f5c9c694792..ac96a8fb979 100644 --- a/plugins/chat/spec/lib/chat_channel_membership_manager_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_membership_manager_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Chat::ChatChannelMembershipManager do +RSpec.describe Chat::ChannelMembershipManager do fab!(:user) { Fabricate(:user) } fab!(:channel1) { Fabricate(:category_channel) } fab!(:channel2) { Fabricate(:category_channel) } @@ -31,7 +31,7 @@ RSpec.describe Chat::ChatChannelMembershipManager do it "creates a membership if one does not exist for the user and channel already" do membership = nil expect { membership = described_class.new(channel1).follow(user) }.to change { - UserChatChannelMembership.count + Chat::UserChatChannelMembership.count }.by(1) expect(membership.following).to eq(true) expect(membership.chat_channel).to eq(channel1) @@ -41,7 +41,12 @@ RSpec.describe Chat::ChatChannelMembershipManager do it "enqueues user_count recalculation and marks user_count_stale as true" do described_class.new(channel1).follow(user) expect(channel1.reload.user_count_stale).to eq(true) - expect_job_enqueued(job: :update_channel_user_count, args: { chat_channel_id: channel1.id }) + expect_job_enqueued( + job: Jobs::Chat::UpdateChannelUserCount, + args: { + chat_channel_id: channel1.id, + }, + ) end it "updates the membership to following if it already existed" do @@ -53,7 +58,7 @@ RSpec.describe Chat::ChatChannelMembershipManager do following: false, ) expect { membership = described_class.new(channel1).follow(user) }.not_to change { - UserChatChannelMembership.count + Chat::UserChatChannelMembership.count } expect(membership.reload.following).to eq(true) end @@ -76,7 +81,12 @@ RSpec.describe Chat::ChatChannelMembershipManager do membership.reload expect(membership.following).to eq(false) expect(channel1.reload.user_count_stale).to eq(true) - expect_job_enqueued(job: :update_channel_user_count, args: { chat_channel_id: channel1.id }) + expect_job_enqueued( + job: Jobs::Chat::UpdateChannelUserCount, + args: { + chat_channel_id: channel1.id, + }, + ) end it "does not recalculate user count if the user was already not following the channel" do @@ -88,7 +98,7 @@ RSpec.describe Chat::ChatChannelMembershipManager do following: false, ) expect_not_enqueued_with( - job: :update_channel_user_count, + job: Jobs::Chat::UpdateChannelUserCount, args: { chat_channel_id: channel1.id, }, diff --git a/plugins/chat/spec/lib/direct_message_channel_creator_spec.rb b/plugins/chat/spec/lib/chat/direct_message_channel_creator_spec.rb similarity index 64% rename from plugins/chat/spec/lib/direct_message_channel_creator_spec.rb rename to plugins/chat/spec/lib/chat/direct_message_channel_creator_spec.rb index 0ae71e0c462..dae249a2d52 100644 --- a/plugins/chat/spec/lib/direct_message_channel_creator_spec.rb +++ b/plugins/chat/spec/lib/chat/direct_message_channel_creator_spec.rb @@ -21,20 +21,20 @@ describe Chat::DirectMessageChannelCreator do existing_channel = nil expect { existing_channel = - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) - }.not_to change { ChatChannel.count } + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.not_to change { Chat::Channel.count } expect(existing_channel).to eq(dm_chat_channel) end - it "creates UserChatChannelMembership records and sets their notification levels, and only updates creator membership to following" do + it "creates Chat::UserChatChannelMembership records and sets their notification levels, and only updates creator membership to following" do Fabricate( :user_chat_channel_membership, user: user_2, chat_channel: dm_chat_channel, following: false, muted: true, - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], ) Fabricate( :user_chat_channel_membership, @@ -42,16 +42,19 @@ describe Chat::DirectMessageChannelCreator do chat_channel: dm_chat_channel, following: false, muted: true, - desktop_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], - mobile_notification_level: UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + desktop_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], + mobile_notification_level: Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:never], ) expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) - }.to change { UserChatChannelMembership.count }.by(1) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to change { Chat::UserChatChannelMembership.count }.by(1) user_1_membership = - UserChatChannelMembership.find_by(user_id: user_1.id, chat_channel_id: dm_chat_channel) + Chat::UserChatChannelMembership.find_by( + user_id: user_1.id, + chat_channel_id: dm_chat_channel, + ) expect(user_1_membership.last_read_message_id).to eq(nil) expect(user_1_membership.desktop_notification_level).to eq("always") expect(user_1_membership.mobile_notification_level).to eq("always") @@ -59,7 +62,10 @@ describe Chat::DirectMessageChannelCreator do expect(user_1_membership.following).to eq(true) user_2_membership = - UserChatChannelMembership.find_by(user_id: user_2.id, chat_channel_id: dm_chat_channel) + Chat::UserChatChannelMembership.find_by( + user_id: user_2.id, + chat_channel_id: dm_chat_channel, + ) expect(user_2_membership.last_read_message_id).to eq(nil) expect(user_2_membership.desktop_notification_level).to eq("never") expect(user_2_membership.mobile_notification_level).to eq("never") @@ -67,7 +73,10 @@ describe Chat::DirectMessageChannelCreator do expect(user_2_membership.following).to eq(false) user_3_membership = - UserChatChannelMembership.find_by(user_id: user_3.id, chat_channel_id: dm_chat_channel) + Chat::UserChatChannelMembership.find_by( + user_id: user_3.id, + chat_channel_id: dm_chat_channel, + ) expect(user_3_membership.last_read_message_id).to eq(nil) expect(user_3_membership.desktop_notification_level).to eq("never") expect(user_3_membership.mobile_notification_level).to eq("never") @@ -79,7 +88,7 @@ describe Chat::DirectMessageChannelCreator do messages = MessageBus .track_publish do - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) end .filter { |m| m.channel == "/chat/new-channel" } @@ -93,16 +102,21 @@ describe Chat::DirectMessageChannelCreator do it "allows a user to create a direct message to themselves, without creating a new channel" do existing_channel = nil expect { - existing_channel = subject.create!(acting_user: user_1, target_users: [user_1]) - }.to not_change { ChatChannel.count }.and change { UserChatChannelMembership.count }.by(1) + existing_channel = described_class.create!(acting_user: user_1, target_users: [user_1]) + }.to not_change { Chat::Channel.count }.and change { + Chat::UserChatChannelMembership.count + }.by(1) expect(existing_channel).to eq(own_chat_channel) end it "deduplicates target_users" do existing_channel = nil expect { - existing_channel = subject.create!(acting_user: user_1, target_users: [user_1, user_1]) - }.to not_change { ChatChannel.count }.and change { UserChatChannelMembership.count }.by(1) + existing_channel = + described_class.create!(acting_user: user_1, target_users: [user_1, user_1]) + }.to not_change { Chat::Channel.count }.and change { + Chat::UserChatChannelMembership.count + }.by(1) expect(existing_channel).to eq(own_chat_channel) end @@ -110,13 +124,14 @@ describe Chat::DirectMessageChannelCreator do before { SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:trust_level_4] } it "raises an error and does not change membership or channel counts" do - channel_count = ChatChannel.count - membership_count = UserChatChannelMembership.count + channel_count = Chat::Channel.count + membership_count = Chat::UserChatChannelMembership.count expect { - existing_channel = subject.create!(acting_user: user_1, target_users: [user_1, user_1]) + existing_channel = + described_class.create!(acting_user: user_1, target_users: [user_1, user_1]) }.to raise_error(Discourse::InvalidAccess) - expect(ChatChannel.count).to eq(channel_count) - expect(UserChatChannelMembership.count).to eq(membership_count) + expect(Chat::Channel.count).to eq(channel_count) + expect(Chat::UserChatChannelMembership.count).to eq(membership_count) end context "when user is staff" do @@ -126,8 +141,8 @@ describe Chat::DirectMessageChannelCreator do existing_channel = nil expect { existing_channel = - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) - }.not_to change { ChatChannel.count } + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.not_to change { Chat::Channel.count } expect(existing_channel).to eq(dm_chat_channel) end end @@ -136,19 +151,19 @@ describe Chat::DirectMessageChannelCreator do context "with non existing direct message channel" do it "creates a new chat channel" do - expect { subject.create!(acting_user: user_1, target_users: [user_1, user_2]) }.to change { - ChatChannel.count - }.by(1) + expect { + described_class.create!(acting_user: user_1, target_users: [user_1, user_2]) + }.to change { Chat::Channel.count }.by(1) end - it "creates UserChatChannelMembership records and sets their notification levels" do - expect { subject.create!(acting_user: user_1, target_users: [user_1, user_2]) }.to change { - UserChatChannelMembership.count - }.by(2) + it "creates Chat::UserChatChannelMembership records and sets their notification levels" do + expect { + described_class.create!(acting_user: user_1, target_users: [user_1, user_2]) + }.to change { Chat::UserChatChannelMembership.count }.by(2) - chat_channel = ChatChannel.last + chat_channel = Chat::Channel.last user_1_membership = - UserChatChannelMembership.find_by(user_id: user_1.id, chat_channel_id: chat_channel) + Chat::UserChatChannelMembership.find_by(user_id: user_1.id, chat_channel_id: chat_channel) expect(user_1_membership.last_read_message_id).to eq(nil) expect(user_1_membership.desktop_notification_level).to eq("always") expect(user_1_membership.mobile_notification_level).to eq("always") @@ -159,10 +174,12 @@ describe Chat::DirectMessageChannelCreator do it "publishes the new DM channel message bus message for each user" do messages = MessageBus - .track_publish { subject.create!(acting_user: user_1, target_users: [user_1, user_2]) } + .track_publish do + described_class.create!(acting_user: user_1, target_users: [user_1, user_2]) + end .filter { |m| m.channel == "/chat/new-channel" } - chat_channel = ChatChannel.last + chat_channel = Chat::Channel.last expect(messages.count).to eq(2) expect(messages.first[:data]).to be_kind_of(Hash) expect(messages.map { |m| m.dig(:data, :channel, :id) }).to eq( @@ -171,15 +188,17 @@ describe Chat::DirectMessageChannelCreator do end it "allows a user to create a direct message to themselves" do - expect { subject.create!(acting_user: user_1, target_users: [user_1]) }.to change { - ChatChannel.count - }.by(1).and change { UserChatChannelMembership.count }.by(1) + expect { described_class.create!(acting_user: user_1, target_users: [user_1]) }.to change { + Chat::Channel.count + }.by(1).and change { Chat::UserChatChannelMembership.count }.by(1) end it "deduplicates target_users" do - expect { subject.create!(acting_user: user_1, target_users: [user_1, user_1]) }.to change { - ChatChannel.count - }.by(1).and change { UserChatChannelMembership.count }.by(1) + expect { + described_class.create!(acting_user: user_1, target_users: [user_1, user_1]) + }.to change { Chat::Channel.count }.by(1).and change { + Chat::UserChatChannelMembership.count + }.by(1) end context "when number of users is over the limit" do @@ -187,7 +206,7 @@ describe Chat::DirectMessageChannelCreator do it "raises an error" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.over_chat_max_direct_message_users", count: 2), @@ -199,8 +218,8 @@ describe Chat::DirectMessageChannelCreator do it "creates a new chat channel" do expect { - subject.create!(acting_user: admin, target_users: [admin, user_1, user_2]) - }.to change { ChatChannel.count }.by(1) + described_class.create!(acting_user: admin, target_users: [admin, user_1, user_2]) + }.to change { Chat::Channel.count }.by(1) end end @@ -209,7 +228,7 @@ describe Chat::DirectMessageChannelCreator do it "raises an error" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.over_chat_max_direct_message_users_allow_self"), @@ -222,8 +241,8 @@ describe Chat::DirectMessageChannelCreator do before { SiteSetting.chat_max_direct_message_users = 0 } it "creates a new chat channel" do - expect { subject.create!(acting_user: user_1, target_users: [user_1]) }.to change { - ChatChannel.count + expect { described_class.create!(acting_user: user_1, target_users: [user_1]) }.to change { + Chat::Channel.count }.by(1) end end @@ -232,8 +251,8 @@ describe Chat::DirectMessageChannelCreator do before { SiteSetting.chat_max_direct_message_users = 1 } it "creates a new chat channel" do - expect { subject.create!(acting_user: user_1, target_users: [user_1]) }.to change { - ChatChannel.count + expect { described_class.create!(acting_user: user_1, target_users: [user_1]) }.to change { + Chat::Channel.count }.by(1) end end @@ -242,13 +261,13 @@ describe Chat::DirectMessageChannelCreator do before { SiteSetting.direct_message_enabled_groups = Group::AUTO_GROUPS[:trust_level_4] } it "raises an error and does not change membership or channel counts" do - channel_count = ChatChannel.count - membership_count = UserChatChannelMembership.count + channel_count = Chat::Channel.count + membership_count = Chat::UserChatChannelMembership.count expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2]) }.to raise_error(Discourse::InvalidAccess) - expect(ChatChannel.count).to eq(channel_count) - expect(UserChatChannelMembership.count).to eq(membership_count) + expect(Chat::Channel.count).to eq(channel_count) + expect(Chat::UserChatChannelMembership.count).to eq(membership_count) end context "when user is staff" do @@ -256,8 +275,8 @@ describe Chat::DirectMessageChannelCreator do it "creates a new chat channel" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2]) - }.to change { ChatChannel.count }.by(1) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2]) + }.to change { Chat::Channel.count }.by(1) end end end @@ -271,7 +290,7 @@ describe Chat::DirectMessageChannelCreator do it "raises an error with a helpful message" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.not_accepting_dms", username: user_2.username), @@ -280,7 +299,7 @@ describe Chat::DirectMessageChannelCreator do it "does not let the ignoring user create a DM either and raises an error with a helpful message" do expect { - subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + described_class.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.actor_ignoring_target_user", username: user_1.username), @@ -293,7 +312,7 @@ describe Chat::DirectMessageChannelCreator do it "raises an error with a helpful message" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.not_accepting_dms", username: user_2.username), @@ -302,7 +321,7 @@ describe Chat::DirectMessageChannelCreator do it "does not let the muting user create a DM either and raises an error with a helpful message" do expect { - subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + described_class.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.actor_muting_target_user", username: user_1.username), @@ -315,7 +334,7 @@ describe Chat::DirectMessageChannelCreator do it "raises an error with a helpful message" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.not_accepting_dms", username: user_2.username), @@ -324,7 +343,7 @@ describe Chat::DirectMessageChannelCreator do it "does not let the user who is preventing PM/DM create a DM either and raises an error with a helpful message" do expect { - subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + described_class.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.actor_disallowed_dms"), @@ -337,20 +356,20 @@ describe Chat::DirectMessageChannelCreator do it "raises an error with a helpful message" do expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) }.to raise_error(Chat::DirectMessageChannelCreator::NotAllowed) end it "does not raise an error if the acting user is allowed to send the PM" do AllowedPmUser.create!(user: user_2, allowed_pm_user: user_1) expect { - subject.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) - }.to change { ChatChannel.count }.by(1) + described_class.create!(acting_user: user_1, target_users: [user_1, user_2, user_3]) + }.to change { Chat::Channel.count }.by(1) end it "does not let the user who is preventing PM/DM create a DM either and raises an error with a helpful message" do expect { - subject.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) + described_class.create!(acting_user: user_2, target_users: [user_2, user_1, user_3]) }.to raise_error( Chat::DirectMessageChannelCreator::NotAllowed, I18n.t("chat.errors.actor_preventing_target_user_from_dm", username: user_1.username), diff --git a/plugins/chat/spec/lib/duplicate_message_validator_spec.rb b/plugins/chat/spec/lib/chat/duplicate_message_validator_spec.rb similarity index 100% rename from plugins/chat/spec/lib/duplicate_message_validator_spec.rb rename to plugins/chat/spec/lib/chat/duplicate_message_validator_spec.rb diff --git a/plugins/chat/spec/lib/guardian_extensions_spec.rb b/plugins/chat/spec/lib/chat/guardian_extensions_spec.rb similarity index 98% rename from plugins/chat/spec/lib/guardian_extensions_spec.rb rename to plugins/chat/spec/lib/chat/guardian_extensions_spec.rb index e2e4c4cbc70..4366cf5df46 100644 --- a/plugins/chat/spec/lib/guardian_extensions_spec.rb +++ b/plugins/chat/spec/lib/chat/guardian_extensions_spec.rb @@ -82,7 +82,7 @@ RSpec.describe Chat::GuardianExtensions do end it "returns true if the user is part of the direct message" do - DirectMessageUser.create!(user: user, direct_message: chatable) + Chat::DirectMessageUser.create!(user: user, direct_message: chatable) expect(guardian.can_join_chat_channel?(channel)).to eq(true) end end @@ -206,7 +206,7 @@ RSpec.describe Chat::GuardianExtensions do end context "for DM channel" do - fab!(:dm_channel) { DirectMessage.create! } + fab!(:dm_channel) { Chat::DirectMessage.create! } before { channel.update(chatable_type: "DirectMessageType", chatable: dm_channel) } @@ -234,7 +234,7 @@ RSpec.describe Chat::GuardianExtensions do end context "when chatable is a direct message" do - fab!(:chatable) { DirectMessage.create! } + fab!(:chatable) { Chat::DirectMessage.create! } it "allows owner to restore" do expect(guardian.can_restore_chat?(message, chatable)).to eq(true) @@ -284,7 +284,7 @@ RSpec.describe Chat::GuardianExtensions do end context "when chatable is a direct message" do - fab!(:chatable) { DirectMessage.create! } + fab!(:chatable) { Chat::DirectMessage.create! } it "allows staff to restore" do expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) @@ -317,7 +317,7 @@ RSpec.describe Chat::GuardianExtensions do end context "when chatable is a direct message" do - fab!(:chatable) { DirectMessage.create! } + fab!(:chatable) { Chat::DirectMessage.create! } it "allows staff to restore" do expect(staff_guardian.can_restore_chat?(message, chatable)).to eq(true) diff --git a/plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb b/plugins/chat/spec/lib/chat/message_bookmarkable_spec.rb similarity index 94% rename from plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb rename to plugins/chat/spec/lib/chat/message_bookmarkable_spec.rb index af7210611a5..d6c97f1eb59 100644 --- a/plugins/chat/spec/lib/chat_message_bookmarkable_spec.rb +++ b/plugins/chat/spec/lib/chat/message_bookmarkable_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe ChatMessageBookmarkable do +describe Chat::MessageBookmarkable do fab!(:user) { Fabricate(:user) } fab!(:guardian) { Guardian.new(user) } fab!(:other_category) { Fabricate(:private_category, group: Fabricate(:group)) } @@ -11,8 +11,8 @@ describe ChatMessageBookmarkable do fab!(:channel) { Fabricate(:category_channel) } before do - register_test_bookmarkable(ChatMessageBookmarkable) - UserChatChannelMembership.create(chat_channel: channel, user: user, following: true) + register_test_bookmarkable(described_class) + Chat::UserChatChannelMembership.create(chat_channel: channel, user: user, following: true) end after { DiscoursePluginRegistry.reset_register!(:bookmarkables) } @@ -25,7 +25,7 @@ describe ChatMessageBookmarkable do let!(:bookmark2) { Fabricate(:bookmark, user: user, bookmarkable: message2) } let!(:bookmark3) { Fabricate(:bookmark) } - subject { RegisteredBookmarkable.new(ChatMessageBookmarkable) } + subject { RegisteredBookmarkable.new(described_class) } describe "#perform_list_query" do it "returns all the user's bookmarks" do @@ -50,7 +50,7 @@ describe ChatMessageBookmarkable do direct_message = Fabricate(:direct_message) channel.update(chatable: direct_message) expect(subject.perform_list_query(user, guardian)).to eq(nil) - DirectMessageUser.create(user: user, direct_message: direct_message) + Chat::DirectMessageUser.create(user: user, direct_message: direct_message) bookmark1.reload user.reload guardian = Guardian.new(user) @@ -109,7 +109,7 @@ describe ChatMessageBookmarkable do bookmark1.bookmarkable.trash! bookmark1.reload expect(subject.can_send_reminder?(bookmark1)).to eq(false) - ChatMessage.with_deleted.find_by(id: bookmark1.bookmarkable_id).recover! + Chat::Message.with_deleted.find_by(id: bookmark1.bookmarkable_id).recover! bookmark1.reload bookmark1.bookmarkable.chat_channel.trash! bookmark1.reload diff --git a/plugins/chat/spec/lib/chat_message_mentions_spec.rb b/plugins/chat/spec/lib/chat/message_mentions_spec.rb similarity index 86% rename from plugins/chat/spec/lib/chat_message_mentions_spec.rb rename to plugins/chat/spec/lib/chat/message_mentions_spec.rb index dd2480d3e0a..4254c9c19ca 100644 --- a/plugins/chat/spec/lib/chat_message_mentions_spec.rb +++ b/plugins/chat/spec/lib/chat/message_mentions_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -RSpec.describe Chat::ChatMessageMentions do +RSpec.describe Chat::MessageMentions do fab!(:channel_member_1) { Fabricate(:user) } fab!(:channel_member_2) { Fabricate(:user) } fab!(:channel_member_3) { Fabricate(:user) } @@ -19,7 +19,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns all members of the channel" do message = create_message("mentioning @all") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.global_mentions.pluck(:username) expect(result).to contain_exactly( @@ -32,7 +32,7 @@ RSpec.describe Chat::ChatMessageMentions do it "doesn't include users that were also mentioned directly" do message = create_message("mentioning @all and @#{channel_member_1.username}") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.global_mentions.pluck(:username) expect(result).to contain_exactly(channel_member_2.username, channel_member_3.username) @@ -41,7 +41,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns an empty list if there are no global mentions" do message = create_message("not mentioning anybody") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.global_mentions.pluck(:username) expect(result).to be_empty @@ -59,7 +59,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns all members of the channel who were online in the last 5 minutes" do message = create_message("mentioning @here") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.here_mentions.pluck(:username) expect(result).to contain_exactly(channel_member_1.username, channel_member_2.username) @@ -68,7 +68,7 @@ RSpec.describe Chat::ChatMessageMentions do it "doesn't include users that were also mentioned directly" do message = create_message("mentioning @here and @#{channel_member_1.username}") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.here_mentions.pluck(:username) expect(result).to contain_exactly(channel_member_2.username) @@ -77,7 +77,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns an empty list if there are no here mentions" do message = create_message("not mentioning anybody") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.here_mentions.pluck(:username) expect(result).to be_empty @@ -89,7 +89,7 @@ RSpec.describe Chat::ChatMessageMentions do message = create_message("mentioning @#{channel_member_1.username} and @#{channel_member_2.username}") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.direct_mentions.pluck(:username) expect(result).to contain_exactly(channel_member_1.username, channel_member_2.username) @@ -98,7 +98,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns a mentioned user even if he's not a member of the channel" do message = create_message("mentioning @#{not_a_channel_member.username}") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.direct_mentions.pluck(:username) expect(result).to contain_exactly(not_a_channel_member.username) @@ -107,7 +107,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns an empty list if no one was mentioned directly" do message = create_message("not mentioning anybody") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.direct_mentions.pluck(:username) expect(result).to be_empty @@ -128,7 +128,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns members of a mentioned group even if some of them is not members of the channel" do message = create_message("mentioning @#{group1.name}") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.group_mentions.pluck(:username) expect(result).to contain_exactly( @@ -141,7 +141,7 @@ RSpec.describe Chat::ChatMessageMentions do it "returns an empty list if no group was mentioned" do message = create_message("not mentioning anyone") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.group_mentions.pluck(:username) expect(result).to be_empty @@ -152,7 +152,7 @@ RSpec.describe Chat::ChatMessageMentions do group1.save! message = create_message("mentioning @#{group1.name}") - mentions = Chat::ChatMessageMentions.new(message) + mentions = described_class.new(message) result = mentions.group_mentions.pluck(:username) expect(result).to be_empty diff --git a/plugins/chat/spec/lib/message_mover_spec.rb b/plugins/chat/spec/lib/chat/message_mover_spec.rb similarity index 88% rename from plugins/chat/spec/lib/message_mover_spec.rb rename to plugins/chat/spec/lib/chat/message_mover_spec.rb index f78d3f6b0f3..3c3781be65e 100644 --- a/plugins/chat/spec/lib/message_mover_spec.rb +++ b/plugins/chat/spec/lib/chat/message_mover_spec.rb @@ -72,8 +72,8 @@ describe Chat::MessageMover do it "deletes the messages from the source channel and sends messagebus delete messages" do messages = MessageBus.track_publish { move! } - expect(ChatMessage.where(id: move_message_ids)).to eq([]) - deleted_messages = ChatMessage.with_deleted.where(id: move_message_ids).order(:id) + expect(Chat::Message.where(id: move_message_ids)).to eq([]) + deleted_messages = Chat::Message.with_deleted.where(id: move_message_ids).order(:id) expect(deleted_messages.count).to eq(3) expect(messages.first.channel).to eq("/chat/#{source_channel.id}") expect(messages.first.data[:typ]).to eq("bulk_delete") @@ -83,9 +83,10 @@ describe Chat::MessageMover do it "creates a message in the source channel to indicate that the messages have been moved" do move! - placeholder_message = ChatMessage.where(chat_channel: source_channel).order(:created_at).last + placeholder_message = + Chat::Message.where(chat_channel: source_channel).order(:created_at).last destination_first_moved_message = - ChatMessage.find_by(chat_channel: destination_channel, message: "the first to be moved") + Chat::Message.find_by(chat_channel: destination_channel, message: "the first to be moved") expect(placeholder_message.message).to eq( I18n.t( "chat.channel.messages_moved", @@ -100,7 +101,10 @@ describe Chat::MessageMover do it "preserves the order of the messages in the destination channel" do move! moved_messages = - ChatMessage.where(chat_channel: destination_channel).order("created_at ASC, id ASC").last(3) + Chat::Message + .where(chat_channel: destination_channel) + .order("created_at ASC, id ASC") + .last(3) expect(moved_messages.map(&:message)).to eq( ["the first to be moved", "message deux @testmovechat", "the third message"], ) @@ -115,7 +119,10 @@ describe Chat::MessageMover do move! moved_messages = - ChatMessage.where(chat_channel: destination_channel).order("created_at ASC, id ASC").last(3) + Chat::Message + .where(chat_channel: destination_channel) + .order("created_at ASC, id ASC") + .last(3) expect(reaction.reload.chat_message_id).to eq(moved_messages.first.id) expect(upload.reload.target_id).to eq(moved_messages.first.id) expect(mention.reload.chat_message_id).to eq(moved_messages.second.id) @@ -128,7 +135,10 @@ describe Chat::MessageMover do message2.update!(in_reply_to: message1) move! moved_messages = - ChatMessage.where(chat_channel: destination_channel).order("created_at ASC, id ASC").last(3) + Chat::Message + .where(chat_channel: destination_channel) + .order("created_at ASC, id ASC") + .last(3) expect(moved_messages.pluck(:in_reply_to_id).uniq).to eq([nil]) end @@ -152,7 +162,7 @@ describe Chat::MessageMover do it "does not preserve thread_ids" do move! moved_messages = - ChatMessage + Chat::Message .where(chat_channel: destination_channel) .order("created_at ASC, id ASC") .last(3) @@ -162,7 +172,7 @@ describe Chat::MessageMover do it "deletes the empty thread" do move! - expect(ChatThread.exists?(id: thread.id)).to eq(false) + expect(Chat::Thread.exists?(id: thread.id)).to eq(false) end it "clears in_reply_to_id for remaining messages when the messages they were replying to are moved but leaves the thread_id" do @@ -190,8 +200,8 @@ describe Chat::MessageMover do message: "the fifth message", thread: thread, ) - expect { move! }.to change { ChatThread.count }.by(1) - new_thread = ChatThread.last + expect { move! }.to change { Chat::Thread.count }.by(1) + new_thread = Chat::Thread.last expect(message4.reload.thread_id).to eq(new_thread.id) expect(message5.reload.thread_id).to eq(new_thread.id) expect(new_thread.channel).to eq(source_channel) @@ -214,9 +224,9 @@ describe Chat::MessageMover do Fabricate(:chat_thread, channel: source_channel, original_message: message4) message4.update!(thread: other_thread) message5.update!(thread: other_thread) - expect { move!([message1.id, message4.id]) }.to change { ChatThread.count }.by(2) + expect { move!([message1.id, message4.id]) }.to change { Chat::Thread.count }.by(2) - new_threads = ChatThread.order(:created_at).last(2) + new_threads = Chat::Thread.order(:created_at).last(2) expect(message3.reload.thread_id).to eq(new_threads.first.id) expect(message5.reload.thread_id).to eq(new_threads.second.id) expect(new_threads.first.channel).to eq(source_channel) diff --git a/plugins/chat/spec/lib/chat_message_processor_spec.rb b/plugins/chat/spec/lib/chat/message_processor_spec.rb similarity index 57% rename from plugins/chat/spec/lib/chat_message_processor_spec.rb rename to plugins/chat/spec/lib/chat/message_processor_spec.rb index 3fdbd19baa7..2fb51caf257 100644 --- a/plugins/chat/spec/lib/chat_message_processor_spec.rb +++ b/plugins/chat/spec/lib/chat/message_processor_spec.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -RSpec.describe Chat::ChatMessageProcessor do +RSpec.describe Chat::MessageProcessor do fab!(:message) { Fabricate(:chat_message) } it "cooks using the last_editor_id of the message" do - ChatMessage.expects(:cook).with(message.message, user_id: message.last_editor_id) + Chat::Message.expects(:cook).with(message.message, user_id: message.last_editor_id) described_class.new(message) end end diff --git a/plugins/chat/spec/lib/chat_message_reactor_spec.rb b/plugins/chat/spec/lib/chat/message_reactor_spec.rb similarity index 78% rename from plugins/chat/spec/lib/chat_message_reactor_spec.rb rename to plugins/chat/spec/lib/chat/message_reactor_spec.rb index 565fab80db1..c6715c3ad16 100644 --- a/plugins/chat/spec/lib/chat_message_reactor_spec.rb +++ b/plugins/chat/spec/lib/chat/message_reactor_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatMessageReactor do +describe Chat::MessageReactor do fab!(:reacting_user) { Fabricate(:user) } fab!(:channel) { Fabricate(:category_channel) } fab!(:reactor) { described_class.new(reacting_user, channel) } @@ -29,14 +29,14 @@ describe Chat::ChatMessageReactor do end it "raises an error if the channel status is not open" do - channel.update!(status: ChatChannel.statuses[:archived]) + channel.update!(status: Chat::Channel.statuses[:archived]) expect { subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") }.to raise_error(Discourse::InvalidAccess) - channel.update!(status: ChatChannel.statuses[:open]) + channel.update!(status: Chat::Channel.statuses[:open]) expect { subject.react!(message_id: message_1.id, react_action: :add, emoji: ":+1:") - }.to change(ChatMessageReaction, :count).by(1) + }.to change(Chat::MessageReaction, :count).by(1) end it "raises an error if the reaction is not valid" do @@ -59,9 +59,9 @@ describe Chat::ChatMessageReactor do context "when max reactions has been reached" do before do - emojis = Emoji.all.slice(0, Chat::ChatMessageReactor::MAX_REACTIONS_LIMIT) + emojis = Emoji.all.slice(0, described_class::MAX_REACTIONS_LIMIT) emojis.each do |emoji| - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message_1, user: reacting_user, emoji: ":#{emoji.name}:", @@ -93,47 +93,51 @@ describe Chat::ChatMessageReactor do it "creates a membership when not present" do expect { reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") - }.to change(UserChatChannelMembership, :count).by(1) + }.to change(Chat::UserChatChannelMembership, :count).by(1) end it "doesn’t create a membership when present" do - UserChatChannelMembership.create!(user: reacting_user, chat_channel: channel, following: true) + Chat::UserChatChannelMembership.create!( + user: reacting_user, + chat_channel: channel, + following: true, + ) expect { reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") - }.not_to change(UserChatChannelMembership, :count) + }.not_to change(Chat::UserChatChannelMembership, :count) end it "can add a reaction" do expect { reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") - }.to change(ChatMessageReaction, :count).by(1) + }.to change(Chat::MessageReaction, :count).by(1) end it "doesn’t duplicate reactions" do - ChatMessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") + Chat::MessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") expect { reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") - }.not_to change(ChatMessageReaction, :count) + }.not_to change(Chat::MessageReaction, :count) end it "can remove an existing reaction" do - ChatMessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") + Chat::MessageReaction.create!(chat_message: message_1, user: reacting_user, emoji: ":heart:") expect { reactor.react!(message_id: message_1.id, react_action: :remove, emoji: ":heart:") - }.to change(ChatMessageReaction, :count).by(-1) + }.to change(Chat::MessageReaction, :count).by(-1) end it "does nothing when removing if no reaction found" do expect { reactor.react!(message_id: message_1.id, react_action: :remove, emoji: ":heart:") - }.not_to change(ChatMessageReaction, :count) + }.not_to change(Chat::MessageReaction, :count) end it "publishes the reaction" do - ChatPublisher.expects(:publish_reaction!).once + Chat::Publisher.expects(:publish_reaction!).once reactor.react!(message_id: message_1.id, react_action: :add, emoji: ":heart:") end diff --git a/plugins/chat/spec/lib/chat_notifier_spec.rb b/plugins/chat/spec/lib/chat/notifier_spec.rb similarity index 99% rename from plugins/chat/spec/lib/chat_notifier_spec.rb rename to plugins/chat/spec/lib/chat/notifier_spec.rb index 75b008a6209..f9ff6d7e1a2 100644 --- a/plugins/chat/spec/lib/chat_notifier_spec.rb +++ b/plugins/chat/spec/lib/chat/notifier_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatNotifier do +describe Chat::Notifier do describe "#notify_new" do fab!(:channel) { Fabricate(:category_channel) } fab!(:user_1) { Fabricate(:user) } @@ -23,7 +23,7 @@ describe Chat::ChatNotifier do end def build_cooked_msg(message_body, user, chat_channel: channel) - ChatMessage.create( + Chat::Message.create( chat_channel: chat_channel, user: user, message: message_body, @@ -233,7 +233,7 @@ describe Chat::ChatNotifier do Fabricate(:muted_user, user: user_2, muted_user: user_1) msg = build_cooked_msg("hey @#{user_2.username} stop muting me!", user_1) - ChatPublisher.expects(:publish_new_mention).never + Chat::Publisher.expects(:publish_new_mention).never to_notify = described_class.new(msg, msg.created_at).notify_new end diff --git a/plugins/chat/spec/lib/post_notification_handler_spec.rb b/plugins/chat/spec/lib/chat/post_notification_handler_spec.rb similarity index 94% rename from plugins/chat/spec/lib/post_notification_handler_spec.rb rename to plugins/chat/spec/lib/chat/post_notification_handler_spec.rb index 620fe991e0c..4d7488bc4b7 100644 --- a/plugins/chat/spec/lib/post_notification_handler_spec.rb +++ b/plugins/chat/spec/lib/chat/post_notification_handler_spec.rb @@ -6,7 +6,7 @@ describe Chat::PostNotificationHandler do let(:acting_user) { Fabricate(:user) } let(:post) { Fabricate(:post) } let(:notified_users) { [] } - let(:subject) { Chat::PostNotificationHandler.new(post, notified_users) } + let(:subject) { described_class.new(post, notified_users) } fab!(:channel) { Fabricate(:category_channel) } fab!(:message1) do @@ -30,7 +30,7 @@ describe Chat::PostNotificationHandler do def update_post_with_chat_quote(messages) quote_markdown = - ChatTranscriptService.new(channel, acting_user, messages_or_ids: messages).generate_markdown + Chat::TranscriptService.new(channel, acting_user, messages_or_ids: messages).generate_markdown post.update!(raw: post.raw + "\n\n" + quote_markdown) end diff --git a/plugins/chat/spec/lib/chat_review_queue_spec.rb b/plugins/chat/spec/lib/chat/review_queue_spec.rb similarity index 94% rename from plugins/chat/spec/lib/chat_review_queue_spec.rb rename to plugins/chat/spec/lib/chat/review_queue_spec.rb index 5559543c52a..672b7dff64d 100644 --- a/plugins/chat/spec/lib/chat_review_queue_spec.rb +++ b/plugins/chat/spec/lib/chat/review_queue_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe Chat::ChatReviewQueue do +describe Chat::ReviewQueue do fab!(:message_poster) { Fabricate(:user) } fab!(:flagger) { Fabricate(:user) } fab!(:chat_channel) { Fabricate(:category_channel) } @@ -32,7 +32,7 @@ describe Chat::ChatReviewQueue do it "stores the message cooked content inside the reviewable" do queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last expect(reviewable.payload["message_cooked"]).to eq(message.cooked) end @@ -73,7 +73,7 @@ describe Chat::ChatReviewQueue do queue.flag_message(message, admin_guardian, ReviewableScore.types[:off_topic]) expect(second_flag_result).to include success: true - reviewable = ReviewableChatMessage.find_by(target: message) + reviewable = Chat::ReviewableMessage.find_by(target: message) scores = reviewable.reviewable_scores expect(scores.size).to eq(2) @@ -99,7 +99,7 @@ describe Chat::ChatReviewQueue do before do queue.flag_message(message, guardian, ReviewableScore.types[:spam]) - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last reviewable.perform(admin, :ignore) end @@ -109,14 +109,14 @@ describe Chat::ChatReviewQueue do end it "allows the user to re-flag after the cooldown period" do - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last reviewable.update!(updated_at: (SiteSetting.cooldown_hours_until_reflag.to_i + 1).hours.ago) expect(second_flag_result).to include success: true end it "ignores the cooldown window when the message is edited" do - Chat::ChatMessageUpdater.update( + Chat::MessageUpdater.update( guardian: Guardian.new(message.user), chat_message: message, new_content: "I'm editing this message. Please flag it.", @@ -157,7 +157,7 @@ describe Chat::ChatReviewQueue do .map(&:data) flag_msg = messages.detect { |m| m["type"] == "flag" } - new_reviewable = ReviewableChatMessage.find_by(target: message) + new_reviewable = Chat::ReviewableMessage.find_by(target: message) expect(flag_msg["chat_message_id"]).to eq(message.id) expect(flag_msg["reviewable_id"]).to eq(new_reviewable.id) @@ -261,7 +261,7 @@ describe Chat::ChatReviewQueue do take_action: true, ) - reviewable = ReviewableChatMessage.find_by(target: message) + reviewable = Chat::ReviewableMessage.find_by(target: message) expect(reviewable.approved?).to eq(true) expect(message.reload.trashed?).to eq(true) @@ -288,7 +288,8 @@ describe Chat::ChatReviewQueue do it "agrees with other flags on the same message" do queue.flag_message(message, guardian, ReviewableScore.types[:off_topic]) - reviewable = ReviewableChatMessage.includes(:reviewable_scores).find_by(target: message) + reviewable = + Chat::ReviewableMessage.includes(:reviewable_scores).find_by(target_id: message) scores = reviewable.reviewable_scores expect(scores.size).to eq(1) @@ -323,7 +324,8 @@ describe Chat::ChatReviewQueue do queue_for_review: true, ) - reviewable = ReviewableChatMessage.includes(:reviewable_scores).find_by(target: message) + reviewable = + Chat::ReviewableMessage.includes(:reviewable_scores).find_by(target_id: message) score = reviewable.reviewable_scores.first expect(score.reason).to eq("chat_message_queued_by_staff") @@ -397,8 +399,7 @@ describe Chat::ChatReviewQueue do it "includes a transcript of the previous 10 message for the rest of the flags" do queue.flag_message(dm_message_12, guardian, ReviewableScore.types[:off_topic]) - - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last expect(reviewable.target).to eq(dm_message_12) transcript_post = Post.find_by(topic_id: reviewable.payload["transcript_topic_id"]) @@ -410,7 +411,7 @@ describe Chat::ChatReviewQueue do it "doesn't include a transcript if there a no previous messages" do queue.flag_message(dm_message_1, guardian, ReviewableScore.types[:off_topic]) - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last expect(reviewable.payload["transcript_topic_id"]).to be_nil end @@ -423,7 +424,7 @@ describe Chat::ChatReviewQueue do queue.flag_message(dm_message_12, guardian, ReviewableScore.types[:off_topic]) - reviewable = ReviewableChatMessage.last + reviewable = Chat::ReviewableMessage.last transcript_topic = Topic.find(reviewable.payload["transcript_topic_id"]) expect(guardian.can_see_topic?(transcript_topic)).to eq(false) diff --git a/plugins/chat/spec/lib/slack_compatibility_spec.rb b/plugins/chat/spec/lib/chat/slack_compatibility_spec.rb similarity index 100% rename from plugins/chat/spec/lib/slack_compatibility_spec.rb rename to plugins/chat/spec/lib/chat/slack_compatibility_spec.rb diff --git a/plugins/chat/spec/lib/chat_statistics_spec.rb b/plugins/chat/spec/lib/chat/statistics_spec.rb similarity index 100% rename from plugins/chat/spec/lib/chat_statistics_spec.rb rename to plugins/chat/spec/lib/chat/statistics_spec.rb diff --git a/plugins/chat/spec/lib/steps_inspector_spec.rb b/plugins/chat/spec/lib/chat/steps_inspector_spec.rb similarity index 86% rename from plugins/chat/spec/lib/steps_inspector_spec.rb rename to plugins/chat/spec/lib/chat/steps_inspector_spec.rb index 4fa9fa5fcb2..c452d8433d1 100644 --- a/plugins/chat/spec/lib/steps_inspector_spec.rb +++ b/plugins/chat/spec/lib/chat/steps_inspector_spec.rb @@ -2,7 +2,7 @@ RSpec.describe Chat::StepsInspector do class DummyService - include Chat::Service::Base + include Service::Base model :model policy :policy @@ -34,7 +34,7 @@ RSpec.describe Chat::StepsInspector do end describe "#inspect" do - subject(:output) { inspector.inspect } + subject(:output) { inspector.inspect.strip } context "when service runs without error" do it "outputs all the steps of the service" do @@ -62,12 +62,12 @@ RSpec.describe Chat::StepsInspector do it "shows the failing step" do expect(output).to eq <<~OUTPUT.chomp [1/7] [model] 'model' ❌ - [2/7] [policy] 'policy' - [3/7] [contract] 'default' + [2/7] [policy] 'policy' + [3/7] [contract] 'default' [4/7] [transaction] - [5/7] [step] 'in_transaction_step_1' - [6/7] [step] 'in_transaction_step_2' - [7/7] [step] 'final_step' + [5/7] [step] 'in_transaction_step_1' + [6/7] [step] 'in_transaction_step_2' + [7/7] [step] 'final_step' OUTPUT end end @@ -85,11 +85,11 @@ RSpec.describe Chat::StepsInspector do expect(output).to eq <<~OUTPUT.chomp [1/7] [model] 'model' ✅ [2/7] [policy] 'policy' ❌ - [3/7] [contract] 'default' + [3/7] [contract] 'default' [4/7] [transaction] - [5/7] [step] 'in_transaction_step_1' - [6/7] [step] 'in_transaction_step_2' - [7/7] [step] 'final_step' + [5/7] [step] 'in_transaction_step_1' + [6/7] [step] 'in_transaction_step_2' + [7/7] [step] 'final_step' OUTPUT end end @@ -103,9 +103,9 @@ RSpec.describe Chat::StepsInspector do [2/7] [policy] 'policy' ✅ [3/7] [contract] 'default' ❌ [4/7] [transaction] - [5/7] [step] 'in_transaction_step_1' - [6/7] [step] 'in_transaction_step_2' - [7/7] [step] 'final_step' + [5/7] [step] 'in_transaction_step_1' + [6/7] [step] 'in_transaction_step_2' + [7/7] [step] 'final_step' OUTPUT end end @@ -127,7 +127,7 @@ RSpec.describe Chat::StepsInspector do [4/7] [transaction] [5/7] [step] 'in_transaction_step_1' ✅ [6/7] [step] 'in_transaction_step_2' ❌ - [7/7] [step] 'final_step' + [7/7] [step] 'final_step' OUTPUT end end @@ -163,11 +163,11 @@ RSpec.describe Chat::StepsInspector do expect(output).to eq <<~OUTPUT.chomp [1/7] [model] 'model' ✅ [2/7] [policy] 'policy' ❌ ⚠️ <= expected to return true but got false instead - [3/7] [contract] 'default' + [3/7] [contract] 'default' [4/7] [transaction] - [5/7] [step] 'in_transaction_step_1' - [6/7] [step] 'in_transaction_step_2' - [7/7] [step] 'final_step' + [5/7] [step] 'in_transaction_step_1' + [6/7] [step] 'in_transaction_step_2' + [7/7] [step] 'final_step' OUTPUT end end diff --git a/plugins/chat/spec/lib/chat_transcript_service_spec.rb b/plugins/chat/spec/lib/chat/transcript_service_spec.rb similarity index 97% rename from plugins/chat/spec/lib/chat_transcript_service_spec.rb rename to plugins/chat/spec/lib/chat/transcript_service_spec.rb index c9bf1b832e1..c99809cf108 100644 --- a/plugins/chat/spec/lib/chat_transcript_service_spec.rb +++ b/plugins/chat/spec/lib/chat/transcript_service_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe ChatTranscriptService do +describe Chat::TranscriptService do let(:acting_user) { Fabricate(:user) } let(:user1) { Fabricate(:user, username: "martinchat") } let(:user2) { Fabricate(:user, username: "brucechat") } @@ -206,27 +206,27 @@ describe ChatTranscriptService do message3 = Fabricate(:chat_message, user: user2, chat_channel: channel, message: "a new perspective") - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message, user: Fabricate(:user, username: "bjorn"), emoji: "heart", ) - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message, user: Fabricate(:user, username: "sigurd"), emoji: "heart", ) - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message, user: Fabricate(:user, username: "hvitserk"), emoji: "+1", ) - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message2, user: Fabricate(:user, username: "ubbe"), emoji: "money_mouth_face", ) - ChatMessageReaction.create!( + Chat::MessageReaction.create!( chat_message: message3, user: Fabricate(:user, username: "ivar"), emoji: "sob", diff --git a/plugins/chat/spec/lib/service_runner_spec.rb b/plugins/chat/spec/lib/service_runner_spec.rb index 4c3fd28d813..82ca9d87c62 100644 --- a/plugins/chat/spec/lib/service_runner_spec.rb +++ b/plugins/chat/spec/lib/service_runner_spec.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -RSpec.describe Chat::ServiceRunner do +RSpec.describe ServiceRunner do class SuccessService - include Chat::Service::Base + include Service::Base end class FailureService - include Chat::Service::Base + include Service::Base step :fail_step @@ -16,7 +16,7 @@ RSpec.describe Chat::ServiceRunner do end class FailedPolicyService - include Chat::Service::Base + include Service::Base policy :test @@ -26,7 +26,7 @@ RSpec.describe Chat::ServiceRunner do end class SuccessPolicyService - include Chat::Service::Base + include Service::Base policy :test @@ -36,7 +36,7 @@ RSpec.describe Chat::ServiceRunner do end class FailedContractService - include Chat::Service::Base + include Service::Base class Contract attribute :test @@ -47,13 +47,13 @@ RSpec.describe Chat::ServiceRunner do end class SuccessContractService - include Chat::Service::Base + include Service::Base contract end class FailureWithModelService - include Chat::Service::Base + include Service::Base model :fake_model, :fetch_fake_model @@ -65,7 +65,7 @@ RSpec.describe Chat::ServiceRunner do end class SuccessWithModelService - include Chat::Service::Base + include Service::Base model :fake_model, :fetch_fake_model @@ -77,7 +77,7 @@ RSpec.describe Chat::ServiceRunner do end class FailureWithCollectionModelService - include Chat::Service::Base + include Service::Base model :fake_model, :fetch_fake_model @@ -89,7 +89,7 @@ RSpec.describe Chat::ServiceRunner do end class SuccessWithCollectionModelService - include Chat::Service::Base + include Service::Base model :fake_model, :fetch_fake_model @@ -109,7 +109,7 @@ RSpec.describe Chat::ServiceRunner do let(:actions) { "proc {}" } let(:object) do Class - .new(Chat::Api) do + .new(Chat::ApiController) do def request OpenStruct.new end @@ -126,7 +126,7 @@ RSpec.describe Chat::ServiceRunner do it "runs the provided service in the context of a controller" do runner - expect(result).to be_a Chat::Service::Base::Context + expect(result).to be_a Service::Base::Context expect(result).to be_a_success end diff --git a/plugins/chat/spec/mailers/user_notifications_spec.rb b/plugins/chat/spec/mailers/user_notifications_spec.rb index 19b980f84bb..12fcfbec3a2 100644 --- a/plugins/chat/spec/mailers/user_notifications_spec.rb +++ b/plugins/chat/spec/mailers/user_notifications_spec.rb @@ -54,7 +54,10 @@ describe UserNotifications do user: another_participant, chat_channel: channel, ) - DirectMessageUser.create!(direct_message: channel.chatable, user: another_participant) + Chat::DirectMessageUser.create!( + direct_message: channel.chatable, + user: another_participant, + ) expected_subject = I18n.t( "user_notifications.chat_summary.subject.direct_message_from_1", @@ -168,7 +171,7 @@ describe UserNotifications do # Sometimes it's not enough to just fabricate a message # and we have to create it like here. In this case all the necessary # db records for mentions and notifications will be created under the hood. - Chat::ChatMessageCreator.create(chat_channel: channel, user: sender, content: content) + Chat::MessageCreator.create(chat_channel: channel, user: sender, content: content) end it "returns email for @all mention by default" do diff --git a/plugins/chat/spec/models/category_spec.rb b/plugins/chat/spec/models/category_spec.rb index bf16ff3e618..d02d3036524 100644 --- a/plugins/chat/spec/models/category_spec.rb +++ b/plugins/chat/spec/models/category_spec.rb @@ -5,7 +5,7 @@ require "rails_helper" RSpec.describe Category do it_behaves_like "a chatable model" do fab!(:chatable) { Fabricate(:category) } - let(:channel_class) { CategoryChannel } + let(:channel_class) { Chat::CategoryChannel } end it { is_expected.to have_one(:category_channel).dependent(:destroy) } diff --git a/plugins/chat/spec/models/category_channel_spec.rb b/plugins/chat/spec/models/chat/category_channel_spec.rb similarity index 99% rename from plugins/chat/spec/models/category_channel_spec.rb rename to plugins/chat/spec/models/chat/category_channel_spec.rb index e78681eb8bc..1408d18829d 100644 --- a/plugins/chat/spec/models/category_channel_spec.rb +++ b/plugins/chat/spec/models/chat/category_channel_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe CategoryChannel do +RSpec.describe Chat::CategoryChannel do subject(:channel) { Fabricate.build(:category_channel) } it_behaves_like "a chat channel model" diff --git a/plugins/chat/spec/models/chat_channel_spec.rb b/plugins/chat/spec/models/chat/channel_spec.rb similarity index 98% rename from plugins/chat/spec/models/chat_channel_spec.rb rename to plugins/chat/spec/models/chat/channel_spec.rb index 8cb1a96d587..efd0a835df7 100644 --- a/plugins/chat/spec/models/chat_channel_spec.rb +++ b/plugins/chat/spec/models/chat/channel_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe ChatChannel do +RSpec.describe Chat::Channel do fab!(:category_channel1) { Fabricate(:category_channel) } fab!(:dm_channel1) { Fabricate(:direct_message_channel) } diff --git a/plugins/chat/spec/models/deleted_chat_user_spec.rb b/plugins/chat/spec/models/chat/deleted_chat_user_spec.rb similarity index 92% rename from plugins/chat/spec/models/deleted_chat_user_spec.rb rename to plugins/chat/spec/models/chat/deleted_chat_user_spec.rb index 387eb5c89f9..3d3284728e9 100644 --- a/plugins/chat/spec/models/deleted_chat_user_spec.rb +++ b/plugins/chat/spec/models/chat/deleted_chat_user_spec.rb @@ -2,7 +2,7 @@ require "rails_helper" -describe DeletedChatUser do +describe Chat::DeletedUser do describe "#username" do it "returns a default username" do expect(subject.username).to eq(I18n.t("chat.deleted_chat_username")) diff --git a/plugins/chat/spec/models/direct_message_channel_spec.rb b/plugins/chat/spec/models/chat/direct_message_channel_spec.rb similarity index 97% rename from plugins/chat/spec/models/direct_message_channel_spec.rb rename to plugins/chat/spec/models/chat/direct_message_channel_spec.rb index 227a143d60d..0c45370f374 100644 --- a/plugins/chat/spec/models/direct_message_channel_spec.rb +++ b/plugins/chat/spec/models/chat/direct_message_channel_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe DirectMessageChannel do +RSpec.describe Chat::DirectMessageChannel do subject(:channel) { Fabricate.build(:direct_message_channel) } it_behaves_like "a chat channel model" diff --git a/plugins/chat/spec/models/direct_message_spec.rb b/plugins/chat/spec/models/chat/direct_message_spec.rb similarity index 96% rename from plugins/chat/spec/models/direct_message_spec.rb rename to plugins/chat/spec/models/chat/direct_message_spec.rb index 8e8443cf90d..9d526f3bb1c 100644 --- a/plugins/chat/spec/models/direct_message_spec.rb +++ b/plugins/chat/spec/models/chat/direct_message_spec.rb @@ -2,14 +2,14 @@ require "rails_helper" -describe DirectMessage do +describe Chat::DirectMessage do fab!(:user1) { Fabricate(:user, username: "chatdmfellow1") } fab!(:user2) { Fabricate(:user, username: "chatdmuser") } fab!(:chat_channel) { Fabricate(:direct_message_channel) } it_behaves_like "a chatable model" do fab!(:chatable) { Fabricate(:direct_message) } - let(:channel_class) { DirectMessageChannel } + let(:channel_class) { Chat::DirectMessageChannel } end describe "#chat_channel_title_for_user" do diff --git a/plugins/chat/spec/models/chat_draft_spec.rb b/plugins/chat/spec/models/chat/draft_spec.rb similarity index 94% rename from plugins/chat/spec/models/chat_draft_spec.rb rename to plugins/chat/spec/models/chat/draft_spec.rb index 27794bafaec..4694c1ff7e8 100644 --- a/plugins/chat/spec/models/chat_draft_spec.rb +++ b/plugins/chat/spec/models/chat/draft_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe ChatDraft do +RSpec.describe Chat::Draft do before { SiteSetting.max_chat_draft_length = 100 } it "errors when data.value is greater than `max_chat_draft_length`" do diff --git a/plugins/chat/spec/models/chat_message_spec.rb b/plugins/chat/spec/models/chat/message_spec.rb similarity index 91% rename from plugins/chat/spec/models/chat_message_spec.rb rename to plugins/chat/spec/models/chat/message_spec.rb index f12d2308e54..e6e50052524 100644 --- a/plugins/chat/spec/models/chat_message_spec.rb +++ b/plugins/chat/spec/models/chat/message_spec.rb @@ -2,38 +2,38 @@ require "rails_helper" -describe ChatMessage do +describe Chat::Message do fab!(:message) { Fabricate(:chat_message, message: "hey friend, what's up?!") } it { is_expected.to have_many(:chat_mentions).dependent(:destroy) } describe ".cook" do it "does not support HTML tags" do - cooked = ChatMessage.cook("

test

") + cooked = described_class.cook("

test

") expect(cooked).to eq("

<h1>test</h1>

") end it "does not support headings" do - cooked = ChatMessage.cook("## heading 2") + cooked = described_class.cook("## heading 2") expect(cooked).to eq("

## heading 2

") end it "does not support horizontal rules" do - cooked = ChatMessage.cook("---") + cooked = described_class.cook("---") expect(cooked).to eq("

---

") end it "supports backticks rule" do - cooked = ChatMessage.cook("`test`") + cooked = described_class.cook("`test`") expect(cooked).to eq("

test

") end it "supports fence rule" do - cooked = ChatMessage.cook(<<~RAW) + cooked = described_class.cook(<<~RAW) ``` something = test ``` @@ -46,7 +46,7 @@ describe ChatMessage do end it "supports fence rule with language support" do - cooked = ChatMessage.cook(<<~RAW) + cooked = described_class.cook(<<~RAW) ```ruby Widget.triangulate(argument: "no u") ``` @@ -59,13 +59,13 @@ describe ChatMessage do end it "supports code rule" do - cooked = ChatMessage.cook(" something = test") + cooked = described_class.cook(" something = test") expect(cooked).to eq("
something = test\n
") end it "supports blockquote rule" do - cooked = ChatMessage.cook("> a quote") + cooked = described_class.cook("> a quote") expect(cooked).to eq("
\n

a quote

\n
") end @@ -77,7 +77,7 @@ describe ChatMessage do avatar_src = "//test.localhost#{User.system_avatar_template(post.user.username).gsub("{size}", "40")}" - cooked = ChatMessage.cook(<<~RAW) + cooked = described_class.cook(<<~RAW) [quote="#{post.user.username}, post:#{post.post_number}, topic:#{topic.id}"] Mark me...this will go down in history. [/quote] @@ -120,8 +120,8 @@ describe ChatMessage do ) other_messages_to_quote = [msg1, msg2] cooked = - ChatMessage.cook( - ChatTranscriptService.new( + described_class.cook( + Chat::TranscriptService.new( chat_channel, Fabricate(:user), messages_or_ids: other_messages_to_quote.map(&:id), @@ -166,13 +166,13 @@ describe ChatMessage do end it "supports strikethrough rule" do - cooked = ChatMessage.cook("~~test~~") + cooked = described_class.cook("~~test~~") expect(cooked).to eq("

test

") end it "supports emphasis rule" do - cooked = ChatMessage.cook("**bold**") + cooked = described_class.cook("**bold**") expect(cooked).to eq("

bold

") end @@ -186,7 +186,7 @@ describe ChatMessage do end it "supports table markdown plugin" do - cooked = ChatMessage.cook(<<~RAW) + cooked = described_class.cook(<<~RAW) | Command | Description | | --- | --- | | git status | List all new or modified files | @@ -215,7 +215,7 @@ describe ChatMessage do end it "supports onebox markdown plugin" do - cooked = ChatMessage.cook("https://www.example.com") + cooked = described_class.cook("https://www.example.com") expect(cooked).to eq( "

https://www.example.com

", @@ -223,7 +223,7 @@ describe ChatMessage do end it "supports emoji plugin" do - cooked = ChatMessage.cook(":grin:") + cooked = described_class.cook(":grin:") expect(cooked).to eq( "

\":grin:\"

", @@ -231,7 +231,7 @@ describe ChatMessage do end it "supports mentions plugin" do - cooked = ChatMessage.cook("@mention") + cooked = described_class.cook("@mention") expect(cooked).to eq("

@mention

") end @@ -242,7 +242,7 @@ describe ChatMessage do category = Fabricate(:category) - cooked = ChatMessage.cook("##{category.slug}") + cooked = described_class.cook("##{category.slug}") expect(cooked).to eq( "

##{category.slug}

", @@ -256,7 +256,7 @@ describe ChatMessage do category = Fabricate(:category) user = Fabricate(:user) - cooked = ChatMessage.cook("##{category.slug}", user_id: user.id) + cooked = described_class.cook("##{category.slug}", user_id: user.id) expect(cooked).to eq( "

#{category.name}

", @@ -266,7 +266,7 @@ describe ChatMessage do it "supports censored plugin" do watched_word = Fabricate(:watched_word, action: WatchedWord.actions[:censor]) - cooked = ChatMessage.cook(watched_word.word) + cooked = described_class.cook(watched_word.word) expect(cooked).to eq("

■■■■■

") end @@ -293,13 +293,13 @@ describe ChatMessage do gif = Fabricate(:upload, original_filename: "cat.gif", width: 400, height: 300, extension: "gif") message = Fabricate(:chat_message, message: "") - UploadReference.create(target: message, upload: gif) + message.attach_uploads([gif]) expect(message.excerpt).to eq "cat.gif" end it "supports autolink with <>" do - cooked = ChatMessage.cook("") + cooked = described_class.cook("") expect(cooked).to eq( "

https://github.com/discourse/discourse-chat/pull/468

", @@ -307,7 +307,7 @@ describe ChatMessage do end it "supports lists" do - cooked = ChatMessage.cook(<<~MSG) + cooked = described_class.cook(<<~MSG) wow look it's a list * item 1 @@ -324,14 +324,14 @@ describe ChatMessage do end it "supports inline emoji" do - cooked = ChatMessage.cook(":D") + cooked = described_class.cook(":D") expect(cooked).to eq(<<~HTML.chomp)

:smiley:

HTML end it "supports emoji shortcuts" do - cooked = ChatMessage.cook("this is a replace test :P :|") + cooked = described_class.cook("this is a replace test :P :|") expect(cooked).to eq(<<~HTML.chomp)

this is a replace test :stuck_out_tongue: :expressionless:

HTML @@ -339,7 +339,8 @@ describe ChatMessage do it "supports spoilers" do if SiteSetting.respond_to?(:spoiler_enabled) && SiteSetting.spoiler_enabled - cooked = ChatMessage.cook("[spoiler]the planet of the apes was earth all along[/spoiler]") + cooked = + described_class.cook("[spoiler]the planet of the apes was earth all along[/spoiler]") expect(cooked).to eq( "
\n

the planet of the apes was earth all along

\n
", @@ -352,7 +353,7 @@ describe ChatMessage do it "cooks unicode mentions" do user = Fabricate(:unicode_user) - cooked = ChatMessage.cook("

@#{user.username}

") + cooked = described_class.cook("

@#{user.username}

") expect(cooked).to eq("

<h1>@#{user.username}</h1>

") end @@ -375,8 +376,7 @@ describe ChatMessage do ) image2 = Fabricate(:upload, original_filename: "meme.jpg", width: 10, height: 10, extension: "jpg") - UploadReference.create!(target: message, upload: image) - UploadReference.create!(target: message, upload: image2) + message.attach_uploads([image, image2]) expect(message.to_markdown).to eq(<<~MSG.chomp) hey friend, what's up?! @@ -388,12 +388,12 @@ describe ChatMessage do describe ".push_notification_excerpt" do it "truncates to 400 characters" do - message = ChatMessage.new(message: "Hello, World!" * 40) + message = described_class.new(message: "Hello, World!" * 40) expect(message.push_notification_excerpt.size).to eq(400) end it "encodes emojis" do - message = ChatMessage.new(message: ":grinning:") + message = described_class.new(message: ":grinning:") expect(message.push_notification_excerpt).to eq("😀") end end @@ -407,7 +407,8 @@ describe ChatMessage do it "blocks duplicate messages for the message, channel user, and message age requirements" do Fabricate(:chat_message, message: "this is duplicate", chat_channel: channel, user: user1) - message = ChatMessage.new(message: "this is duplicate", chat_channel: channel, user: user2) + message = + described_class.new(message: "this is duplicate", chat_channel: channel, user: user2) message.validate_message(has_uploads: false) expect(message.errors.full_messages).to include(I18n.t("chat.errors.duplicate_message")) end @@ -482,7 +483,7 @@ describe ChatMessage do end describe "bookmarks" do - before { register_test_bookmarkable(ChatMessageBookmarkable) } + before { register_test_bookmarkable(Chat::MessageBookmarkable) } after { DiscoursePluginRegistry.reset_register!(:bookmarkables) } @@ -539,11 +540,11 @@ describe ChatMessage do expect(chat_upload_count([upload_1, upload_2])).to eq(0) expect(upload_references.count).to eq(2) expect(upload_references.map(&:target_id).uniq).to eq([chat_message.id]) - expect(upload_references.map(&:target_type).uniq).to eq(["ChatMessage"]) + expect(upload_references.map(&:target_type).uniq).to eq([Chat::Message.sti_name]) end it "does nothing if the message record is new" do - expect { ChatMessage.new.attach_uploads([upload_1, upload_2]) }.to not_change { + expect { described_class.new.attach_uploads([upload_1, upload_2]) }.to not_change { chat_upload_count }.and not_change { UploadReference.count } end diff --git a/plugins/chat/spec/models/reviewable_chat_message_spec.rb b/plugins/chat/spec/models/chat/reviewable_chat_message_spec.rb similarity index 91% rename from plugins/chat/spec/models/reviewable_chat_message_spec.rb rename to plugins/chat/spec/models/chat/reviewable_chat_message_spec.rb index 95e7fab29a0..d7f6a951018 100644 --- a/plugins/chat/spec/models/reviewable_chat_message_spec.rb +++ b/plugins/chat/spec/models/chat/reviewable_chat_message_spec.rb @@ -2,13 +2,13 @@ require "rails_helper" -RSpec.describe ReviewableChatMessage, type: :model do +RSpec.describe Chat::ReviewableMessage, type: :model do fab!(:moderator) { Fabricate(:moderator) } fab!(:user) { Fabricate(:user) } fab!(:chat_channel) { Fabricate(:chat_channel) } fab!(:chat_message) { Fabricate(:chat_message, chat_channel: chat_channel, user: user) } fab!(:reviewable) do - Fabricate(:reviewable_chat_message, target: chat_message, created_by: moderator) + Fabricate(:chat_reviewable_message, target: chat_message, created_by: moderator) end it "agree_and_keep agrees with the flag and doesn't delete the message" do @@ -23,7 +23,7 @@ RSpec.describe ReviewableChatMessage, type: :model do reviewable.perform(moderator, :agree_and_delete) expect(reviewable).to be_approved - expect(ChatMessage.with_deleted.find_by(id: chat_message_id).deleted_at).to be_present + expect(Chat::Message.with_deleted.find_by(id: chat_message_id).deleted_at).to be_present end it "agree_and_restore agrees with the flag and restores the message" do diff --git a/plugins/chat/spec/plugin_helper.rb b/plugins/chat/spec/plugin_helper.rb index c3c52cf6a5e..e9662c7555a 100644 --- a/plugins/chat/spec/plugin_helper.rb +++ b/plugins/chat/spec/plugin_helper.rb @@ -29,7 +29,7 @@ module ChatSystemHelpers thread_id = i.zero? ? nil : last_message.thread_id last_user = last_user.present? ? (users - [last_user]).sample : users.sample creator = - Chat::ChatMessageCreator.new( + Chat::MessageCreator.new( chat_channel: channel, in_reply_to_id: in_reply_to, thread_id: thread_id, @@ -49,4 +49,9 @@ end RSpec.configure do |config| config.include ChatSystemHelpers, type: :system config.include Chat::ServiceMatchers + + config.expect_with :rspec do |c| + # Or a very large value, if you do want to truncate at some point + c.max_formatted_output_length = nil + end end diff --git a/plugins/chat/spec/plugin_spec.rb b/plugins/chat/spec/plugin_spec.rb index b90f1861e64..9d2a5d82cd0 100644 --- a/plugins/chat/spec/plugin_spec.rb +++ b/plugins/chat/spec/plugin_spec.rb @@ -17,7 +17,7 @@ describe Chat do fab!(:unused_upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } let!(:chat_message) do - Chat::ChatMessageCreator.create( + Chat::MessageCreator.create( chat_channel: chat_channel, user: user, in_reply_to_id: nil, @@ -43,7 +43,7 @@ describe Chat do fab!(:unused_upload) { Fabricate(:upload, user: user, created_at: 1.month.ago) } let!(:chat_message) do - Chat::ChatMessageCreator.create( + Chat::MessageCreator.create( chat_channel: chat_channel, user: user, in_reply_to_id: nil, @@ -53,7 +53,7 @@ describe Chat do end let!(:draft_message) do - ChatDraft.create!( + Chat::Draft.create!( user: user, chat_channel: chat_channel, data: @@ -135,7 +135,7 @@ describe Chat do fab!(:user_4) { Fabricate(:user, suspended_till: 3.weeks.from_now) } let!(:chat_message) do - Chat::ChatMessageCreator.create( + Chat::MessageCreator.create( chat_channel: chat_channel, user: user, in_reply_to_id: nil, @@ -170,7 +170,7 @@ describe Chat do user_2.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) user_3.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) user_4.user_chat_channel_memberships.create!(chat_channel: chat_channel, following: true) - Jobs::UpdateUserCountsForChatChannels.new.execute({}) + Jobs::Chat::UpdateUserCountsForChannels.new.execute({}) expect(Oneboxer.preview(chat_url)).to match_html <<~HTML