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:
parent
720c0c6e4d
commit
3ea31f443c
|
@ -411,6 +411,10 @@ $mobile-breakpoint: 700px;
|
|||
.admin-container {
|
||||
margin-top: 10px;
|
||||
|
||||
.admin-section {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.username {
|
||||
input {
|
||||
min-width: 15em;
|
||||
|
|
|
@ -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
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
<Chat::Admin::ExportMessages />
|
||||
|
||||
{{#if this.selectedWebhook}}
|
||||
<DButton
|
||||
@class="incoming-chat-webhooks-back"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue