ZaStoGram_desktop/Telegram/SourceFiles/main/main_account_archive.cpp
loop-uh 2575a66b83
All checks were successful
Desktop source guards / guards (push) Successful in 6s
Архив аккаунта: список чатов принимается как const
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 13:37:38 -04:00

661 lines
20 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#include "main/main_account_archive.h"
#include "base/call_delayed.h"
#include "base/timer.h"
#include "base/unixtime.h"
#include "boxes/abstract_box.h"
#include "core/application.h"
#include "core/file_utilities.h"
#include "data/data_channel.h"
#include "data/data_chat.h"
#include "data/data_document.h"
#include "data/data_folder.h"
#include "data/data_media_types.h"
#include "data/data_peer.h"
#include "data/data_photo.h"
#include "data/data_photo_media.h"
#include "data/data_session.h"
#include "data/data_user.h"
#include "dialogs/dialogs_main_list.h"
#include "dialogs/dialogs_indexed_list.h"
#include "dialogs/dialogs_row.h"
#include "history/history.h"
#include "history/history_item.h"
#include "history/view/history_view_element.h"
#include "main/main_account.h"
#include "main/main_session.h"
#include "ui/boxes/confirm_box.h"
#include "ui/image/image.h"
#include <QtCore/QDateTime>
#include <QtCore/QDir>
#include <QtCore/QFile>
#include <QtCore/QFileInfo>
#include <QtCore/QJsonArray>
#include <QtCore/QJsonDocument>
#include <QtCore/QJsonObject>
#include <QtCore/QRegularExpression>
#include <QtCore/QTextStream>
#include <QtCore/QUrl>
#include <QtGui/QDesktopServices>
namespace Main::AccountArchive {
namespace {
constexpr auto kLiveFirstDelay = 2 * 60 * crl::time(1000);
constexpr auto kLiveInterval = 15 * 60 * crl::time(1000);
constexpr auto kMediaBudget = int64(2) << 30;
struct PendingNotice {
QString folder;
Reason reason = Reason::ForcedLogout;
};
PendingNotice Pending;
struct ChatInfo {
uint64 id = 0;
QString title;
QString kind;
QString username;
QString link;
QString inviteLink;
QString about;
QString fileName;
int messages = 0;
int own = 0;
int files = 0;
};
[[nodiscard]] QString Esc(const QString &text) {
return text.toHtmlEscaped();
}
[[nodiscard]] QString Linkify(QString escaped) {
static const auto expression = QRegularExpression(
u"(https?://[^\\s<]+)"_q);
return escaped.replace(expression, u"<a href=\"\\1\">\\1</a>"_q);
}
[[nodiscard]] QString ReasonText(Reason reason) {
switch (reason) {
case Reason::ForcedLogout:
return u"Telegram завершил сессию (аккаунт удалён или заблокирован)"_q;
case Reason::Frozen:
return u"Аккаунт заморожен"_q;
case Reason::Manual:
return u"Сохранено вручную"_q;
case Reason::Live:
return u"Автоматическая копия"_q;
}
return QString();
}
[[nodiscard]] QString ReasonSlug(Reason reason) {
switch (reason) {
case Reason::ForcedLogout: return u"logout"_q;
case Reason::Frozen: return u"frozen"_q;
case Reason::Manual: return u"manual"_q;
case Reason::Live: return u"live"_q;
}
return u"unknown"_q;
}
[[nodiscard]] bool WriteText(const QString &path, const QString &text) {
auto file = QFile(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false;
}
file.write(text.toUtf8());
return true;
}
[[nodiscard]] bool WriteJson(const QString &path, const QJsonValue &value) {
auto file = QFile(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false;
}
const auto document = value.isArray()
? QJsonDocument(value.toArray())
: QJsonDocument(value.toObject());
file.write(document.toJson(QJsonDocument::Indented));
return true;
}
[[nodiscard]] QString UnseenPath() {
return RootPath() + u"/unseen"_q;
}
[[nodiscard]] QString SafeName(QString name) {
static const auto bad = QRegularExpression(u"[\\\\/:*?\"<>|]"_q);
return name.replace(bad, u"_"_q);
}
[[nodiscard]] QString KindOf(not_null<PeerData*> peer) {
if (peer->isUser()) {
return peer->isBot() ? u"bot"_q : u"user"_q;
} else if (peer->isChat()) {
return u"group"_q;
} else if (peer->isMegagroup()) {
return u"supergroup"_q;
} else if (peer->isChannel()) {
return u"channel"_q;
}
return u"chat"_q;
}
[[nodiscard]] QString ChatLink(const ChatInfo &info, not_null<PeerData*> peer) {
if (!info.username.isEmpty()) {
return u"https://t.me/"_q + info.username;
} else if (peer->isChannel()) {
return u"https://t.me/c/"_q
+ QString::number(peerToChannel(peer->id).bare);
}
return QString();
}
[[nodiscard]] QString MessageLink(
const ChatInfo &info,
not_null<PeerData*> peer,
MsgId id) {
if (peer->isUser()) {
return QString();
} else if (!info.username.isEmpty()) {
return u"https://t.me/"_q + info.username
+ '/' + QString::number(id.bare);
} else if (peer->isChannel()) {
return u"https://t.me/c/"_q
+ QString::number(peerToChannel(peer->id).bare)
+ '/' + QString::number(id.bare);
}
return QString();
}
constexpr auto kStyle = R"(<style>body{font:15px/1.4 sans-serif;max-width:860px;margin:0 auto;padding:16px;background:#f4f4f5;color:#111}
a{color:#2a7ae4}.m{background:#fff;border-radius:10px;padding:8px 12px;margin:8px 0;max-width:85%}.m.out{margin-left:auto;background:#e3f2fd}
.h span{color:#888;font-size:12px}.f,.d{color:#555;font-size:13px;margin-top:2px}.t{margin-top:4px;word-wrap:break-word}
table{border-collapse:collapse;width:100%;background:#fff}td,th{padding:6px 8px;border-bottom:1px solid #eee;text-align:left;vertical-align:top}
.about{white-space:pre-wrap;color:#444}</style>)";
struct Writer {
QString folder;
bool copyFiles = false;
int64 budget = kMediaBudget;
};
// Puts the downloaded file or loaded photo of a message into media/,
// returns the path relative to the report folder (empty when not available).
[[nodiscard]] QString SaveMedia(
not_null<Writer*> writer,
not_null<HistoryItem*> item,
const ChatInfo &info,
QString *description) {
const auto media = item->media();
if (!media) {
return QString();
}
if (const auto document = media->document()) {
auto name = document->filename();
*description = u"Файл"_q
+ (name.isEmpty() ? QString() : (u": "_q + name))
+ (document->mimeString().isEmpty()
? QString()
: (u" ("_q + document->mimeString() + ')'));
const auto source = document->filepath(true);
if (source.isEmpty() || !QFileInfo::exists(source)) {
return QString();
}
if (!writer->copyFiles) {
return QUrl::fromLocalFile(source).toString();
}
const auto size = QFileInfo(source).size();
if (size > writer->budget) {
return QString();
}
const auto folder = writer->folder + u"/media/"_q + info.fileName;
QDir().mkpath(folder);
const auto base = QString::number(item->id.bare)
+ '_'
+ SafeName(QFileInfo(source).fileName());
const auto target = folder + '/' + base;
if (QFileInfo::exists(target) || QFile::copy(source, target)) {
writer->budget -= size;
return u"media/"_q + info.fileName + '/' + base;
}
return QString();
} else if (const auto photo = media->photo()) {
*description = u"Фото"_q;
if (!writer->copyFiles) {
return QString();
}
const auto view = photo->createMediaView();
const auto image = view->image(Data::PhotoSize::Large);
if (!image) {
return QString();
}
const auto original = image->original();
if (original.isNull()) {
return QString();
}
const auto folder = writer->folder + u"/media/"_q + info.fileName;
QDir().mkpath(folder);
const auto base = QString::number(item->id.bare) + u".jpg"_q;
if (original.save(folder + '/' + base, "JPG", 90)) {
return u"media/"_q + info.fileName + '/' + base;
}
return QString();
} else if (media->webpage()) {
*description = u"Ссылка"_q;
} else {
*description = u"Вложение"_q;
}
return QString();
}
void WriteChat(
not_null<Writer*> writer,
not_null<History*> history,
ChatInfo &info) {
const auto peer = history->peer;
auto messages = QJsonArray();
auto html = QString();
html += u"<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>"_q
+ Esc(info.title) + u"</title>"_q + QString::fromUtf8(kStyle)
+ u"</head><body><p><a href=\"index.html\">← Все чаты</a></p><h1>"_q
+ Esc(info.title) + u"</h1>"_q;
if (!info.link.isEmpty()) {
html += u"<p><a href=\""_q + Esc(info.link) + u"\">"_q
+ Esc(info.link) + u"</a></p>"_q;
}
if (!info.inviteLink.isEmpty()) {
html += u"<p>Ссылка-приглашение: <a href=\""_q
+ Esc(info.inviteLink) + u"\">"_q
+ Esc(info.inviteLink) + u"</a></p>"_q;
}
if (!info.about.isEmpty()) {
html += u"<p class=\"about\">"_q + Esc(info.about) + u"</p>"_q;
}
auto items = std::vector<not_null<HistoryItem*>>();
for (const auto &block : history->blocks) {
for (const auto &view : block->messages) {
items.push_back(view->data());
}
}
if (items.empty()) {
if (const auto last = history->lastMessage()) {
items.push_back(last);
}
}
for (const auto item : items) {
const auto when = base::unixtime::parse(item->date());
const auto time = when.toString(u"yyyy-MM-dd HH:mm:ss"_q);
const auto from = item->from()->name();
auto text = item->originalText().text;
if (text.isEmpty() && item->isService()) {
text = item->notificationText().text;
}
auto description = QString();
const auto saved = SaveMedia(writer, item, info, &description);
const auto link = MessageLink(info, peer, item->id);
++info.messages;
if (item->out()) {
++info.own;
}
if (!saved.isEmpty() && saved.startsWith(u"media/"_q)) {
++info.files;
}
auto entry = QJsonObject();
entry.insert(u"id"_q, double(item->id.bare));
entry.insert(u"date"_q, double(item->date()));
entry.insert(u"time"_q, time);
entry.insert(u"out"_q, item->out());
entry.insert(u"from_id"_q, QString::number(item->from()->id.value));
entry.insert(u"from"_q, from);
entry.insert(u"text"_q, text);
if (!description.isEmpty()) {
entry.insert(u"media"_q, description);
}
if (!saved.isEmpty()) {
entry.insert(u"file"_q, saved);
}
if (item->isDeletedBySender()) {
entry.insert(u"deleted_by_sender"_q, true);
}
if (!link.isEmpty()) {
entry.insert(u"link"_q, link);
}
messages.push_back(entry);
html += u"<div class=\"m"_q + (item->out() ? u" out"_q : QString())
+ u"\"><div class=\"h\"><b>"_q + Esc(from) + u"</b> <span>"_q
+ time + u"</span>"_q;
if (!link.isEmpty()) {
html += u" <a href=\""_q + Esc(link) + u"\">#"_q
+ QString::number(item->id.bare) + u"</a>"_q;
}
if (item->isDeletedBySender()) {
html += u" <span>удалено отправителем</span>"_q;
}
html += u"</div>"_q;
if (!text.isEmpty()) {
html += u"<div class=\"t\">"_q
+ Linkify(Esc(text)).replace('\n', u"<br>"_q)
+ u"</div>"_q;
}
if (!description.isEmpty()) {
html += u"<div class=\"d\">"_q + Esc(description) + u"</div>"_q;
}
if (!saved.isEmpty()) {
const auto name = saved.mid(saved.lastIndexOf('/') + 1);
const auto href = saved.startsWith(u"media/"_q)
? saved
: saved;
html += u"<div class=\"d\"><a href=\""_q + Esc(href) + u"\">"_q
+ Esc(name) + u"</a></div>"_q;
}
html += u"</div>"_q;
}
html += u"</body></html>"_q;
auto chat = QJsonObject();
chat.insert(u"id"_q, QString::number(info.id));
chat.insert(u"title"_q, info.title);
chat.insert(u"type"_q, info.kind);
chat.insert(u"username"_q, info.username);
chat.insert(u"link"_q, info.link);
chat.insert(u"invite_link"_q, info.inviteLink);
chat.insert(u"about"_q, info.about);
chat.insert(u"messages"_q, messages);
[[maybe_unused]] const auto okJson = WriteJson(
writer->folder + '/' + info.fileName + u".json"_q,
chat);
[[maybe_unused]] const auto okHtml = WriteText(
writer->folder + '/' + info.fileName + u".html"_q,
html);
}
void WriteIndex(
const QString &folder,
not_null<Session*> session,
Reason reason,
const std::vector<ChatInfo> &chats) {
const auto user = session->user();
auto html = QString();
html += u"<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Архив аккаунта</title>"_q
+ QString::fromUtf8(kStyle) + u"</head><body><h1>Архив аккаунта "_q
+ Esc(user->name()) + u"</h1><p>ID: "_q
+ QString::number(peerToUser(user->id).bare);
if (!user->phone().isEmpty()) {
html += u", телефон +"_q + Esc(user->phone());
}
if (!user->username().isEmpty()) {
html += u", <a href=\"https://t.me/"_q + Esc(user->username())
+ u"\">@"_q + Esc(user->username()) + u"</a>"_q;
}
html += u"<br>Сохранено: "_q
+ QDateTime::currentDateTime().toString(u"yyyy-MM-dd HH:mm"_q)
+ u"<br>Причина: "_q + Esc(ReasonText(reason)) + u"</p>"_q;
html += u"<p>Приложение для ПК не хранит историю переписки на диске, поэтому сохранено то, что к этому моменту было загружено в память: список чатов, открывавшиеся переписки и скачанные файлы. Чего не было загружено, на устройстве нет.</p>"_q;
html += u"<table><tr><th>Чат</th><th>Тип</th><th>Ссылка</th><th>Сообщений</th><th>Ваших</th><th>Файлов</th></tr>"_q;
for (const auto &info : chats) {
html += u"<tr><td><a href=\""_q + info.fileName + u".html\">"_q
+ Esc(info.title) + u"</a></td><td>"_q + Esc(info.kind)
+ u"</td><td>"_q;
if (!info.link.isEmpty()) {
html += u"<a href=\""_q + Esc(info.link) + u"\">"_q
+ Esc(info.link) + u"</a>"_q;
}
if (!info.inviteLink.isEmpty()) {
html += u"<br><a href=\""_q + Esc(info.inviteLink) + u"\">"_q
+ Esc(info.inviteLink) + u"</a>"_q;
}
html += u"</td><td>"_q + QString::number(info.messages)
+ u"</td><td>"_q + QString::number(info.own)
+ u"</td><td>"_q + QString::number(info.files)
+ u"</td></tr>"_q;
}
html += u"</table></body></html>"_q;
[[maybe_unused]] const auto okIndex = WriteText(
folder + u"/index.html"_q,
html);
auto links = QString();
auto array = QJsonArray();
for (const auto &info : chats) {
if (!info.link.isEmpty() || !info.inviteLink.isEmpty()) {
links += info.title + u" ["_q + info.kind + u"]\n"_q;
if (!info.link.isEmpty()) {
links += u" "_q + info.link + '\n';
}
if (!info.inviteLink.isEmpty()) {
links += u" "_q + info.inviteLink + '\n';
}
}
auto entry = QJsonObject();
entry.insert(u"id"_q, QString::number(info.id));
entry.insert(u"title"_q, info.title);
entry.insert(u"type"_q, info.kind);
entry.insert(u"username"_q, info.username);
entry.insert(u"link"_q, info.link);
entry.insert(u"invite_link"_q, info.inviteLink);
entry.insert(u"about"_q, info.about);
entry.insert(u"messages"_q, info.messages);
entry.insert(u"own_messages"_q, info.own);
entry.insert(u"files"_q, info.files);
entry.insert(u"file"_q, info.fileName + u".json"_q);
array.push_back(entry);
}
[[maybe_unused]] const auto okLinks = WriteText(
folder + u"/links.txt"_q,
links);
[[maybe_unused]] const auto okChats = WriteJson(
folder + u"/chats.json"_q,
array);
}
void CollectHistories(
not_null<const Dialogs::MainList*> list,
std::vector<not_null<History*>> &result,
base::flat_set<not_null<History*>> &seen) {
for (const auto &row : list->indexed()->all()) {
const auto history = row->history();
if (history && seen.emplace(history).second) {
result.push_back(history);
}
}
}
[[nodiscard]] ChatInfo Describe(not_null<History*> history) {
const auto peer = history->peer;
auto info = ChatInfo();
info.id = peer->id.value;
info.title = peer->name();
if (info.title.isEmpty()) {
info.title = u"Чат "_q + QString::number(info.id);
}
info.kind = KindOf(peer);
info.username = peer->username();
info.about = peer->about();
info.link = ChatLink(info, peer);
if (const auto chat = peer->asChat()) {
info.inviteLink = chat->inviteLink();
} else if (const auto channel = peer->asChannel()) {
info.inviteLink = channel->inviteLink();
}
info.fileName = u"chat_"_q + QString::number(info.id);
return info;
}
} // namespace
QString RootPath() {
return cWorkingDir() + u"ZaStoArchive"_q;
}
QString Save(not_null<Session*> session, Reason reason) {
const auto user = session->user();
const auto userId = peerToUser(user->id).bare;
const auto live = (reason == Reason::Live);
const auto root = RootPath();
QDir().mkpath(root);
auto histories = std::vector<not_null<History*>>();
auto seen = base::flat_set<not_null<History*>>();
const auto &owner = session->data();
CollectHistories(owner.chatsList(), histories, seen);
if (const auto folder = owner.folderLoaded(Data::Folder::kId)) {
CollectHistories(folder->chatsList(), histories, seen);
}
if (histories.empty()) {
// Nothing is loaded (the app was just started): keep what the
// earlier live copy has instead of replacing it with an empty one.
const auto existing = root
+ u"/live_"_q + QString::number(userId);
if (!live && QFileInfo::exists(existing + u"/index.html"_q)) {
[[maybe_unused]] const auto ok = WriteText(
UnseenPath(),
QString::number(userId));
return existing;
}
return QString();
}
const auto stamp = QDateTime::currentDateTime().toString(
u"yyyyMMdd-HHmmss"_q);
const auto final = live
? (root + u"/live_"_q + QString::number(userId))
: (root
+ '/' + QString::number(userId)
+ '_' + stamp
+ '_' + ReasonSlug(reason));
const auto folder = live ? (final + u".tmp"_q) : final;
QDir(folder).removeRecursively();
if (!QDir().mkpath(folder)) {
return QString();
}
auto writer = Writer{ .folder = folder, .copyFiles = !live };
auto chats = std::vector<ChatInfo>();
chats.reserve(histories.size());
auto totalMessages = 0;
auto totalFiles = 0;
for (const auto history : histories) {
auto info = Describe(history);
WriteChat(&writer, history, info);
totalMessages += info.messages;
totalFiles += info.files;
chats.push_back(std::move(info));
}
WriteIndex(folder, session, reason, chats);
auto meta = QJsonObject();
meta.insert(u"format"_q, 1);
meta.insert(u"user_id"_q, QString::number(userId));
meta.insert(u"name"_q, user->name());
meta.insert(u"phone"_q, user->phone());
meta.insert(u"username"_q, user->username());
meta.insert(u"reason"_q, ReasonSlug(reason));
meta.insert(u"created"_q, double(QDateTime::currentSecsSinceEpoch()));
meta.insert(u"chats"_q, int(chats.size()));
meta.insert(u"messages"_q, totalMessages);
meta.insert(u"files"_q, totalFiles);
[[maybe_unused]] const auto okMeta = WriteJson(
folder + u"/meta.json"_q,
meta);
if (live) {
QDir(final).removeRecursively();
if (!QDir().rename(folder, final)) {
return QString();
}
return final;
}
[[maybe_unused]] const auto okUnseen = WriteText(
UnseenPath(),
QString::number(userId));
return final;
}
void Watch(not_null<Session*> session) {
const auto timer = session->lifetime().make_state<base::Timer>([=] {
if (!session->account().loggingOut()) {
Save(session, Reason::Live);
}
});
timer->callEach(kLiveInterval);
base::call_delayed(kLiveFirstDelay, session, [=] {
if (!session->account().loggingOut()) {
Save(session, Reason::Live);
}
});
}
void SaveFrozenOnce(not_null<Session*> session, TimeId since) {
if (!since) {
return;
}
const auto userId = peerToUser(session->user()->id).bare;
const auto marker = RootPath()
+ u"/frozen_"_q + QString::number(userId)
+ '_' + QString::number(since);
if (QFileInfo::exists(marker)) {
return;
}
// Give the chat list time to load after the app starts.
base::call_delayed(kLiveFirstDelay, session, [=] {
if (QFileInfo::exists(marker)) {
return;
}
const auto folder = Save(session, Reason::Frozen);
if (!folder.isEmpty()) {
QDir().mkpath(RootPath());
[[maybe_unused]] const auto ok = WriteText(marker, folder);
}
});
}
void SetPending(const QString &folder, Reason reason) {
Pending.folder = folder;
Pending.reason = reason;
}
void NotifyPending() {
const auto folder = base::take(Pending.folder);
const auto reason = Pending.reason;
if (folder.isEmpty()) {
return;
}
const auto text = ReasonText(reason)
+ u".\n\nЛокальная копия аккаунта (чаты, каналы, группы, ссылки, сообщения и файлы, которые были загружены в приложение) сохранена в папке:\n"_q
+ QDir::toNativeSeparators(folder)
+ u"\n\nОткрыть: index.html в этой папке."_q;
Ui::show(Ui::MakeConfirmBox({
.text = text,
.confirmed = [=](Fn<void()> close) {
File::ShowInFolder(folder + u"/index.html"_q);
close();
},
.confirmText = u"Открыть папку"_q,
.cancelText = u"Закрыть"_q,
}));
MarkSeen();
}
bool HasUnseen() {
return QFileInfo::exists(UnseenPath());
}
void MarkSeen() {
QFile::remove(UnseenPath());
}
} // namespace Main::AccountArchive