feat: TikTok temp downloads + Lavalink timeout fix + ModerationLogger null check
- TikTok: videos >8MB saved to temp files, download link via /d/{key} (24h TTL)
- TempFileManager: persistent JSON metadata, lazy cleanup
- HealthHttpServer: added /d/{key} endpoint for file downloads
- BotConfig: added externalUrl field (EXTERNAL_URL env var)
- LavalinkManager: httpTimeout 30s → 60s
- BotInitializer: stagger delays between bot Lavalink connections
- ModerationLogger: null check for log_channel_id config
- TikTokCommand: empty title fallback (Title may not be empty fix)
This commit is contained in:
parent
43735de681
commit
ce1d863009
11 changed files with 334 additions and 76 deletions
|
|
@ -18,6 +18,7 @@ public class BotConfig {
|
||||||
private final int aiDossierUpdateHours;
|
private final int aiDossierUpdateHours;
|
||||||
private final String aiDebateChannelId;
|
private final String aiDebateChannelId;
|
||||||
private final LavalinkConfig lavalinkConfig;
|
private final LavalinkConfig lavalinkConfig;
|
||||||
|
private final String externalUrl;
|
||||||
|
|
||||||
public BotConfig(
|
public BotConfig(
|
||||||
String mainToken,
|
String mainToken,
|
||||||
|
|
@ -36,7 +37,8 @@ public class BotConfig {
|
||||||
int aiDossierMsgs,
|
int aiDossierMsgs,
|
||||||
int aiDossierUpdateHours,
|
int aiDossierUpdateHours,
|
||||||
String aiDebateChannelId,
|
String aiDebateChannelId,
|
||||||
LavalinkConfig lavalinkConfig) {
|
LavalinkConfig lavalinkConfig,
|
||||||
|
String externalUrl) {
|
||||||
this.mainToken = mainToken;
|
this.mainToken = mainToken;
|
||||||
this.musicToken = musicToken;
|
this.musicToken = musicToken;
|
||||||
this.radioToken = radioToken;
|
this.radioToken = radioToken;
|
||||||
|
|
@ -54,6 +56,7 @@ public class BotConfig {
|
||||||
this.aiDossierUpdateHours = aiDossierUpdateHours;
|
this.aiDossierUpdateHours = aiDossierUpdateHours;
|
||||||
this.aiDebateChannelId = aiDebateChannelId;
|
this.aiDebateChannelId = aiDebateChannelId;
|
||||||
this.lavalinkConfig = lavalinkConfig;
|
this.lavalinkConfig = lavalinkConfig;
|
||||||
|
this.externalUrl = externalUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getMainToken() {
|
public String getMainToken() {
|
||||||
|
|
@ -127,4 +130,8 @@ public class BotConfig {
|
||||||
public LavalinkConfig getLavalinkConfig() {
|
public LavalinkConfig getLavalinkConfig() {
|
||||||
return lavalinkConfig;
|
return lavalinkConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getExternalUrl() {
|
||||||
|
return externalUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,9 @@ public final class ConfigLoader {
|
||||||
String aiDebateChannelId = dotenv.get("AI_DEBATE_CHANNEL_ID");
|
String aiDebateChannelId = dotenv.get("AI_DEBATE_CHANNEL_ID");
|
||||||
|
|
||||||
LavalinkConfig lavalinkConfig = getLavalinkConfig();
|
LavalinkConfig lavalinkConfig = getLavalinkConfig();
|
||||||
|
|
||||||
|
String externalUrl = getEnv(dotenv, "EXTERNAL_URL", "http://127.0.0.1:8712");
|
||||||
|
|
||||||
LoggerDecorator.success(logger, "Конфигурация загружена успешно");
|
LoggerDecorator.success(logger, "Конфигурация загружена успешно");
|
||||||
return new BotConfig(
|
return new BotConfig(
|
||||||
mainToken,
|
mainToken,
|
||||||
|
|
@ -104,6 +107,7 @@ public final class ConfigLoader {
|
||||||
aiDossierMsgs,
|
aiDossierMsgs,
|
||||||
aiDossierUpdateHours,
|
aiDossierUpdateHours,
|
||||||
aiDebateChannelId,
|
aiDebateChannelId,
|
||||||
lavalinkConfig);
|
lavalinkConfig,
|
||||||
|
externalUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ public class BotInitializer {
|
||||||
JDA musicJda = createMusicJDA();
|
JDA musicJda = createMusicJDA();
|
||||||
JDA radioJda = null;
|
JDA radioJda = null;
|
||||||
if (config.getRadioToken() != null && !config.getRadioToken().isBlank()) {
|
if (config.getRadioToken() != null && !config.getRadioToken().isBlank()) {
|
||||||
|
sleep(3000);
|
||||||
LavalinkManager radioLavalink =
|
LavalinkManager radioLavalink =
|
||||||
LavalinkManagerFactory.create(config, queueRepository, config.getRadioToken());
|
LavalinkManagerFactory.create(config, queueRepository, config.getRadioToken());
|
||||||
radioJda = createRadioJDA(radioLavalink);
|
radioJda = createRadioJDA(radioLavalink);
|
||||||
|
|
@ -144,4 +145,12 @@ public class BotInitializer {
|
||||||
}
|
}
|
||||||
return JdaFactory.awaitJda(builder.build());
|
return JdaFactory.awaitJda(builder.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void sleep(long ms) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(ms);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
package creator.loki.feature.metrics;
|
package creator.loki.feature.metrics;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.net.BindException;
|
import java.net.BindException;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
import com.google.inject.Inject;
|
import com.google.inject.Inject;
|
||||||
|
|
@ -12,26 +15,27 @@ import com.sun.net.httpserver.HttpExchange;
|
||||||
import com.sun.net.httpserver.HttpServer;
|
import com.sun.net.httpserver.HttpServer;
|
||||||
import creator.loki.config.BotConfig;
|
import creator.loki.config.BotConfig;
|
||||||
import creator.loki.core.Lifecycle;
|
import creator.loki.core.Lifecycle;
|
||||||
|
import creator.loki.feature.tiktok.TempFile;
|
||||||
|
import creator.loki.feature.tiktok.TempFileManager;
|
||||||
import creator.loki.util.LoggerDecorator;
|
import creator.loki.util.LoggerDecorator;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
/**
|
|
||||||
* Минимальный HTTP {@code GET /health} для оркестраторов (Docker/K8s). Порт из {@link
|
|
||||||
* BotConfig#getHealthHttpPort()} (0 = выкл).
|
|
||||||
*/
|
|
||||||
@Singleton
|
@Singleton
|
||||||
public class HealthHttpServer implements Lifecycle {
|
public class HealthHttpServer implements Lifecycle {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(HealthHttpServer.class);
|
private static final Logger logger = LoggerFactory.getLogger(HealthHttpServer.class);
|
||||||
|
|
||||||
private final BotConfig config;
|
private final BotConfig config;
|
||||||
private final MetricsCollector metrics;
|
private final MetricsCollector metrics;
|
||||||
|
private final TempFileManager tempFileManager;
|
||||||
private HttpServer server;
|
private HttpServer server;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public HealthHttpServer(BotConfig config, MetricsCollector metrics) {
|
public HealthHttpServer(
|
||||||
|
BotConfig config, MetricsCollector metrics, TempFileManager tempFileManager) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.metrics = metrics;
|
this.metrics = metrics;
|
||||||
|
this.tempFileManager = tempFileManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void start() {
|
public synchronized void start() {
|
||||||
|
|
@ -46,6 +50,7 @@ public class HealthHttpServer implements Lifecycle {
|
||||||
try {
|
try {
|
||||||
server = bindHealthServer(port);
|
server = bindHealthServer(port);
|
||||||
server.createContext("/health", this::handleHealth);
|
server.createContext("/health", this::handleHealth);
|
||||||
|
server.createContext("/d/", this::handleDownload);
|
||||||
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
|
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
|
||||||
server.start();
|
server.start();
|
||||||
int bound = server.getAddress().getPort();
|
int bound = server.getAddress().getPort();
|
||||||
|
|
@ -67,7 +72,6 @@ public class HealthHttpServer implements Lifecycle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Пробуем заданный порт; при {@link BindException} - системный свободный порт (0). */
|
|
||||||
private static HttpServer bindHealthServer(final int preferredPort) throws Exception {
|
private static HttpServer bindHealthServer(final int preferredPort) throws Exception {
|
||||||
try {
|
try {
|
||||||
return HttpServer.create(new InetSocketAddress(preferredPort), 0);
|
return HttpServer.create(new InetSocketAddress(preferredPort), 0);
|
||||||
|
|
@ -107,6 +111,56 @@ public class HealthHttpServer implements Lifecycle {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleDownload(final HttpExchange exchange) {
|
||||||
|
try {
|
||||||
|
if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||||
|
exchange.sendResponseHeaders(405, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String path = exchange.getRequestURI().getPath();
|
||||||
|
String key = path.substring("/d/".length());
|
||||||
|
if (key.isEmpty()) {
|
||||||
|
exchange.sendResponseHeaders(400, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TempFile entry = tempFileManager.getEntry(key);
|
||||||
|
if (entry == null) {
|
||||||
|
String body = "File not found or expired";
|
||||||
|
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8");
|
||||||
|
exchange.sendResponseHeaders(404, bytes.length);
|
||||||
|
try (final OutputStream os = exchange.getResponseBody()) {
|
||||||
|
os.write(bytes);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Path filePath = tempFileManager.getFilePath(entry);
|
||||||
|
if (!Files.exists(filePath)) {
|
||||||
|
tempFileManager.getEntry(key);
|
||||||
|
exchange.sendResponseHeaders(404, -1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long fileSize = Files.size(filePath);
|
||||||
|
exchange.getResponseHeaders().add("Content-Type", "video/mp4");
|
||||||
|
exchange
|
||||||
|
.getResponseHeaders()
|
||||||
|
.add("Content-Disposition", "attachment; filename=\"" + entry.filename() + "\"");
|
||||||
|
exchange.sendResponseHeaders(200, fileSize);
|
||||||
|
try (InputStream is = Files.newInputStream(filePath);
|
||||||
|
OutputStream os = exchange.getResponseBody()) {
|
||||||
|
is.transferTo(os);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LoggerDecorator.error(logger, "Ошибка ответа /d/", e);
|
||||||
|
try {
|
||||||
|
exchange.sendResponseHeaders(500, -1);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
exchange.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public synchronized void shutdown() {
|
public synchronized void shutdown() {
|
||||||
if (server != null) {
|
if (server != null) {
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,9 @@ public class ModerationLogger extends ListenerAdapter {
|
||||||
|
|
||||||
private void send(final Guild g, final EmbedBuilder eb) {
|
private void send(final Guild g, final EmbedBuilder eb) {
|
||||||
if (eb == null) return;
|
if (eb == null) return;
|
||||||
var ch = g.getTextChannelById(configRepo.get("moderation.log_channel_id"));
|
String channelId = configRepo.get("moderation.log_channel_id");
|
||||||
|
if (channelId == null || channelId.isBlank()) return;
|
||||||
|
var ch = g.getTextChannelById(channelId);
|
||||||
if (ch == null) return;
|
if (ch == null) return;
|
||||||
ch.sendMessageEmbeds(eb.setTimestamp(OffsetDateTime.now()).build()).queue();
|
ch.sendMessageEmbeds(eb.setTimestamp(OffsetDateTime.now()).build()).queue();
|
||||||
}
|
}
|
||||||
|
|
@ -46,11 +48,16 @@ public class ModerationLogger extends ListenerAdapter {
|
||||||
public void onMessageDelete(MessageDeleteEvent e) {
|
public void onMessageDelete(MessageDeleteEvent e) {
|
||||||
var content = messageCache.getContent(e.getMessageId());
|
var content = messageCache.getContent(e.getMessageId());
|
||||||
var g = e.getGuild();
|
var g = e.getGuild();
|
||||||
g.retrieveAuditLogs().type(ActionType.MESSAGE_DELETE).limit(5).queue(
|
g.retrieveAuditLogs()
|
||||||
|
.type(ActionType.MESSAGE_DELETE)
|
||||||
|
.limit(5)
|
||||||
|
.queue(
|
||||||
logs -> {
|
logs -> {
|
||||||
var entry = logs.stream()
|
var entry =
|
||||||
|
logs.stream()
|
||||||
.filter(log -> e.getMessageId().equals(log.getTargetId()))
|
.filter(log -> e.getMessageId().equals(log.getTargetId()))
|
||||||
.findFirst().orElse(null);
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
send(g, LogEmbeds.messageDelete(entry, e.getChannel().getAsMention(), content));
|
send(g, LogEmbeds.messageDelete(entry, e.getChannel().getAsMention(), content));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +66,10 @@ public class ModerationLogger extends ListenerAdapter {
|
||||||
public void onMessageBulkDelete(MessageBulkDeleteEvent e) {
|
public void onMessageBulkDelete(MessageBulkDeleteEvent e) {
|
||||||
var g = e.getGuild();
|
var g = e.getGuild();
|
||||||
var size = e.getMessageIds().size();
|
var size = e.getMessageIds().size();
|
||||||
g.retrieveAuditLogs().type(ActionType.MESSAGE_BULK_DELETE).limit(3).queue(
|
g.retrieveAuditLogs()
|
||||||
|
.type(ActionType.MESSAGE_BULK_DELETE)
|
||||||
|
.limit(3)
|
||||||
|
.queue(
|
||||||
logs -> {
|
logs -> {
|
||||||
var entry = logs.stream().findFirst().orElse(null);
|
var entry = logs.stream().findFirst().orElse(null);
|
||||||
send(g, LogEmbeds.messageBulkDelete(entry, e.getChannel().getAsMention(), size));
|
send(g, LogEmbeds.messageBulkDelete(entry, e.getChannel().getAsMention(), size));
|
||||||
|
|
@ -77,10 +87,7 @@ public class ModerationLogger extends ListenerAdapter {
|
||||||
var entry = findEntry(l, ev.getUser().getId());
|
var entry = findEntry(l, ev.getUser().getId());
|
||||||
if (entry == null) return null;
|
if (entry == null) return null;
|
||||||
return LogEmbeds.ban(
|
return LogEmbeds.ban(
|
||||||
ev.getUser().getAsMention(),
|
ev.getUser().getAsMention(), mod(entry), entry.getReason(), ev.getUser().getAvatarUrl());
|
||||||
mod(entry),
|
|
||||||
entry.getReason(),
|
|
||||||
ev.getUser().getAvatarUrl());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -91,10 +98,7 @@ public class ModerationLogger extends ListenerAdapter {
|
||||||
private EmbedBuilder unban(GuildUnbanEvent ev, List<AuditLogEntry> l) {
|
private EmbedBuilder unban(GuildUnbanEvent ev, List<AuditLogEntry> l) {
|
||||||
var entry = findEntry(l, ev.getUser().getId());
|
var entry = findEntry(l, ev.getUser().getId());
|
||||||
if (entry == null) return null;
|
if (entry == null) return null;
|
||||||
return LogEmbeds.unban(
|
return LogEmbeds.unban(ev.getUser().getAsMention(), mod(entry), ev.getUser().getAvatarUrl());
|
||||||
ev.getUser().getAsMention(),
|
|
||||||
mod(entry),
|
|
||||||
ev.getUser().getAvatarUrl());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Kick ──────────────────────────────────────────────────────────────────
|
// ── Kick ──────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -108,10 +112,7 @@ public class ModerationLogger extends ListenerAdapter {
|
||||||
var entry = findEntry(l, ev.getUser().getId());
|
var entry = findEntry(l, ev.getUser().getId());
|
||||||
if (entry == null) return null;
|
if (entry == null) return null;
|
||||||
return LogEmbeds.kick(
|
return LogEmbeds.kick(
|
||||||
ev.getUser().getAsMention(),
|
ev.getUser().getAsMention(), mod(entry), entry.getReason(), ev.getUser().getAvatarUrl());
|
||||||
mod(entry),
|
|
||||||
entry.getReason(),
|
|
||||||
ev.getUser().getAvatarUrl());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Timeout ────────────────────────────────────────────────────────────────
|
// ── Timeout ────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -122,7 +123,8 @@ public class ModerationLogger extends ListenerAdapter {
|
||||||
handle(e.getGuild(), ActionType.MEMBER_UPDATE, 5, l -> timeout(e, muted, l));
|
handle(e.getGuild(), ActionType.MEMBER_UPDATE, 5, l -> timeout(e, muted, l));
|
||||||
}
|
}
|
||||||
|
|
||||||
private EmbedBuilder timeout(GuildMemberUpdateTimeOutEvent e, boolean muted, List<AuditLogEntry> l) {
|
private EmbedBuilder timeout(
|
||||||
|
GuildMemberUpdateTimeOutEvent e, boolean muted, List<AuditLogEntry> l) {
|
||||||
var entry = findEntry(l, e.getUser().getId());
|
var entry = findEntry(l, e.getUser().getId());
|
||||||
if (entry == null) return null;
|
if (entry == null) return null;
|
||||||
return LogEmbeds.timeout(
|
return LogEmbeds.timeout(
|
||||||
|
|
@ -157,7 +159,8 @@ public class ModerationLogger extends ListenerAdapter {
|
||||||
handle(e.getGuild(), ActionType.MEMBER_UPDATE, 5, l -> voiceDeafen(e, deaf, l));
|
handle(e.getGuild(), ActionType.MEMBER_UPDATE, 5, l -> voiceDeafen(e, deaf, l));
|
||||||
}
|
}
|
||||||
|
|
||||||
private EmbedBuilder voiceDeafen(GuildVoiceGuildDeafenEvent e, boolean deaf, List<AuditLogEntry> l) {
|
private EmbedBuilder voiceDeafen(
|
||||||
|
GuildVoiceGuildDeafenEvent e, boolean deaf, List<AuditLogEntry> l) {
|
||||||
var member = e.getMember();
|
var member = e.getMember();
|
||||||
var entry = findEntry(l, member.getId(), "deaf");
|
var entry = findEntry(l, member.getId(), "deaf");
|
||||||
return LogEmbeds.voiceDeafen(
|
return LogEmbeds.voiceDeafen(
|
||||||
|
|
@ -169,9 +172,9 @@ public class ModerationLogger extends ListenerAdapter {
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void handle(Guild g, ActionType t, int limit, Function<List<AuditLogEntry>, EmbedBuilder> fn) {
|
private void handle(
|
||||||
g.retrieveAuditLogs().type(t).limit(limit).queue(
|
Guild g, ActionType t, int limit, Function<List<AuditLogEntry>, EmbedBuilder> fn) {
|
||||||
logs -> send(g, fn.apply(logs)));
|
g.retrieveAuditLogs().type(t).limit(limit).queue(logs -> send(g, fn.apply(logs)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuditLogEntry findEntry(List<AuditLogEntry> l, String targetId, String... key) {
|
private static AuditLogEntry findEntry(List<AuditLogEntry> l, String targetId, String... key) {
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ public class LavalinkManager {
|
||||||
.setName("main")
|
.setName("main")
|
||||||
.setServerUri(lavalinkConfig.getWsUri())
|
.setServerUri(lavalinkConfig.getWsUri())
|
||||||
.setPassword(lavalinkConfig.getPassword())
|
.setPassword(lavalinkConfig.getPassword())
|
||||||
.setHttpTimeout(30000L)
|
.setHttpTimeout(60000L)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
registerListeners();
|
registerListeners();
|
||||||
|
|
|
||||||
7
src/main/java/creator/loki/feature/tiktok/TempFile.java
Normal file
7
src/main/java/creator/loki/feature/tiktok/TempFile.java
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
package creator.loki.feature.tiktok;
|
||||||
|
|
||||||
|
public record TempFile(String key, String filename, long expiresAt) {
|
||||||
|
public boolean isExpired() {
|
||||||
|
return System.currentTimeMillis() > expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/main/java/creator/loki/feature/tiktok/TempFileManager.java
Normal file
136
src/main/java/creator/loki/feature/tiktok/TempFileManager.java
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
package creator.loki.feature.tiktok;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
import creator.loki.util.LoggerDecorator;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
public class TempFileManager {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(TempFileManager.class);
|
||||||
|
private static final long TTL_MS = 24 * 60 * 60 * 1000L;
|
||||||
|
private static final String KEY_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
private static final int KEY_LENGTH = 15;
|
||||||
|
private static final SecureRandom RANDOM = new SecureRandom();
|
||||||
|
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||||
|
|
||||||
|
private final Path cacheDir;
|
||||||
|
private final ConcurrentHashMap<String, TempFile> entries = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public TempFileManager(String dataDir) {
|
||||||
|
this.cacheDir = Path.of(dataDir).resolve("tiktok-cache");
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(cacheDir);
|
||||||
|
loadMeta();
|
||||||
|
cleanup();
|
||||||
|
LoggerDecorator.database(logger, "TempFileManager: " + entries.size() + " активных ссылок");
|
||||||
|
} catch (IOException e) {
|
||||||
|
LoggerDecorator.error(logger, "TempFileManager init error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized String save(byte[] data, String originalName) throws IOException {
|
||||||
|
cleanup();
|
||||||
|
String key = generateKey();
|
||||||
|
String filename = key + ".mp4";
|
||||||
|
Files.write(cacheDir.resolve(filename), data);
|
||||||
|
TempFile entry = new TempFile(key, filename, System.currentTimeMillis() + TTL_MS);
|
||||||
|
entries.put(key, entry);
|
||||||
|
saveMeta();
|
||||||
|
LoggerDecorator.database(
|
||||||
|
logger,
|
||||||
|
"TempFileManager: saved " + filename + " (" + (data.length / 1024 / 1024) + "MB)");
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TempFile getEntry(String key) {
|
||||||
|
TempFile entry = entries.get(key);
|
||||||
|
if (entry == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (entry.isExpired()) {
|
||||||
|
deleteFile(entry);
|
||||||
|
entries.remove(key);
|
||||||
|
saveMeta();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Path filePath = cacheDir.resolve(entry.filename());
|
||||||
|
if (!Files.exists(filePath)) {
|
||||||
|
entries.remove(key);
|
||||||
|
saveMeta();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path getFilePath(TempFile entry) {
|
||||||
|
return cacheDir.resolve(entry.filename());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cleanup() {
|
||||||
|
entries.entrySet().removeIf(e -> {
|
||||||
|
if (e.getValue().isExpired()) {
|
||||||
|
deleteFile(e.getValue());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteFile(TempFile entry) {
|
||||||
|
try {
|
||||||
|
Path file = cacheDir.resolve(entry.filename());
|
||||||
|
Files.deleteIfExists(file);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LoggerDecorator.warn(logger, "TempFileManager: failed to delete " + entry.filename());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateKey() {
|
||||||
|
StringBuilder sb = new StringBuilder(KEY_LENGTH);
|
||||||
|
for (int i = 0; i < KEY_LENGTH; i++) {
|
||||||
|
sb.append(KEY_CHARS.charAt(RANDOM.nextInt(KEY_CHARS.length())));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadMeta() {
|
||||||
|
Path metaFile = cacheDir.resolve("cache-meta.json");
|
||||||
|
if (!Files.exists(metaFile)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String json = Files.readString(metaFile, StandardCharsets.UTF_8);
|
||||||
|
Map<String, TempFile> loaded =
|
||||||
|
GSON.fromJson(json, new TypeToken<Map<String, TempFile>>() {}.getType());
|
||||||
|
if (loaded != null) {
|
||||||
|
entries.putAll(loaded);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
LoggerDecorator.warn(logger, "TempFileManager: failed to load meta, starting fresh");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void saveMeta() {
|
||||||
|
Path metaFile = cacheDir.resolve("cache-meta.json");
|
||||||
|
try {
|
||||||
|
String json = GSON.toJson(entries);
|
||||||
|
Files.writeString(metaFile, json, StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
LoggerDecorator.warn(logger, "TempFileManager: failed to save meta");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,10 +25,15 @@ public class TikTokCommand implements Command {
|
||||||
|
|
||||||
private final TikTokApi tikTokApi;
|
private final TikTokApi tikTokApi;
|
||||||
private final OkHttpClient downloadClient;
|
private final OkHttpClient downloadClient;
|
||||||
|
private final TempFileManager tempFileManager;
|
||||||
|
private final String externalUrl;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public TikTokCommand(TikTokApi tikTokApi) {
|
public TikTokCommand(
|
||||||
|
TikTokApi tikTokApi, TempFileManager tempFileManager, String externalUrl) {
|
||||||
this.tikTokApi = tikTokApi;
|
this.tikTokApi = tikTokApi;
|
||||||
|
this.tempFileManager = tempFileManager;
|
||||||
|
this.externalUrl = externalUrl;
|
||||||
this.downloadClient =
|
this.downloadClient =
|
||||||
new OkHttpClient.Builder()
|
new OkHttpClient.Builder()
|
||||||
.connectTimeout(15, TimeUnit.SECONDS)
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
|
@ -88,16 +93,9 @@ public class TikTokCommand implements Command {
|
||||||
|
|
||||||
byte[] videoBytes = downloadVideo(result.getVideoUrl());
|
byte[] videoBytes = downloadVideo(result.getVideoUrl());
|
||||||
|
|
||||||
if (videoBytes.length > MAX_FILE_SIZE) {
|
|
||||||
loadingMsg
|
|
||||||
.editMessageEmbeds(
|
|
||||||
EmbedTemplates.error("Ошибка", "Видео слишком большое (> 8MB)"))
|
|
||||||
.queue();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String durationStr = formatDuration(result.getDuration());
|
String durationStr = formatDuration(result.getDuration());
|
||||||
|
|
||||||
|
if (videoBytes.length <= MAX_FILE_SIZE) {
|
||||||
MessageEmbed embed =
|
MessageEmbed embed =
|
||||||
EmbedFactory.create()
|
EmbedFactory.create()
|
||||||
.withTitle(truncate(result.getTitle(), 256))
|
.withTitle(truncate(result.getTitle(), 256))
|
||||||
|
|
@ -119,6 +117,33 @@ public class TikTokCommand implements Command {
|
||||||
EmbedTemplates.error(
|
EmbedTemplates.error(
|
||||||
"Ошибка", "Не удалось отправить видео"))
|
"Ошибка", "Не удалось отправить видео"))
|
||||||
.queue());
|
.queue());
|
||||||
|
} else {
|
||||||
|
String key = tempFileManager.save(videoBytes, "tiktok.mp4");
|
||||||
|
String downloadUrl = externalUrl + "/d/" + key;
|
||||||
|
double sizeMb = videoBytes.length / (1024.0 * 1024.0);
|
||||||
|
|
||||||
|
MessageEmbed embed =
|
||||||
|
EmbedFactory.create()
|
||||||
|
.withTitle(truncate(result.getTitle(), 256))
|
||||||
|
.withAuthor("@" + result.getAuthor())
|
||||||
|
.withColor(new Color(0, 0, 0))
|
||||||
|
.addField("Длительность", durationStr, true)
|
||||||
|
.addField(
|
||||||
|
"Размер",
|
||||||
|
String.format("%.1f MB", sizeMb),
|
||||||
|
true)
|
||||||
|
.addField(
|
||||||
|
"Скачать",
|
||||||
|
"[Нажми чтобы скачать](" + downloadUrl + ")",
|
||||||
|
false)
|
||||||
|
.withFooter("Ссылка действительна 24 часа")
|
||||||
|
.withThumbnail(result.getCover())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
loadingMsg
|
||||||
|
.editMessageEmbeds(embed)
|
||||||
|
.queue();
|
||||||
|
}
|
||||||
|
|
||||||
} catch (TikTokApi.TikTokApiException e) {
|
} catch (TikTokApi.TikTokApiException e) {
|
||||||
loadingMsg
|
loadingMsg
|
||||||
|
|
@ -158,8 +183,8 @@ public class TikTokCommand implements Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
private String truncate(String str, final int maxLen) {
|
private String truncate(String str, final int maxLen) {
|
||||||
if (str == null) {
|
if (str == null || str.isBlank()) {
|
||||||
return "";
|
return "TikTok Video";
|
||||||
}
|
}
|
||||||
return str.length() > maxLen ? str.substring(0, maxLen - 3) + "..." : str;
|
return str.length() > maxLen ? str.substring(0, maxLen - 3) + "..." : str;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,29 @@
|
||||||
package creator.loki.feature.tiktok;
|
package creator.loki.feature.tiktok;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
import com.google.inject.AbstractModule;
|
import com.google.inject.AbstractModule;
|
||||||
import com.google.inject.Provides;
|
import com.google.inject.Provides;
|
||||||
import com.google.inject.Singleton;
|
import com.google.inject.Singleton;
|
||||||
|
import creator.loki.config.BotConfig;
|
||||||
|
|
||||||
public class TikTokModule extends AbstractModule {
|
public class TikTokModule extends AbstractModule {
|
||||||
@Override
|
@Override
|
||||||
protected void configure() {
|
protected void configure() {}
|
||||||
bind(TikTokApi.class).in(Singleton.class);
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
TempFileManager provideTempFileManager(final BotConfig config) {
|
||||||
|
String dbPath = config.getDbPath();
|
||||||
|
String dataDir = Path.of(dbPath).getParent() != null
|
||||||
|
? Path.of(dbPath).getParent().toString()
|
||||||
|
: "data";
|
||||||
|
return new TempFileManager(dataDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
TikTokCommand provideTikTokCommand(final TikTokApi tikTokApi) {
|
TikTokCommand provideTikTokCommand(
|
||||||
return new TikTokCommand(tikTokApi);
|
final TikTokApi tikTokApi, final TempFileManager tempFileManager, final BotConfig config) {
|
||||||
|
return new TikTokCommand(tikTokApi, tempFileManager, config.getExternalUrl());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ class BotConfigTest {
|
||||||
30,
|
30,
|
||||||
24,
|
24,
|
||||||
null,
|
null,
|
||||||
lavalink);
|
lavalink,
|
||||||
|
"http://127.0.0.1:8712");
|
||||||
assertEquals("token123", config.getMainToken());
|
assertEquals("token123", config.getMainToken());
|
||||||
assertEquals("token123", config.getToken());
|
assertEquals("token123", config.getToken());
|
||||||
assertNull(config.getMusicToken());
|
assertNull(config.getMusicToken());
|
||||||
|
|
@ -46,7 +47,7 @@ class BotConfigTest {
|
||||||
BotConfig config =
|
BotConfig config =
|
||||||
new BotConfig(
|
new BotConfig(
|
||||||
null, null, null, null, null, 0, null, null, null, null, 10, 300, 3000, 30, 24, null,
|
null, null, null, null, null, 0, null, null, null, null, 10, 300, 3000, 30, 24, null,
|
||||||
null);
|
null, null);
|
||||||
assertNull(config.getMainToken());
|
assertNull(config.getMainToken());
|
||||||
assertNull(config.getMusicToken());
|
assertNull(config.getMusicToken());
|
||||||
assertNull(config.getRadioToken());
|
assertNull(config.getRadioToken());
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue