1
0
forked from BRT/arc

Add PayByCardScript and PayByPhoneScript for Dushanbe automation, including transaction flow, OCR-based operation ID extraction, and enhanced logging.

This commit is contained in:
slava 2026-04-06 12:51:18 +07:00
parent 37b72938e8
commit 3ed2d4da20
51 changed files with 1600 additions and 102 deletions

View File

@ -132,6 +132,7 @@ void GetCardInfoScript::doStart() {
materialBody["bank_profile_id"] = updatedApp.bankProfileId;
materialBody["card_number"] = savedAccount.cardNumber;
materialBody["name"] = updatedApp.fullName;
materialBody["state"] = materialStatusToString(savedAccount.status) == "active" ? "enabled" : "disabled";
ns.setPayload(QJsonDocument(materialBody).toJson());
ns.addExtra("account_id", savedAccount.id);
ns.postMaterial();

View File

@ -174,8 +174,9 @@ void GetCardInfoScript::doStart() {
ns.setDeviceId(m_deviceId);
QJsonObject materialBody;
materialBody["bank_profile_id"] = updatedApp.bankProfileId;
materialBody["card_number"] = fullNumber;
materialBody["card_number"] = QString(fullNumber).remove(' ');
materialBody["name"] = updatedApp.fullName;
materialBody["state"] = materialStatusToString(savedAccount.status) == "active" ? "enabled" : "disabled";
ns.setPayload(QJsonDocument(materialBody).toJson());
ns.addExtra("account_id", savedAccount.id);
ns.postMaterial();

View File

@ -1,8 +1,12 @@
#include "GetLastTransactionsScript.h"
#include <QRegularExpression>
#include <QThread>
#include <QTimeZone>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
@ -10,6 +14,215 @@
#include "dao/TransactionDAO.h"
#include "AppLogger.h"
static QString recognizeTextFromImage(const QByteArray &pngData) {
auto *api = new tesseract::TessBaseAPI();
if (api->Init(nullptr, "rus+eng")) {
qWarning() << "[Dushanbe::OCR] Failed to init Tesseract";
delete api;
return {};
}
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) {
api->End(); delete api;
return {};
}
api->SetImage(pix);
char *text = api->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&pix);
api->End();
delete api;
return result;
}
// Точечный OCR: находит "Номер операции" через общий OCR, затем кропает строку правее
// и распознаёт только цифры. Работает с любым разрешением экрана.
static QString recognizeOperationId(const QByteArray &pngData) {
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) return {};
const int w = pixGetWidth(pix);
const int h = pixGetHeight(pix);
// 1. Полный OCR с координатами слов — ищем "операции"
auto *api = new tesseract::TessBaseAPI();
if (api->Init(nullptr, "rus+eng")) {
pixDestroy(&pix); delete api;
return {};
}
api->SetImage(pix);
api->Recognize(nullptr);
int opLineY = -1;
int opLineH = 0;
bool prevWasNomer = false;
tesseract::ResultIterator *ri = api->GetIterator();
if (ri) {
do {
const char *word = ri->GetUTF8Text(tesseract::RIL_WORD);
if (word) {
const QString w_str = QString::fromUtf8(word);
delete[] word;
if (w_str.contains(QString::fromUtf8("омер")) ||
w_str.contains(QString::fromUtf8("omep"))) {
prevWasNomer = true;
continue;
}
if (prevWasNomer &&
(w_str.contains(QString::fromUtf8("операции")) ||
w_str.contains(QString::fromUtf8("onepaции")))) {
int x1, y1, x2, y2;
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
opLineY = y1;
opLineH = y2 - y1;
break;
}
prevWasNomer = false;
}
} while (ri->Next(tesseract::RIL_WORD));
}
api->End();
delete api;
if (opLineY < 0) {
pixDestroy(&pix);
return {};
}
// 2. Кропаем правую половину этой строки и OCR только цифры
const int cropY = qMax(0, opLineY - opLineH / 2);
const int cropH = opLineH * 2;
BOX *box = boxCreate(w / 2, cropY, w / 2, qMin(cropH, h - cropY));
Pix *cropped = pixClipRectangle(pix, box, nullptr);
boxDestroy(&box);
pixDestroy(&pix);
if (!cropped) return {};
auto *api2 = new tesseract::TessBaseAPI();
if (api2->Init(nullptr, "eng")) {
pixDestroy(&cropped); delete api2;
return {};
}
api2->SetVariable("tessedit_char_whitelist", "0123456789");
api2->SetPageSegMode(tesseract::PSM_SINGLE_LINE);
api2->SetImage(cropped);
char *text = api2->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&cropped);
api2->End();
delete api2;
result.remove(QRegularExpression("[^0-9]"));
if (result.length() >= 8) {
qDebug() << "[Dushanbe::OCR] Precise operation ID:" << result;
return result;
}
return {};
}
struct OcrTransactionDetail {
QString date;
QString time;
QString operationId;
QString provider;
QString senderAccount;
QString receiverAccount;
double amount = 0.0;
double fee = 0.0;
QString status;
};
static OcrTransactionDetail parseDetailFromOcr(const QString &ocrText) {
OcrTransactionDetail d;
const QStringList lines = ocrText.split('\n');
for (const QString &rawLine : lines) {
const QString line = rawLine.trimmed();
// "Дата операции: 05.04.2026" или "Дата операции:" на отдельной строке
if (line.contains(QString::fromUtf8("Дата операции"))) {
QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))");
if (auto m = re.match(line); m.hasMatch()) d.date = m.captured(1);
}
// "Время операции: 18:19:54"
else if (line.contains(QString::fromUtf8("Время операции"))) {
QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))");
if (auto m = re.match(line); m.hasMatch()) d.time = m.captured(1);
}
// "Номер операции: 1598279763"
else if (line.contains(QString::fromUtf8("омер операции"))) {
QRegularExpression re(R"((\d{5,}))");
if (auto m = re.match(line); m.hasMatch()) d.operationId = m.captured(1);
}
// "Поставщик: DC (по номеру карты)"
else if (line.contains(QString::fromUtf8("Поставщик"))) {
QRegularExpression re(R"(Поставщик[:\s]*(.+))");
if (auto m = re.match(line); m.hasMatch()) d.provider = m.captured(1).trimmed();
}
// "Счет отправителя: 9762***3258"
else if (line.contains(QString::fromUtf8("Счет отправителя")) ||
line.contains(QString::fromUtf8("чет отправителя"))) {
QRegularExpression re(R"((\d[\d*]+))");
if (auto m = re.match(line); m.hasMatch()) d.senderAccount = m.captured(1);
}
// "Счет получателя: 9762000186263942" или "992111100664"
else if (line.contains(QString::fromUtf8("Счет получателя")) ||
line.contains(QString::fromUtf8("чет получателя"))) {
QRegularExpression re(R"((\d{6,}))");
if (auto m = re.match(line); m.hasMatch()) d.receiverAccount = m.captured(1);
}
// "Сумма операции: 1.00"
else if (line.contains(QString::fromUtf8("Сумма операции")) ||
line.contains(QString::fromUtf8("умма операции"))) {
QRegularExpression re(R"((\d+[\.,]\d{2}))");
if (auto m = re.match(line); m.hasMatch()) {
QString val = m.captured(1);
val.replace(',', '.');
d.amount = val.toDouble();
}
}
// "Комиссия: 0.00"
else if (line.contains(QString::fromUtf8("Комиссия")) ||
line.contains(QString::fromUtf8("омиссия"))) {
QRegularExpression re(R"((\d+[\.,]\d{2}))");
if (auto m = re.match(line); m.hasMatch()) {
QString val = m.captured(1);
val.replace(',', '.');
d.fee = val.toDouble();
}
}
// "Статус: Успешный"
else if (line.contains(QString::fromUtf8("Статус"))) {
if (line.contains(QString::fromUtf8("Успешн"))) {
d.status = QString::fromUtf8("Успешный");
}
}
}
// Fallback: если дата/время не найдены в "ключ: значение", ищем паттерном
if (d.date.isEmpty()) {
QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))");
if (auto m = re.match(ocrText); m.hasMatch()) d.date = m.captured(1);
}
if (d.time.isEmpty()) {
QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))");
auto it = re.globalMatch(ocrText);
QStringList times;
while (it.hasNext()) times.append(it.next().captured(1));
if (times.size() >= 2) d.time = times[1];
else if (times.size() == 1) d.time = times[0];
}
if (ocrText.contains(QString::fromUtf8("Успешн"))) d.status = QString::fromUtf8("Успешный");
return d;
}
namespace Dushanbe {
GetLastTransactionsScript::GetLastTransactionsScript(
@ -117,7 +330,7 @@ void GetLastTransactionsScript::doStart() {
// Ищем соответствующий node чтобы по нему тапнуть
Node txNode;
for (const Node &node : xmlScreenParser.findAllNodes()) {
if (node.className != "android.widget.ImageView" || !node.clickable) continue;
if (node.className != "android.widget.ImageView") continue;
if (!node.contentDesc.contains("***")) continue;
// Проверяем что содержит нужную сумму
QString amountStr = QString::number(tx.amount, 'f', 2);
@ -133,13 +346,14 @@ void GetLastTransactionsScript::doStart() {
}
AdbUtils::makeTap(m_deviceId, txNode.x(), txNode.y());
QThread::msleep(2000);
QThread::msleep(3000);
// Ждём экран деталей
// Ждём экран деталей — ищем кнопку "Назад" (признак экрана детали)
bool detailLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (xmlScreenParser.isTransactionDetailScreen()) {
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Назад")).isEmpty() ||
!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Поделиться")).isEmpty()) {
detailLoaded = true;
break;
}
@ -153,7 +367,18 @@ void GetLastTransactionsScript::doStart() {
continue;
}
const auto detail = xmlScreenParser.parseTransactionDetail();
// OCR скриншота деталей
const QByteArray detailScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
const QString ocrText = recognizeTextFromImage(detailScreenshot);
qDebug() << "[Dushanbe::GetLastTransactions] OCR result:" << ocrText;
OcrTransactionDetail detail = parseDetailFromOcr(ocrText);
// Точечный OCR для номера операции (кроп + только цифры)
const QString preciseOpId = recognizeOperationId(detailScreenshot);
if (!preciseOpId.isEmpty()) {
detail.operationId = preciseOpId;
}
// Проверяем время ±1 час
if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) {
@ -193,7 +418,7 @@ void GetLastTransactionsScript::doStart() {
saveTx.description = detail.provider;
saveTx.phone = detail.receiverAccount;
saveTx.name = detail.receiverAccount;
saveTx.status = (detail.status == QString::fromUtf8("Успешный"))
saveTx.status = detail.status.contains(QString::fromUtf8("Успешн"))
? TransactionStatus::Complete : TransactionStatus::Unknown;
saveTx.type = TransactionType::Phone;
saveTx.timestamp = QDateTime::currentDateTimeUtc();

View File

@ -2,6 +2,7 @@
#include <QJsonDocument>
#include <QJsonObject>
#include <QRegularExpression>
#include <QThread>
#include "adb/AdbUtils.h"
@ -96,7 +97,8 @@ void GetProfileInfoScript::doStart() {
// Парсим ФИО и телефон из бокового меню
const QString fullName = xmlScreenParser.parseProfileFullName();
const QString phone = xmlScreenParser.parseProfilePhone();
QString phone = xmlScreenParser.parseProfilePhone();
phone.remove(QRegularExpression("[^0-9+]"));
qDebug() << "[Dushanbe::GetProfileInfo] fullName:" << fullName << "phone:" << phone;
if (!fullName.isEmpty()) {

View File

@ -0,0 +1,488 @@
#include "PayByCardScript.h"
#include <QRegularExpression>
#include <QThread>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h"
#include "AppLogger.h"
// Точечный OCR: находит строку "операции" и кропает правую часть для digit-only OCR
static QString recognizeOperationId(const QByteArray &pngData) {
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) return {};
const int w = pixGetWidth(pix);
const int h = pixGetHeight(pix);
auto *api = new tesseract::TessBaseAPI();
if (api->Init(nullptr, "rus+eng")) {
pixDestroy(&pix); delete api;
return {};
}
api->SetImage(pix);
api->Recognize(nullptr);
int opLineY = -1, opLineH = 0;
bool prevWasNomer = false;
tesseract::ResultIterator *ri = api->GetIterator();
if (ri) {
do {
const char *word = ri->GetUTF8Text(tesseract::RIL_WORD);
if (word) {
const QString w_str = QString::fromUtf8(word);
delete[] word;
// Ищем "Номер" (или OCR-артефакты: "Homep", "Номep")
if (w_str.contains(QString::fromUtf8("омер")) ||
w_str.contains(QString::fromUtf8("omep"))) {
prevWasNomer = true;
continue;
}
// После "Номер" ищем "операции"
if (prevWasNomer &&
(w_str.contains(QString::fromUtf8("операции")) ||
w_str.contains(QString::fromUtf8("onepaции")))) {
int x1, y1, x2, y2;
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
opLineY = y1;
opLineH = y2 - y1;
break;
}
prevWasNomer = false;
}
} while (ri->Next(tesseract::RIL_WORD));
}
api->End();
delete api;
if (opLineY < 0) { pixDestroy(&pix); return {}; }
const int cropY = qMax(0, opLineY - opLineH / 2);
const int cropH = opLineH * 2;
BOX *box = boxCreate(w / 2, cropY, w / 2, qMin(cropH, h - cropY));
Pix *cropped = pixClipRectangle(pix, box, nullptr);
boxDestroy(&box);
pixDestroy(&pix);
if (!cropped) return {};
auto *api2 = new tesseract::TessBaseAPI();
if (api2->Init(nullptr, "eng")) {
pixDestroy(&cropped); delete api2;
return {};
}
api2->SetVariable("tessedit_char_whitelist", "0123456789");
api2->SetPageSegMode(tesseract::PSM_SINGLE_LINE);
api2->SetImage(cropped);
char *text = api2->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&cropped);
api2->End();
delete api2;
result.remove(QRegularExpression("[^0-9]"));
if (result.length() >= 8) {
qDebug() << "[Dushanbe::OCR] Precise operation ID:" << result;
return result;
}
return {};
}
namespace Dushanbe {
PayByCardScript::PayByCardScript(
MaterialInfo account,
QString cardNumber,
double amount,
QObject *parent
) : CommonScript(parent),
m_account(std::move(account)),
m_cardNumber(std::move(cardNumber)),
m_amount(amount) {
}
PayByCardScript::~PayByCardScript() = default;
void PayByCardScript::doStart() {
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_account.deviceId;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe::PayByCard] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(device.adbSerial, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.id;
qWarning() << m_error;
AppLogger::log("dushanbe/PayByCard", device.id, m_error,
AdbUtils::captureScreenshotBytes(device.adbSerial),
"CREATE_TRANSACTION");
AdbUtils::tryToKillApplication(device.adbSerial, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
}
makePayment(device.adbSerial, app.pinCode, device.screenWidth, device.screenHeight);
emit finishedWithResult(m_error);
}
void PayByCardScript::makePayment(
const QString &deviceId,
const QString &pinCode,
const int width,
const int height
) {
// 1. Тапаем "Переводы" в нижней навигации
Node transfersTab = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Переводы"));
if (transfersTab.isEmpty()) {
m_error = "Tab 'Переводы' not found on home screen";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByCard] Tapping 'Переводы' tab";
AdbUtils::makeTap(deviceId, transfersTab.x(), transfersTab.y());
QThread::msleep(2000);
// 2. Ждём экран переводов — ищем "DC (по номеру карты)"
bool transfersLoaded = false;
Node cardTransferBtn;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
cardTransferBtn = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("DC (по номеру карты)"), true);
if (!cardTransferBtn.isEmpty()) {
transfersLoaded = true;
break;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for transfers screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!transfersLoaded) {
m_error = "Transfers screen not loaded or 'DC (по номеру карты)' not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 3. Тапаем "DC (по номеру карты)"
qDebug() << "[Dushanbe::PayByCard] Tapping 'DC (по номеру карты)'";
AdbUtils::makeTap(deviceId, cardTransferBtn.x(), cardTransferBtn.y());
QThread::msleep(2000);
// 4. Ждём экран ввода — ищем EditText с hint "Номер карты получателя"
bool inputScreenLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node editText = xmlScreenParser.findEditTextByHint(
QString::fromUtf8("Номер карты получателя"));
if (editText.isEmpty()) {
editText = xmlScreenParser.findFirstEditText();
}
if (!editText.isEmpty()) {
inputScreenLoaded = true;
break;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for card input screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!inputScreenLoaded) {
m_error = "Card input screen not loaded";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 5. Вводим номер карты в поле "Номер карты получателя"
Node cardEditText = xmlScreenParser.findEditTextByHint(
QString::fromUtf8("Номер карты получателя"));
if (cardEditText.isEmpty()) {
cardEditText = xmlScreenParser.findFirstEditText();
}
AdbUtils::makeTap(deviceId, cardEditText.x(), cardEditText.y());
QThread::msleep(500);
AdbUtils::inputText(deviceId, m_cardNumber);
QThread::msleep(1000);
// Скрываем клавиатуру чтобы поле суммы стало видимым
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
// 6. Вводим сумму в поле "Сумма" (hint содержит "Сумма")
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node sumEditText = xmlScreenParser.findEditTextByHint(QString::fromUtf8("Сумма"));
if (sumEditText.isEmpty()) {
m_error = "Sum input field not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
AdbUtils::makeTap(deviceId, sumEditText.x(), sumEditText.y());
QThread::msleep(500);
// Сумма приходит с копейками (например 100.50), форматируем как целое или с точкой
const QString amountStr = (m_amount == static_cast<int>(m_amount))
? QString::number(static_cast<int>(m_amount))
: QString::number(m_amount, 'f', 2);
AdbUtils::inputText(deviceId, amountStr);
QThread::msleep(1000);
// 7. Скрываем клавиатуру и нажимаем "Далее"
AdbUtils::goBack(deviceId);
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node daleeBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Далее"));
if (daleeBtn.isEmpty()) {
daleeBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Далее"), false);
}
if (daleeBtn.isEmpty()) {
m_error = "Button 'Далее' not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByCard] Tapping 'Далее'";
AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y());
QThread::msleep(2000);
// 8. После "Далее" кнопка меняется на "Оплатить" — ждём и нажимаем
bool oplatitFound = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (oplatitBtn.isEmpty()) {
oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
}
if (!oplatitBtn.isEmpty()) {
qDebug() << "[Dushanbe::PayByCard] Tapping 'Оплатить'";
AdbUtils::makeTap(deviceId, oplatitBtn.x(), oplatitBtn.y());
oplatitFound = true;
QThread::msleep(3000);
break;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for 'Оплатить', attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!oplatitFound) {
m_error = "Button 'Оплатить' not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
// content-desc содержит "Подтверждение платежа...Подтверждаю"
// Кнопка "Подтверждаю" визуально внизу bottom sheet
bool confirmed = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
// Пин-код может появиться
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe::PayByCard] PIN code screen, entering PIN...";
inputPinCode(deviceId, pinCode);
QThread::msleep(3000);
continue;
}
// Ищем элемент с "Подтверждение платежа" и "Подтверждаю" в content-desc
Node confirmNode = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Подтверждение платежа"), true);
if (!confirmNode.isEmpty() &&
confirmNode.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
// Тапаем внизу этого элемента (кнопка "Подтверждаю" в нижней части)
// Кнопка "Подтверждаю" всегда внизу bottom sheet — тапаем по 92% высоты экрана
const int tapY = height * 92 / 100;
qDebug() << "[Dushanbe::PayByCard] Tapping 'Подтверждаю' at y=" << tapY;
AdbUtils::makeTap(deviceId, confirmNode.x(), tapY);
confirmed = true;
QThread::msleep(4000);
break;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for confirmation sheet, attempt" << attempt + 1;
QThread::msleep(2000);
}
if (!confirmed) {
m_error = "Confirmation sheet not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 10. Ждём результат — "Платеж выполнен" или ошибку
bool success = false;
for (int attempt = 0; attempt < 15; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
// Пин-код
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe::PayByCard] PIN code screen (retry), entering PIN...";
inputPinCode(deviceId, pinCode);
QThread::msleep(3000);
continue;
}
// Успех: content-desc начинается с "Платеж выполнен"
if (!xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
success = true;
break;
}
// Ошибка
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
m_error = "Transfer failed: error screen detected";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for result, attempt" << attempt + 1;
QThread::msleep(2000);
}
if (!success) {
m_error = "Payment result not received within timeout";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByCard] Payment successful!";
m_error.clear();
// Скриншот чека
const QByteArray receiptImage = AdbUtils::captureScreenshotBytes(deviceId);
// Парсим детали из content-desc "Платеж выполнен"
// Формат: "Платеж выполнен\nПоставщик:\n...\nКомиссия:\n0.00\nДата:\n05.04.26\nВремя:\n20:19:56\nНа главную"
QString bankTime;
double fee = 0;
Node resultNode = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true);
if (!resultNode.isEmpty()) {
const QStringList lines = resultNode.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) {
const QString line = lines[i].trimmed();
if (line.startsWith(QString::fromUtf8("Комиссия:")) && i + 1 < lines.size()) {
fee = lines[i + 1].trimmed().toDouble();
} else if (line.startsWith(QString::fromUtf8("Дата:")) && i + 1 < lines.size()) {
bankTime = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Время:")) && i + 1 < lines.size()) {
bankTime += " " + lines[i + 1].trimmed();
}
}
qDebug() << "[Dushanbe::PayByCard] Result: date=" << bankTime << "fee=" << fee;
}
// Переходим в историю для получения ID операции через OCR
// 1. Тапаем "На главную" (внизу экрана результата)
Node naGlavnuyuNode = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true);
if (!naGlavnuyuNode.isEmpty()) {
const int tapY = height * 92 / 100;
AdbUtils::makeTap(deviceId, width / 2, tapY);
QThread::msleep(2000);
}
// 2. Тапаем "История" в нижней навигации
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node historyTab = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("История"));
if (!historyTab.isEmpty()) {
AdbUtils::makeTap(deviceId, historyTab.x(), historyTab.y());
QThread::msleep(3000);
}
// 3. Тапаем первую транзакцию в списке (содержит m_cardNumber)
QString operationId;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node txNode = xmlScreenParser.findNodeByContentDesc(m_cardNumber, true);
if (txNode.isEmpty()) {
// Fallback: ищем "Сегодня" — транзакция обычно сразу после
txNode = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Сегодня"), true);
}
if (!txNode.isEmpty()) {
AdbUtils::makeTap(deviceId, txNode.x(), txNode.y());
QThread::msleep(3000);
// 4. Делаем скриншот детали и OCR
const QByteArray detailScreenshot = AdbUtils::captureScreenshotBytes(deviceId);
if (!detailScreenshot.isEmpty()) {
operationId = recognizeOperationId(detailScreenshot);
if (!operationId.isEmpty()) {
qDebug() << "[Dushanbe::PayByCard] Operation ID:" << operationId;
} else {
qWarning() << "[Dushanbe::PayByCard] Could not extract operation ID";
}
}
// Назад из деталей
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
} else {
qWarning() << "[Dushanbe::PayByCard] Transaction not found in history";
}
// Сохраняем транзакцию
TransactionInfo tx;
tx.accountId = m_account.id;
tx.amount = -m_amount;
tx.fee = fee;
tx.status = TransactionStatus::Complete;
tx.type = TransactionType::Card;
tx.phone = m_cardNumber;
tx.bankName = "dushanbe_city_bank";
tx.bankTime = bankTime;
tx.bankTrExternalId = operationId;
tx.timestamp = QDateTime::currentDateTimeUtc();
tx.completeTime = QDateTime::currentDateTimeUtc();
tx.receiptImage = receiptImage;
const int txId = TransactionDAO::insertTransaction(tx);
if (txId != -1) {
qDebug() << "[Dushanbe::PayByCard] Transaction saved, id:" << txId
<< "operationId:" << operationId;
if (!receiptImage.isEmpty()) {
TransactionDAO::updateReceiptImage(txId, receiptImage);
}
} else {
qWarning() << "[Dushanbe::PayByCard] Failed to save transaction";
}
}
} // namespace Dushanbe

View File

@ -0,0 +1,36 @@
#pragma once
#include "CommonScript.h"
namespace Dushanbe {
class PayByCardScript final : public CommonScript {
Q_OBJECT
public:
PayByCardScript(
MaterialInfo account,
QString cardNumber,
double amount,
QObject *parent = nullptr
);
~PayByCardScript() override;
protected:
void doStart() override;
private:
const MaterialInfo m_account;
const QString m_cardNumber;
const double m_amount;
QString m_error;
void makePayment(
const QString &deviceId,
const QString &pinCode,
int width,
int height
);
};
} // namespace Dushanbe

View File

@ -0,0 +1,473 @@
#include "PayByPhoneScript.h"
#include <QRegularExpression>
#include <QThread>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h"
#include "AppLogger.h"
static QString recognizeOperationId(const QByteArray &pngData) {
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) return {};
const int w = pixGetWidth(pix);
const int h = pixGetHeight(pix);
auto *api = new tesseract::TessBaseAPI();
if (api->Init(nullptr, "rus+eng")) {
pixDestroy(&pix); delete api;
return {};
}
api->SetImage(pix);
api->Recognize(nullptr);
int opLineY = -1, opLineH = 0;
bool prevWasNomer = false;
tesseract::ResultIterator *ri = api->GetIterator();
if (ri) {
do {
const char *word = ri->GetUTF8Text(tesseract::RIL_WORD);
if (word) {
const QString w_str = QString::fromUtf8(word);
delete[] word;
if (w_str.contains(QString::fromUtf8("омер")) ||
w_str.contains(QString::fromUtf8("omep"))) {
prevWasNomer = true;
continue;
}
if (prevWasNomer &&
(w_str.contains(QString::fromUtf8("операции")) ||
w_str.contains(QString::fromUtf8("onepaции")))) {
int x1, y1, x2, y2;
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
opLineY = y1;
opLineH = y2 - y1;
break;
}
prevWasNomer = false;
}
} while (ri->Next(tesseract::RIL_WORD));
}
api->End();
delete api;
if (opLineY < 0) { pixDestroy(&pix); return {}; }
const int cropY = qMax(0, opLineY - opLineH / 2);
const int cropH = opLineH * 2;
BOX *box = boxCreate(w / 2, cropY, w / 2, qMin(cropH, h - cropY));
Pix *cropped = pixClipRectangle(pix, box, nullptr);
boxDestroy(&box);
pixDestroy(&pix);
if (!cropped) return {};
auto *api2 = new tesseract::TessBaseAPI();
if (api2->Init(nullptr, "eng")) {
pixDestroy(&cropped); delete api2;
return {};
}
api2->SetVariable("tessedit_char_whitelist", "0123456789");
api2->SetPageSegMode(tesseract::PSM_SINGLE_LINE);
api2->SetImage(cropped);
char *text = api2->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&cropped);
api2->End();
delete api2;
result.remove(QRegularExpression("[^0-9]"));
if (result.length() >= 8) {
qDebug() << "[Dushanbe::OCR] Precise operation ID:" << result;
return result;
}
return {};
}
namespace Dushanbe {
PayByPhoneScript::PayByPhoneScript(
MaterialInfo account,
QString phone,
double amount,
QObject *parent
) : CommonScript(parent),
m_account(std::move(account)),
m_phone(std::move(phone)),
m_amount(amount) {
}
PayByPhoneScript::~PayByPhoneScript() = default;
void PayByPhoneScript::doStart() {
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_account.deviceId;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe::PayByPhone] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(device.adbSerial, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.id;
qWarning() << m_error;
AppLogger::log("dushanbe/PayByPhone", device.id, m_error,
AdbUtils::captureScreenshotBytes(device.adbSerial),
"CREATE_TRANSACTION");
AdbUtils::tryToKillApplication(device.adbSerial, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
}
makePayment(device.adbSerial, app.pinCode, device.screenWidth, device.screenHeight);
emit finishedWithResult(m_error);
}
void PayByPhoneScript::makePayment(
const QString &deviceId,
const QString &pinCode,
const int width,
const int height
) {
// 1. Тапаем "Переводы" в нижней навигации
Node transfersTab = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Переводы"));
if (transfersTab.isEmpty()) {
m_error = "Tab 'Переводы' not found on home screen";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByPhone] Tapping 'Переводы' tab";
AdbUtils::makeTap(deviceId, transfersTab.x(), transfersTab.y());
QThread::msleep(2000);
// 2. Ждём экран переводов — ищем "DC (по номеру телефона)"
bool transfersLoaded = false;
Node phoneTransferBtn;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
phoneTransferBtn = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("DC (по номеру телефона)"), true);
if (!phoneTransferBtn.isEmpty()) {
transfersLoaded = true;
break;
}
qDebug() << "[Dushanbe::PayByPhone] Waiting for transfers screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!transfersLoaded) {
m_error = "Transfers screen not loaded or 'DC (по номеру телефона)' not found";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 3. Тапаем "DC (по номеру телефона)"
qDebug() << "[Dushanbe::PayByPhone] Tapping 'DC (по номеру телефона)'";
AdbUtils::makeTap(deviceId, phoneTransferBtn.x(), phoneTransferBtn.y());
QThread::msleep(2000);
// 4. Ждём экран ввода — ищем EditText с hint "Номер телефона получателя"
bool inputScreenLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node editText = xmlScreenParser.findEditTextByHint(
QString::fromUtf8("Номер телефона получателя"));
if (editText.isEmpty()) {
editText = xmlScreenParser.findFirstEditText();
}
if (!editText.isEmpty()) {
inputScreenLoaded = true;
break;
}
qDebug() << "[Dushanbe::PayByPhone] Waiting for phone input screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!inputScreenLoaded) {
m_error = "Phone input screen not loaded";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 5. Вводим номер телефона
Node phoneEditText = xmlScreenParser.findEditTextByHint(
QString::fromUtf8("Номер телефона получателя"));
if (phoneEditText.isEmpty()) {
phoneEditText = xmlScreenParser.findFirstEditText();
}
AdbUtils::makeTap(deviceId, phoneEditText.x(), phoneEditText.y());
QThread::msleep(500);
// Убираем "+" из номера если есть — вводим только цифры
QString phoneDigits = m_phone;
phoneDigits.remove('+');
AdbUtils::inputText(deviceId, phoneDigits);
QThread::msleep(1000);
// Скрываем клавиатуру чтобы поле суммы стало видимым
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
// 6. Вводим сумму в поле "Сумма"
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node sumEditText = xmlScreenParser.findEditTextByHint(QString::fromUtf8("Сумма"));
if (sumEditText.isEmpty()) {
m_error = "Sum input field not found";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
AdbUtils::makeTap(deviceId, sumEditText.x(), sumEditText.y());
QThread::msleep(500);
const QString amountStr = (m_amount == static_cast<int>(m_amount))
? QString::number(static_cast<int>(m_amount))
: QString::number(m_amount, 'f', 2);
AdbUtils::inputText(deviceId, amountStr);
QThread::msleep(1000);
// 7. Скрываем клавиатуру и нажимаем "Далее"
AdbUtils::goBack(deviceId);
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node daleeBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Далее"));
if (daleeBtn.isEmpty()) {
daleeBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Далее"), false);
}
if (daleeBtn.isEmpty()) {
m_error = "Button 'Далее' not found";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByPhone] Tapping 'Далее'";
AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y());
QThread::msleep(2000);
// 8. После "Далее" кнопка меняется на "Оплатить"
bool oplatitFound = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (oplatitBtn.isEmpty()) {
oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
}
if (!oplatitBtn.isEmpty()) {
qDebug() << "[Dushanbe::PayByPhone] Tapping 'Оплатить'";
AdbUtils::makeTap(deviceId, oplatitBtn.x(), oplatitBtn.y());
oplatitFound = true;
QThread::msleep(3000);
break;
}
qDebug() << "[Dushanbe::PayByPhone] Waiting for 'Оплатить', attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!oplatitFound) {
m_error = "Button 'Оплатить' not found";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
bool confirmed = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe::PayByPhone] PIN code screen, entering PIN...";
inputPinCode(deviceId, pinCode);
QThread::msleep(3000);
continue;
}
Node confirmNode = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Подтверждение платежа"), true);
if (!confirmNode.isEmpty() &&
confirmNode.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
// Кнопка "Подтверждаю" всегда внизу bottom sheet — тапаем по 92% высоты экрана
const int tapY = height * 92 / 100;
qDebug() << "[Dushanbe::PayByPhone] Tapping 'Подтверждаю' at y=" << tapY;
AdbUtils::makeTap(deviceId, confirmNode.x(), tapY);
confirmed = true;
QThread::msleep(4000);
break;
}
qDebug() << "[Dushanbe::PayByPhone] Waiting for confirmation sheet, attempt" << attempt + 1;
QThread::msleep(2000);
}
if (!confirmed) {
m_error = "Confirmation sheet not found";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 10. Ждём результат — "Платеж выполнен" или ошибку
bool success = false;
for (int attempt = 0; attempt < 15; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe::PayByPhone] PIN code screen (retry), entering PIN...";
inputPinCode(deviceId, pinCode);
QThread::msleep(3000);
continue;
}
if (!xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
success = true;
break;
}
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
m_error = "Transfer failed: error screen detected";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByPhone] Waiting for result, attempt" << attempt + 1;
QThread::msleep(2000);
}
if (!success) {
m_error = "Payment result not received within timeout";
qWarning() << "[Dushanbe::PayByPhone]" << m_error;
AppLogger::log("dushanbe/PayByPhone", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByPhone] Payment successful!";
m_error.clear();
// Скриншот чека
const QByteArray receiptImage = AdbUtils::captureScreenshotBytes(deviceId);
// Парсим детали из content-desc "Платеж выполнен"
QString bankTime;
double fee = 0;
Node resultNode = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true);
if (!resultNode.isEmpty()) {
const QStringList lines = resultNode.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) {
const QString line = lines[i].trimmed();
if (line.startsWith(QString::fromUtf8("Комиссия:")) && i + 1 < lines.size()) {
fee = lines[i + 1].trimmed().toDouble();
} else if (line.startsWith(QString::fromUtf8("Дата:")) && i + 1 < lines.size()) {
bankTime = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Время:")) && i + 1 < lines.size()) {
bankTime += " " + lines[i + 1].trimmed();
}
}
qDebug() << "[Dushanbe::PayByPhone] Result: date=" << bankTime << "fee=" << fee;
}
// Переходим в историю для получения ID операции через OCR
Node naGlavnuyuNode = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true);
if (!naGlavnuyuNode.isEmpty()) {
const int tapY = height * 92 / 100;
AdbUtils::makeTap(deviceId, width / 2, tapY);
QThread::msleep(2000);
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node historyTab = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("История"));
if (!historyTab.isEmpty()) {
AdbUtils::makeTap(deviceId, historyTab.x(), historyTab.y());
QThread::msleep(3000);
}
QString operationId;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node txNode = xmlScreenParser.findNodeByContentDesc(phoneDigits, true);
if (txNode.isEmpty()) {
txNode = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Сегодня"), true);
}
if (!txNode.isEmpty()) {
AdbUtils::makeTap(deviceId, txNode.x(), txNode.y());
QThread::msleep(3000);
const QByteArray detailScreenshot = AdbUtils::captureScreenshotBytes(deviceId);
if (!detailScreenshot.isEmpty()) {
operationId = recognizeOperationId(detailScreenshot);
if (!operationId.isEmpty()) {
qDebug() << "[Dushanbe::PayByPhone] Operation ID:" << operationId;
} else {
qWarning() << "[Dushanbe::PayByPhone] Could not extract operation ID";
}
}
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
} else {
qWarning() << "[Dushanbe::PayByPhone] Transaction not found in history";
}
// Сохраняем транзакцию
TransactionInfo tx;
tx.accountId = m_account.id;
tx.amount = -m_amount;
tx.fee = fee;
tx.status = TransactionStatus::Complete;
tx.type = TransactionType::Phone;
tx.phone = m_phone;
tx.bankName = "dushanbe_city_bank";
tx.bankTime = bankTime;
tx.bankTrExternalId = operationId;
tx.timestamp = QDateTime::currentDateTimeUtc();
tx.completeTime = QDateTime::currentDateTimeUtc();
tx.receiptImage = receiptImage;
const int txId = TransactionDAO::insertTransaction(tx);
if (txId != -1) {
qDebug() << "[Dushanbe::PayByPhone] Transaction saved, id:" << txId
<< "operationId:" << operationId;
if (!receiptImage.isEmpty()) {
TransactionDAO::updateReceiptImage(txId, receiptImage);
}
} else {
qWarning() << "[Dushanbe::PayByPhone] Failed to save transaction";
}
}
} // namespace Dushanbe

