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.Future;
import io.vertx.core.Promise;
import io.vertx.core.json.Json;
import io.vertx.core.json.JsonObject;
import org.drinkless.tdlib.TdApi;
import org.jooq.lambda.tuple.Tuple2;
@ -47,9 +46,9 @@ public class AutoDownloadVerticle extends AbstractVerticle {
private int limit = DEFAULT_LIMIT;
public AutoDownloadVerticle(AutoRecordsHolder autoRecordsHolder) {
this.autoRecords = autoRecordsHolder.autoRecords();
autoRecordsHolder.registerOnRemoveListener(removedItems -> removedItems.forEach(item ->
public AutoDownloadVerticle() {
this.autoRecords = AutoRecordsHolder.INSTANCE.autoRecords();
AutoRecordsHolder.INSTANCE.registerOnRemoveListener(removedItems -> removedItems.forEach(item ->
waitingDownloadMessages.getOrDefault(item.telegramId, new LinkedList<>())
.removeIf(m -> m.chatId == item.chatId)));
}
@ -60,7 +59,10 @@ public class AutoDownloadVerticle extends AbstractVerticle {
.compose(v -> this.initEventConsumer())
.onSuccess(v -> {
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,
id -> waitingDownloadMessages.keySet().forEach(this::download));
@ -70,7 +72,7 @@ public class AutoDownloadVerticle extends AbstractVerticle {
|Download interval: %s ms
|Download limit: %s per telegram account!
|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();
})
@ -78,12 +80,8 @@ public class AutoDownloadVerticle extends AbstractVerticle {
}
@Override
public void stop(Promise<Void> stopPromise) {
saveAutoRecords()
.onComplete(r -> {
public void stop() {
log.info("Auto download verticle stopped!");
stopPromise.complete();
});
}
private Future<Void> initAutoDownload() {
@ -109,19 +107,6 @@ public class AutoDownloadVerticle extends AbstractVerticle {
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) {
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) {
@ -158,12 +143,21 @@ public class AutoDownloadVerticle extends AbstractVerticle {
addHistoryMessage(auto, currentTimeMillis);
} else {
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 {
DataVerticle.fileRepository.getFilesByUniqueId(TdApiHelp.getFileUniqueIds(Arrays.asList(foundChatMessages.messages)))
.onSuccess(existFiles -> {
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();
if (CollUtil.isEmpty(messages)) {
auto.nextFromMessageId = foundChatMessages.nextFromMessageId;
@ -259,7 +253,7 @@ public class AutoDownloadVerticle extends AbstractVerticle {
long telegramId = jsonObject.getLong("telegramId");
long chatId = jsonObject.getLong("chatId");
long messageId = jsonObject.getLong("messageId");
autoRecords.items.stream()
autoRecords.getDownloadEnabledItems().stream()
.filter(item -> item.telegramId == telegramId && item.chatId == chatId)
.findFirst()
.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.LogFactory;
import io.vertx.core.Future;
import io.vertx.core.json.Json;
import telegram.files.repository.SettingAutoRecords;
import telegram.files.repository.SettingKey;
@ -18,7 +19,11 @@ public class AutoRecordsHolder {
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() {
@ -29,9 +34,13 @@ public class AutoRecordsHolder {
onRemoveListeners.add(onRemove);
}
public Future<Void> init() {
public synchronized Future<Void> init() {
if (initialized) {
return Future.succeededFuture();
}
return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload)
.onSuccess(settingAutoRecords -> {
initialized = true;
if (settingAutoRecords == null) {
return;
}
@ -77,4 +86,17 @@ public class AutoRecordsHolder {
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
private final Map<String, TelegramVerticle> sessionTelegramVerticles = new ConcurrentHashMap<>();
private final AutoRecordsHolder autoRecordsHolder = new AutoRecordsHolder();
private final FileRouteHandler fileRouteHandler = new FileRouteHandler();
private static final String SESSION_COOKIE_NAME = "tf";
@ -54,17 +52,22 @@ public class HttpVerticle extends AbstractVerticle {
public void start(Promise<Void> startPromise) {
initHttpServer()
.compose(r -> initTelegramVerticles())
.compose(r -> autoRecordsHolder.init())
.compose(r -> AutoRecordsHolder.INSTANCE.init())
.compose(r -> initAutoDownloadVerticle())
.compose(r -> initTransferVerticle())
.compose(r -> initPreloadMessageVerticle())
.compose(r -> initEventConsumer())
.onSuccess(startPromise::complete)
.onFailure(startPromise::fail);
}
@Override
public void stop() {
public void stop(Promise<Void> stopPromise) {
AutoRecordsHolder.INSTANCE.saveAutoRecords()
.onComplete(ignore -> {
log.info("Http verticle stopped!");
stopPromise.complete();
});
}
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/toggle-pause-download").handler(this::handleFileTogglePauseDownload);
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()
.failureHandler(ctx -> {
@ -182,15 +185,19 @@ public class HttpVerticle extends AbstractVerticle {
}
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();
}
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();
}
public Future<Void> initPreloadMessageVerticle() {
return vertx.deployVerticle(new PreloadMessageVerticle(), Config.VIRTUAL_THREAD_DEPLOYMENT_OPTIONS)
.mapEmpty();
}
private Future<Void> initEventConsumer() {
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 -> {
log.debug("Auto download update: %s".formatted(message.body()));
autoRecordsHolder.onAutoRecordsUpdate(Json.decodeValue(message.body().toString(), SettingAutoRecords.class));
log.debug("Auto settings update: %s".formatted(message.body()));
AutoRecordsHolder.INSTANCE.onAutoRecordsUpdate(Json.decodeValue(message.body().toString(), SettingAutoRecords.class));
});
return Future.succeededFuture();
}
@ -607,7 +614,7 @@ public class HttpVerticle extends AbstractVerticle {
.onFailure(ctx::fail);
}
private void handleAutoDownload(RoutingContext ctx) {
private void handleAutoSettingsUpdate(RoutingContext ctx) {
TelegramVerticle telegramVerticle = TelegramVerticles.getOrElseThrow(ctx.pathParam("telegramId"));
String chatId = ctx.request().getParam("chatId");
@ -616,7 +623,7 @@ public class HttpVerticle extends AbstractVerticle {
return;
}
JsonObject params = ctx.body().asJsonObject();
telegramVerticle.toggleAutoDownload(Convert.toLong(chatId), params)
telegramVerticle.updateAutoSettings(Convert.toLong(chatId), params)
.onSuccess(r -> ctx.end())
.onFailure(ctx::fail);
}

View file

@ -85,4 +85,51 @@ public class MessyUtils {
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 -> {
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)
.orElseThrow(() -> new NoStackTraceException("not support message type"));
FileRecord fileRecord = fileHandler.convertFileRecord(telegramRecord.id());
return DataVerticle.fileRepository.create(fileRecord)
.compose(r ->
client.execute(new TdApi.AddFileToDownloads(fileId, chatId, messageId, 32))
)
.onSuccess(r ->
return DataVerticle.fileRepository.createIfNotExist(fileRecord)
.compose(r -> {
if (!r) {
return DataVerticle.fileRepository.updateFileId(fileRecord.id(), fileRecord.uniqueId());
}
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()
.put("fileId", fileId)
.put("uniqueId", fileRecord.uniqueId())
@ -430,24 +434,33 @@ public class TelegramVerticle extends AbstractVerticle {
.mapEmpty();
}
public Future<Void> toggleAutoDownload(Long chatId, JsonObject params) {
public Future<Void> updateAutoSettings(Long chatId, JsonObject params) {
return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload)
.compose(settingAutoRecords -> {
if (settingAutoRecords == null) {
settingAutoRecords = new SettingAutoRecords();
}
if (settingAutoRecords.exists(this.telegramRecord.id(), chatId)) {
settingAutoRecords.remove(this.telegramRecord.id(), chatId);
} else {
SettingAutoRecords.Rule rule = null;
if (params != null && params.containsKey("rule")) {
rule = params.getJsonObject("rule").mapTo(SettingAutoRecords.Rule.class);
SettingAutoRecords.Rule rule = params.getJsonObject("rule").mapTo(SettingAutoRecords.Rule.class);
if (StrUtil.isBlank(rule.query) && CollUtil.isEmpty(rule.fileTypes) && rule.transferRule == null) {
rule = null;
}
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);
}
settingAutoRecords.add(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));
})
.onSuccess(r -> vertx.eventBus().publish(EventEnum.AUTO_DOWNLOAD_UPDATE.name(), r.value()))
@ -807,12 +820,10 @@ public class TelegramVerticle extends AbstractVerticle {
//<-----------------------------convert---------------------------------->
private Future<JsonArray> convertChat(List<TdApi.Chat> chats) {
return DataVerticle.settingRepository.<SettingAutoRecords>getByKey(SettingKey.autoDownload)
.map(settingAutoRecords -> settingAutoRecords == null ? new HashMap<Long, SettingAutoRecords.Item>()
: settingAutoRecords.getItems(this.telegramRecord.id()))
.map(enableAutoChats -> new JsonArray(chats.stream()
Map<Long, SettingAutoRecords.Item> enableAutoChats = AutoRecordsHolder.INSTANCE.autoRecords().getItems(this.telegramRecord.id());
return Future.succeededFuture(new JsonArray(chats.stream()
.map(chat -> {
SettingAutoRecords.Item autoItem = enableAutoChats.get(chat.id);
SettingAutoRecords.Item auto = enableAutoChats.get(chat.id);
return new JsonObject()
.put("id", Convert.toStr(chat.id))
.put("name", chat.id == this.telegramRecord.id() ? "Saved Messages" : chat.title)
@ -821,8 +832,7 @@ public class TelegramVerticle extends AbstractVerticle {
.put("unreadCount", chat.unreadCount)
.put("lastMessage", "")
.put("lastMessageTime", "")
.put("autoEnabled", autoItem != null)
.put("autoRule", autoItem == null ? null : autoItem.rule);
.put("auto", auto);
})
.toList()
));

View file

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

View file

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

View file

@ -1,5 +1,7 @@
package telegram.files.repository;
import com.fasterxml.jackson.annotation.JsonIgnore;
import telegram.files.MessyUtils;
import telegram.files.Transfer;
import java.util.ArrayList;
@ -12,6 +14,12 @@ public class SettingAutoRecords {
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 long telegramId;
@ -23,7 +31,17 @@ public class SettingAutoRecords {
public Rule rule;
public boolean downloadEnabled;
public boolean preloadEnabled;
public long nextFromMessageIdForPreload;
public int state;
public Item() {
// downloadEnabled default is true
this.downloadEnabled = true;
}
public Item(long telegramId, long chatId, Rule rule) {
@ -35,6 +53,17 @@ public class SettingAutoRecords {
public String uniqueKey() {
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 {
@ -102,6 +131,20 @@ public class SettingAutoRecords {
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) {
return items.stream()
.filter(item -> item.telegramId == telegramId)

View file

@ -57,11 +57,19 @@ public class FileRepositoryImpl implements FileRepository {
.execute(fileRecord)
.map(r -> fileRecord)
.compose(r -> this.updateCaptionByMediaAlbumId(fileRecord.mediaAlbumId(), fileRecord.caption()).map(r))
.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()))
);
.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())));
}
@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

View file

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

View file

@ -8,16 +8,19 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { type TelegramChat } from "@/lib/types";
interface AutoDownloadButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
autoEnabled?: boolean;
auto?: TelegramChat["auto"];
}
const AutoDownloadButton = React.forwardRef<
HTMLButtonElement,
AutoDownloadButtonProps
>(({ autoEnabled = false, className, ...props }, ref) => {
>(({ auto, className, ...props }, ref) => {
const autoEnabled = auto && (auto.downloadEnabled || auto.preloadEnabled);
return (
<TooltipProvider>
<Tooltip>
@ -40,15 +43,19 @@ const AutoDownloadButton = React.forwardRef<
<div
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",
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>
</button>
</TooltipTrigger>
<TooltipContent>
{autoEnabled
? "Auto download is enabled, you can disable it by clicking the button"
: "Auto download is disabled, you can enable it by clicking the button"}
? "Automation is enabled, you can disable it by clicking the button"
: "Automation is disabled, you can enable it by clicking the button"}
</TooltipContent>
</Tooltip>
</TooltipProvider>

View file

@ -8,7 +8,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
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 { POST } from "@/lib/api";
import { useDebounce } from "use-debounce";
@ -55,6 +55,9 @@ export default function AutoDownloadDialog() {
const { isLoading, chat, reload } = useTelegramChat();
const { toast } = useToast();
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>({
query: "",
fileTypes: [],
@ -62,18 +65,28 @@ export default function AutoDownloadDialog() {
const { trigger: triggerAuto, isMutating: isAutoMutating } = useSWRMutation(
!accountId || !chat
? undefined
: `/${accountId}/file/auto-download?telegramId=${accountId}&chatId=${chat?.id}`,
(key, { arg }: { arg: { rule: AutoDownloadRule } }) => {
: `/${accountId}/file/update-auto-settings?telegramId=${accountId}&chatId=${chat?.id}`,
(
key,
{
arg,
}: {
arg: {
downloadEnabled: boolean;
preloadEnabled: boolean;
rule: AutoDownloadRule;
};
},
) => {
return POST(key, arg);
},
{
onSuccess: () => {
toast({
title: chat?.autoEnabled
? "Auto download disabled for this chat!"
: "Auto download enabled for this chat!",
title: "Auto settings updated!",
});
void reload();
setEditMode(false);
setTimeout(() => {
setOpen(false);
}, 1000);
@ -85,6 +98,18 @@ export default function AutoDownloadDialog() {
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) {
return (
<div className="h-8 w-32 animate-pulse bg-gray-200 dark:bg-gray-700"></div>
@ -100,38 +125,69 @@ export default function AutoDownloadDialog() {
setOpen(!open);
}}
>
{chat && <AutoDownloadButton autoEnabled={chat.autoEnabled} />}
{chat && <AutoDownloadButton auto={chat.auto} />}
</DialogTrigger>
<DialogContent
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"
className="h-full w-full overflow-auto md:h-auto md:max-h-[85%] md:min-w-[400px]"
>
<DialogHeader>
<DialogTitle>
{chat?.autoEnabled
? "Disable Auto Download"
: "Enable auto download for this chat?"}
Update Auto Settings for {chat?.name ?? "Unknown Chat"}
</DialogTitle>
</DialogHeader>
<DialogDescription></DialogDescription>
{chat?.autoEnabled ? (
{!editMode && chat?.auto ? (
<div className="space-y-4">
{chat.autoRule && (
<div className="space-y-4">
<div className="flex items-center">
<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 className="text-sm font-semibold text-gray-900 dark:text-gray-300">
Current Rule
Auto preload
</Label>
<Badge
variant="outline"
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"
className={cn(
"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
? "bg-green-500 dark:bg-green-800 dark:text-green-200"
: "bg-gray-500 dark:bg-gray-800 dark:text-gray-300",
)}
>
Active
{chat.auto.preloadEnabled ? "Enabled" : "Disabled"}
</Badge>
</div>
{(chat.auto.state & (1 << 1)) != 0 && (
<p className="text-xs text-muted-foreground">
All historical files are preloaded.
</p>
)}
</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 className="text-sm font-semibold text-gray-900 dark:text-gray-300">
Auto download
</Label>
<Badge
variant="outline"
className={cn(
"border-none px-2 py-0.5 text-xs text-white",
chat.auto.downloadEnabled
? "bg-green-500 dark:bg-green-800 dark:text-green-200"
: "bg-gray-500 dark:bg-gray-800 dark:text-gray-300",
)}
>
{chat.auto.downloadEnabled ? "Enabled" : "Disabled"}
</Badge>
</div>
{(chat.auto.state & (1 << 2)) != 0 && (
<p className="text-xs text-muted-foreground">
All historical files are downloaded.
</p>
)}
{chat.auto.rule && (
<div className="space-y-4">
<div className="space-y-3">
{/* Filter Keyword Section */}
<div className="rounded-lg bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800">
@ -140,7 +196,7 @@ export default function AutoDownloadDialog() {
Filter Keyword
</span>
<span className="text-sm text-gray-500 dark:text-gray-300">
{chat.autoRule.query || "No keyword specified"}
{chat.auto.rule.query || "No keyword specified"}
</span>
</div>
</div>
@ -150,8 +206,8 @@ export default function AutoDownloadDialog() {
File Types
</span>
<div className="mt-2 flex flex-wrap gap-2">
{chat.autoRule.fileTypes.length > 0 ? (
chat.autoRule.fileTypes.map((type) => (
{chat.auto.rule.fileTypes.length > 0 ? (
chat.auto.rule.fileTypes.map((type) => (
<Badge
key={type}
variant="secondary"
@ -169,7 +225,7 @@ export default function AutoDownloadDialog() {
</div>
{/* Transfer Rule Section */}
{chat.autoRule.transferRule && (
{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">
@ -181,7 +237,7 @@ export default function AutoDownloadDialog() {
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}
{chat.auto.rule.transferRule.destination}
</span>
</div>
<div className="flex items-center justify-between">
@ -189,7 +245,7 @@ export default function AutoDownloadDialog() {
Transfer Policy
</span>
<Badge variant="outline" className="font-normal">
{chat.autoRule.transferRule.transferPolicy}
{chat.auto.rule.transferRule.transferPolicy}
</Badge>
</div>
<div className="flex items-center justify-between">
@ -197,7 +253,7 @@ export default function AutoDownloadDialog() {
Duplication Policy
</span>
<Badge variant="outline" className="font-normal">
{chat.autoRule.transferRule.duplicationPolicy}
{chat.auto.rule.transferRule.duplicationPolicy}
</Badge>
</div>
<div className="flex items-center justify-between">
@ -205,7 +261,7 @@ export default function AutoDownloadDialog() {
Transfer History
</span>
<Badge>
{chat.autoRule.transferRule.transferHistory
{chat.auto.rule.transferRule.transferHistory
? "Enabled"
: "Disabled"}
</Badge>
@ -217,38 +273,66 @@ export default function AutoDownloadDialog() {
</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 className="space-y-4">
<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-preload">Enable Preload</Label>
<Switch
id="enable-preload"
checked={preloadEnabled}
onCheckedChange={setPreloadEnabled}
/>
</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 auto download for this chat. Files will be
downloaded automatically.
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.
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">
@ -262,9 +346,18 @@ export default function AutoDownloadDialog() {
</div>
</div>
<AutoDownloadRule value={rule} onChange={setRule} />
</>
)}
</div>
</div>
)}
<DialogFooter className="gap-2">
{!editMode && chat?.auto ? (
<Button variant="outline" onClick={() => setEditMode(true)}>
Edit
</Button>
) : (
<>
<Button
variant="outline"
onClick={() => setOpen(false)}
@ -274,28 +367,27 @@ export default function AutoDownloadDialog() {
</Button>
<Button
onClick={() => {
const folderPathRegex = /^[\/\\]?(?:[^<>:"|?*\/\\]+[\/\\]?)*$/;
const folderPathRegex =
/^[\/\\]?(?:[^<>:"|?*\/\\]+[\/\\]?)*$/;
if (
rule.transferRule &&
!folderPathRegex.test(rule.transferRule.destination)
) {
toast({
title: "Invalid destination folder",
description: "Please enter a valid destination folder path",
description:
"Please enter a valid destination folder path",
});
return;
}
void triggerAuto({ rule });
void triggerAuto({ downloadEnabled, preloadEnabled, rule });
}}
variant={chat?.autoEnabled ? "destructive" : "default"}
disabled={debounceIsAutoMutating}
>
{debounceIsAutoMutating
? "Submitting..."
: chat?.autoEnabled
? "Disable"
: "Enable"}
{debounceIsAutoMutating ? "Submitting..." : "Submit"}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
@ -546,11 +638,13 @@ const PolicyLegends: Record<
},
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: {
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: {
title: "Rename",

View file

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

View file

@ -6,7 +6,7 @@ export default function TelegramIcon({ className }: { className?: string }) {
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className={cn(className, "dark:fill-current")}
className={cn(className, "dark:fill-white")}
>
<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" />

View file

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