FEATURE: Export chat messages to CSV file (#22113)

To export chat messages, go to `/admin/plugins/chat` and click the Create export 
button in the _Export chat messages_ section. You'll receive a direct message 
when the export is finished.

Currently, this exports all messages from the last 6 months, but not more than 
10000 messages.

This exports all chat messages, including messages from private channels and 
users' direct conversations. This also exports messages that were deleted.
This commit is contained in:
Andrei Prigorshnev 2023-06-21 16:13:36 +04:00 committed by GitHub
parent 720c0c6e4d
commit 3ea31f443c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 230 additions and 1 deletions

View File

@ -411,6 +411,10 @@ $mobile-breakpoint: 700px;
.admin-container {
margin-top: 10px;
.admin-section {
margin-bottom: 1em;
}
.username {
input {
min-width: 15em;

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
module Chat
module Admin
class ExportController < ::Admin::AdminController
requires_plugin Chat::PLUGIN_NAME
def export_messages
entity = "chat_message"
Jobs.enqueue(:export_csv_file, entity: entity, user_id: current_user.id)
StaffActionLogger.new(current_user).log_entity_export(entity)
end
end
end
end

View File

@ -0,0 +1,10 @@
<section class="admin-section">
<h3>{{i18n "chat.admin.export_messages.title"}}</h3>
<p>{{i18n "chat.admin.export_messages.description"}}</p>
<DButton
@label="chat.admin.export_messages.create_export"
@title="chat.admin.export_messages.create_export"
@class="btn-primary"
@action={{action this.exportMessages}}
/>
</section>

View File

@ -0,0 +1,22 @@
import Component from "@ember/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { popupAjaxError } from "discourse/lib/ajax-error";
import I18n from "I18n";
export default class ExportMessages extends Component {
@service chatAdminApi;
@service dialog;
@action
async exportMessages() {
try {
await this.chatAdminApi.exportMessages();
this.dialog.alert(
I18n.t("chat.admin.export_messages.export_has_started")
);
} catch (error) {
popupAjaxError(error);
}
}
}

View File

@ -0,0 +1,19 @@
import Service from "@ember/service";
import { ajax } from "discourse/lib/ajax";
export default class ChatAdminApi extends Service {
async exportMessages() {
await this.#post(`/export/messages`);
}
get #basePath() {
return "/chat/admin";
}
#post(endpoint, data = {}) {
return ajax(`${this.#basePath}${endpoint}`, {
type: "POST",
data,
});
}
}

View File

@ -1,3 +1,5 @@
<Chat::Admin::ExportMessages />
{{#if this.selectedWebhook}}
<DButton
@class="incoming-chat-webhooks-back"

View File

@ -120,7 +120,7 @@ en:
description: "Select an option below to summarize the conversation sent during the desired timeframe."
summarize: "Summarize"
since:
one: "Last hour"
one: "Last hour"
other: "Last %{count} hours"
mention_warning:
dismiss: "dismiss"
@ -451,6 +451,11 @@ en:
admin:
title: "Chat"
export_messages:
title: "Export chat messages"
description: "Export is currently limited to 10000 most recent messages in the last 6 months."
create_export: "Create export"
export_has_started: "The export has started. You'll receive a PM when it's ready."
direct_messages:
title: "Personal chat"

View File

@ -41,6 +41,10 @@ Chat::Engine.routes.draw do
get "/channels/:channel_id/summarize" => "summaries#get_summary"
end
namespace :admin, defaults: { format: :json, constraints: StaffConstraint.new } do
post "export/messages" => "export#export_messages"
end
# direct_messages_controller routes
get "/direct_messages" => "direct_messages#index"
post "/direct_messages/create" => "direct_messages#create"

View File

@ -0,0 +1,55 @@
# frozen_string_literal: true
module Chat
module MessagesExporter
LIMIT = 10_000
def chat_message_export
Chat::Message
.unscoped
.where(created_at: 6.months.ago..Time.current)
.joins(:chat_channel)
.joins(:user)
.joins("INNER JOIN users last_editors ON chat_messages.last_editor_id = last_editors.id")
.order(:created_at)
.limit(LIMIT)
.pluck(
"chat_messages.id",
"chat_channels.id",
"chat_channels.name",
"users.id",
"users.username",
"chat_messages.message",
"chat_messages.cooked",
"chat_messages.created_at",
"chat_messages.updated_at",
"chat_messages.deleted_at",
"chat_messages.in_reply_to_id",
"last_editors.id",
"last_editors.username",
)
end
def get_header(entity)
if entity === "chat_message"
%w[
id
chat_channel_id
chat_channel_name
user_id
username
message
cooked
created_at
updated_at
deleted_at
in_reply_to_id
last_editor_id
last_editor_username
]
else
super
end
end
end
end

View File

@ -63,6 +63,7 @@ after_initialize do
User.prepend Chat::UserExtension
Jobs::UserEmail.prepend Chat::UserEmailExtension
Plugin::Instance.prepend Chat::PluginInstanceExtension
Jobs::ExportCsvFile.class_eval { prepend Chat::MessagesExporter }
end
if Oneboxer.respond_to?(:register_local_handler)

View File

@ -0,0 +1,52 @@
# frozen_string_literal: true
describe Chat::MessagesExporter do
fab!(:public_channel) { Fabricate(:chat_channel) }
fab!(:public_channel_message_1) { Fabricate(:chat_message, chat_channel: public_channel) }
fab!(:public_channel_message_2) { Fabricate(:chat_message, chat_channel: public_channel) }
# this message is deleted in the before block:
fab!(:deleted_message) { Fabricate(:chat_message, chat_channel: public_channel) }
fab!(:private_channel) { Fabricate(:private_category_channel, group: Fabricate(:group)) }
fab!(:private_channel_message_1) { Fabricate(:chat_message, chat_channel: private_channel) }
fab!(:private_channel_message_2) { Fabricate(:chat_message, chat_channel: private_channel) }
fab!(:user_1) { Fabricate(:user) }
fab!(:user_2) { Fabricate(:user) }
fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user_1, user_2]) }
fab!(:direct_message_1) { Fabricate(:chat_message, chat_channel: private_channel, user: user_1) }
fab!(:direct_message_2) { Fabricate(:chat_message, chat_channel: private_channel, user: user_2) }
before { deleted_message.trash! }
it "exports messages" do
exporter = Class.new.extend(Chat::MessagesExporter)
result = exporter.chat_message_export.to_a
expect(result.length).to be(7)
assert_exported_message(result[0], public_channel_message_1)
assert_exported_message(result[1], public_channel_message_2)
assert_exported_message(result[2], deleted_message)
assert_exported_message(result[3], private_channel_message_1)
assert_exported_message(result[4], private_channel_message_2)
assert_exported_message(result[5], direct_message_1)
assert_exported_message(result[6], direct_message_2)
end
def assert_exported_message(data_row, message)
expect(data_row[0]).to eq(message.id)
expect(data_row[1]).to eq(message.chat_channel.id)
expect(data_row[2]).to eq(message.chat_channel.name)
expect(data_row[3]).to eq(message.user.id)
expect(data_row[4]).to eq(message.user.username)
expect(data_row[5]).to eq(message.message)
expect(data_row[6]).to eq(message.cooked)
expect(data_row[7]).to eq_time(message.created_at)
expect(data_row[8]).to eq_time(message.updated_at)
expect(data_row[9]).to eq_time(message.deleted_at)
expect(data_row[10]).to eq(message.in_reply_to_id)
expect(data_row[11]).to eq(message.last_editor.id)
expect(data_row[12]).to eq(message.last_editor.username)
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Chat::ChatController do
describe "#export_messages" do
fab!(:user) { Fabricate(:user) }
fab!(:moderator) { Fabricate(:moderator) }
fab!(:admin) { Fabricate(:admin) }
it "enqueues the export job and logs into staff actions" do
sign_in(admin)
post "/chat/admin/export/messages"
expect(response.status).to eq(204)
expect(Jobs::ExportCsvFile.jobs.size).to eq(1)
job_data = Jobs::ExportCsvFile.jobs.first["args"].first
expect(job_data["entity"]).to eq("chat_message")
expect(job_data["user_id"]).to eq(admin.id)
staff_log_entry = UserHistory.last
expect(staff_log_entry.acting_user_id).to eq(admin.id)
expect(staff_log_entry.subject).to eq("chat_message")
end
it "regular users don't have access" do
sign_in(user)
post "/chat/admin/export/messages"
expect(response.status).to eq(403)
end
it "moderators don't have access" do
sign_in(moderator)
post "/chat/admin/export/messages"
expect(response.status).to eq(403)
end
end
end