feat: Support transfer files.

This commit is contained in:
jarvis2f 2025-01-21 17:44:28 +08:00
parent 22c9fb638d
commit fb511b4d1a
32 changed files with 2209 additions and 164 deletions

View file

@ -28,6 +28,7 @@ dependencies {
testImplementation 'io.vertx:vertx-junit5:4.5.11'
testImplementation platform('org.junit:junit-bom:5.10.0')
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.mockito:mockito-core:5.15.2'
}
test {
@ -43,7 +44,9 @@ test {
}
}
}
jvmArgs "-Djava.library.path=${System.getenv('TDLIB_PATH')}"
jvmArgs "-Djava.library.path=${System.getenv('TDLIB_PATH')}",
"-XX:+EnableDynamicAgentLoading",
"-javaagent:${configurations.testRuntimeClasspath.find { it.name.contains('mockito-core') }}"
environment "APP_ROOT", "${testClassesDirs.asPath}"
useJUnitPlatform()
finalizedBy jacocoTestReport

View file

@ -16,7 +16,10 @@ import telegram.files.repository.FileRecord;
import telegram.files.repository.SettingAutoRecords;
import telegram.files.repository.SettingKey;
import java.util.*;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.IntStream;
import java.util.stream.Stream;
@ -40,10 +43,17 @@ public class AutoDownloadVerticle extends AbstractVerticle {
// telegramId -> messages
private final Map<Long, LinkedList<TdApi.Message>> waitingDownloadMessages = new ConcurrentHashMap<>();
private final SettingAutoRecords autoRecords = new SettingAutoRecords();
private final SettingAutoRecords autoRecords;
private int limit = DEFAULT_LIMIT;
public AutoDownloadVerticle(AutoRecordsHolder autoRecordsHolder) {
this.autoRecords = autoRecordsHolder.autoRecords();
autoRecordsHolder.registerOnRemoveListener(removedItems -> removedItems.forEach(item ->
waitingDownloadMessages.getOrDefault(item.telegramId, new LinkedList<>())
.removeIf(m -> m.chatId == item.chatId)));
}
@Override
public void start(Promise<Void> startPromise) {
initAutoDownload()
@ -77,37 +87,17 @@ public class AutoDownloadVerticle extends AbstractVerticle {
}
private Future<Void> initAutoDownload() {
return Future.all(
DataVerticle.settingRepository.<Integer>getByKey(SettingKey.autoDownloadLimit)
.onSuccess(limit -> {
if (limit != null) {
this.limit = limit;
}
})
.onFailure(e -> log.error("Get Auto download limit failed!", e)),
DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload)
.onSuccess(settingAutoRecords -> {
if (settingAutoRecords == null) {
return;
}
settingAutoRecords.items.forEach(item -> HttpVerticle.getTelegramVerticle(item.telegramId)
.ifPresentOrElse(telegramVerticle -> {
if (telegramVerticle.authorized) {
autoRecords.add(item);
} else {
log.warn("Init auto download fail. Telegram verticle not authorized: %s".formatted(item.telegramId));
}
}, () -> log.warn("Init auto download fail. Telegram verticle not found: %s".formatted(item.telegramId))));
})
.onFailure(e -> log.error("Init Auto download failed!", e))
).mapEmpty();
return DataVerticle.settingRepository.<Integer>getByKey(SettingKey.autoDownloadLimit)
.onSuccess(limit -> {
if (limit != null) {
this.limit = limit;
}
})
.onFailure(e -> log.error("Get Auto download limit failed!", e))
.mapEmpty();
}
private Future<Void> initEventConsumer() {
vertx.eventBus().consumer(EventEnum.AUTO_DOWNLOAD_UPDATE.address(), message -> {
log.debug("Auto download update: %s".formatted(message.body()));
this.onAutoRecordsUpdate(Json.decodeValue(message.body().toString(), SettingAutoRecords.class));
});
vertx.eventBus().consumer(EventEnum.SETTING_UPDATE.address(SettingKey.autoDownloadLimit.name()), message -> {
log.debug("Auto download limit update: %s".formatted(message.body()));
this.limit = Convert.toInt(message.body(), DEFAULT_LIMIT);
@ -250,7 +240,7 @@ public class AutoDownloadVerticle extends AbstractVerticle {
.toList();
downloadMessages.forEach(message -> {
Integer fileId = TdApiHelp.getFileId(message);
log.debug("Start process file: %s".formatted(fileId));
log.debug("Start download file: %s".formatted(fileId));
telegramVerticle.startDownload(message.chatId, message.id, fileId)
.onSuccess(v -> log.info("Start download file success! ChatId: %d MessageId:%d FileId:%d"
.formatted(message.chatId, message.id, fileId))
@ -266,37 +256,6 @@ public class AutoDownloadVerticle extends AbstractVerticle {
.orElseThrow(() -> new IllegalArgumentException("Telegram verticle not found: %s".formatted(telegramId)));
}
private void onAutoRecordsUpdate(SettingAutoRecords records) {
for (SettingAutoRecords.Item item : records.items) {
if (!autoRecords.exists(item.telegramId, item.chatId)) {
// new enabled
HttpVerticle.getTelegramVerticle(item.telegramId)
.ifPresentOrElse(telegramVerticle -> {
if (telegramVerticle.authorized) {
autoRecords.add(item);
log.info("Add auto download success: %s".formatted(item.uniqueKey()));
} else {
log.warn("Add auto download fail. Telegram verticle not authorized: %s".formatted(item.telegramId));
}
}, () -> log.warn("Add auto download fail. Telegram verticle not found: %s".formatted(item.telegramId)));
}
}
// remove disabled
List<SettingAutoRecords.Item> removedItems = new ArrayList<>();
autoRecords.items.removeIf(item -> {
if (records.exists(item.telegramId, item.chatId)) {
return false;
}
removedItems.add(item);
log.info("Remove auto download success: %s".formatted(item.uniqueKey()));
return true;
});
removedItems.forEach(item ->
waitingDownloadMessages.getOrDefault(item.telegramId, new LinkedList<>())
.removeIf(message -> message.chatId == item.chatId)
);
}
private void onNewMessage(JsonObject jsonObject) {
long telegramId = jsonObject.getLong("telegramId");
long chatId = jsonObject.getLong("chatId");

View file

@ -0,0 +1,80 @@
package telegram.files;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
import io.vertx.core.Future;
import telegram.files.repository.SettingAutoRecords;
import telegram.files.repository.SettingKey;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
public class AutoRecordsHolder {
private final Log log = LogFactory.get();
private final SettingAutoRecords autoRecords = new SettingAutoRecords();
private final List<Consumer<List<SettingAutoRecords.Item>>> onRemoveListeners = new ArrayList<>();
public AutoRecordsHolder() {
}
public SettingAutoRecords autoRecords() {
return autoRecords;
}
public void registerOnRemoveListener(Consumer<List<SettingAutoRecords.Item>> onRemove) {
onRemoveListeners.add(onRemove);
}
public Future<Void> init() {
return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload)
.onSuccess(settingAutoRecords -> {
if (settingAutoRecords == null) {
return;
}
settingAutoRecords.items.forEach(item -> HttpVerticle.getTelegramVerticle(item.telegramId)
.ifPresentOrElse(telegramVerticle -> {
if (telegramVerticle.authorized) {
autoRecords.add(item);
} else {
log.warn("Init auto records fail. Telegram verticle not authorized: %s".formatted(item.telegramId));
}
}, () -> log.warn("Init auto records fail. Telegram verticle not found: %s".formatted(item.telegramId))));
})
.onFailure(e -> log.error("Init auto records failed!", e))
.mapEmpty();
}
public void onAutoRecordsUpdate(SettingAutoRecords records) {
for (SettingAutoRecords.Item item : records.items) {
if (!autoRecords.exists(item.telegramId, item.chatId)) {
// new enabled
HttpVerticle.getTelegramVerticle(item.telegramId)
.ifPresentOrElse(telegramVerticle -> {
if (telegramVerticle.authorized) {
autoRecords.add(item);
log.info("Add auto records success: %s".formatted(item.uniqueKey()));
} else {
log.warn("Add auto records fail. Telegram verticle not authorized: %s".formatted(item.telegramId));
}
}, () -> log.warn("Add auto records fail. Telegram verticle not found: %s".formatted(item.telegramId)));
}
}
// remove disabled
List<SettingAutoRecords.Item> removedItems = new ArrayList<>();
autoRecords.items.removeIf(item -> {
if (records.exists(item.telegramId, item.chatId)) {
return false;
}
removedItems.add(item);
log.info("Remove auto records success: %s".formatted(item.uniqueKey()));
return true;
});
if (CollUtil.isNotEmpty(removedItems)) {
onRemoveListeners.forEach(listener -> listener.accept(removedItems));
}
}
}

View file

@ -26,6 +26,7 @@ import io.vertx.ext.web.handler.SessionHandler;
import io.vertx.ext.web.sstore.LocalSessionStore;
import io.vertx.ext.web.sstore.SessionStore;
import org.drinkless.tdlib.TdApi;
import telegram.files.repository.SettingAutoRecords;
import telegram.files.repository.SettingKey;
import telegram.files.repository.SettingRecord;
import telegram.files.repository.TelegramRecord;
@ -47,7 +48,7 @@ public class HttpVerticle extends AbstractVerticle {
// session id -> telegram verticle
private final Map<String, TelegramVerticle> sessionTelegramVerticles = new ConcurrentHashMap<>();
private AutoDownloadVerticle autoDownloadVerticle;
private final AutoRecordsHolder autoRecordsHolder = new AutoRecordsHolder();
private static final String SESSION_COOKIE_NAME = "tf";
@ -55,7 +56,9 @@ public class HttpVerticle extends AbstractVerticle {
public void start(Promise<Void> startPromise) {
initHttpServer()
.compose(r -> initTelegramVerticles())
.compose(r -> autoRecordsHolder.init())
.compose(r -> initAutoDownloadVerticle())
.compose(r -> initTransferVerticle())
.compose(r -> initEventConsumer())
.onSuccess(startPromise::complete)
.onFailure(startPromise::fail);
@ -212,13 +215,20 @@ public class HttpVerticle extends AbstractVerticle {
}
public Future<Void> initAutoDownloadVerticle() {
autoDownloadVerticle = new AutoDownloadVerticle();
return vertx.deployVerticle(autoDownloadVerticle, new DeploymentOptions()
return vertx.deployVerticle(new AutoDownloadVerticle(autoRecordsHolder), new DeploymentOptions()
.setThreadingModel(ThreadingModel.VIRTUAL_THREAD)
)
.mapEmpty();
}
public Future<Void> initTransferVerticle() {
return vertx.deployVerticle(new TransferVerticle(autoRecordsHolder), new DeploymentOptions()
.setThreadingModel(ThreadingModel.VIRTUAL_THREAD)
)
.mapEmpty();
}
private Future<Void> initEventConsumer() {
vertx.eventBus().consumer(EventEnum.TELEGRAM_EVENT.address(), message -> {
log.debug("Received telegram event: %s".formatted(message.body()));
@ -237,6 +247,10 @@ public class HttpVerticle extends AbstractVerticle {
});
});
vertx.eventBus().consumer(EventEnum.AUTO_DOWNLOAD_UPDATE.address(), message -> {
log.debug("Auto download update: %s".formatted(message.body()));
autoRecordsHolder.onAutoRecordsUpdate(Json.decodeValue(message.body().toString(), SettingAutoRecords.class));
});
return Future.succeededFuture();
}

View file

@ -1,6 +1,12 @@
package telegram.files;
import java.io.File;
import java.io.FileInputStream;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.util.concurrent.CompletableFuture;
public class MessyUtils {
@ -10,4 +16,49 @@ public class MessyUtils {
int newMinute = minuteGroup * 5;
return time.withMinute(newMinute).withSecond(0).withNano(0);
}
public static String calculateFileMD5(File file) {
try (FileInputStream fis = new FileInputStream(file);
FileChannel channel = fis.getChannel()) {
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, file.length());
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(buffer);
byte[] md5Bytes = md.digest();
StringBuilder hexString = new StringBuilder();
for (byte b : md5Bytes) {
hexString.append(String.format("%02x", b));
}
return hexString.toString();
} catch (Exception e) {
return null;
}
}
public static boolean compareFilesMD5(File file1, File file2) {
CompletableFuture<String> md5Task1 = CompletableFuture.supplyAsync(() -> {
try {
return calculateFileMD5(file1);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
CompletableFuture<String> md5Task2 = CompletableFuture.supplyAsync(() -> {
try {
return calculateFileMD5(file2);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
String md5File1 = md5Task1.join();
String md5File2 = md5Task2.join();
return md5File1.equals(md5File2);
}
}

View file

@ -9,7 +9,7 @@ import java.util.concurrent.CountDownLatch;
public class Start {
private static final Log log = LogFactory.get();
public static final String VERSION = "0.1.11";
public static final String VERSION = "0.1.12";
private static final CountDownLatch shutdownLatch = new CountDownLatch(1);

View file

@ -8,13 +8,11 @@ import cn.hutool.core.convert.Convert;
import cn.hutool.core.convert.TypeConverter;
import cn.hutool.core.util.ClassUtil;
import cn.hutool.core.util.ReflectUtil;
import io.vertx.core.Future;
import io.vertx.core.impl.NoStackTraceException;
import org.drinkless.tdlib.TdApi;
import org.jooq.lambda.tuple.Tuple;
import org.jooq.lambda.tuple.Tuple1;
import telegram.files.repository.FileRecord;
import telegram.files.repository.SettingKey;
import java.util.*;
@ -283,6 +281,7 @@ public class TdApiHelp {
content.caption.text,
null,
"idle",
"idle",
System.currentTimeMillis(),
null
);
@ -343,6 +342,7 @@ public class TdApiHelp {
content.caption.text,
null,
"idle",
"idle",
System.currentTimeMillis(),
null
);
@ -390,6 +390,7 @@ public class TdApiHelp {
content.caption.text,
null,
"idle",
"idle",
System.currentTimeMillis(),
null
);
@ -437,6 +438,7 @@ public class TdApiHelp {
content.caption.text,
null,
"idle",
"idle",
System.currentTimeMillis(),
null
);

View file

@ -178,7 +178,9 @@ public class TelegramVerticle extends AbstractVerticle {
public Future<JsonObject> getChatFiles(long chatId, MultiMap filter) {
String status = filter.get("status");
if (Arrays.asList("downloading", "paused", "completed", "error").contains(status)) {
return DataVerticle.fileRepository.getFiles(chatId, filter)
Map<String, String> filterMap = new HashMap<>();
filter.forEach(filterMap::put);
return DataVerticle.fileRepository.getFiles(chatId, filterMap)
.compose(r -> {
long[] messageIds = r.v1.stream().mapToLong(FileRecord::messageId).toArray();
return client.execute(new TdApi.GetMessages(chatId, messageIds))
@ -351,7 +353,7 @@ public class TelegramVerticle extends AbstractVerticle {
TdApi.Message message = results.resultAt(1);
if (file.local != null) {
if (file.local.isDownloadingCompleted) {
return DataVerticle.fileRepository.updateStatus(
return DataVerticle.fileRepository.updateDownloadStatus(
file.id,
file.remote.uniqueId,
file.local.path,
@ -373,8 +375,9 @@ public class TelegramVerticle extends AbstractVerticle {
client.execute(new TdApi.AddFileToDownloads(fileId, chatId, messageId, 32))
)
.onSuccess(r ->
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
sendEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
.put("fileId", fileId)
.put("uniqueId", fileRecord.uniqueId())
.put("downloadStatus", FileRecord.DownloadStatus.downloading)
))
);
@ -396,10 +399,11 @@ public class TelegramVerticle extends AbstractVerticle {
.map(file);
})
.compose(file -> client.execute(new TdApi.DeleteFile(fileId)).map(file))
.compose(file -> DataVerticle.fileRepository.deleteByUniqueId(file.remote.uniqueId))
.onSuccess(r ->
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
.compose(file -> DataVerticle.fileRepository.deleteByUniqueId(file.remote.uniqueId).map(file))
.onSuccess(file ->
sendEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
.put("fileId", fileId)
.put("uniqueId", file.remote.uniqueId)
.put("downloadStatus", FileRecord.DownloadStatus.idle)
)))
.mapEmpty();
@ -416,7 +420,7 @@ public class TelegramVerticle extends AbstractVerticle {
return Future.failedFuture("File not started downloading");
}
if (file.local.isDownloadingCompleted) {
return DataVerticle.fileRepository.updateStatus(
return DataVerticle.fileRepository.updateDownloadStatus(
file.id,
file.remote.uniqueId,
file.local.path,
@ -455,7 +459,7 @@ public class TelegramVerticle extends AbstractVerticle {
SettingAutoRecords.Rule rule = null;
if (params != null && params.containsKey("rule")) {
rule = params.getJsonObject("rule").mapTo(SettingAutoRecords.Rule.class);
if (StrUtil.isBlank(rule.query) && CollUtil.isEmpty(rule.fileTypes)) {
if (StrUtil.isBlank(rule.query) && CollUtil.isEmpty(rule.fileTypes) && rule.transferRule == null) {
rule = null;
}
}
@ -619,15 +623,16 @@ public class TelegramVerticle extends AbstractVerticle {
});
}
private void sendHttpEvent(EventPayload payload) {
vertx.eventBus().send(EventEnum.TELEGRAM_EVENT.address(),
private void sendEvent(EventPayload payload) {
vertx.eventBus().publish(EventEnum.TELEGRAM_EVENT.address(),
JsonObject.of("telegramId", this.getId(), "payload", JsonObject.mapFrom(payload)));
}
private void sendFileStatusHttpEvent(TdApi.File file, JsonObject fileUpdated) {
if (fileUpdated == null || fileUpdated.isEmpty()) return;
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
sendEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
.put("fileId", file.id)
.put("uniqueId", file.remote.uniqueId)
.put("downloadStatus", fileUpdated.getString("downloadStatus"))
.put("localPath", fileUpdated.getString("localPath"))
.put("completionDate", fileUpdated.getLong("completionDate"))
@ -638,7 +643,7 @@ public class TelegramVerticle extends AbstractVerticle {
private void handleAuthorizationResult(TdApi.Object object) {
switch (object.getConstructor()) {
case TdApi.Error.CONSTRUCTOR:
sendHttpEvent(EventPayload.build(EventPayload.TYPE_ERROR, object));
sendEvent(EventPayload.build(EventPayload.TYPE_ERROR, object));
break;
case TdApi.Ok.CONSTRUCTOR:
break;
@ -649,9 +654,9 @@ public class TelegramVerticle extends AbstractVerticle {
private void handleDefaultResult(TdApi.Object object, String code) {
if (object.getConstructor() == TdApi.Error.CONSTRUCTOR) {
sendHttpEvent(EventPayload.build(EventPayload.TYPE_ERROR, code, object));
sendEvent(EventPayload.build(EventPayload.TYPE_ERROR, code, object));
} else {
sendHttpEvent(EventPayload.build(EventPayload.TYPE_METHOD_RESULT, code, object));
sendEvent(EventPayload.build(EventPayload.TYPE_METHOD_RESULT, code, object));
}
}
@ -734,7 +739,7 @@ public class TelegramVerticle extends AbstractVerticle {
case TdApi.AuthorizationStateWaitCode.CONSTRUCTOR:
case TdApi.AuthorizationStateWaitRegistration.CONSTRUCTOR:
case TdApi.AuthorizationStateWaitPassword.CONSTRUCTOR:
sendHttpEvent(EventPayload.build(EventPayload.TYPE_AUTHORIZATION, authorizationState));
sendEvent(EventPayload.build(EventPayload.TYPE_AUTHORIZATION, authorizationState));
break;
case TdApi.AuthorizationStateReady.CONSTRUCTOR:
authorized = true;
@ -751,7 +756,7 @@ public class TelegramVerticle extends AbstractVerticle {
} else {
log.info("[%s] %s Authorization Ready".formatted(getRootId(), this.telegramRecord.firstName()));
}
sendHttpEvent(EventPayload.build(EventPayload.TYPE_AUTHORIZATION, authorizationState));
sendEvent(EventPayload.build(EventPayload.TYPE_AUTHORIZATION, authorizationState));
telegramChats.loadMainChatList();
telegramChats.loadArchivedChatList();
break;
@ -784,16 +789,17 @@ public class TelegramVerticle extends AbstractVerticle {
completionDate = System.currentTimeMillis();
}
FileRecord.DownloadStatus downloadStatus = TdApiHelp.getDownloadStatus(file);
DataVerticle.fileRepository.updateStatus(file.id,
DataVerticle.fileRepository.updateDownloadStatus(file.id,
file.remote.uniqueId,
localPath,
downloadStatus,
completionDate)
.onSuccess(r -> sendFileStatusHttpEvent(file, r));
}
if (lastFileEventTime == 0 || System.currentTimeMillis() - lastFileEventTime > 1000) {
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE, updateFile));
lastFileEventTime = System.currentTimeMillis();
if (completionDate != null || lastFileEventTime == 0 || System.currentTimeMillis() - lastFileEventTime > 1000) {
sendEvent(EventPayload.build(EventPayload.TYPE_FILE, updateFile));
lastFileEventTime = System.currentTimeMillis();
}
}
}
@ -801,7 +807,7 @@ public class TelegramVerticle extends AbstractVerticle {
log.trace("[%s] Receive file downloads update: %s".formatted(getRootId(), updateFileDownloads));
avgSpeed.update(updateFileDownloads.downloadedSize, System.currentTimeMillis());
if (lastFileDownloadEventTime == 0 || System.currentTimeMillis() - lastFileDownloadEventTime > 1000) {
sendHttpEvent(EventPayload.build(EventPayload.TYPE_FILE_DOWNLOAD, updateFileDownloads));
sendEvent(EventPayload.build(EventPayload.TYPE_FILE_DOWNLOAD, updateFileDownloads));
lastFileDownloadEventTime = System.currentTimeMillis();
}
}

View file

@ -0,0 +1,162 @@
package telegram.files;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.io.FileUtil;
import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
import telegram.files.repository.FileRecord;
import java.nio.file.Path;
import java.util.function.Consumer;
public abstract class Transfer {
private static final Log log = LogFactory.get();
public String destination;
public DuplicationPolicy duplicationPolicy;
public boolean transferHistory;
public Consumer<TransferStatusUpdated> transferStatusUpdated;
private FileRecord transferRecord;
public static Transfer create(TransferPolicy transferPolicy) {
return switch (transferPolicy) {
case GROUP_BY_CHAT -> new GroupByChat();
case GROUP_BY_TYPE -> new GroupByType();
};
}
public void transfer(FileRecord fileRecord) {
log.debug("Start transfer file {}", fileRecord.id());
transferRecord = fileRecord;
transferStatusUpdated.accept(new TransferStatusUpdated(fileRecord, FileRecord.TransferStatus.transferring, null));
try {
String transferPath = getTransferPath(fileRecord);
boolean isOverwrite = false;
if (FileUtil.exist(transferPath)) {
if (duplicationPolicy == DuplicationPolicy.SKIP) {
log.trace("Skip file {}", fileRecord.id());
transferStatusUpdated.accept(new TransferStatusUpdated(fileRecord, FileRecord.TransferStatus.idle, null));
return;
}
if (duplicationPolicy == DuplicationPolicy.OVERWRITE) {
log.trace("Overwrite file {}", fileRecord.id());
isOverwrite = true;
}
if (duplicationPolicy == DuplicationPolicy.RENAME) {
transferPath = getUniquePath(transferPath);
log.trace("Rename file {} to {}", fileRecord.id(), transferPath);
}
if (duplicationPolicy == DuplicationPolicy.HASH) {
if (MessyUtils.compareFilesMD5(FileUtil.file(fileRecord.localPath()), FileUtil.file(transferPath))) {
log.trace("File {} is the same as {}", fileRecord.id(), transferPath);
FileUtil.del(fileRecord.localPath());
transferStatusUpdated.accept(new TransferStatusUpdated(fileRecord, FileRecord.TransferStatus.completed, transferPath));
return;
} else {
transferPath = getUniquePath(transferPath);
log.trace("Rename file {} to {}", fileRecord.id(), transferPath);
}
}
}
FileUtil.move(Path.of(fileRecord.localPath()), Path.of(transferPath), isOverwrite);
transferStatusUpdated.accept(new TransferStatusUpdated(fileRecord, FileRecord.TransferStatus.completed, transferPath));
} catch (Exception e) {
log.error(e, "Transfer file {} error", fileRecord.id());
transferStatusUpdated.accept(new TransferStatusUpdated(fileRecord, FileRecord.TransferStatus.error, null));
} finally {
transferRecord = null;
}
}
private String getUniquePath(String path) {
if (!FileUtil.exist(path)) {
return path;
}
String name = FileUtil.getName(path);
String parent = FileUtil.getParent(path, 1);
String extension = FileUtil.extName(name);
String baseName = FileUtil.mainName(name);
int i = 1;
while (FileUtil.exists(Path.of(parent, "%s-%d.%s".formatted(baseName, i, extension)), false)) {
i++;
}
return Path.of(parent, "%s-%d.%s".formatted(baseName, i, extension)).toString();
}
public FileRecord getTransferRecord() {
return transferRecord;
}
protected abstract String getTransferPath(FileRecord fileRecord);
static class GroupByChat extends Transfer {
@Override
protected String getTransferPath(FileRecord fileRecord) {
String name = FileUtil.getName(fileRecord.localPath());
return Path.of(destination,
Convert.toStr(fileRecord.telegramId()),
Convert.toStr(fileRecord.chatId()),
name
).toString();
}
}
static class GroupByType extends Transfer {
@Override
protected String getTransferPath(FileRecord fileRecord) {
String name = FileUtil.getName(fileRecord.localPath());
return Path.of(destination,
fileRecord.type(),
name
).toString();
}
}
public record TransferStatusUpdated(FileRecord fileRecord,
FileRecord.TransferStatus transferStatus,
String localPath) {
}
public enum TransferPolicy {
/**
* Transfer files by chat id
*/
GROUP_BY_CHAT,
/**
* Transfer files by type
*/
GROUP_BY_TYPE,
;
}
public enum DuplicationPolicy {
/**
* Overwrite the existing file
*/
OVERWRITE,
/**
* Rename the file with a suffix
*/
RENAME,
/**
* Skip the file
*/
SKIP,
/**
* Calculate the hash of the file and compare with the existing file, if the hash is the same,
* delete the original file and set the local path to the existing file, otherwise, move the file
*/
HASH,
;
}
}

View file

@ -0,0 +1,249 @@
package telegram.files;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
import io.vertx.core.AbstractVerticle;
import io.vertx.core.Future;
import io.vertx.core.Promise;
import io.vertx.core.json.JsonObject;
import org.jooq.lambda.tuple.Tuple3;
import telegram.files.repository.FileRecord;
import telegram.files.repository.SettingAutoRecords;
import java.io.File;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
public class TransferVerticle extends AbstractVerticle {
private static final Log log = LogFactory.get();
private static final int HISTORY_SCAN_INTERVAL = 2 * 60 * 1000;
private static final int TRANSFER_INTERVAL = 3 * 1000;
private final SettingAutoRecords autoRecords;
private final Map<String, Transfer> transfers = new HashMap<>();
private final BlockingQueue<WaitingTransferFile> waitingTransferFiles = new LinkedBlockingQueue<>();
private volatile boolean isStopped = false;
private volatile Transfer beingTransferred;
public TransferVerticle(AutoRecordsHolder autoRecordsHolder) {
this.autoRecords = autoRecordsHolder.autoRecords();
autoRecordsHolder.registerOnRemoveListener(removedItems -> removedItems.forEach(item -> {
waitingTransferFiles.removeIf(waitingTransferFile -> waitingTransferFile.uniqueId().equals(item.uniqueKey()));
transfers.remove(item.uniqueKey());
}));
}
@Override
public void start(Promise<Void> startPromise) {
initEventConsumer().onSuccess(v -> {
vertx.setPeriodic(0, HISTORY_SCAN_INTERVAL, id -> addHistoryFiles());
vertx.setPeriodic(0, TRANSFER_INTERVAL, id -> startTransfer());
log.info("Transfer verticle started");
startPromise.complete();
}).onFailure(startPromise::fail);
}
@Override
public void stop(Promise<Void> stopPromise) {
isStopped = true;
if (beingTransferred != null) {
log.info("Wait for transfer to complete, file: %s".formatted(beingTransferred.getTransferRecord().uniqueId()));
while (beingTransferred != null) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
log.error("Stop transfer verticle error: %s".formatted(e.getMessage()));
stopPromise.fail(e);
}
}
}
log.info("Transfer verticle stopped");
stopPromise.complete();
}
private Future<Void> initEventConsumer() {
vertx.eventBus().consumer(EventEnum.TELEGRAM_EVENT.address(), message -> {
JsonObject jsonObject = (JsonObject) message.body();
EventPayload payload = jsonObject.getJsonObject("payload").mapTo(EventPayload.class);
if (payload == null || payload.type() != EventPayload.TYPE_FILE_STATUS) {
return;
}
if (payload.data() != null && payload.data() instanceof Map<?, ?> data && StrUtil.isNotBlank((String) data.get("downloadStatus"))) {
FileRecord.DownloadStatus downloadStatus = FileRecord.DownloadStatus.valueOf((String) data.get("downloadStatus"));
if (downloadStatus != FileRecord.DownloadStatus.completed) {
return;
}
FileRecord fileRecord = Future.await(DataVerticle.fileRepository.getByUniqueId((String) data.get("uniqueId")));
if (getTransfer(autoRecords.getItem(fileRecord.telegramId(), fileRecord.chatId())) == null) {
return;
}
if (addWaitingTransferFile(fileRecord)) {
log.debug("Add file to transfer queue: %s".formatted(fileRecord.uniqueId()));
}
}
});
return Future.succeededFuture();
}
private void addHistoryFiles() {
if (CollUtil.isEmpty(autoRecords.items)) {
return;
}
log.debug("Start scan history files for transfer");
for (SettingAutoRecords.Item item : autoRecords.items) {
Transfer transfer = getTransfer(item);
if (transfer == null || !transfer.transferHistory) {
continue;
}
Tuple3<List<FileRecord>, Long, Long> filesTuple = Future.await(DataVerticle.fileRepository.getFiles(item.chatId,
Map.of("status", FileRecord.DownloadStatus.completed.name(),
"transferStatus", FileRecord.TransferStatus.idle.name()
)
));
List<FileRecord> files = filesTuple.v1;
if (CollUtil.isEmpty(files)) {
continue;
}
int count = 0;
for (FileRecord fileRecord : files) {
if (addWaitingTransferFile(fileRecord)) {
count++;
}
}
if (count > 0) {
log.info("Add history files to transfer queue: %s".formatted(count));
break;
}
}
}
private boolean addWaitingTransferFile(FileRecord fileRecord) {
WaitingTransferFile waitingTransferFile = new WaitingTransferFile(fileRecord.telegramId(),
fileRecord.chatId(),
fileRecord.uniqueId());
if (!waitingTransferFiles.contains(waitingTransferFile)) {
waitingTransferFiles.add(waitingTransferFile);
return true;
}
return false;
}
private Transfer getTransfer(SettingAutoRecords.Item item) {
if (item == null) {
return null;
}
if (transfers.containsKey(item.uniqueKey())) {
return transfers.get(item.uniqueKey());
}
SettingAutoRecords.TransferRule transferRule = BeanUtil.getProperty(item, "rule.transferRule");
if (transferRule == null) {
return null;
}
return transfers.computeIfAbsent(item.uniqueKey(), k -> {
Transfer transfer = Transfer.create(transferRule.transferPolicy);
transfer.destination = transferRule.destination;
transfer.duplicationPolicy = transferRule.duplicationPolicy;
transfer.transferHistory = transferRule.transferHistory;
transfer.transferStatusUpdated = updated ->
updateTransferStatus(updated.fileRecord(), updated.transferStatus(), updated.localPath());
return transfer;
});
}
public void startTransfer() {
if (beingTransferred != null) {
return;
}
try {
WaitingTransferFile waitingTransferFile = waitingTransferFiles.poll(1, TimeUnit.SECONDS);
if (waitingTransferFile == null) {
log.trace("No file to transfer");
return;
}
Transfer transfer = transfers.get("%d:%d".formatted(waitingTransferFile.telegramId(), waitingTransferFile.chatId()));
if (transfer == null) {
return;
}
if (beingTransferred == transfer) {
waitingTransferFiles.add(waitingTransferFile);
log.debug("Transfer is busy: %s".formatted(waitingTransferFile.uniqueId));
return;
}
FileRecord fileRecord = Future.await(DataVerticle.fileRepository.getByUniqueId(waitingTransferFile.uniqueId));
if (fileRecord == null) {
log.error("File not found: %s".formatted(waitingTransferFile.uniqueId));
return;
}
startTransfer(fileRecord, transfer);
} catch (Exception e) {
if (e instanceof InterruptedException) {
log.debug("Transfer loop interrupted");
} else {
log.error(e, "Transfer error");
}
}
}
public void startTransfer(FileRecord fileRecord, Transfer transfer) {
if (isStopped) {
return;
}
if (fileRecord.transferStatus() != null
&& !fileRecord.isTransferStatus(FileRecord.TransferStatus.idle)) {
log.debug("File {} transfer status is not idle: {}", fileRecord.id(), fileRecord.transferStatus());
return;
}
File originFile = new File(fileRecord.localPath());
if (!originFile.exists()) {
log.error("File {} not found: {}", fileRecord.id(), fileRecord.localPath());
return;
}
beingTransferred = transfer;
transfer.transfer(fileRecord);
beingTransferred = null;
}
private void updateTransferStatus(FileRecord fileRecord, FileRecord.TransferStatus transferStatus, String localPath) {
Future.await(DataVerticle.fileRepository.updateTransferStatus(fileRecord.uniqueId(), transferStatus, localPath)
.onSuccess(fileUpdated -> {
if (fileUpdated != null && !fileUpdated.isEmpty()) {
EventPayload payload = EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
.put("fileId", fileRecord.id())
.put("uniqueId", fileRecord.uniqueId())
.put("transferStatus", fileUpdated.getString("transferStatus"))
.put("localPath", fileUpdated.getString("localPath"))
);
vertx.eventBus().publish(EventEnum.TELEGRAM_EVENT.address(),
JsonObject.of("telegramId", fileRecord.telegramId(), "payload", JsonObject.mapFrom(payload))
);
}
}));
}
private record WaitingTransferFile(long telegramId, long chatId, String uniqueId) {
}
}

View file

@ -25,6 +25,7 @@ public record FileRecord(int id, //file id will change
String caption,
String localPath,
String downloadStatus, // 'idle' | 'downloading' | 'paused' | 'completed' | 'error'
String transferStatus, // 'idle' | 'transferring' | 'completed' | 'error'
long startDate, // date when the file was started to download
Long completionDate // date when the file was downloaded
) {
@ -33,6 +34,10 @@ public record FileRecord(int id, //file id will change
idle, downloading, paused, completed, error
}
public enum TransferStatus {
idle, transferring, completed, error
}
public static final String SCHEME = """
CREATE TABLE IF NOT EXISTS file_record
(
@ -52,6 +57,7 @@ public record FileRecord(int id, //file id will change
caption VARCHAR(255),
local_path VARCHAR(255),
download_status VARCHAR(255),
transfer_status VARCHAR(255),
start_date BIGINT,
completion_date BIGINT,
PRIMARY KEY (id, unique_id)
@ -62,6 +68,9 @@ public record FileRecord(int id, //file id will change
MapUtil.entry(new Version("0.1.7"), new String[]{
"ALTER TABLE file_record ADD COLUMN start_date BIGINT;",
"ALTER TABLE file_record ADD COLUMN completion_date BIGINT;",
}),
MapUtil.entry(new Version("0.1.12"), new String[]{
"ALTER TABLE file_record ADD COLUMN transfer_status VARCHAR(255) DEFAULT 'idle';",
})
));
@ -94,6 +103,7 @@ public record FileRecord(int id, //file id will change
row.getString("caption"),
row.getString("local_path"),
row.getString("download_status"),
row.getString("transfer_status"),
Objects.requireNonNullElse(row.getLong("start_date"), 0L),
row.getLong("completion_date")
);
@ -116,12 +126,27 @@ public record FileRecord(int id, //file id will change
MapUtil.entry("caption", r.caption()),
MapUtil.entry("local_path", r.localPath()),
MapUtil.entry("download_status", r.downloadStatus()),
MapUtil.entry("transfer_status", r.transferStatus()),
MapUtil.entry("start_date", r.startDate()),
MapUtil.entry("completion_date", r.completionDate())
));
public FileRecord withSourceField(int id, long downloadedSize) {
return new FileRecord(id, uniqueId, telegramId, chatId, messageId, date, hasSensitiveContent, size, downloadedSize, type, mimeType, fileName, thumbnail, caption, localPath, downloadStatus, startDate, completionDate);
return new FileRecord(id, uniqueId, telegramId, chatId, messageId, date, hasSensitiveContent, size, downloadedSize, type, mimeType, fileName, thumbnail, caption, localPath, downloadStatus, transferStatus, startDate, completionDate);
}
public boolean isDownloadStatus(DownloadStatus status) {
if (status == null && downloadStatus == null) {
return true;
}
return downloadStatus != null && DownloadStatus.valueOf(downloadStatus) == status;
}
public boolean isTransferStatus(TransferStatus status) {
if (status == null && transferStatus == null) {
return true;
}
return transferStatus != null && TransferStatus.valueOf(transferStatus) == status;
}
}

View file

@ -1,7 +1,6 @@
package telegram.files.repository;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import org.jooq.lambda.tuple.Tuple3;
@ -14,7 +13,7 @@ public interface FileRepository {
Future<Map<Integer, FileRecord>> getFiles(long chatId, List<Integer> fileIds);
Future<Tuple3<List<FileRecord>, Long, Long>> getFiles(long chatId, MultiMap filter);
Future<Tuple3<List<FileRecord>, Long, Long>> getFiles(long chatId, Map<String, String> filter);
Future<Map<String, FileRecord>> getFilesByUniqueId(List<String> uniqueIds);
@ -28,11 +27,15 @@ public interface FileRepository {
Future<Integer> countByStatus(long telegramId, FileRecord.DownloadStatus downloadStatus);
Future<JsonObject> updateStatus(int fileId,
String uniqueId,
String localPath,
FileRecord.DownloadStatus downloadStatus,
Long completionDate);
Future<JsonObject> updateDownloadStatus(int fileId,
String uniqueId,
String localPath,
FileRecord.DownloadStatus downloadStatus,
Long completionDate);
Future<JsonObject> updateTransferStatus(String uniqueId,
FileRecord.TransferStatus transferStatus,
String localPath);
Future<Void> updateFileId(int fileId, String uniqueId);

View file

@ -1,5 +1,7 @@
package telegram.files.repository;
import telegram.files.Transfer;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@ -40,12 +42,38 @@ public class SettingAutoRecords {
public List<String> fileTypes;
public TransferRule transferRule;
public Rule() {
}
public Rule(String query, List<String> fileTypes) {
public Rule(String query, List<String> fileTypes, TransferRule transferRule) {
this.query = query;
this.fileTypes = fileTypes;
this.transferRule = transferRule;
}
}
public static class TransferRule {
public boolean transferHistory;
public String destination;
public Transfer.TransferPolicy transferPolicy;
public Transfer.DuplicationPolicy duplicationPolicy;
public TransferRule() {
}
public TransferRule(boolean transferHistory,
String destination,
Transfer.TransferPolicy transferPolicy,
Transfer.DuplicationPolicy duplicationPolicy) {
this.transferHistory = transferHistory;
this.destination = destination;
this.transferPolicy = transferPolicy;
this.duplicationPolicy = duplicationPolicy;
}
}
@ -80,4 +108,11 @@ public class SettingAutoRecords {
.collect(Collectors.toMap(i -> i.chatId, Function.identity()));
}
public Item getItem(long telegramId, long chatId) {
return items.stream()
.filter(item -> item.telegramId == telegramId && item.chatId == chatId)
.findFirst()
.orElse(null);
}
}

View file

@ -10,7 +10,6 @@ import cn.hutool.core.util.StrUtil;
import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
import io.vertx.core.Future;
import io.vertx.core.MultiMap;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.jdbcclient.JDBCPool;
@ -42,9 +41,9 @@ public class FileRepositoryImpl implements FileRepository {
INSERT INTO file_record(id, unique_id, telegram_id, chat_id, message_id, date, has_sensitive_content, size, downloaded_size,
type, mime_type,
file_name, thumbnail, caption, local_path,
download_status, start_date)
download_status, start_date, transfer_status)
values (#{id}, #{unique_id}, #{telegram_id}, #{chat_id}, #{message_id}, #{date}, #{has_sensitive_content}, #{size}, #{downloaded_size}, #{type},
#{mime_type}, #{file_name}, #{thumbnail}, #{caption}, #{local_path}, #{download_status}, #{start_date})
#{mime_type}, #{file_name}, #{thumbnail}, #{caption}, #{local_path}, #{download_status}, #{start_date}, #{transfer_status})
""")
.mapFrom(FileRecord.PARAM_MAPPER)
.execute(fileRecord)
@ -78,18 +77,25 @@ public class FileRepositoryImpl implements FileRepository {
}
@Override
public Future<Tuple3<List<FileRecord>, Long, Long>> getFiles(long chatId, MultiMap filter) {
public Future<Tuple3<List<FileRecord>, Long, Long>> getFiles(long chatId, Map<String, String> filter) {
String status = filter.get("status");
String transferStatus = filter.get("transferStatus");
String search = filter.get("search");
Long fromMessageId = Convert.toLong(filter.get("fromMessageId"), 0L);
String type = filter.get("type");
int limit = Convert.toInt(filter.get("limit"), 20);
String whereClause = "chat_id = #{chatId}";
Map<String, Object> params = MapUtil.of("chatId", chatId);
params.put("limit", limit);
if (StrUtil.isNotBlank(status)) {
whereClause += " AND download_status = #{status}";
params.put("status", status);
}
if (StrUtil.isNotBlank(transferStatus)) {
whereClause += " AND transfer_status = #{transferStatus}";
params.put("transferStatus", transferStatus);
}
if (StrUtil.isNotBlank(search)) {
whereClause += " AND (file_name LIKE #{search} OR caption LIKE #{search})";
params.put("search", "%%" + search + "%%");
@ -110,7 +116,7 @@ public class FileRepositoryImpl implements FileRepository {
return Future.all(
SqlTemplate
.forQuery(pool, """
SELECT * FROM file_record WHERE %s ORDER BY message_id desc LIMIT 20
SELECT * FROM file_record WHERE %s ORDER BY message_id desc LIMIT #{limit}
""".formatted(whereClause))
.mapTo(FileRecord.ROW_MAPPER)
.execute(params)
@ -293,21 +299,26 @@ public class FileRepositoryImpl implements FileRepository {
}
@Override
public Future<JsonObject> updateStatus(int fileId,
String uniqueId,
String localPath,
FileRecord.DownloadStatus downloadStatus,
Long completionDate) {
public Future<JsonObject> updateDownloadStatus(int fileId,
String uniqueId,
String localPath,
FileRecord.DownloadStatus downloadStatus,
Long completionDate) {
if (StrUtil.isBlank(localPath) && downloadStatus == null) {
return Future.succeededFuture(null);
}
return getByUniqueId(uniqueId)
.compose(record -> {
if (record == null) {
if (record == null
// Because if file transfer is completed,
// the telegram client will detect the file and change the download status to paused
|| (downloadStatus == FileRecord.DownloadStatus.paused
&& record.isTransferStatus(FileRecord.TransferStatus.completed))
) {
return Future.succeededFuture(null);
}
boolean pathUpdated = !Objects.equals(record.localPath(), localPath);
boolean downloadStatusUpdated = !Objects.equals(record.downloadStatus(), downloadStatus.name());
boolean downloadStatusUpdated = !record.isDownloadStatus(downloadStatus);
if (!pathUpdated && !downloadStatusUpdated) {
return Future.succeededFuture(null);
}
@ -339,7 +350,54 @@ public class FileRepositoryImpl implements FileRepository {
result.put("downloadStatus", downloadStatus.name());
}
log.debug("Successfully updated file record: %s, path: %s, status: %s, before: %s, %s"
.formatted(fileId, localPath, downloadStatus.name(), record.localPath(), record.downloadStatus()));
.formatted(uniqueId, localPath, downloadStatus.name(), record.localPath(), record.downloadStatus()));
return result;
});
});
}
@Override
public Future<JsonObject> updateTransferStatus(String uniqueId,
FileRecord.TransferStatus transferStatus,
String localPath) {
if (StrUtil.isBlank(localPath) && transferStatus == null) {
return Future.succeededFuture(null);
}
return getByUniqueId(uniqueId)
.compose(record -> {
if (record == null) {
return Future.succeededFuture(null);
}
boolean pathUpdated = StrUtil.isNotBlank(localPath) && !Objects.equals(record.localPath(), localPath);
boolean transferStatusUpdated = !record.isTransferStatus(transferStatus);
if (!pathUpdated && !transferStatusUpdated) {
return Future.succeededFuture(null);
}
return SqlTemplate
.forUpdate(pool, """
UPDATE file_record
SET transfer_status = #{transferStatus},
local_path = #{localPath}
WHERE unique_id = #{uniqueId}
""")
.execute(MapUtil.ofEntries(MapUtil.entry("uniqueId", uniqueId),
MapUtil.entry("localPath", pathUpdated ? localPath : record.localPath()),
MapUtil.entry("transferStatus", transferStatusUpdated ? transferStatus.name() : record.transferStatus())
))
.onFailure(err ->
log.error("Failed to update file record: %s".formatted(err.getMessage()))
)
.map(r -> {
JsonObject result = JsonObject.of();
if (pathUpdated) {
result.put("localPath", localPath);
}
if (transferStatusUpdated) {
result.put("transferStatus", transferStatus.name());
}
log.debug("Successfully updated file record: %s, path: %s, transfer status: %s, before: %s %s"
.formatted(uniqueId, localPath, transferStatus.name(), record.localPath(), record.transferStatus()));
return result;
});
});

View file

@ -0,0 +1,124 @@
package telegram.files;
import io.vertx.core.Future;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import telegram.files.repository.SettingAutoRecords;
import telegram.files.repository.SettingKey;
import telegram.files.repository.SettingRepository;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
public class AutoRecordsHolderTest {
private AutoRecordsHolder autoRecordsHolder;
private MockedStatic<HttpVerticle> httpVerticleMockedStatic;
private SettingAutoRecords settingAutoRecords1;
private SettingAutoRecords.Item item1;
@BeforeEach
public void setUp() {
autoRecordsHolder = new AutoRecordsHolder();
httpVerticleMockedStatic = mockStatic(HttpVerticle.class);
// Prepare test data
settingAutoRecords1 = new SettingAutoRecords();
item1 = new SettingAutoRecords.Item();
item1.telegramId = 123L;
item1.chatId = 456L;
settingAutoRecords1.items.add(item1);
// Mock dependencies
TelegramVerticle mockTelegramVerticle = mock(TelegramVerticle.class);
mockTelegramVerticle.authorized = true;
when(HttpVerticle.getTelegramVerticle(item1.telegramId))
.thenReturn(Optional.of(mockTelegramVerticle));
}
@AfterEach
public void tearDown() {
httpVerticleMockedStatic.close();
}
@Test
public void testInit_WhenSettingExists_AddsAuthorizedRecords() {
DataVerticle.settingRepository = mock(SettingRepository.class);
// Mock dependencies
when(DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload))
.thenReturn(Future.succeededFuture(settingAutoRecords1));
// Execute
Future<Void> result = autoRecordsHolder.init();
// Verify
assertNotNull(result);
assertTrue(autoRecordsHolder.autoRecords().exists(item1.telegramId, item1.chatId));
}
@Test
public void testOnAutoRecordsUpdate_AddingNewRecords() {
// Execute
autoRecordsHolder.onAutoRecordsUpdate(settingAutoRecords1);
// Verify
assertTrue(autoRecordsHolder.autoRecords().exists(item1.telegramId, item1.chatId));
}
@Test
public void testOnAutoRecordsUpdate_RemovingRecords() {
autoRecordsHolder.onAutoRecordsUpdate(settingAutoRecords1);
// Prepare new records with no items
SettingAutoRecords newRecords = new SettingAutoRecords();
// Mock listener
Consumer<List<SettingAutoRecords.Item>> mockListener = mock(Consumer.class);
autoRecordsHolder.registerOnRemoveListener(mockListener);
// Execute
autoRecordsHolder.onAutoRecordsUpdate(newRecords);
// Verify
assertFalse(autoRecordsHolder.autoRecords().exists(item1.telegramId, item1.chatId));
verify(mockListener).accept(argThat(list ->
list.size() == 1 &&
list.getFirst().telegramId == item1.telegramId &&
list.getFirst().chatId == item1.chatId
));
}
@Test
public void testRegisterOnRemoveListener() {
// Prepare test listener
List<SettingAutoRecords.Item> receivedItems = new ArrayList<>();
Consumer<List<SettingAutoRecords.Item>> listener = receivedItems::addAll;
// Register listener
autoRecordsHolder.registerOnRemoveListener(listener);
// Prepare initial and new records
autoRecordsHolder.onAutoRecordsUpdate(settingAutoRecords1);
SettingAutoRecords newRecords = new SettingAutoRecords();
// Execute
autoRecordsHolder.onAutoRecordsUpdate(newRecords);
// Verify
assertFalse(receivedItems.isEmpty());
assertEquals(1, receivedItems.size());
assertEquals(item1.telegramId, receivedItems.getFirst().telegramId);
assertEquals(item1.chatId, receivedItems.getFirst().chatId);
}
}

View file

@ -82,7 +82,7 @@ public class DataVerticleTest {
@DisplayName("Test Get file record by primary key")
void getFileRecordByPrimaryKeyTest(Vertx vertx, VertxTestContext testContext) {
FileRecord fileRecord = new FileRecord(
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", "local_path", "download_status", 0, null
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", "local_path", "download_status", "transfer_status", 0, null
);
DataVerticle.fileRepository.create(fileRecord)
.compose(r -> DataVerticle.fileRepository.getByPrimaryKey(r.id(), r.uniqueId()))
@ -93,16 +93,16 @@ public class DataVerticleTest {
}
@Test
@DisplayName("Test update file status")
void updateFileStatusTest(Vertx vertx, VertxTestContext testContext) {
@DisplayName("Test update file download status")
void updateFileDownloadStatusTest(Vertx vertx, VertxTestContext testContext) {
FileRecord fileRecord = new FileRecord(
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", null, "download_status", 0, null
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", null, FileRecord.DownloadStatus.idle.name(), FileRecord.TransferStatus.idle.name(), 0, null
);
String updateLocalPath = "local_path";
Long completionDate = 1L;
int newFileId = 2;
DataVerticle.fileRepository.create(fileRecord)
.compose(r -> DataVerticle.fileRepository.updateStatus(newFileId, r.uniqueId(), updateLocalPath, FileRecord.DownloadStatus.downloading, completionDate))
.compose(r -> DataVerticle.fileRepository.updateDownloadStatus(newFileId, r.uniqueId(), updateLocalPath, FileRecord.DownloadStatus.downloading, completionDate))
.compose(r -> {
testContext.verify(() -> {
Assertions.assertEquals(updateLocalPath, r.getString("localPath"));
@ -119,4 +119,27 @@ public class DataVerticleTest {
})));
}
@Test
@DisplayName("Test update file transfer status")
void updateFileTransferStatusTest(Vertx vertx, VertxTestContext testContext) {
FileRecord fileRecord = new FileRecord(
1, "unique_id", 1, 1, 1, 1, false, 1, 0, "type", "mime_type", "file_name", "thumbnail", "caption", null, FileRecord.DownloadStatus.idle.name(), FileRecord.TransferStatus.idle.name(), 0, null
);
String updateLocalPath = "local_path";
DataVerticle.fileRepository.create(fileRecord)
.compose(r -> DataVerticle.fileRepository.updateTransferStatus(r.uniqueId(), FileRecord.TransferStatus.completed, updateLocalPath))
.compose(r -> {
testContext.verify(() -> {
Assertions.assertEquals(updateLocalPath, r.getString("localPath"));
Assertions.assertEquals(FileRecord.TransferStatus.completed.name(), r.getString("transferStatus"));
});
return DataVerticle.fileRepository.getByUniqueId(fileRecord.uniqueId());
})
.onComplete(testContext.succeeding(r -> testContext.verify(() -> {
Assertions.assertEquals(FileRecord.TransferStatus.completed.name(), r.transferStatus());
Assertions.assertEquals(updateLocalPath, r.localPath());
testContext.completeNow();
})));
}
}

View file

@ -0,0 +1,86 @@
package telegram.files;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.FileWriter;
import java.nio.file.Files;
import java.security.MessageDigest;
import static org.junit.jupiter.api.Assertions.*;
class MessyUtilsTest {
private File smallFile;
private File largeFile;
@BeforeEach
void setUp() throws Exception {
smallFile = new File("small_test_file.txt");
try (FileWriter writer = new FileWriter(smallFile)) {
writer.write("Hello, this is a small test file!");
}
largeFile = new File("large_test_file.txt");
try (FileWriter writer = new FileWriter(largeFile)) {
for (int i = 0; i < 1_000_000; i++) {
writer.write("This is a large test file for MD5 computation.\n");
}
}
}
@AfterEach
void tearDown() {
if (smallFile.exists()) {
smallFile.delete();
}
if (largeFile.exists()) {
largeFile.delete();
}
}
@Test
void testCalculateFileMD5ForSmallFile() throws Exception {
String md5 = MessyUtils.calculateFileMD5(smallFile);
String expectedMd5 = calculateExpectedMD5(smallFile);
assertEquals(expectedMd5, md5, "MD5 hash for the small file does not match!");
}
@Test
void testCalculateFileMD5ForLargeFile() throws Exception {
String md5 = MessyUtils.calculateFileMD5(largeFile);
String expectedMd5 = calculateExpectedMD5(largeFile);
assertEquals(expectedMd5, md5, "MD5 hash for the large file does not match!");
}
@Test
void testCalculateFileMD5ForNonExistentFile() {
File nonExistentFile = new File("non_existent_file.txt");
String md5 = MessyUtils.calculateFileMD5(nonExistentFile);
assertNull(md5, "MD5 hash for non-existent file should be null!");
}
@Test
void testCompareFilesMD5() {
assertTrue(MessyUtils.compareFilesMD5(smallFile, smallFile), "MD5 hashes for the same file should match!");
assertFalse(MessyUtils.compareFilesMD5(smallFile, largeFile), "MD5 hashes for different files should not match!");
}
private String calculateExpectedMD5(File file) throws Exception {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] fileBytes = Files.readAllBytes(file.toPath());
byte[] md5Bytes = md.digest(fileBytes);
StringBuilder hexString = new StringBuilder();
for (byte b : md5Bytes) {
hexString.append(String.format("%02x", b));
}
return hexString.toString();
}
}

View file

@ -0,0 +1,148 @@
package telegram.files;
import cn.hutool.core.io.FileUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import telegram.files.Transfer.DuplicationPolicy;
import telegram.files.Transfer.TransferPolicy;
import telegram.files.repository.FileRecord;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class TransferTest {
private Transfer transfer;
private FileRecord mockFileRecord;
private Consumer<Transfer.TransferStatusUpdated> mockStatusUpdater;
@BeforeEach
void setUp(@TempDir Path tempDir) {
mockFileRecord = mock(FileRecord.class);
mockStatusUpdater = mock(Consumer.class);
transfer = Transfer.create(TransferPolicy.GROUP_BY_CHAT);
transfer.destination = tempDir.toString();
transfer.duplicationPolicy = DuplicationPolicy.OVERWRITE;
transfer.transferStatusUpdated = mockStatusUpdater;
}
@Test
void testCreateTransfer() {
Transfer chatTransfer = Transfer.create(TransferPolicy.GROUP_BY_CHAT);
Transfer typeTransfer = Transfer.create(TransferPolicy.GROUP_BY_TYPE);
assertNotNull(chatTransfer);
assertNotNull(typeTransfer);
assertInstanceOf(Transfer.GroupByChat.class, chatTransfer);
assertInstanceOf(Transfer.GroupByType.class, typeTransfer);
}
@Test
void testTransferSuccessful(@TempDir Path tempDir) {
// Prepare mock file record
String fileName = "source.txt";
String sourcePath = mockWaitingTransfer(tempDir, fileName);
// Transfer
transfer.transfer(mockFileRecord);
// Verify status updates and file moved
verify(mockStatusUpdater, times(2)).accept(any());
// Check final destination
Path expectedDestination = Path.of(transfer.destination).resolve("456").resolve("789").resolve(fileName);
assertTrue(Files.exists(expectedDestination));
assertFalse(Files.exists(Paths.get(sourcePath)));
}
@Test
void testDuplicationPolicySkip(@TempDir Path tempDir) {
String fileName = "source.txt";
// Prepare existing file
createExistingFile(fileName);
// Setup mock
mockWaitingTransfer(tempDir, fileName);
// Set skip policy
transfer.duplicationPolicy = DuplicationPolicy.SKIP;
// Transfer
transfer.transfer(mockFileRecord);
// Verify status updates
verify(mockStatusUpdater, times(1)).accept(argThat(
status -> status.transferStatus() == FileRecord.TransferStatus.idle
));
}
@Test
void testDuplicationPolicyRename(@TempDir Path tempDir) {
String fileName = "source.txt";
// Prepare existing file
String existingFile = createExistingFile(fileName);
// Setup mock
mockWaitingTransfer(tempDir, fileName);
// Set rename policy
transfer.duplicationPolicy = DuplicationPolicy.RENAME;
// Transfer
transfer.transfer(mockFileRecord);
// Verify a new file was created with a suffix
File[] filesInDir = FileUtil.ls(Path.of(existingFile).getParent().toString());
assertTrue(filesInDir.length >= 2);
assertTrue(Arrays.stream(filesInDir)
.anyMatch(f -> f.getName().contains("source-1.txt")));
}
@Test
void testTransferError(@TempDir Path tempDir) {
// Force an error by providing an invalid path
when(mockFileRecord.id()).thenReturn(123);
when(mockFileRecord.localPath()).thenReturn("/invalid/nonexistent/path");
// Transfer
transfer.transfer(mockFileRecord);
// Verify error status update
verify(mockStatusUpdater, times(1)).accept(argThat(
status -> status.transferStatus() == FileRecord.TransferStatus.error
));
}
private String mockWaitingTransfer(Path tempDir, String fileName) {
String sourcePath = tempDir.resolve(fileName).toString();
FileUtil.writeUtf8String("test content", sourcePath);
when(mockFileRecord.id()).thenReturn(123);
when(mockFileRecord.localPath()).thenReturn(sourcePath);
when(mockFileRecord.telegramId()).thenReturn(456L);
when(mockFileRecord.chatId()).thenReturn(789L);
return sourcePath;
}
private String createExistingFile(String fileName) {
Path existingFile = Path.of(transfer.destination).resolve("456").resolve("789").resolve(fileName);
FileUtil.createTempFile(existingFile.getParent().toFile());
FileUtil.writeUtf8String("existing content", existingFile.toString());
return existingFile.toString();
}
}

463
web/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "telegram-files-web",
"version": "0.1.9",
"version": "0.1.11",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "telegram-files-web",
"version": "0.1.9",
"version": "0.1.11",
"dependencies": {
"@dnd-kit/core": "^6.2.0",
"@dnd-kit/modifiers": "^8.0.0",
@ -17,12 +17,14 @@
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-toggle": "^1.1.1",
@ -1658,6 +1660,229 @@
}
}
},
"node_modules/@radix-ui/react-hover-card": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.4.tgz",
"integrity": "sha512-QSUUnRA3PQ2UhvoCv3eYvMnCAgGQW+sTu86QPuNb+ZMi+ZENd6UWpiXbcWDQ4AEaKF9KKpCHBeaJz9Rw6lRlaQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.3",
"@radix-ui/react-popper": "1.2.1",
"@radix-ui/react-portal": "1.1.3",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-arrow": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
"integrity": "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz",
"integrity": "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-popper": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
"integrity": "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-use-rect": "1.1.0",
"@radix-ui/react-use-size": "1.1.0",
"@radix-ui/rect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-portal": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz",
"integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-presence": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
"integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz",
@ -2100,6 +2325,97 @@
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz",
"integrity": "sha512-zGukiWHjEdBCRyXvKR6iXAQG6qXm2esuAD6kDOi9Cn+1X6ev3ASo4+CsYaD6Fov9r/AQFekqnD/7+V0Cs6/98g==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-use-size": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz",
@ -8963,6 +9279,106 @@
"@radix-ui/react-use-callback-ref": "1.1.0"
}
},
"@radix-ui/react-hover-card": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.4.tgz",
"integrity": "sha512-QSUUnRA3PQ2UhvoCv3eYvMnCAgGQW+sTu86QPuNb+ZMi+ZENd6UWpiXbcWDQ4AEaKF9KKpCHBeaJz9Rw6lRlaQ==",
"requires": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.3",
"@radix-ui/react-popper": "1.2.1",
"@radix-ui/react-portal": "1.1.3",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"dependencies": {
"@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="
},
"@radix-ui/react-arrow": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
"integrity": "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==",
"requires": {
"@radix-ui/react-primitive": "2.0.1"
}
},
"@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"requires": {}
},
"@radix-ui/react-dismissable-layer": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz",
"integrity": "sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg==",
"requires": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
}
},
"@radix-ui/react-popper": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
"integrity": "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==",
"requires": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-use-rect": "1.1.0",
"@radix-ui/react-use-size": "1.1.0",
"@radix-ui/rect": "1.1.0"
}
},
"@radix-ui/react-portal": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz",
"integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==",
"requires": {
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-layout-effect": "1.1.0"
}
},
"@radix-ui/react-presence": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
"integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
"requires": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.0"
}
},
"@radix-ui/react-primitive": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
"requires": {
"@radix-ui/react-slot": "1.1.1"
}
},
"@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"requires": {
"@radix-ui/react-compose-refs": "1.1.1"
}
}
}
},
"@radix-ui/react-id": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz",
@ -9186,6 +9602,49 @@
"@radix-ui/react-compose-refs": "1.1.0"
}
},
"@radix-ui/react-switch": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz",
"integrity": "sha512-zGukiWHjEdBCRyXvKR6iXAQG6qXm2esuAD6kDOi9Cn+1X6ev3ASo4+CsYaD6Fov9r/AQFekqnD/7+V0Cs6/98g==",
"requires": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-use-size": "1.1.0"
},
"dependencies": {
"@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="
},
"@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"requires": {}
},
"@radix-ui/react-primitive": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
"requires": {
"@radix-ui/react-slot": "1.1.1"
}
},
"@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"requires": {
"@radix-ui/react-compose-refs": "1.1.1"
}
}
}
},
"@radix-ui/react-tabs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz",

View file

@ -25,12 +25,14 @@
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.4",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-toggle": "^1.1.1",

View file

@ -2,12 +2,13 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import useSWRMutation from "swr/mutation";
import { POST } from "@/lib/api";
import { useDebounce } from "use-debounce";
@ -23,7 +24,15 @@ import {
} from "./ui/accordion";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { type AutoDownloadRule, type FileType } from "@/lib/types";
import {
type AutoDownloadRule,
DuplicationPolicies,
type DuplicationPolicy,
type FileType,
TransferPolices,
type TransferPolicy,
type TransferRule,
} from "@/lib/types";
import {
Select,
SelectContent,
@ -32,7 +41,14 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Badge } from "./ui/badge";
import { X } from "lucide-react";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { motion } from "framer-motion";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
import { Command, CommandGroup, CommandItem, CommandList } from "./ui/command";
import { cn } from "@/lib/utils";
import { useMutationObserver } from "@/hooks/use-mutation-observer";
export default function AutoDownloadDialog() {
const { accountId } = useTelegramAccount();
@ -90,6 +106,7 @@ export default function AutoDownloadDialog() {
aria-describedby={undefined}
onPointerDownOutside={() => setOpen(false)}
onClick={(e) => e.stopPropagation()}
className="h-full w-full overflow-auto md:h-auto md:max-h-[85%] md:w-auto"
>
<DialogHeader>
<DialogTitle>
@ -122,14 +139,14 @@ export default function AutoDownloadDialog() {
<span className="text-xs font-medium text-gray-500">
Filter Keyword
</span>
<span className="text-sm text-gray-900 dark:text-gray-300">
<span className="text-sm text-gray-500 dark:text-gray-300">
{chat.autoRule.query || "No keyword specified"}
</span>
</div>
</div>
<div className="rounded-lg bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800">
<span className="text-xs font-medium text-gray-500 dark:text-gray-300">
<span className="text-xs font-medium text-gray-500">
File Types
</span>
<div className="mt-2 flex flex-wrap gap-2">
@ -150,6 +167,53 @@ export default function AutoDownloadDialog() {
)}
</div>
</div>
{/* Transfer Rule Section */}
{chat.autoRule.transferRule && (
<div className="rounded-lg bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800">
<div className="flex flex-col space-y-2">
<span className="text-xs font-medium text-gray-500">
Transfer Rule
</span>
<div className="mt-2 flex flex-col space-y-2">
<div className="flex flex-col space-y-1">
<span className="text-xs text-gray-500 dark:text-gray-300">
Destination Folder
</span>
<span className="rounded border border-gray-200 px-1.5 py-0.5 text-sm dark:border-gray-700 dark:bg-gray-800">
{chat.autoRule.transferRule.destination}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500 dark:text-gray-300">
Transfer Policy
</span>
<Badge variant="outline" className="font-normal">
{chat.autoRule.transferRule.transferPolicy}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500 dark:text-gray-300">
Duplication Policy
</span>
<Badge variant="outline" className="font-normal">
{chat.autoRule.transferRule.duplicationPolicy}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500 dark:text-gray-300">
Transfer History
</span>
<Badge>
{chat.autoRule.transferRule.transferHistory
? "Enabled"
: "Disabled"}
</Badge>
</div>
</div>
</div>
</div>
)}
</div>
</div>
)}
@ -200,14 +264,7 @@ export default function AutoDownloadDialog() {
<AutoDownloadRule value={rule} onChange={setRule} />
</div>
)}
<div className="flex justify-end gap-2">
<Button
onClick={() => triggerAuto({ rule })}
variant={chat?.autoEnabled ? "destructive" : "default"}
disabled={debounceIsAutoMutating}
>
{debounceIsAutoMutating ? "Submitting..." : "Submit"}
</Button>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setOpen(false)}
@ -215,7 +272,31 @@ export default function AutoDownloadDialog() {
>
Cancel
</Button>
</div>
<Button
onClick={() => {
const folderPathRegex = /^([\/\\])?([^<>:"|?*]+([\/\\])?)+$/;
if (
rule.transferRule &&
!folderPathRegex.test(rule.transferRule.destination)
) {
toast({
title: "Invalid destination folder",
description: "Please enter a valid destination folder path",
});
return;
}
void triggerAuto({ rule });
}}
variant={chat?.autoEnabled ? "destructive" : "default"}
disabled={debounceIsAutoMutating}
>
{debounceIsAutoMutating
? "Submitting..."
: chat?.autoEnabled
? "Disable"
: "Enable"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
@ -227,6 +308,8 @@ interface AutoDownloadRuleProps {
}
function AutoDownloadRule({ value, onChange }: AutoDownloadRuleProps) {
const [autoTransfer, setAutoTransfer] = useState(false);
const handleQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange({
...value,
@ -252,6 +335,20 @@ function AutoDownloadRule({ value, onChange }: AutoDownloadRuleProps) {
});
};
const handleTransferRuleChange = (changes: Partial<TransferRule>) => {
if (!value.transferRule) {
return;
}
onChange({
...value,
transferRule: {
...value.transferRule,
...changes,
},
});
};
return (
<Accordion type="single" collapsible>
<AccordionItem value="advanced">
@ -304,8 +401,278 @@ function AutoDownloadRule({ value, onChange }: AutoDownloadRuleProps) {
</div>
</div>
</div>
<div className="mt-4 flex flex-col rounded-md border p-4 shadow">
<div className="flex h-full items-start justify-between">
<Label htmlFor="auto-transfer" className="contents">
Enable auto transfer files
</Label>
<Switch
id="auto-transfer"
checked={autoTransfer}
onCheckedChange={(checked) => {
if (checked) {
onChange({
...value,
transferRule: {
destination: "",
transferPolicy: "GROUP_BY_CHAT",
duplicationPolicy: "OVERWRITE",
transferHistory: false,
},
});
} else {
onChange({
...value,
transferRule: undefined,
});
}
setAutoTransfer(checked);
}}
/>
</div>
<motion.div
className="flex flex-col space-y-4 px-1"
initial="collapsed"
animate={autoTransfer ? "expanded" : "collapsed"}
variants={{
collapsed: { opacity: 0, height: 0, overflow: "hidden" },
expanded: { opacity: 1, height: "auto", marginTop: "1rem" },
}}
transition={{ duration: 0.2 }}
>
<div className="flex flex-col space-y-2">
<Label htmlFor="destination">
Destination folder for auto transfer
</Label>
<Input
id="destination"
type="text"
className="w-full"
placeholder="Enter a destination folder"
value={value.transferRule?.destination ?? ""}
onChange={(e) => {
handleTransferRuleChange({ destination: e.target.value });
}}
/>
</div>
<div className="flex flex-col space-y-2">
<Label htmlFor="transfer-policy">Transfer Policy</Label>
<PolicySelect
policyType="transfer"
value={value.transferRule?.transferPolicy}
onChange={(policy) =>
handleTransferRuleChange({
transferPolicy: policy as TransferPolicy,
})
}
/>
</div>
<div className="flex flex-col space-y-2">
<Label htmlFor="duplication-policy">Duplication Policy</Label>
<PolicySelect
policyType="duplication"
value={value.transferRule?.duplicationPolicy}
onChange={(policy) =>
handleTransferRuleChange({
duplicationPolicy: policy as DuplicationPolicy,
})
}
/>
</div>
<div className="rounded-md border p-4">
<div className="flex items-center justify-between">
<Label htmlFor="transfer-history">Transfer History</Label>
<Switch
id="transfer-history"
checked={value.transferRule?.transferHistory}
onCheckedChange={(checked) =>
handleTransferRuleChange({ transferHistory: checked })
}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Transfer files that are already downloaded to the specified
location.
</p>
</div>
</motion.div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
}
const PolicyLegends: Record<
TransferPolicy | DuplicationPolicy,
{
title: string;
description: string | React.ReactNode;
}
> = {
GROUP_BY_CHAT: {
title: "Group by Chat",
description: (
<div className="space-y-2">
<p className="text-sm">
Transfer files to folders based on the chat name.
</p>
<p className="text-xs text-muted-foreground">Example:</p>
<p className="inline-block rounded bg-gray-100 p-1 text-xs text-muted-foreground dark:bg-gray-800 dark:text-gray-300">
{"/${Destination Folder}/${Telegram Id}/${Chat Id}/${file}"}
</p>
</div>
),
},
GROUP_BY_TYPE: {
title: "Group by Type",
description: (
<div className="space-y-2">
<p className="text-sm">
Transfer files to folders based on the file type. <br />
All account files will be transferred to the same folder.
</p>
<p className="text-xs text-muted-foreground">Example:</p>
<p className="inline-block rounded bg-gray-100 p-1 text-xs text-muted-foreground dark:bg-gray-800 dark:text-gray-300">
{"/${Destination Folder}/${File Type}/${file}"}
</p>
</div>
),
},
OVERWRITE: {
title: "Overwrite",
description: "This will overwrite the existing file if it already exists.",
},
SKIP: {
title: "Skip",
description: "This will skip the file if it already exists.",
},
RENAME: {
title: "Rename",
description:
"This strategy will rename the file, add a serial number after the file name, and then move the file to the destination folder",
},
HASH: {
title: "Hash",
description:
"Calculate the hash (md5) of the file and compare with the existing file, if the hash is the same, delete the original file and set the local path to the existing file, otherwise, move the file",
},
};
interface PolicySelectProps {
policyType: "transfer" | "duplication";
value?: string;
onChange: (value: string) => void;
}
function PolicySelect({ policyType, value, onChange }: PolicySelectProps) {
const [open, setOpen] = useState(false);
const polices =
policyType === "transfer" ? TransferPolices : DuplicationPolicies;
const [peekedPolicy, setPeekedPolicy] = useState<string>(value ?? polices[0]);
const peekPolicyLegend = useMemo(() => {
return PolicyLegends[peekedPolicy as TransferPolicy | DuplicationPolicy];
}, [peekedPolicy]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
aria-label="Select a policy"
className="w-full justify-between"
>
{value ?? "Select a policy..."}
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[250px] p-0" modal={true}>
<HoverCard>
<HoverCardContent
side="top"
align="start"
forceMount
className="min-h-[150px] w-auto min-w-64 max-w-[380px]"
>
<div className="grid gap-2">
<h4 className="font-medium leading-none">
{peekPolicyLegend?.title}
</h4>
{typeof peekPolicyLegend?.description === "string" ? (
<p className="text-sm text-muted-foreground">
{peekPolicyLegend?.description ?? ""}
</p>
) : (
peekPolicyLegend?.description
)}
</div>
</HoverCardContent>
<Command>
<CommandList className="h-[var(--cmdk-list-height)] max-h-[400px]">
<HoverCardTrigger />
<CommandGroup>
{polices.map((policy) => (
<PolicyItem
key={policy}
policy={policy ?? ""}
isSelected={value === policy}
onPeek={setPeekedPolicy}
onSelect={() => {
onChange(policy);
setOpen(false);
}}
/>
))}
</CommandGroup>
</CommandList>
</Command>
</HoverCard>
</PopoverContent>
</Popover>
);
}
interface PolicyItemProps {
policy: string;
isSelected: boolean;
onSelect: () => void;
onPeek: (policy: string) => void;
}
function PolicyItem({ policy, isSelected, onSelect, onPeek }: PolicyItemProps) {
const ref = React.useRef<HTMLDivElement>(null);
useMutationObserver(ref, (mutations) => {
mutations.forEach((mutation) => {
if (
mutation.type === "attributes" &&
mutation.attributeName === "aria-selected" &&
ref.current?.getAttribute("aria-selected") === "true"
) {
onPeek(policy);
}
});
});
return (
<CommandItem
key={policy}
onSelect={onSelect}
ref={ref}
className="data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground"
>
{policy}
<Check
className={cn("ml-auto", isSelected ? "opacity-100" : "opacity-0")}
/>
</CommandItem>
);
}

View file

@ -55,7 +55,7 @@ export function FileCard({ file }: FileCardProps) {
<span>
{prettyBytes(file.size)} {file.type}
</span>
<FileStatus status={file.downloadStatus} />
<FileStatus file={file} />
</div>
<div
className={cn(
@ -66,7 +66,11 @@ export function FileCard({ file }: FileCardProps) {
<FileProgress file={file} downloadProgress={downloadProgress} />
</div>
<div className="flex items-center justify-end">
<FileControl file={file} downloadSpeed={downloadSpeed} isMobile={true}/>
<FileControl
file={file}
downloadSpeed={downloadSpeed}
isMobile={true}
/>
</div>
</div>
</div>
@ -154,7 +158,7 @@ function FileDrawer({
<span className="max-w-64 truncate">{file.fileName}</span>
</DrawerTitle>
<div className="py-1">
<FileStatus status={file.downloadStatus} />
<FileStatus file={file} />
</div>
{file.caption && (
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>

View file

@ -95,7 +95,7 @@ function FilePath({ file }: { file: TelegramFile }) {
</div>
</TooltipTrigger>
<TooltipContent>
<div className="max-w-80 text-wrap rounded p-2">{file.localPath}</div>
<div className="max-w-80 text-wrap rounded overflow-y-scroll">{file.localPath}</div>
</TooltipContent>
</Tooltip>
);

View file

@ -16,7 +16,6 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import PhotoPreview from "@/components/photo-preview";
import { env } from "@/env";
import prettyBytes from "pretty-bytes";
import FileStatus from "@/components/file-status";
import FileExtra from "@/components/file-extra";
@ -114,13 +113,13 @@ export default function FileRow({
type: (
<div className="flex flex-col items-center">
<span className="capitalize">{file.type}</span>
{env.NEXT_PUBLIC_MOCK_DATA === true && (
{process.env.NODE_ENV === "development" && (
<span className="text-xs">{file.id}</span>
)}
</div>
),
size: <span>{prettyBytes(file.size)}</span>,
status: <FileStatus status={file.downloadStatus} />,
status: <FileStatus file={file} />,
extra: <FileExtra file={file} rowHeight={rowHeight} />,
actions: (
<FileControl

View file

@ -1,10 +1,12 @@
import { type DownloadStatus } from "@/lib/types";
import { type TelegramFile } from "@/lib/types";
import { Badge } from "@/components/ui/badge";
import React from "react";
import { cn } from "@/lib/utils";
import { TooltipWrapper } from "@/components/ui/tooltip";
import { AnimatePresence, motion } from "framer-motion";
export default function FileStatus({ status }: { status: DownloadStatus }) {
const STATUS = {
export default function FileStatus({ file }: { file: TelegramFile }) {
const DOWNLOAD_STATUS = {
idle: {
className: "bg-gray-100 text-gray-600",
text: "Idle",
@ -27,11 +29,81 @@ export default function FileStatus({ status }: { status: DownloadStatus }) {
},
};
const TRANSFER_STATUS = {
idle: {
className: "bg-gray-100 text-gray-600",
text: "Idle",
},
transferring: {
className: "bg-blue-100 text-blue-600",
text: "Transferring",
},
completed: {
className: "bg-green-100 text-green-600",
text: "Transferred",
},
error: {
className: "bg-red-100 text-red-600",
text: "Transfer Error",
},
};
const badgeVariants = {
initial: { opacity: 0, scale: 0.9 },
animate: {
opacity: 1,
scale: 1,
transition: { type: "spring", stiffness: 300 },
},
exit: { opacity: 0, scale: 0.9, transition: { duration: 0.2 } },
};
return (
<Badge
className={cn("text-xs hover:bg-gray-200", STATUS[status].className)}
>
{STATUS[status].text}
</Badge>
<div className="flex items-center justify-center space-x-2">
<AnimatePresence>
{file.transferStatus === "idle" && (
<motion.div
key="download-status"
variants={badgeVariants}
initial="initial"
animate="animate"
exit="exit"
>
<TooltipWrapper content="Download Status">
<Badge
className={cn(
"text-xs hover:bg-gray-200",
DOWNLOAD_STATUS[file.downloadStatus].className,
)}
>
{DOWNLOAD_STATUS[file.downloadStatus].text}
</Badge>
</TooltipWrapper>
</motion.div>
)}
{file.downloadStatus === "completed" &&
file.transferStatus &&
file.transferStatus !== "idle" && (
<motion.div
key="transfer-status"
variants={badgeVariants}
initial="initial"
animate="animate"
exit="exit"
>
<TooltipWrapper content="Transfer Status">
<Badge
className={cn(
"text-xs hover:bg-gray-200",
TRANSFER_STATUS[file.transferStatus].className,
)}
>
{TRANSFER_STATUS[file.transferStatus].text}
</Badge>
</TooltipWrapper>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View file

@ -224,7 +224,7 @@ export default function Proxys({
))}
</div>
{enableSelect && (
<div className="w-full absolute bottom-0 justify-end flex items-center space-x-4">
<div className="absolute bottom-0 flex w-full items-center justify-end space-x-4">
<Button
className="w-full md:w-auto"
disabled={isToggleProxyMutating}
@ -350,7 +350,7 @@ export default function Proxys({
</div>
</div>
<DialogFooter>
<Button onClick={handleCloseDialog} variant="ghost">
<Button onClick={handleCloseDialog} variant="outline">
Cancel
</Button>
<Button onClick={handleSaveProxy}>

View file

@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View file

@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View file

@ -13,7 +13,7 @@ export function useCopyToClipboard(): [string | null, (value: string) => void] {
toast({
title: "Successfully copied to clipboard",
description: (
<p className="overflow-hidden">
<p className="line-clamp-1">
<code className="text-xs">{value}</code>
</p>
),
@ -26,7 +26,12 @@ export function useCopyToClipboard(): [string | null, (value: string) => void] {
oldSchoolCopy(value);
setState(value);
toast({
description: `Successfully copied to clipboard: ${value}`,
title: "Successfully copied to clipboard",
description: (
<p className="line-clamp-1">
<code className="text-xs">{value}</code>
</p>
),
});
}
};

View file

@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import {
type DownloadStatus,
type FileFilter,
type TelegramFile,
type TelegramFile, type TransferStatus,
} from "@/lib/types";
import useSWRInfinite from "swr/infinite";
import { useWebsocket } from "@/hooks/use-websocket";
@ -26,12 +26,14 @@ export function useFiles(accountId: string, chatId: string) {
const { lastJsonMessage } = useWebsocket();
const [latestFilesStatus, setLatestFileStatus] = useState<
Record<
number,
string,
{
fileId: number;
downloadStatus: DownloadStatus;
localPath: string;
completionDate: number;
downloadedSize: number;
transferStatus?: TransferStatus;
}
>
>({});
@ -74,22 +76,27 @@ export function useFiles(accountId: string, chatId: string) {
}
const data = lastJsonMessage.data as {
fileId: number;
uniqueId: string;
downloadStatus: DownloadStatus;
localPath: string;
completionDate: number;
downloadedSize: number;
transferStatus?: TransferStatus;
};
setLatestFileStatus((prev) => ({
...prev,
[data.fileId]: {
[data.uniqueId]: {
fileId: data.fileId,
downloadStatus:
data.downloadStatus ?? prev[data.fileId]?.downloadStatus,
localPath: data.localPath ?? prev[data.fileId]?.localPath,
data.downloadStatus ?? prev[data.uniqueId]?.downloadStatus,
localPath: data.localPath ?? prev[data.uniqueId]?.localPath,
completionDate:
data.completionDate ?? prev[data.fileId]?.completionDate,
data.completionDate ?? prev[data.uniqueId]?.completionDate,
downloadedSize:
data.downloadedSize ?? prev[data.fileId]?.downloadedSize,
data.downloadedSize ?? prev[data.uniqueId]?.downloadedSize,
transferStatus:
data.transferStatus ?? prev[data.uniqueId]?.transferStatus,
},
}));
}, [lastJsonMessage]);
@ -101,13 +108,16 @@ export function useFiles(accountId: string, chatId: string) {
page.files.forEach((file) => {
files.push({
...file,
id: latestFilesStatus[file.uniqueId]?.fileId ?? file.id,
downloadStatus:
latestFilesStatus[file.id]?.downloadStatus ?? file.downloadStatus,
localPath: latestFilesStatus[file.id]?.localPath ?? file.localPath,
latestFilesStatus[file.uniqueId]?.downloadStatus ?? file.downloadStatus,
localPath: latestFilesStatus[file.uniqueId]?.localPath ?? file.localPath,
completionDate:
latestFilesStatus[file.id]?.completionDate ?? file.completionDate,
latestFilesStatus[file.uniqueId]?.completionDate ?? file.completionDate,
downloadedSize:
latestFilesStatus[file.id]?.downloadedSize ?? file.downloadedSize,
latestFilesStatus[file.uniqueId]?.downloadedSize ?? file.downloadedSize,
transferStatus:
latestFilesStatus[file.uniqueId]?.transferStatus ?? file.transferStatus,
});
});
});

View file

@ -0,0 +1,20 @@
import * as React from "react"
export const useMutationObserver = (
ref: React.MutableRefObject<HTMLElement | null>,
callback: MutationCallback,
options = {
attributes: true,
characterData: true,
childList: true,
subtree: true,
}
) => {
React.useEffect(() => {
if (ref.current) {
const observer = new MutationObserver(callback)
observer.observe(ref.current, options)
return () => observer.disconnect()
}
}, [ref, callback, options])
}

View file

@ -31,6 +31,8 @@ export type DownloadStatus =
| "completed"
| "error";
export type TransferStatus = "idle" | "transferring" | "completed" | "error";
export type TelegramFile = {
id: number;
uniqueId: string;
@ -49,6 +51,7 @@ export type TelegramFile = {
hasSensitiveContent: boolean;
startDate: number;
completionDate: number;
transferStatus?: TransferStatus;
};
export type TDFile = {
@ -110,7 +113,25 @@ export type Proxy = {
isEnabled?: boolean;
};
export const TransferPolices = ["GROUP_BY_CHAT", "GROUP_BY_TYPE"] as const;
export type TransferPolicy = (typeof TransferPolices)[number];
export const DuplicationPolicies = [
"OVERWRITE",
"RENAME",
"SKIP",
"HASH",
] as const;
export type DuplicationPolicy = (typeof DuplicationPolicies)[number];
export type TransferRule = {
transferHistory: boolean;
destination: string;
transferPolicy: TransferPolicy;
duplicationPolicy: DuplicationPolicy;
};
export type AutoDownloadRule = {
query: string;
fileTypes: Array<Exclude<FileType, "media">>;
transferRule?: TransferRule;
};