View File

@ -0,0 +1,36 @@
#pragma once
#include "CommonScript.h"
namespace Dushanbe {
class PayByPhoneScript final : public CommonScript {
Q_OBJECT
public:
PayByPhoneScript(
MaterialInfo account,
QString phone,
double amount,
QObject *parent = nullptr
);
~PayByPhoneScript() override;
protected:
void doStart() override;
private:
const MaterialInfo m_account;
const QString m_phone;
const double m_amount;
QString m_error;
void makePayment(
const QString &deviceId,
const QString &pinCode,
int width,
int height
);
};
} // namespace Dushanbe

View File

@ -467,7 +467,7 @@ ScreenXmlParser::TransactionDetail ScreenXmlParser::parseTransactionDetail() {
Node ScreenXmlParser::findEditTextByHint(const QString &hint) {
for (const Node &node : m_nodes) {
if (node.className == "android.widget.EditText" && node.hint == hint) {
if (node.className == "android.widget.EditText" && node.hint.contains(hint)) {
return node;
}
}

View File

@ -1,7 +1,6 @@
#include "GetProfileInfoScript.h"
#include <QJsonDocument>
#include <QRegularExpression>
#include <QJsonObject>
#include <QMap>
#include <QtGlobal>

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

View File

@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.Button" package="tj.dc.next1" content-desc="Подтверждение платежа&#10;1.00 TJS&#10;Поставщик:&#10;DC (по номеру карты) &#10;Номер/счет:&#10;9762000186263942&#10;Способ оплаты:&#10;9762 **** **** 3258&#10;Сумма зачисления:&#10;1.00 TJS&#10;Комиссия:&#10;0.00 TJS&#10;Сумма списания:&#10;1.00 TJS&#10;Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,1020][1080,2400]" drawing-order="0" hint="" /></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="" /></node></node></node></node></node></node></node></hierarchy>UI hierchary dumped to: /dev/tty

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

View File

@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.ImageView" package="tj.dc.next1" content-desc="Платеж выполнен&#10;Поставщик: &#10;DC (по номеру карты) &#10;Получатель: &#10;9762000186263942&#10;К зачислению: &#10;1&#10;Комиссия: &#10;0.00&#10;Дата: &#10;05.04.26&#10;Время: &#10;20:19:56&#10;На главную" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,558][1080,2400]" drawing-order="0" hint="" /></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="" /></node></node></node></node></node></node></node></hierarchy>UI hierchary dumped to: /dev/tty

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View File

@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.Button" package="tj.dc.next1" content-desc="Подтверждение платежа&#10;1.00 TJS&#10;Поставщик:&#10;DC (по номеру телефона) &#10;Номер/счет:&#10;992111100664&#10;Способ оплаты:&#10;9762 **** **** 3258&#10;Сумма зачисления:&#10;1.00 TJS&#10;Комиссия:&#10;0.00 TJS&#10;Сумма списания:&#10;1.00 TJS&#10;Подтверждаю" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,1020][1080,2400]" drawing-order="0" hint="" /></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="" /></node></node></node></node></node></node></node></hierarchy>UI hierchary dumped to: /dev/tty

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View File

@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="true" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.ImageView" package="tj.dc.next1" content-desc="Платеж выполнен&#10;Поставщик: &#10;DC (по номеру телефона) &#10;Получатель: &#10;992111100664&#10;К зачислению: &#10;1&#10;Комиссия: &#10;0.00&#10;Дата: &#10;05.04.26&#10;Время: &#10;20:36:19&#10;На главную" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,558][1080,2400]" drawing-order="0" hint="" /></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="" /></node></node></node></node></node></node></node></hierarchy>UI hierchary dumped to: /dev/tty

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
[apps]
list=ozon, black, dushanbe
list=ozon, black, dushanbe_city_bank
[common]
currencies=USD, ARS, RUB, TJS
@ -30,9 +30,8 @@ currency=RUB
name=Ozon
phone_code=7
[dushanbe]
[dushanbe_city_bank]
android_package_name=tj.dc.next1
currency=TJS
name=Dushanbe City
phone_code=992

View File

@ -2,6 +2,7 @@
#include <QSqlError>
#include <QSqlQuery>
#include <QSqlRecord>
#include "DatabaseManager.h"
#include "time/DateUtils.h"
@ -23,6 +24,10 @@ EventInfo parseEvent(const QSqlQuery &query) {
event.timestamp = DateUtils::fromUtcString(query.value("timestamp").toString());
event.updateTime = DateUtils::fromUtcString(query.value("update_time").toString());
event.completeTime = DateUtils::fromUtcString(query.value("complete_time").toString());
const int hasScreenshotIdx = query.record().indexOf("has_screenshot");
if (hasScreenshotIdx >= 0) {
event.hasScreenshot = query.value(hasScreenshotIdx).toBool();
}
return event;
}
@ -189,7 +194,8 @@ QList<EventInfo> EventDAO::getEvents(int page, int pageSize) {
QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"(
SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp,
(LENGTH(screenshot) > 0) as has_screenshot
FROM events
ORDER BY id DESC
LIMIT :limit OFFSET :offset
@ -340,3 +346,24 @@ QByteArray EventDAO::getScreenshot(int id) {
if (!query.exec() || !query.next()) return {};
return query.value(0).toByteArray();
}
QList<EventInfo> EventDAO::getRecentEvents(int hours) {
QList<EventInfo> events;
QSqlQuery query(DatabaseManager::instance().database());
query.prepare(R"(
SELECT id, account_id, device_id, external_id, amount, phone, bank_name,
status, type, comment, json, sent_to_server, update_time, complete_time, timestamp
FROM events
WHERE timestamp >= datetime('now', :hours)
ORDER BY id DESC
)");
query.bindValue(":hours", QString("-%1 hours").arg(hours));
if (!query.exec()) {
qWarning() << "Ошибка при getRecentEvents:" << query.lastError().text();
return events;
}
while (query.next()) {
events.append(parseEvent(query));
}
return events;
}

View File

@ -38,4 +38,6 @@ public:
[[nodiscard]] static bool hasPendingEvents();
[[nodiscard]] static QSet<QString> getDevicesWithPendingEvents();
[[nodiscard]] static QList<EventInfo> getRecentEvents(int hours);
};

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -71,6 +71,7 @@ struct EventInfo {
bool sentToServer = false;
QByteArray screenshot;
bool hasScreenshot = false;
QDateTime updateTime;
QDateTime completeTime;

View File

@ -29,6 +29,8 @@
#include "ozon/PayByPhoneScript.h"
#include "dushanbe/LoginAndCheckAccountsScript.h"
#include "dushanbe/GetLastTransactionsScript.h"
#include "dushanbe/PayByCardScript.h"
#include "dushanbe/PayByPhoneScript.h"
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data);
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description);
@ -125,6 +127,9 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
event.json = QJsonDocument(task).toJson(QJsonDocument::Compact);
const QJsonObject body = task["body"].toObject();
if (eventType == EventType::FetchTransactions) {
event.amount = body["amount"].toDouble() / 100.0;
}
if (eventType == EventType::CreateTransaction) {
event.amount = body["amount"].toDouble() / 100.0;
event.phone = body["bank_participator_id"].toString();
@ -169,10 +174,16 @@ static void sendTaskResult(const QString &type, const QString &materialId, const
auto *networkService = new NetworkService;
networkService->moveToThread(thread);
networkService->setPayload(QJsonDocument(obj).toJson());
QObject::connect(thread, &QThread::started, networkService, [networkService, eventId]() {
networkService->postTaskResult();
QObject::connect(thread, &QThread::started, networkService, &NetworkService::postTaskResult);
QObject::connect(networkService, &NetworkService::finishedWithResult, networkService,
[eventId](int result) {
if (eventId != -1) {
if (result == 0) {
EventDAO::markSentToServer(eventId);
} else {
EventDAO::updateEvent(eventId, EventStatus::Error, "Failed to send result to server");
qWarning() << "[EventHandler] Task result not sent, marking event" << eventId << "as error";
}
}
});
QObject::connect(networkService, &NetworkService::finished, thread, &QThread::quit);
@ -306,18 +317,26 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
QJsonObject extraObj;
extraObj["fee"] = static_cast<int>(tx.fee * 100);
txObj["extra"] = extraObj;
if (tx.timestamp.isValid()) {
txObj["created_at"] = tx.timestamp.toTimeZone(QTimeZone("Europe/Moscow")).toString(Qt::ISODateWithMs);
} else if (!tx.bankTime.isEmpty()) {
const QTimeZone bankTz(event.bankName == "dushanbe_city_bank"
? "Asia/Dushanbe" : "Europe/Moscow");
// Приоритет: bankTime (время операции) > timestamp (время сохранения)
// Сервер ожидает UTC ("Z")
bool createdAtSet = false;
if (!tx.bankTime.isEmpty()) {
QDateTime dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy, HH:mm");
if (!dt.isValid()) dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy HH:mm");
if (!dt.isValid()) dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy HH:mm:ss");
if (dt.isValid()) {
dt.setTimeZone(QTimeZone("Europe/Moscow"));
txObj["created_at"] = dt.toString(Qt::ISODateWithMs);
} else {
txObj["created_at"] = QJsonValue(QJsonValue::Null);
dt.setTimeZone(bankTz);
txObj["created_at"] = dt.toUTC().toString(Qt::ISODate);
createdAtSet = true;
}
} else {
}
if (!createdAtSet && tx.timestamp.isValid()) {
txObj["created_at"] = tx.timestamp.toUTC().toString(Qt::ISODate);
createdAtSet = true;
}
if (!createdAtSet) {
txObj["created_at"] = QJsonValue(QJsonValue::Null);
}
arr.append(txObj);
@ -334,7 +353,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event,
obj["transaction_type"] = txType.isEmpty() ? "top-up" : txType;
obj["bank_participator_id"]= event.phone;
obj["status"] = "completed";
obj["created_at"] = QDateTime::currentDateTime().toTimeZone(QTimeZone("Europe/Moscow")).toString(Qt::ISODateWithMs);
obj["created_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
// Берём bank_transaction_id и fee из последней сохранённой транзакции
QList<TransactionInfo> txList = TransactionDAO::getTransactionsWithinHours(event.accountId, 1);
@ -420,7 +439,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
if (event.type == EventType::FetchProfile) {
if (appCode == "ozon") setup(new Ozon::LoginAndCheckAccountsScript(adbSerial, appCode, nullptr, true));
else if (appCode == "black") setup(new Black::LoginAndCheckAccountsScript(adbSerial, appCode));
else if (appCode == "dushanbe") setup(new Dushanbe::LoginAndCheckAccountsScript(adbSerial, appCode));
else if (appCode == "dushanbe_city_bank") setup(new Dushanbe::LoginAndCheckAccountsScript(adbSerial, appCode));
else qWarning() << "[EventHandler] FETCH_PROFILE: unknown appCode:" << appCode;
}
@ -435,7 +454,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
setup(new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, searchAmount, searchTime));
else if (appCode == "black")
setup(new Black::GetLastTransactionsScript(adbSerial, appCode));
else if (appCode == "dushanbe")
else if (appCode == "dushanbe_city_bank")
setup(new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime));
else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode;
}
@ -451,10 +470,12 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn
// Перевод по номеру карты
if (appCode == "ozon") setup(new Ozon::PayByCardScript(account, event.phone, event.amount));
else if (appCode == "black") setup(new Black::PayByCardScript(account, event.phone, event.amount));
else if (appCode == "dushanbe_city_bank") setup(new Dushanbe::PayByCardScript(account, event.phone, event.amount));
else qWarning() << "[EventHandler] CREATE_TRANSACTION bank: unknown appCode:" << appCode;
} else {
// Перевод по номеру телефона (top-up)
if (appCode == "ozon") setup(new Ozon::PayByPhoneScript(account, event.phone, recipientBank, event.amount));
else if (appCode == "dushanbe_city_bank") setup(new Dushanbe::PayByPhoneScript(account, event.phone, event.amount));
else qWarning() << "[EventHandler] CREATE_TRANSACTION: unknown appCode:" << appCode;
}
}

View File

@ -1228,6 +1228,9 @@ void NetworkService::postTaskResult() {
const QJsonValue result = postJson(m_apiBase + m_pathTaskResult, body, true);
if (result.isUndefined()) {
qWarning() << "[NetworkService] postTaskResult failed, body:" << QJsonDocument(body).toJson(QJsonDocument::Compact);
emit finishedWithResult(-1);
} else {
emit finishedWithResult(0);
}
emit finished();
}
@ -1251,8 +1254,13 @@ void NetworkService::postMonitoringLog(const QString &type, const QString &event
multiPart->append(imagePart);
}
ensureValidToken();
const QUrl url(m_apiBase + m_pathMonitoringLog);
QNetworkRequest request(url);
if (!m_accessToken.isEmpty()) {
request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8());
}
QNetworkReply *reply = m_manager->post(request, multiPart);
multiPart->setParent(reply);

BIN
tutorials/image_1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

BIN
tutorials/image_2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@ -1,17 +0,0 @@
# Руководство: Запуск приложения TutorialWindow
Этот файл объясняет, как собрать и запустить ваше Qt6-приложение с окном TutorialWindow.
---
## 1. Требования
- Qt 6 (рекомендуется версия 6.2 или выше)
- C++17-совместимый компилятор (GCC, Clang, MSVC)
- CMake (версия ≥ 3.16) или qmake
---
## 2. Структура проекта
[Текст ссылки](https://example.com)

View File

@ -1,16 +0,0 @@
# Руководство: Запуск приложения TutorialWindow
Этот файл объясняет, как собрать и запустить ваше Qt6-приложение с окном TutorialWindow.
---
## 1. Требования
- Qt 6 (рекомендуется версия 6.2 или выше)
- C++17-совместимый компилятор (GCC, Clang, MSVC)
- CMake (версия ≥ 3.16) или qmake
---
## 2. Структура проекта

View File

@ -1,16 +0,0 @@
# Руководство: Запуск приложения TutorialWindow
Этот файл объясняет, как собрать и запустить ваше Qt6-приложение с окном TutorialWindow.
---
## 1. Требования
- Qt 6 (рекомендуется версия 6.2 или выше)
- C++17-совместимый компилятор (GCC, Clang, MSVC)
- CMake (версия ≥ 3.16) или qmake
---
## 2. Структура проекта

38
tutorials/ui_info.md Normal file
View File

@ -0,0 +1,38 @@
# Описание интерфейса
---
## Главный экран — Устройство
<table>
<tr>
<td width="40%"><img src="image_1.jpg" width="280"/></td>
<td valign="top">
<p><b>1</b> — Название устройства и ID</p>
<p><b>2</b> — Кнопка настроек устройств (там вы увидите какие банки поддерживаются приложением)</p>
<p><b>3</b> — Кнопка удаления устройства (удалит все данные устройства и если подключите снова, создаст новое)</p>
<p><b>4</b> — Статус банковского профиля, красный - выключен, зеленый - включен</p>
<p><b>5</b> — Настройки банковского профиля. Если нет то самый первый раз будет мастер настроеек профиля. </p>
<p><b>6</b> — Имя профиля и номер телефона банковского профиля (если настроен)</p>
<p><b>7</b> — Кнопка включения/выключения банковского профиля</p>
</td>
</tr>
</table>
---
## Настройки банка
<table>
<tr>
<td width="55%"><img src="image_2.jpg" width="450"/></td>
<td valign="top">
<p><b>1</b> — Включить / выключить профиль банка</p>
<p><b>2</b> — Редактировать пин-код</p>
<p><b>3</b> — Проверить пин-код на устройстве</p>
<p><b>4</b> — Включить / выключить счёт</p>
<p><b>5</b> — Обновить профиль (ФИО, телефон, баланс)</p>
<p><b>6</b> — Проверить аккаунты (карты и счета)</p>
</td>
</tr>
</table>

View File

@ -8,11 +8,16 @@
#include <QMenuBar>
#include <QStackedWidget>
#include <QMessageBox>
#include <QThread>
#include "TutorialWindow.h"
#include "bank/BankListWindow.h"
#include "event/EventWindow.h"
#include "log/LogWindow.h"
#include "log/FileLogWindow.h"
#include "dao/GeneralLogDAO.h"
#include "net/NetworkService.h"
#include "db/DeviceInfo.h"
#include "device/DeviceWidget.h"
#include "dao/DeviceDAO.h"
@ -96,13 +101,19 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) {
m_eventWindow->activateWindow();
});
connect(fileLogAction, &QAction::triggered, this, [=]() {
connect(fileLogAction, &QAction::triggered, this, [this]() {
QMessageBox msgBox(this);
msgBox.setWindowTitle("Логи");
msgBox.setText("Отправить логи на сервер?");
auto *yesBtn = msgBox.addButton("Отправить", QMessageBox::YesRole);
msgBox.addButton("Отмена", QMessageBox::NoRole);
msgBox.exec();
if (msgBox.clickedButton() == yesBtn) {
if (!m_fileLogWindow) {
m_fileLogWindow = new FileLogWindow(this);
}
m_fileLogWindow->show();
m_fileLogWindow->raise();
m_fileLogWindow->activateWindow();
m_fileLogWindow->sendLogToTelegram();
}
});
if (SettingsDAO::get("tutorial_shown").isEmpty()) {

View File

@ -9,6 +9,7 @@
#include "TutorialWindow.h"
#include "bank/AccountWindow.h"
#include "db/DeviceInfo.h"
#include "log/FileLogWindow.h"
#include "widget/common/FlowLayout.h"
class MainWindow final : public QMainWindow {
@ -32,7 +33,7 @@ private:
QWidget *m_accountsWindow = nullptr;
QWidget *m_tutorialWindow = nullptr;
QWidget *m_bankListWindow = nullptr;
QWidget *m_fileLogWindow = nullptr;
FileLogWindow *m_fileLogWindow = nullptr;
QWidget *m_eventWindow = nullptr;
void createDevicePage();

View File

@ -76,7 +76,7 @@ void BankSettingsWindow::startCheckPinCode(const QString &appCode) {
connect(worker, &Black::LoginAndCheckAccountsScript::finished, thread, &QThread::quit);
connect(worker, &Black::LoginAndCheckAccountsScript::finished, worker, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QObject::deleteLater);
} else if (appCode == "dushanbe") {
} else if (appCode == "dushanbe_city_bank") {
auto *worker = new Dushanbe::LoginAndCheckAccountsScript(m_deviceId, appCode, nullptr);
worker->moveToThread(thread);
connect(thread, &QThread::started, worker, &Dushanbe::LoginAndCheckAccountsScript::start);

View File

@ -398,7 +398,7 @@ void BankSetupWizard::runPinCheck() {
}, Qt::QueuedConnection);
connect(worker, &Black::LoginAndCheckAccountsScript::finished, worker, &QObject::deleteLater);
connect(worker, &Black::LoginAndCheckAccountsScript::finished, m_workerThread, &QThread::quit);
} else if (appCode == "dushanbe") {
} else if (appCode == "dushanbe_city_bank") {
auto *worker = new Dushanbe::LoginAndCheckAccountsScript(deviceId, appCode, nullptr, true);
worker->moveToThread(m_workerThread);
connect(m_workerThread, &QThread::started, worker, &Dushanbe::LoginAndCheckAccountsScript::start);
@ -459,7 +459,7 @@ void BankSetupWizard::runProfileCheck() {
connect(worker, &Black::GetProfileInfoScript::finishedWithResult, this, onProfileResult, Qt::QueuedConnection);
connect(worker, &Black::GetProfileInfoScript::finished, worker, &QObject::deleteLater);
connect(worker, &Black::GetProfileInfoScript::finished, m_workerThread, &QThread::quit);
} else if (appCode == "dushanbe") {
} else if (appCode == "dushanbe_city_bank") {
auto *worker = new Dushanbe::GetProfileInfoScript(deviceId, appCode, nullptr);
worker->moveToThread(m_workerThread);
connect(m_workerThread, &QThread::started, worker, &Dushanbe::GetProfileInfoScript::start);
@ -514,7 +514,7 @@ void BankSetupWizard::runCardCheck() {
connect(worker, &Black::GetCardInfoScript::finishedWithResult, this, onCardResult, Qt::QueuedConnection);
connect(worker, &Black::GetCardInfoScript::finished, worker, &QObject::deleteLater);
connect(worker, &Black::GetCardInfoScript::finished, m_workerThread, &QThread::quit);
} else if (appCode == "dushanbe") {
} else if (appCode == "dushanbe_city_bank") {
auto *worker = new Dushanbe::GetCardInfoScript(deviceId, appCode, nullptr);
worker->moveToThread(m_workerThread);
connect(m_workerThread, &QThread::started, worker, &Dushanbe::GetCardInfoScript::start);

View File

@ -147,7 +147,7 @@ void DeviceSettingsWindow::reloadContent(QTableWidget *tableWidget) {
connect(settingsBtn, &QPushButton::clicked, this, [this, app, tableWidget]() {
const BankProfileInfo dbApp = BankProfileDAO::getApplication(m_deviceId, app.code);
if (dbApp.id < 0 || dbApp.bankProfileId.isEmpty()) {
if (dbApp.id < 0 || !dbApp.pinCodeChecked) {
auto *wizard = new BankSetupWizard(this, m_deviceId, dbApp.id >= 0 ? dbApp : app);
wizard->setAttribute(Qt::WA_DeleteOnClose);
connect(wizard, &QDialog::finished, this, [this, tableWidget]() {

View File

@ -239,7 +239,7 @@ void DeviceWidget::setOpenBanksSettings(QPushButton *button, const BankProfileIn
QString deviceName = m_device.name;
connect(button, &QPushButton::clicked, this, [deviceId, deviceName, app]() {
const BankProfileInfo dbApp = BankProfileDAO::getApplication(deviceId, app.code);
if (dbApp.id < 0 || dbApp.bankProfileId.isEmpty()) {
if (dbApp.id < 0 || !dbApp.pinCodeChecked) {
auto *wizard = new BankSetupWizard(nullptr, deviceId, dbApp.id >= 0 ? dbApp : app);
wizard->setAttribute(Qt::WA_DeleteOnClose);
wizard->show();

View File

@ -1,15 +1,23 @@
#include "EventWindow.h"
#include <QDialog>
#include <QDir>
#include <QFile>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QHttpMultiPart>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMessageBox>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QPlainTextEdit>
#include <QProcess>
#include <QThread>
#include <QVBoxLayout>
#include "dao/EventDAO.h"
#include "dao/SettingsDAO.h"
#include "net/NetworkService.h"
static constexpr int COL_ID = 0;
@ -22,7 +30,8 @@ static constexpr int COL_AMOUNT = 6;
static constexpr int COL_COMMENT = 7;
static constexpr int COL_JSON = 8;
static constexpr int COL_SENT = 9;
static constexpr int COL_ACTION = 10;
static constexpr int COL_IMG = 10;
static constexpr int COL_ACTION = 11;
static void sendErrorToServer(const QString &type, const QString &materialId, const QString &bankName) {
QJsonObject errData;
@ -71,15 +80,15 @@ EventWindow::EventWindow(QWidget *parent) : QDialog(parent) {
// ── Таблица ───────────────────────────────────────────────────────────────
m_table = new QTableWidget(this);
m_table->setColumnCount(11);
m_table->setColumnCount(12);
m_table->setHorizontalHeaderLabels({
"#", "Время", "Тип", "Банк", "Устройство", "Статус", "Сумма", "Комментарий", "JSON", "Отпр.", ""
"#", "Время", "Тип", "Банк", "Устройство", "Статус", "Сумма", "Комментарий", "JSON", "Отпр.", "Скрин", ""
});
m_table->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_table->setSelectionBehavior(QAbstractItemView::SelectRows);
m_table->setSelectionMode(QAbstractItemView::SingleSelection);
m_table->verticalHeader()->hide();
m_table->horizontalHeader()->setStretchLastSection(true);
m_table->horizontalHeader()->setStretchLastSection(false);
m_table->setColumnWidth(COL_ID, 45);
m_table->setColumnWidth(COL_TIME, 145);
m_table->setColumnWidth(COL_TYPE, 100);
@ -87,9 +96,11 @@ EventWindow::EventWindow(QWidget *parent) : QDialog(parent) {
m_table->setColumnWidth(COL_DEVICE, 120);
m_table->setColumnWidth(COL_STATUS, 100);
m_table->setColumnWidth(COL_AMOUNT, 80);
m_table->setColumnWidth(COL_JSON, 200);
m_table->setColumnHidden(COL_JSON, true);
m_table->setColumnHidden(COL_ACTION, true);
m_table->setColumnWidth(COL_SENT, 45);
m_table->setColumnWidth(COL_ACTION, 80);
m_table->setColumnWidth(COL_IMG, 45);
m_table->horizontalHeader()->setSectionResizeMode(COL_COMMENT, QHeaderView::Stretch);
// ── Панель скриншота ──────────────────────────────────────────────────────
m_screenshotLabel = new QLabel(this);
@ -112,6 +123,8 @@ EventWindow::EventWindow(QWidget *parent) : QDialog(parent) {
m_pageLabel->setMinimumWidth(140);
auto *refreshBtn = new QPushButton("Обновить", this);
m_sendBtn = new QPushButton("Отправить", this);
m_sendBtn->setStyleSheet("background-color: #0088cc; color: white; border-radius: 4px; padding: 4px 12px;");
auto *paginationLayout = new QHBoxLayout;
paginationLayout->addWidget(m_prevBtn);
@ -119,6 +132,7 @@ EventWindow::EventWindow(QWidget *parent) : QDialog(parent) {
paginationLayout->addWidget(m_nextBtn);
paginationLayout->addStretch();
paginationLayout->addWidget(refreshBtn);
paginationLayout->addWidget(m_sendBtn);
auto *mainLayout = new QVBoxLayout(this);
mainLayout->addWidget(splitter, 1);
@ -140,6 +154,8 @@ EventWindow::EventWindow(QWidget *parent) : QDialog(parent) {
showScreenshot(m_eventIds[row]);
}
});
connect(m_sendBtn, &QPushButton::clicked, this, &EventWindow::sendEventsToCsv);
connect(m_table, &QTableWidget::cellDoubleClicked, this, [this](int row, int col) {
if (col != COL_JSON) return;
auto *item = m_table->item(row, col);
@ -190,6 +206,7 @@ void EventWindow::loadPage() {
m_table->setItem(i, COL_JSON, jsonItem);
m_table->setItem(i, COL_SENT, new QTableWidgetItem(e.sentToServer ? "+" : ""));
m_table->setItem(i, COL_IMG, new QTableWidgetItem(e.hasScreenshot ? "+" : ""));
// Подсветка строки по статусу
QColor bg;
@ -247,3 +264,138 @@ void EventWindow::updatePagination() {
m_prevBtn->setEnabled(m_page > 0);
m_nextBtn->setEnabled(m_page < m_totalPages - 1);
}
void EventWindow::sendEventsToCsv() {
const QList<EventInfo> events = EventDAO::getRecentEvents(48);
if (events.isEmpty()) {
QMessageBox::information(this, "События", "Нет событий за последние 48 часов.");
return;
}
m_sendBtn->setEnabled(false);
m_sendBtn->setText("Отправка...");
// Формируем CSV
QString csv;
csv += "id;time;type;bank;device;status;amount;phone;comment;sent\n";
for (const auto &e : events) {
csv += QString("%1;%2;%3;%4;%5;%6;%7;%8;%9;%10\n")
.arg(e.id)
.arg(e.timestamp.toLocalTime().toString("dd.MM.yyyy HH:mm:ss"))
.arg(eventTypeToString(e.type))
.arg(e.bankName)
.arg(e.deviceId)
.arg(eventStatusToString(e.status))
.arg(e.amount, 0, 'f', 2)
.arg(e.phone)
.arg(QString(e.comment).replace(';', ','))
.arg(e.sentToServer ? "yes" : "no");
}
// Собираем временную папку
const QString tmpDir = QDir::tempPath() + "/arc_events_export";
QDir().mkpath(tmpDir);
QDir().mkpath(tmpDir + "/screenshots");
// Сохраняем CSV
{
QFile csvFile(tmpDir + "/events.csv");
if (csvFile.open(QIODevice::WriteOnly | QIODevice::Text)) {
csvFile.write(csv.toUtf8());
csvFile.close();
}
}
// Сохраняем скриншоты
int screenshotCount = 0;
for (const auto &e : events) {
const QByteArray screenshot = EventDAO::getScreenshot(e.id);
if (screenshot.isEmpty()) continue;
const QString fileName = QString("screenshots/%1_%2_%3.png")
.arg(e.id)
.arg(eventTypeToString(e.type))
.arg(e.timestamp.toString("yyyyMMdd_HHmmss"));
QFile imgFile(tmpDir + "/" + fileName);
if (imgFile.open(QIODevice::WriteOnly)) {
imgFile.write(screenshot);
imgFile.close();
++screenshotCount;
}
}
// Создаём ZIP
const QString zipPath = QDir::tempPath() + "/arc_events.zip";
QFile::remove(zipPath);
QProcess zip;
zip.setWorkingDirectory(tmpDir);
zip.start("zip", QStringList() << "-r" << zipPath << ".");
zip.waitForFinished(30000);
QDir(tmpDir).removeRecursively();
if (zip.exitCode() != 0 || !QFile::exists(zipPath)) {
QMessageBox::warning(this, "Ошибка", "Не удалось создать архив");
m_sendBtn->setEnabled(true);
m_sendBtn->setText("Отправить");
return;
}
// Отправляем в Telegram
const QString botToken = "8256716069:AAFgZRB_0Y6KTpqFQmCxIlZiWdY5m2dR2D8";
const QString chatId = "-5177086220";
const QString desktopId = SettingsDAO::get("desktop_id");
const QString caption = "Events (48h) from desktop: " + desktopId
+ "\nTotal: " + QString::number(events.size())
+ "\nScreenshots: " + QString::number(screenshotCount);
auto *sendFile = new QFile(zipPath);
if (!sendFile->open(QIODevice::ReadOnly)) {
QMessageBox::warning(this, "Ошибка", "Не удалось открыть архив");
delete sendFile;
m_sendBtn->setEnabled(true);
m_sendBtn->setText("Отправить");
return;
}
const QUrl url("https://api.telegram.org/bot" + botToken + "/sendDocument");
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
QHttpPart chatPart;
chatPart.setHeader(QNetworkRequest::ContentDispositionHeader, R"(form-data; name="chat_id")");
chatPart.setBody(chatId.toUtf8());
multiPart->append(chatPart);
QHttpPart captionPart;
captionPart.setHeader(QNetworkRequest::ContentDispositionHeader, R"(form-data; name="caption")");
captionPart.setBody(caption.toUtf8());
multiPart->append(captionPart);
QHttpPart filePart;
filePart.setHeader(QNetworkRequest::ContentTypeHeader, "application/zip");
filePart.setHeader(QNetworkRequest::ContentDispositionHeader,
R"(form-data; name="document"; filename="arc_events.zip")");
filePart.setBodyDevice(sendFile);
sendFile->setParent(multiPart);
multiPart->append(filePart);
auto *manager = new QNetworkAccessManager(this);
QNetworkReply *reply = manager->post(QNetworkRequest(url), multiPart);
multiPart->setParent(reply);
connect(reply, &QNetworkReply::finished, this, [this, reply, manager, zipPath]() {
reply->deleteLater();
manager->deleteLater();
QFile::remove(zipPath);
m_sendBtn->setEnabled(true);
m_sendBtn->setText("Отправить");
if (reply->error() != QNetworkReply::NoError) {
QMessageBox::warning(this, "Ошибка", "Не удалось отправить: " + reply->errorString());
return;
}
QMessageBox::information(this, "Готово", "События отправлены.");
});
}

View File

@ -17,6 +17,7 @@ private:
QLabel *m_screenshotLabel;
QPushButton *m_prevBtn;
QPushButton *m_nextBtn;
QPushButton *m_sendBtn;
int m_page = 0;
int m_pageSize = 50;
@ -28,4 +29,5 @@ private:
void loadPage();
void updatePagination();
void showScreenshot(int eventId);
void sendEventsToCsv();
};

View File

@ -13,6 +13,7 @@ class FileLogWindow final : public QDialog {
public:
explicit FileLogWindow(QWidget *parent = nullptr);
void sendLogToTelegram();
private:
QTableWidget *m_table;
@ -33,5 +34,4 @@ private:
void loadPage();
void updatePagination();
void showScreenshot(int row);
void sendLogToTelegram();
};

View File

@ -2,7 +2,9 @@
#include <QVBoxLayout>
#include <QTextBrowser>
#include <QEvent>
#include <QCoreApplication>
#include <QDesktopServices>
#include <QRegularExpression>
#include <QApplication>
CollapsibleSection::CollapsibleSection(
@ -26,7 +28,20 @@ CollapsibleSection::CollapsibleSection(
// Контент без собственного скрола, займёт доступное пространство
m_contentBrowser = new QTextBrowser(this);
m_contentBrowser->setSearchPaths({QCoreApplication::applicationDirPath() + "/tutorials"});
if (markdownContent.contains("<table") || markdownContent.contains("<img")) {
// Контент с HTML — конвертируем markdown-элементы вручную
QString html = markdownContent;
// ## заголовок → <h2>
html.replace(QRegularExpression(R"(^## (.+)$)", QRegularExpression::MultilineOption), R"(<h3>\1</h3>)");
// --- → <hr>
html.replace(QRegularExpression(R"(^---$)", QRegularExpression::MultilineOption), "<hr>");
// **text** → <b>text</b>
html.replace(QRegularExpression(R"(\*\*([^*]+)\*\*)"), R"(<b>\1</b>)");
m_contentBrowser->setHtml(html);
} else {
m_contentBrowser->setMarkdown(markdownContent);
}
m_contentBrowser->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_contentBrowser->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_contentBrowser->setFrameShape(QFrame::NoFrame);