feat: Support preload file messages.

This commit is contained in:
jarvis2f 2025-03-05 16:39:30 +08:00
parent 23fd65b7f0
commit 6431ce7846
17 changed files with 642 additions and 272 deletions

View file

@ -8,7 +8,6 @@ import cn.hutool.log.LogFactory;
import io.vertx.core.AbstractVerticle; import io.vertx.core.AbstractVerticle;
import io.vertx.core.Future; import io.vertx.core.Future;
import io.vertx.core.Promise; import io.vertx.core.Promise;
import io.vertx.core.json.Json;
import io.vertx.core.json.JsonObject; import io.vertx.core.json.JsonObject;
import org.drinkless.tdlib.TdApi; import org.drinkless.tdlib.TdApi;
import org.jooq.lambda.tuple.Tuple2; import org.jooq.lambda.tuple.Tuple2;
@ -47,9 +46,9 @@ public class AutoDownloadVerticle extends AbstractVerticle {
private int limit = DEFAULT_LIMIT; private int limit = DEFAULT_LIMIT;
public AutoDownloadVerticle(AutoRecordsHolder autoRecordsHolder) { public AutoDownloadVerticle() {
this.autoRecords = autoRecordsHolder.autoRecords(); this.autoRecords = AutoRecordsHolder.INSTANCE.autoRecords();
autoRecordsHolder.registerOnRemoveListener(removedItems -> removedItems.forEach(item -> AutoRecordsHolder.INSTANCE.registerOnRemoveListener(removedItems -> removedItems.forEach(item ->
waitingDownloadMessages.getOrDefault(item.telegramId, new LinkedList<>()) waitingDownloadMessages.getOrDefault(item.telegramId, new LinkedList<>())
.removeIf(m -> m.chatId == item.chatId))); .removeIf(m -> m.chatId == item.chatId)));
} }
@ -60,7 +59,10 @@ public class AutoDownloadVerticle extends AbstractVerticle {
.compose(v -> this.initEventConsumer()) .compose(v -> this.initEventConsumer())
.onSuccess(v -> { .onSuccess(v -> {
vertx.setPeriodic(0, HISTORY_SCAN_INTERVAL, vertx.setPeriodic(0, HISTORY_SCAN_INTERVAL,
id -> autoRecords.items.forEach(auto -> addHistoryMessage(auto, System.currentTimeMillis()))); id -> autoRecords.getDownloadEnabledItems()
.stream()
.filter(auto -> auto.isNotComplete(SettingAutoRecords.HISTORY_DOWNLOAD_STATE))
.forEach(auto -> addHistoryMessage(auto, System.currentTimeMillis())));
vertx.setPeriodic(0, DOWNLOAD_INTERVAL, vertx.setPeriodic(0, DOWNLOAD_INTERVAL,
id -> waitingDownloadMessages.keySet().forEach(this::download)); id -> waitingDownloadMessages.keySet().forEach(this::download));
@ -70,7 +72,7 @@ public class AutoDownloadVerticle extends AbstractVerticle {
|Download interval: %s ms |Download interval: %s ms
|Download limit: %s per telegram account! |Download limit: %s per telegram account!
|Auto chats: %s |Auto chats: %s
""".formatted(HISTORY_SCAN_INTERVAL, DOWNLOAD_INTERVAL, limit, autoRecords.items.size())); """.formatted(HISTORY_SCAN_INTERVAL, DOWNLOAD_INTERVAL, limit, autoRecords.getDownloadEnabledItems().size()));
startPromise.complete(); startPromise.complete();
}) })
@ -78,12 +80,8 @@ public class AutoDownloadVerticle extends AbstractVerticle {
} }
@Override @Override
public void stop(Promise<Void> stopPromise) { public void stop() {
saveAutoRecords() log.info("Auto download verticle stopped!");
.onComplete(r -> {
log.info("Auto download verticle stopped!");
stopPromise.complete();
});
} }
private Future<Void> initAutoDownload() { private Future<Void> initAutoDownload() {
@ -109,19 +107,6 @@ public class AutoDownloadVerticle extends AbstractVerticle {
return Future.succeededFuture(); return Future.succeededFuture();
} }
private Future<Void> saveAutoRecords() {
return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload)
.compose(settingAutoRecords -> {
if (settingAutoRecords == null) {
settingAutoRecords = new SettingAutoRecords();
}
autoRecords.items.forEach(settingAutoRecords::add);
return DataVerticle.settingRepository.createOrUpdate(SettingKey.autoDownload.name(), Json.encode(settingAutoRecords));
})
.onFailure(e -> log.error("Save auto records failed!", e))
.mapEmpty();
}
private void addHistoryMessage(SettingAutoRecords.Item auto, long currentTimeMillis) { private void addHistoryMessage(SettingAutoRecords.Item auto, long currentTimeMillis) {
log.debug("Start scan history! TelegramId: %d ChatId: %d FileType: %s".formatted(auto.telegramId, auto.chatId, auto.nextFileType)); log.debug("Start scan history! TelegramId: %d ChatId: %d FileType: %s".formatted(auto.telegramId, auto.chatId, auto.nextFileType));
if (System.currentTimeMillis() - currentTimeMillis > MAX_HISTORY_SCAN_TIME) { if (System.currentTimeMillis() - currentTimeMillis > MAX_HISTORY_SCAN_TIME) {
@ -158,12 +143,21 @@ public class AutoDownloadVerticle extends AbstractVerticle {
addHistoryMessage(auto, currentTimeMillis); addHistoryMessage(auto, currentTimeMillis);
} else { } else {
log.debug("%s No more history files found! TelegramId: %d ChatId: %d".formatted(auto.uniqueKey(), auto.telegramId, auto.chatId)); log.debug("%s No more history files found! TelegramId: %d ChatId: %d".formatted(auto.uniqueKey(), auto.telegramId, auto.chatId));
auto.complete(SettingAutoRecords.HISTORY_DOWNLOAD_STATE);
} }
} else { } else {
DataVerticle.fileRepository.getFilesByUniqueId(TdApiHelp.getFileUniqueIds(Arrays.asList(foundChatMessages.messages))) DataVerticle.fileRepository.getFilesByUniqueId(TdApiHelp.getFileUniqueIds(Arrays.asList(foundChatMessages.messages)))
.onSuccess(existFiles -> { .onSuccess(existFiles -> {
List<TdApi.Message> messages = Stream.of(foundChatMessages.messages) List<TdApi.Message> messages = Stream.of(foundChatMessages.messages)
.filter(message -> !existFiles.containsKey(TdApiHelp.getFileUniqueId(message))) .filter(message -> {
String uniqueId = TdApiHelp.getFileUniqueId(message);
if (!existFiles.containsKey(uniqueId)) {
return true;
} else {
FileRecord fileRecord = existFiles.get(uniqueId);
return fileRecord.isDownloadStatus(FileRecord.DownloadStatus.idle);
}
})
.toList(); .toList();
if (CollUtil.isEmpty(messages)) { if (CollUtil.isEmpty(messages)) {
auto.nextFromMessageId = foundChatMessages.nextFromMessageId; auto.nextFromMessageId = foundChatMessages.nextFromMessageId;
@ -259,7 +253,7 @@ public class AutoDownloadVerticle extends AbstractVerticle {
long telegramId = jsonObject.getLong("telegramId"); long telegramId = jsonObject.getLong("telegramId");
long chatId = jsonObject.getLong("chatId"); long chatId = jsonObject.getLong("chatId");
long messageId = jsonObject.getLong("messageId"); long messageId = jsonObject.getLong("messageId");
autoRecords.items.stream() autoRecords.getDownloadEnabledItems().stream()
.filter(item -> item.telegramId == telegramId && item.chatId == chatId) .filter(item -> item.telegramId == telegramId && item.chatId == chatId)
.findFirst() .findFirst()
.flatMap(item -> TelegramVerticles.get(telegramId)) .flatMap(item -> TelegramVerticles.get(telegramId))

View file

@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil;
import cn.hutool.log.Log; import cn.hutool.log.Log;
import cn.hutool.log.LogFactory; import cn.hutool.log.LogFactory;
import io.vertx.core.Future; import io.vertx.core.Future;
import io.vertx.core.json.Json;
import telegram.files.repository.SettingAutoRecords; import telegram.files.repository.SettingAutoRecords;
import telegram.files.repository.SettingKey; import telegram.files.repository.SettingKey;
@ -18,7 +19,11 @@ public class AutoRecordsHolder {
private final List<Consumer<List<SettingAutoRecords.Item>>> onRemoveListeners = new ArrayList<>(); private final List<Consumer<List<SettingAutoRecords.Item>>> onRemoveListeners = new ArrayList<>();
public AutoRecordsHolder() { private volatile boolean initialized = false;
public static final AutoRecordsHolder INSTANCE = new AutoRecordsHolder();
private AutoRecordsHolder() {
} }
public SettingAutoRecords autoRecords() { public SettingAutoRecords autoRecords() {
@ -29,9 +34,13 @@ public class AutoRecordsHolder {
onRemoveListeners.add(onRemove); onRemoveListeners.add(onRemove);
} }
public Future<Void> init() { public synchronized Future<Void> init() {
if (initialized) {
return Future.succeededFuture();
}
return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload) return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload)
.onSuccess(settingAutoRecords -> { .onSuccess(settingAutoRecords -> {
initialized = true;
if (settingAutoRecords == null) { if (settingAutoRecords == null) {
return; return;
} }
@ -77,4 +86,17 @@ public class AutoRecordsHolder {
onRemoveListeners.forEach(listener -> listener.accept(removedItems)); onRemoveListeners.forEach(listener -> listener.accept(removedItems));
} }
} }
public Future<Void> saveAutoRecords() {
return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload)
.compose(settingAutoRecords -> {
if (settingAutoRecords == null) {
settingAutoRecords = new SettingAutoRecords();
}
autoRecords.items.forEach(settingAutoRecords::add);
return DataVerticle.settingRepository.createOrUpdate(SettingKey.autoDownload.name(), Json.encode(settingAutoRecords));
})
.onFailure(e -> log.error("Save auto records failed!", e))
.mapEmpty();
}
} }

View file

@ -44,8 +44,6 @@ public class HttpVerticle extends AbstractVerticle {
// session id -> telegram verticle // session id -> telegram verticle
private final Map<String, TelegramVerticle> sessionTelegramVerticles = new ConcurrentHashMap<>(); private final Map<String, TelegramVerticle> sessionTelegramVerticles = new ConcurrentHashMap<>();
private final AutoRecordsHolder autoRecordsHolder = new AutoRecordsHolder();
private final FileRouteHandler fileRouteHandler = new FileRouteHandler(); private final FileRouteHandler fileRouteHandler = new FileRouteHandler();
private static final String SESSION_COOKIE_NAME = "tf"; private static final String SESSION_COOKIE_NAME = "tf";
@ -54,17 +52,22 @@ public class HttpVerticle extends AbstractVerticle {
public void start(Promise<Void> startPromise) { public void start(Promise<Void> startPromise) {
initHttpServer() initHttpServer()
.compose(r -> initTelegramVerticles()) .compose(r -> initTelegramVerticles())
.compose(r -> autoRecordsHolder.init()) .compose(r -> AutoRecordsHolder.INSTANCE.init())
.compose(r -> initAutoDownloadVerticle()) .compose(r -> initAutoDownloadVerticle())
.compose(r -> initTransferVerticle()) .compose(r -> initTransferVerticle())
.compose(r -> initPreloadMessageVerticle())
.compose(r -> initEventConsumer()) .compose(r -> initEventConsumer())
.onSuccess(startPromise::complete) .onSuccess(startPromise::complete)
.onFailure(startPromise::fail); .onFailure(startPromise::fail);
} }
@Override @Override
public void stop() { public void stop(Promise<Void> stopPromise) {
log.info("Http verticle stopped!"); AutoRecordsHolder.INSTANCE.saveAutoRecords()
.onComplete(ignore -> {
log.info("Http verticle stopped!");
stopPromise.complete();
});
} }
public Future<Void> initHttpServer() { public Future<Void> initHttpServer() {
@ -153,7 +156,7 @@ public class HttpVerticle extends AbstractVerticle {
router.post("/:telegramId/file/cancel-download").handler(this::handleFileCancelDownload); router.post("/:telegramId/file/cancel-download").handler(this::handleFileCancelDownload);
router.post("/:telegramId/file/toggle-pause-download").handler(this::handleFileTogglePauseDownload); router.post("/:telegramId/file/toggle-pause-download").handler(this::handleFileTogglePauseDownload);
router.post("/:telegramId/file/remove").handler(this::handleFileRemove); router.post("/:telegramId/file/remove").handler(this::handleFileRemove);
router.post("/:telegramId/file/auto-download").handler(this::handleAutoDownload); router.post("/:telegramId/file/update-auto-settings").handler(this::handleAutoSettingsUpdate);
router.route() router.route()
.failureHandler(ctx -> { .failureHandler(ctx -> {
@ -182,15 +185,19 @@ public class HttpVerticle extends AbstractVerticle {
} }
public Future<Void> initAutoDownloadVerticle() { public Future<Void> initAutoDownloadVerticle() {
return vertx.deployVerticle(new AutoDownloadVerticle(autoRecordsHolder), Config.VIRTUAL_THREAD_DEPLOYMENT_OPTIONS) return vertx.deployVerticle(new AutoDownloadVerticle(), Config.VIRTUAL_THREAD_DEPLOYMENT_OPTIONS)
.mapEmpty(); .mapEmpty();
} }
public Future<Void> initTransferVerticle() { public Future<Void> initTransferVerticle() {
return vertx.deployVerticle(new TransferVerticle(autoRecordsHolder), Config.VIRTUAL_THREAD_DEPLOYMENT_OPTIONS) return vertx.deployVerticle(new TransferVerticle(), Config.VIRTUAL_THREAD_DEPLOYMENT_OPTIONS)
.mapEmpty(); .mapEmpty();
} }
public Future<Void> initPreloadMessageVerticle() {
return vertx.deployVerticle(new PreloadMessageVerticle(), Config.VIRTUAL_THREAD_DEPLOYMENT_OPTIONS)
.mapEmpty();
}
private Future<Void> initEventConsumer() { private Future<Void> initEventConsumer() {
vertx.eventBus().consumer(EventEnum.TELEGRAM_EVENT.address(), message -> { vertx.eventBus().consumer(EventEnum.TELEGRAM_EVENT.address(), message -> {
@ -211,8 +218,8 @@ public class HttpVerticle extends AbstractVerticle {
}); });
vertx.eventBus().consumer(EventEnum.AUTO_DOWNLOAD_UPDATE.address(), message -> { vertx.eventBus().consumer(EventEnum.AUTO_DOWNLOAD_UPDATE.address(), message -> {
log.debug("Auto download update: %s".formatted(message.body())); log.debug("Auto settings update: %s".formatted(message.body()));
autoRecordsHolder.onAutoRecordsUpdate(Json.decodeValue(message.body().toString(), SettingAutoRecords.class)); AutoRecordsHolder.INSTANCE.onAutoRecordsUpdate(Json.decodeValue(message.body().toString(), SettingAutoRecords.class));
}); });
return Future.succeededFuture(); return Future.succeededFuture();
} }
@ -607,7 +614,7 @@ public class HttpVerticle extends AbstractVerticle {
.onFailure(ctx::fail); .onFailure(ctx::fail);
} }
private void handleAutoDownload(RoutingContext ctx) { private void handleAutoSettingsUpdate(RoutingContext ctx) {
TelegramVerticle telegramVerticle = TelegramVerticles.getOrElseThrow(ctx.pathParam("telegramId")); TelegramVerticle telegramVerticle = TelegramVerticles.getOrElseThrow(ctx.pathParam("telegramId"));
String chatId = ctx.request().getParam("chatId"); String chatId = ctx.request().getParam("chatId");
@ -616,7 +623,7 @@ public class HttpVerticle extends AbstractVerticle {
return; return;
} }
JsonObject params = ctx.body().asJsonObject(); JsonObject params = ctx.body().asJsonObject();
telegramVerticle.toggleAutoDownload(Convert.toLong(chatId), params) telegramVerticle.updateAutoSettings(Convert.toLong(chatId), params)
.onSuccess(r -> ctx.end()) .onSuccess(r -> ctx.end())
.onFailure(ctx::fail); .onFailure(ctx::fail);
} }

View file

@ -85,4 +85,51 @@ public class MessyUtils {
default -> throw new IllegalArgumentException("Unknown unit: " + unit); default -> throw new IllegalArgumentException("Unknown unit: " + unit);
}; };
} }
public static class BitState {
private int state;
public BitState(int state) {
this.state = state;
}
/**
* 开启某个状态
*/
public void enableState(int n) {
state |= (1 << n);
}
/**
* 关闭某个状态
*/
public void disableState(int n) {
state &= ~(1 << n);
}
/**
* 切换某个状态开启->关闭关闭->开启
*/
public void toggleState(int n) {
state ^= (1 << n);
}
/**
* 检查某个状态是否开启
*/
public boolean isStateEnabled(int n) {
return (state & (1 << n)) != 0;
}
public int getState() {
return state;
}
/**
* 获取当前状态的二进制表示
*/
public String getBinaryState() {
return String.format("%8s", Integer.toBinaryString(state)).replace(' ', '0');
}
}
} }

View file

@ -0,0 +1,120 @@
package telegram.files;
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.drinkless.tdlib.TdApi;
import telegram.files.repository.FileRecord;
import telegram.files.repository.SettingAutoRecords;
import java.util.Optional;
public class PreloadMessageVerticle extends AbstractVerticle {
private static final Log log = LogFactory.get();
private static final int HISTORY_SCAN_INTERVAL = 30 * 1000;
private static final int MAX_HISTORY_SCAN_TIME = 10 * 1000;
private final SettingAutoRecords autoRecords;
public PreloadMessageVerticle() {
this.autoRecords = AutoRecordsHolder.INSTANCE.autoRecords();
}
@Override
public void start(Promise<Void> startPromise) {
initEventConsumer()
.onSuccess(r -> {
vertx.setPeriodic(0, HISTORY_SCAN_INTERVAL,
id -> autoRecords.getPreloadEnabledItems()
.stream()
.filter(auto -> auto.isNotComplete(SettingAutoRecords.HISTORY_PRELOAD_STATE))
.forEach(auto -> addHistoryMessage(auto, System.currentTimeMillis())));
log.info("""
Preload message verticle started!
|Auto chats: %s
""".formatted(autoRecords.getPreloadEnabledItems().size()));
startPromise.complete();
})
.onFailure(startPromise::fail);
}
@Override
public void stop() {
log.info("Preload message verticle stopped!");
}
private Future<Void> initEventConsumer() {
vertx.eventBus().consumer(EventEnum.MESSAGE_RECEIVED.address(), message -> {
log.trace("Auto download message received: %s".formatted(message.body()));
this.onNewMessage((JsonObject) message.body());
});
return Future.succeededFuture();
}
private void addHistoryMessage(SettingAutoRecords.Item auto, long currentTimeMillis) {
log.debug("Start load history message! TelegramId: %d ChatId: %d".formatted(auto.telegramId, auto.chatId));
if (System.currentTimeMillis() - currentTimeMillis > MAX_HISTORY_SCAN_TIME) {
log.debug("Load history message timeout! TelegramId: %d ChatId: %d".formatted(auto.telegramId, auto.chatId));
return;
}
TelegramVerticle telegramVerticle = TelegramVerticles.getOrElseThrow(auto.telegramId);
TdApi.SearchChatMessages searchChatMessages = new TdApi.SearchChatMessages();
searchChatMessages.chatId = auto.chatId;
searchChatMessages.fromMessageId = auto.nextFromMessageIdForPreload;
searchChatMessages.limit = 100;
TdApi.FoundChatMessages foundChatMessages = Future.await(telegramVerticle.client.execute(searchChatMessages)
.onFailure(r -> log.error("Search chat messages failed! TelegramId: %d ChatId: %d".formatted(auto.telegramId, auto.chatId), r))
);
if (foundChatMessages == null || foundChatMessages.messages.length == 0) {
log.debug("%s No more history message found! TelegramId: %d ChatId: %d".formatted(auto.uniqueKey(), auto.telegramId, auto.chatId));
auto.complete(SettingAutoRecords.HISTORY_PRELOAD_STATE);
return;
}
int count = 0;
for (TdApi.Message message : foundChatMessages.messages) {
Optional<TdApiHelp.FileHandler<? extends TdApi.MessageContent>> fileHandlerOptional = TdApiHelp.getFileHandler(message);
if (fileHandlerOptional.isEmpty()) {
continue;
}
FileRecord fileRecord = fileHandlerOptional.get().convertFileRecord(auto.telegramId);
if (Future.await(DataVerticle.fileRepository.createIfNotExist(fileRecord))) {
count++;
}
}
if (log.isDebugEnabled() && count > 0) {
log.debug("Load history message success! TelegramId: %d ChatId: %d Count: %d".formatted(auto.telegramId, auto.chatId, count));
}
auto.nextFromMessageIdForPreload = foundChatMessages.nextFromMessageId;
addHistoryMessage(auto, currentTimeMillis);
}
private void onNewMessage(JsonObject jsonObject) {
long telegramId = jsonObject.getLong("telegramId");
long chatId = jsonObject.getLong("chatId");
long messageId = jsonObject.getLong("messageId");
autoRecords.getPreloadEnabledItems().stream()
.filter(item -> item.telegramId == telegramId && item.chatId == chatId)
.findFirst()
.flatMap(ignore -> TelegramVerticles.get(telegramId))
.ifPresent(telegramVerticle -> {
if (!telegramVerticle.authorized) return;
telegramVerticle.client.execute(new TdApi.GetMessage(chatId, messageId))
.onSuccess(message -> TdApiHelp.getFileHandler(message).ifPresent(fileHandler -> {
FileRecord fileRecord = fileHandler.convertFileRecord(telegramId);
DataVerticle.fileRepository.createIfNotExist(fileRecord);
}))
.onFailure(e -> log.error("Preload message fail. Get message failed: %s".formatted(e.getMessage())));
});
}
}

View file

@ -193,7 +193,9 @@ public class TdApiHelp {
case TdApi.MessageDocument.CONSTRUCTOR -> { case TdApi.MessageDocument.CONSTRUCTOR -> {
return Optional.of((T) new DocumentHandler(message)); return Optional.of((T) new DocumentHandler(message));
} }
default -> throw new NoStackTraceException("Unsupported message type: " + message.content.getConstructor()); default -> {
return Optional.empty();
}
} }
} }

View file

@ -312,11 +312,15 @@ public class TelegramVerticle extends AbstractVerticle {
TdApiHelp.FileHandler<? extends TdApi.MessageContent> fileHandler = TdApiHelp.getFileHandler(message) TdApiHelp.FileHandler<? extends TdApi.MessageContent> fileHandler = TdApiHelp.getFileHandler(message)
.orElseThrow(() -> new NoStackTraceException("not support message type")); .orElseThrow(() -> new NoStackTraceException("not support message type"));
FileRecord fileRecord = fileHandler.convertFileRecord(telegramRecord.id()); FileRecord fileRecord = fileHandler.convertFileRecord(telegramRecord.id());
return DataVerticle.fileRepository.create(fileRecord) return DataVerticle.fileRepository.createIfNotExist(fileRecord)
.compose(r -> .compose(r -> {
client.execute(new TdApi.AddFileToDownloads(fileId, chatId, messageId, 32)) if (!r) {
) return DataVerticle.fileRepository.updateFileId(fileRecord.id(), fileRecord.uniqueId());
.onSuccess(r -> }
return Future.succeededFuture();
})
.compose(ignore -> client.execute(new TdApi.AddFileToDownloads(fileId, chatId, messageId, 32)))
.onSuccess(ignore ->
sendEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject() sendEvent(EventPayload.build(EventPayload.TYPE_FILE_STATUS, new JsonObject()
.put("fileId", fileId) .put("fileId", fileId)
.put("uniqueId", fileRecord.uniqueId()) .put("uniqueId", fileRecord.uniqueId())
@ -430,24 +434,33 @@ public class TelegramVerticle extends AbstractVerticle {
.mapEmpty(); .mapEmpty();
} }
public Future<Void> toggleAutoDownload(Long chatId, JsonObject params) { public Future<Void> updateAutoSettings(Long chatId, JsonObject params) {
return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload) return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload)
.compose(settingAutoRecords -> { .compose(settingAutoRecords -> {
if (settingAutoRecords == null) { if (settingAutoRecords == null) {
settingAutoRecords = new SettingAutoRecords(); settingAutoRecords = new SettingAutoRecords();
} }
if (settingAutoRecords.exists(this.telegramRecord.id(), chatId)) { SettingAutoRecords.Rule rule = params.getJsonObject("rule").mapTo(SettingAutoRecords.Rule.class);
settingAutoRecords.remove(this.telegramRecord.id(), chatId); if (StrUtil.isBlank(rule.query) && CollUtil.isEmpty(rule.fileTypes) && rule.transferRule == null) {
} else { rule = null;
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) && rule.transferRule == null) {
rule = null;
}
}
settingAutoRecords.add(this.telegramRecord.id(), chatId, rule);
} }
boolean downloadEnabled = params.getBoolean("downloadEnabled", false);
boolean preloadEnabled = params.getBoolean("preloadEnabled", false);
SettingAutoRecords.Item item = settingAutoRecords.getItem(this.telegramRecord.id(), chatId);
if (downloadEnabled || preloadEnabled) {
if (item == null) {
item = new SettingAutoRecords.Item(this.telegramRecord.id(), chatId, rule);
}
item.downloadEnabled = downloadEnabled;
item.preloadEnabled = preloadEnabled;
item.rule = downloadEnabled ? rule : null;
settingAutoRecords.add(item);
} else if (item != null) {
settingAutoRecords.remove(this.telegramRecord.id(), chatId);
}
return DataVerticle.settingRepository.createOrUpdate(SettingKey.autoDownload.name(), Json.encode(settingAutoRecords)); return DataVerticle.settingRepository.createOrUpdate(SettingKey.autoDownload.name(), Json.encode(settingAutoRecords));
}) })
.onSuccess(r -> vertx.eventBus().publish(EventEnum.AUTO_DOWNLOAD_UPDATE.name(), r.value())) .onSuccess(r -> vertx.eventBus().publish(EventEnum.AUTO_DOWNLOAD_UPDATE.name(), r.value()))
@ -807,25 +820,22 @@ public class TelegramVerticle extends AbstractVerticle {
//<-----------------------------convert----------------------------------> //<-----------------------------convert---------------------------------->
private Future<JsonArray> convertChat(List<TdApi.Chat> chats) { private Future<JsonArray> convertChat(List<TdApi.Chat> chats) {
return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload) Map<Long, SettingAutoRecords.Item> enableAutoChats = AutoRecordsHolder.INSTANCE.autoRecords().getItems(this.telegramRecord.id());
.map(settingAutoRecords -> settingAutoRecords == null ? new HashMap<Long, SettingAutoRecords.Item>() return Future.succeededFuture(new JsonArray(chats.stream()
: settingAutoRecords.getItems(this.telegramRecord.id())) .map(chat -> {
.map(enableAutoChats -> new JsonArray(chats.stream() SettingAutoRecords.Item auto = enableAutoChats.get(chat.id);
.map(chat -> { return new JsonObject()
SettingAutoRecords.Item autoItem = enableAutoChats.get(chat.id); .put("id", Convert.toStr(chat.id))
return new JsonObject() .put("name", chat.id == this.telegramRecord.id() ? "Saved Messages" : chat.title)
.put("id", Convert.toStr(chat.id)) .put("type", TdApiHelp.getChatType(chat.type))
.put("name", chat.id == this.telegramRecord.id() ? "Saved Messages" : chat.title) .put("avatar", Base64.encode((byte[]) BeanUtil.getProperty(chat, "photo.minithumbnail.data")))
.put("type", TdApiHelp.getChatType(chat.type)) .put("unreadCount", chat.unreadCount)
.put("avatar", Base64.encode((byte[]) BeanUtil.getProperty(chat, "photo.minithumbnail.data"))) .put("lastMessage", "")
.put("unreadCount", chat.unreadCount) .put("lastMessageTime", "")
.put("lastMessage", "") .put("auto", auto);
.put("lastMessageTime", "") })
.put("autoEnabled", autoItem != null) .toList()
.put("autoRule", autoItem == null ? null : autoItem.rule); ));
})
.toList()
));
} }
private Future<JsonObject> convertFiles(Tuple2<TdApi.FoundChatMessages, Map<String, FileRecord>> tuple) { private Future<JsonObject> convertFiles(Tuple2<TdApi.FoundChatMessages, Map<String, FileRecord>> tuple) {

View file

@ -38,9 +38,9 @@ public class TransferVerticle extends AbstractVerticle {
private volatile Transfer beingTransferred; private volatile Transfer beingTransferred;
public TransferVerticle(AutoRecordsHolder autoRecordsHolder) { public TransferVerticle() {
this.autoRecords = autoRecordsHolder.autoRecords(); this.autoRecords = AutoRecordsHolder.INSTANCE.autoRecords();
autoRecordsHolder.registerOnRemoveListener(removedItems -> removedItems.forEach(item -> { AutoRecordsHolder.INSTANCE.registerOnRemoveListener(removedItems -> removedItems.forEach(item -> {
waitingTransferFiles.removeIf(waitingTransferFile -> waitingTransferFile.uniqueId().equals(item.uniqueKey())); waitingTransferFiles.removeIf(waitingTransferFile -> waitingTransferFile.uniqueId().equals(item.uniqueKey()));
transfers.remove(item.uniqueKey()); transfers.remove(item.uniqueKey());
})); }));
@ -109,7 +109,7 @@ public class TransferVerticle extends AbstractVerticle {
log.debug("Start scan history files for transfer"); log.debug("Start scan history files for transfer");
for (SettingAutoRecords.Item item : autoRecords.items) { for (SettingAutoRecords.Item item : autoRecords.items) {
Transfer transfer = getTransfer(item); Transfer transfer = getTransfer(item);
if (transfer == null || !transfer.transferHistory) { if (transfer == null || !transfer.transferHistory || item.isNotComplete(SettingAutoRecords.HISTORY_PRELOAD_STATE)) {
continue; continue;
} }
Tuple3<List<FileRecord>, Long, Long> filesTuple = Future.await(DataVerticle.fileRepository.getFiles(item.chatId, Tuple3<List<FileRecord>, Long, Long> filesTuple = Future.await(DataVerticle.fileRepository.getFiles(item.chatId,
@ -119,6 +119,8 @@ public class TransferVerticle extends AbstractVerticle {
)); ));
List<FileRecord> files = filesTuple.v1; List<FileRecord> files = filesTuple.v1;
if (CollUtil.isEmpty(files)) { if (CollUtil.isEmpty(files)) {
log.debug("No history files found for transfer: %s".formatted(item.uniqueKey()));
item.complete(SettingAutoRecords.HISTORY_TRANSFER_STATE);
continue; continue;
} }

View file

@ -11,6 +11,8 @@ import java.util.Map;
public interface FileRepository { public interface FileRepository {
Future<FileRecord> create(FileRecord fileRecord); Future<FileRecord> create(FileRecord fileRecord);
Future<Boolean> createIfNotExist(FileRecord fileRecord);
Future<Map<Integer, FileRecord>> getFiles(long chatId, List<Integer> fileIds); Future<Map<Integer, FileRecord>> getFiles(long chatId, List<Integer> fileIds);
Future<Tuple3<List<FileRecord>, Long, Long>> getFiles(long chatId, Map<String, String> filter); Future<Tuple3<List<FileRecord>, Long, Long>> getFiles(long chatId, Map<String, String> filter);

View file

@ -1,5 +1,7 @@
package telegram.files.repository; package telegram.files.repository;
import com.fasterxml.jackson.annotation.JsonIgnore;
import telegram.files.MessyUtils;
import telegram.files.Transfer; import telegram.files.Transfer;
import java.util.ArrayList; import java.util.ArrayList;
@ -12,6 +14,12 @@ public class SettingAutoRecords {
public List<Item> items; public List<Item> items;
public static final int HISTORY_PRELOAD_STATE = 1;
public static final int HISTORY_DOWNLOAD_STATE = 2;
public static final int HISTORY_TRANSFER_STATE = 3;
public static class Item { public static class Item {
public long telegramId; public long telegramId;
@ -23,7 +31,17 @@ public class SettingAutoRecords {
public Rule rule; public Rule rule;
public boolean downloadEnabled;
public boolean preloadEnabled;
public long nextFromMessageIdForPreload;
public int state;
public Item() { public Item() {
// downloadEnabled default is true
this.downloadEnabled = true;
} }
public Item(long telegramId, long chatId, Rule rule) { public Item(long telegramId, long chatId, Rule rule) {
@ -35,6 +53,17 @@ public class SettingAutoRecords {
public String uniqueKey() { public String uniqueKey() {
return telegramId + ":" + chatId; return telegramId + ":" + chatId;
} }
public void complete(int bitwise) {
MessyUtils.BitState bitState = new MessyUtils.BitState(state);
bitState.enableState(bitwise);
state = bitState.getState();
}
public boolean isNotComplete(int bitwise) {
MessyUtils.BitState bitState = new MessyUtils.BitState(state);
return !bitState.isStateEnabled(bitwise);
}
} }
public static class Rule { public static class Rule {
@ -102,6 +131,20 @@ public class SettingAutoRecords {
items.removeIf(item -> item.telegramId == telegramId && item.chatId == chatId); items.removeIf(item -> item.telegramId == telegramId && item.chatId == chatId);
} }
@JsonIgnore
public List<Item> getDownloadEnabledItems() {
return items.stream()
.filter(i -> i.downloadEnabled)
.toList();
}
@JsonIgnore
public List<Item> getPreloadEnabledItems() {
return items.stream()
.filter(i -> i.preloadEnabled)
.toList();
}
public Map<Long, Item> getItems(long telegramId) { public Map<Long, Item> getItems(long telegramId) {
return items.stream() return items.stream()
.filter(item -> item.telegramId == telegramId) .filter(item -> item.telegramId == telegramId)

View file

@ -57,11 +57,19 @@ public class FileRepositoryImpl implements FileRepository {
.execute(fileRecord) .execute(fileRecord)
.map(r -> fileRecord) .map(r -> fileRecord)
.compose(r -> this.updateCaptionByMediaAlbumId(fileRecord.mediaAlbumId(), fileRecord.caption()).map(r)) .compose(r -> this.updateCaptionByMediaAlbumId(fileRecord.mediaAlbumId(), fileRecord.caption()).map(r))
.onSuccess(r -> log.trace("Successfully created file record: %s".formatted(fileRecord.id())) .onSuccess(r -> log.trace("Successfully created file record: %s".formatted(fileRecord.id())))
) .onFailure(err -> log.error("Failed to create file record: %s".formatted(err.getMessage())));
.onFailure( }
err -> log.error("Failed to create file record: %s".formatted(err.getMessage()))
); @Override
public Future<Boolean> createIfNotExist(FileRecord fileRecord) {
return this.getByUniqueId(fileRecord.uniqueId())
.compose(record -> {
if (record != null) {
return Future.succeededFuture(false);
}
return this.create(fileRecord).map(true);
});
} }
@Override @Override

View file

@ -28,7 +28,7 @@ public class AutoRecordsHolderTest {
@BeforeEach @BeforeEach
public void setUp() { public void setUp() {
autoRecordsHolder = new AutoRecordsHolder(); autoRecordsHolder = AutoRecordsHolder.INSTANCE;
telegramVerticlesMockedStatic = mockStatic(TelegramVerticles.class); telegramVerticlesMockedStatic = mockStatic(TelegramVerticles.class);
// Prepare test data // Prepare test data

View file

@ -8,16 +8,19 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { type TelegramChat } from "@/lib/types";
interface AutoDownloadButtonProps interface AutoDownloadButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> { extends React.ButtonHTMLAttributes<HTMLButtonElement> {
autoEnabled?: boolean; auto?: TelegramChat["auto"];
} }
const AutoDownloadButton = React.forwardRef< const AutoDownloadButton = React.forwardRef<
HTMLButtonElement, HTMLButtonElement,
AutoDownloadButtonProps AutoDownloadButtonProps
>(({ autoEnabled = false, className, ...props }, ref) => { >(({ auto, className, ...props }, ref) => {
const autoEnabled = auto && (auto.downloadEnabled || auto.preloadEnabled);
return ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@ -40,15 +43,19 @@ const AutoDownloadButton = React.forwardRef<
<div <div
className={cn( className={cn(
"absolute left-[10%] top-[40%] h-2 w-2 scale-[1] rounded-lg bg-primary transition-all duration-300 group-hover:left-[0%] group-hover:top-[0%] group-hover:h-full group-hover:w-full group-hover:scale-[1.8] group-hover:animate-none group-hover:bg-primary", "absolute left-[10%] top-[40%] h-2 w-2 scale-[1] rounded-lg bg-primary transition-all duration-300 group-hover:left-[0%] group-hover:top-[0%] group-hover:h-full group-hover:w-full group-hover:scale-[1.8] group-hover:animate-none group-hover:bg-primary",
autoEnabled ? "animate-breathing bg-green-500" : "bg-red-500", autoEnabled
? auto?.downloadEnabled && auto.preloadEnabled
? "animate-breathing bg-green-500"
: "animate-breathing bg-blue-500"
: "bg-red-500",
)} )}
></div> ></div>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{autoEnabled {autoEnabled
? "Auto download is enabled, you can disable it by clicking the button" ? "Automation is enabled, you can disable it by clicking the button"
: "Auto download is disabled, you can enable it by clicking the button"} : "Automation is disabled, you can enable it by clicking the button"}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>

View file

@ -8,7 +8,7 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import React, { useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import useSWRMutation from "swr/mutation"; import useSWRMutation from "swr/mutation";
import { POST } from "@/lib/api"; import { POST } from "@/lib/api";
import { useDebounce } from "use-debounce"; import { useDebounce } from "use-debounce";
@ -55,6 +55,9 @@ export default function AutoDownloadDialog() {
const { isLoading, chat, reload } = useTelegramChat(); const { isLoading, chat, reload } = useTelegramChat();
const { toast } = useToast(); const { toast } = useToast();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [downloadEnabled, setDownloadEnabled] = useState(false);
const [preloadEnabled, setPreloadEnabled] = useState(false);
const [rule, setRule] = useState<AutoDownloadRule>({ const [rule, setRule] = useState<AutoDownloadRule>({
query: "", query: "",
fileTypes: [], fileTypes: [],
@ -62,18 +65,28 @@ export default function AutoDownloadDialog() {
const { trigger: triggerAuto, isMutating: isAutoMutating } = useSWRMutation( const { trigger: triggerAuto, isMutating: isAutoMutating } = useSWRMutation(
!accountId || !chat !accountId || !chat
? undefined ? undefined
: `/${accountId}/file/auto-download?telegramId=${accountId}&chatId=${chat?.id}`, : `/${accountId}/file/update-auto-settings?telegramId=${accountId}&chatId=${chat?.id}`,
(key, { arg }: { arg: { rule: AutoDownloadRule } }) => { (
key,
{
arg,
}: {
arg: {
downloadEnabled: boolean;
preloadEnabled: boolean;
rule: AutoDownloadRule;
};
},
) => {
return POST(key, arg); return POST(key, arg);
}, },
{ {
onSuccess: () => { onSuccess: () => {
toast({ toast({
title: chat?.autoEnabled title: "Auto settings updated!",
? "Auto download disabled for this chat!"
: "Auto download enabled for this chat!",
}); });
void reload(); void reload();
setEditMode(false);
setTimeout(() => { setTimeout(() => {
setOpen(false); setOpen(false);
}, 1000); }, 1000);
@ -85,6 +98,18 @@ export default function AutoDownloadDialog() {
leading: true, leading: true,
}); });
useEffect(() => {
if (chat?.auto) {
setDownloadEnabled(chat.auto.downloadEnabled);
setPreloadEnabled(chat.auto.preloadEnabled);
setRule(chat.auto.rule ?? { query: "", fileTypes: [] });
} else {
setDownloadEnabled(false);
setPreloadEnabled(false);
setRule({ query: "", fileTypes: [] });
}
}, [chat]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="h-8 w-32 animate-pulse bg-gray-200 dark:bg-gray-700"></div> <div className="h-8 w-32 animate-pulse bg-gray-200 dark:bg-gray-700"></div>
@ -100,202 +125,269 @@ export default function AutoDownloadDialog() {
setOpen(!open); setOpen(!open);
}} }}
> >
{chat && <AutoDownloadButton autoEnabled={chat.autoEnabled} />} {chat && <AutoDownloadButton auto={chat.auto} />}
</DialogTrigger> </DialogTrigger>
<DialogContent <DialogContent
aria-describedby={undefined} aria-describedby={undefined}
onPointerDownOutside={() => setOpen(false)} onPointerDownOutside={() => setOpen(false)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="h-full w-full overflow-auto md:h-auto md:max-h-[85%] md:w-auto" className="h-full w-full overflow-auto md:h-auto md:max-h-[85%] md:min-w-[400px]"
> >
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{chat?.autoEnabled Update Auto Settings for {chat?.name ?? "Unknown Chat"}
? "Disable Auto Download"
: "Enable auto download for this chat?"}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<DialogDescription></DialogDescription> <DialogDescription></DialogDescription>
{chat?.autoEnabled ? ( {!editMode && chat?.auto ? (
<div className="space-y-4"> <div className="space-y-4">
{chat.autoRule && ( <div className="space-y-4 rounded-md border border-gray-200 p-4 dark:border-gray-700">
<div className="space-y-4"> <div className="flex items-center justify-between">
<div className="flex items-center"> <Label className="text-sm font-semibold text-gray-900 dark:text-gray-300">
<Label className="text-sm font-semibold text-gray-900 dark:text-gray-300"> Auto preload
Current Rule </Label>
</Label> <Badge
<Badge variant="outline"
variant="outline" className={cn(
className="ml-2 border-none bg-green-500 px-2 py-0.5 text-xs text-white dark:bg-green-800 dark:text-green-200" "border-none bg-green-500 px-2 py-0.5 text-xs text-white dark:bg-green-800 dark:text-green-200",
> chat.auto.preloadEnabled
Active ? "bg-green-500 dark:bg-green-800 dark:text-green-200"
</Badge> : "bg-gray-500 dark:bg-gray-800 dark:text-gray-300",
</div> )}
>
<div className="space-y-3"> {chat.auto.preloadEnabled ? "Enabled" : "Disabled"}
{/* Filter Keyword Section */} </Badge>
<div className="rounded-lg bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"> </div>
<div className="flex flex-col space-y-1"> {(chat.auto.state & (1 << 1)) != 0 && (
<span className="text-xs font-medium text-gray-500"> <p className="text-xs text-muted-foreground">
Filter Keyword All historical files are preloaded.
</span> </p>
<span className="text-sm text-gray-500 dark:text-gray-300"> )}
{chat.autoRule.query || "No keyword specified"} </div>
</span> <div className="space-y-4 rounded-md border border-gray-200 p-4 dark:border-gray-700">
</div> <div className="flex items-center justify-between">
</div> <Label className="text-sm font-semibold text-gray-900 dark:text-gray-300">
Auto download
<div className="rounded-lg bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800"> </Label>
<span className="text-xs font-medium text-gray-500"> <Badge
File Types variant="outline"
</span> className={cn(
<div className="mt-2 flex flex-wrap gap-2"> "border-none px-2 py-0.5 text-xs text-white",
{chat.autoRule.fileTypes.length > 0 ? ( chat.auto.downloadEnabled
chat.autoRule.fileTypes.map((type) => ( ? "bg-green-500 dark:bg-green-800 dark:text-green-200"
<Badge : "bg-gray-500 dark:bg-gray-800 dark:text-gray-300",
key={type} )}
variant="secondary" >
className="flex items-center gap-1 border-gray-200 bg-white px-3 py-1 capitalize text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300" {chat.auto.downloadEnabled ? "Enabled" : "Disabled"}
> </Badge>
{type} </div>
</Badge> {(chat.auto.state & (1 << 2)) != 0 && (
)) <p className="text-xs text-muted-foreground">
) : ( All historical files are downloaded.
<span className="text-sm text-gray-500 dark:text-gray-300"> </p>
No file types selected )}
</span> {chat.auto.rule && (
)} <div className="space-y-4">
</div> <div className="space-y-3">
</div> {/* Filter Keyword Section */}
{/* 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="rounded-lg bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800">
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-1">
<span className="text-xs font-medium text-gray-500"> <span className="text-xs font-medium text-gray-500">
Transfer Rule Filter Keyword
</span> </span>
<div className="mt-2 flex flex-col space-y-2"> <span className="text-sm text-gray-500 dark:text-gray-300">
<div className="flex flex-col space-y-1"> {chat.auto.rule.query || "No keyword specified"}
<span className="text-xs text-gray-500 dark:text-gray-300"> </span>
Destination Folder </div>
</span> </div>
<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} <div className="rounded-lg bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800">
</span> <span className="text-xs font-medium text-gray-500">
</div> File Types
<div className="flex items-center justify-between"> </span>
<span className="text-xs text-gray-500 dark:text-gray-300"> <div className="mt-2 flex flex-wrap gap-2">
Transfer Policy {chat.auto.rule.fileTypes.length > 0 ? (
</span> chat.auto.rule.fileTypes.map((type) => (
<Badge variant="outline" className="font-normal"> <Badge
{chat.autoRule.transferRule.transferPolicy} key={type}
</Badge> variant="secondary"
</div> className="flex items-center gap-1 border-gray-200 bg-white px-3 py-1 capitalize text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
<div className="flex items-center justify-between"> >
<span className="text-xs text-gray-500 dark:text-gray-300"> {type}
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> </Badge>
))
) : (
<span className="text-sm text-gray-500 dark:text-gray-300">
No file types selected
</span>
)}
</div>
</div>
{/* Transfer Rule Section */}
{chat.auto.rule.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.auto.rule.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.auto.rule.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.auto.rule.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.auto.rule.transferRule.transferHistory
? "Enabled"
: "Disabled"}
</Badge>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> )}
)} </div>
</div> </div>
</div> )}
)}
<div className="space-y-4 rounded-md border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-start">
<span className="mr-3 mt-1.5 h-3 w-2 flex-shrink-0 rounded-full bg-yellow-400"></span>
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
This will disable auto download for this chat. You can always
enable it later.
</p>
</div>
<div className="flex items-start">
<span className="mr-3 mt-1.5 h-3 w-2 flex-shrink-0 rounded-full bg-yellow-400"></span>
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
Files that are being downloaded will be paused and you can
enable automatic downloading again later.
</p>
</div>
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-4 rounded-md border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"> <div className="space-y-4 rounded-md border border-gray-200 p-4 dark:border-gray-700">
<div className="flex items-start"> <div className="flex items-center justify-between">
<span className="mr-3 mt-1.5 h-3 w-2 flex-shrink-0 rounded-full bg-cyan-400"></span> <Label htmlFor="enable-preload">Enable Preload</Label>
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300"> <Switch
This will enable auto download for this chat. Files will be id="enable-preload"
downloaded automatically. checked={preloadEnabled}
</p> onCheckedChange={setPreloadEnabled}
</div> />
<div className="flex items-start">
<span className="mr-3 mt-1.5 h-3 w-2 flex-shrink-0 rounded-full bg-cyan-400"></span>
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
Files in historical messages will be downloaded first, and
then files in new messages will be downloaded automatically.
</p>
</div>
<div className="flex items-start">
<span className="mr-3 mt-1.5 h-3 w-2 flex-shrink-0 rounded-full bg-cyan-400"></span>
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
Download Order:
<span className="ml-1 rounded bg-blue-100 px-2 text-blue-700 dark:bg-blue-800 dark:text-blue-200">
{"Photo -> Video -> Audio -> File"}
</span>
</p>
</div> </div>
{preloadEnabled && (
<div className="space-y-4 rounded-md border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-start">
<span className="mr-3 mt-1.5 h-3 w-2 flex-shrink-0 rounded-full bg-cyan-400"></span>
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
This will enable preload for this chat. All files will be
loaded, but not downloaded, then you can search offline.
</p>
</div>
</div>
)}
</div>
<div className="space-y-4 rounded-md border border-gray-200 p-4 dark:border-gray-700">
<div className="flex items-center justify-between">
<Label htmlFor="enable-auto-download">
Enable Auto Download
</Label>
<Switch
id="enable-auto-download"
checked={downloadEnabled}
onCheckedChange={(checked) => {
if (checked) {
setRule({
query: "",
fileTypes: [],
});
}
setDownloadEnabled(checked);
}}
/>
</div>
{downloadEnabled && (
<>
<div className="space-y-4 rounded-md border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800">
<div className="flex items-start">
<span className="mr-3 mt-1.5 h-3 w-2 flex-shrink-0 rounded-full bg-cyan-400"></span>
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
This will enable auto download for this chat. Files will
be downloaded automatically.
</p>
</div>
<div className="flex items-start">
<span className="mr-3 mt-1.5 h-3 w-2 flex-shrink-0 rounded-full bg-cyan-400"></span>
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
Files in historical messages will be downloaded first,
and then files in new messages will be downloaded
automatically.
</p>
</div>
<div className="flex items-start">
<span className="mr-3 mt-1.5 h-3 w-2 flex-shrink-0 rounded-full bg-cyan-400"></span>
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">
Download Order:
<span className="ml-1 rounded bg-blue-100 px-2 text-blue-700 dark:bg-blue-800 dark:text-blue-200">
{"Photo -> Video -> Audio -> File"}
</span>
</p>
</div>
</div>
<AutoDownloadRule value={rule} onChange={setRule} />
</>
)}
</div> </div>
<AutoDownloadRule value={rule} onChange={setRule} />
</div> </div>
)} )}
<DialogFooter className="gap-2"> <DialogFooter className="gap-2">
<Button {!editMode && chat?.auto ? (
variant="outline" <Button variant="outline" onClick={() => setEditMode(true)}>
onClick={() => setOpen(false)} Edit
disabled={debounceIsAutoMutating} </Button>
> ) : (
Cancel <>
</Button> <Button
<Button variant="outline"
onClick={() => { onClick={() => setOpen(false)}
const folderPathRegex = /^[\/\\]?(?:[^<>:"|?*\/\\]+[\/\\]?)*$/; disabled={debounceIsAutoMutating}
if ( >
rule.transferRule && Cancel
!folderPathRegex.test(rule.transferRule.destination) </Button>
) { <Button
toast({ onClick={() => {
title: "Invalid destination folder", const folderPathRegex =
description: "Please enter a valid destination folder path", /^[\/\\]?(?:[^<>:"|?*\/\\]+[\/\\]?)*$/;
}); if (
return; rule.transferRule &&
} !folderPathRegex.test(rule.transferRule.destination)
void triggerAuto({ rule }); ) {
}} toast({
variant={chat?.autoEnabled ? "destructive" : "default"} title: "Invalid destination folder",
disabled={debounceIsAutoMutating} description:
> "Please enter a valid destination folder path",
{debounceIsAutoMutating });
? "Submitting..." return;
: chat?.autoEnabled }
? "Disable" void triggerAuto({ downloadEnabled, preloadEnabled, rule });
: "Enable"} }}
</Button> disabled={debounceIsAutoMutating}
>
{debounceIsAutoMutating ? "Submitting..." : "Submit"}
</Button>
</>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -546,11 +638,13 @@ const PolicyLegends: Record<
}, },
OVERWRITE: { OVERWRITE: {
title: "Overwrite", title: "Overwrite",
description: "If destination exists same name file, move and overwrite the file.", description:
"If destination exists same name file, move and overwrite the file.",
}, },
SKIP: { SKIP: {
title: "Skip", title: "Skip",
description: "If destination exists same name file, skip the file, nothing to do.", description:
"If destination exists same name file, skip the file, nothing to do.",
}, },
RENAME: { RENAME: {
title: "Rename", title: "Rename",

View file

@ -10,6 +10,7 @@ import useSWR from "swr";
import { Ellipsis } from "lucide-react"; import { Ellipsis } from "lucide-react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import * as React from "react"; import * as React from "react";
import { useEffect } from "react";
interface FileTypeFilterProps { interface FileTypeFilterProps {
offline: boolean; offline: boolean;
@ -55,6 +56,13 @@ export default function FileTypeFilter({
); );
}; };
useEffect(() => {
if (!offline && localType === "all") {
setLocalType("media");
onChange("media");
}
}, [localType, offline, onChange]);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<Label>Type</Label> <Label>Type</Label>
@ -63,7 +71,7 @@ export default function FileTypeFilter({
<SelectValue placeholder="File type" /> <SelectValue placeholder="File type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{offline && <SelectItem value="all">All files</SelectItem>} {offline && <SelectItem value="all">All Files</SelectItem>}
<FileTypeSelectItem value="media" /> <FileTypeSelectItem value="media" />
<FileTypeSelectItem value="photo" /> <FileTypeSelectItem value="photo" />
<FileTypeSelectItem value="video" /> <FileTypeSelectItem value="video" />

View file

@ -6,7 +6,7 @@ export default function TelegramIcon({ className }: { className?: string }) {
role="img" role="img"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className={cn(className, "dark:fill-current")} className={cn(className, "dark:fill-white")}
> >
<title>Telegram</title> <title>Telegram</title>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" /> <path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />

View file

@ -19,8 +19,12 @@ export type TelegramChat = {
unreadCount?: number; unreadCount?: number;
lastMessage?: string; lastMessage?: string;
lastMessageTime?: string; lastMessageTime?: string;
autoEnabled: boolean; auto?: {
autoRule?: AutoDownloadRule; preloadEnabled: boolean;
downloadEnabled: boolean;
rule?: AutoDownloadRule;
state: number;
};
}; };
export type FileType = "media" | "photo" | "video" | "audio" | "file"; export type FileType = "media" | "photo" | "video" | "audio" | "file";