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:
loki5512344 2026-08-06 17:47:27 +02:00
parent 43735de681
commit ce1d863009
Signed by: boba
GPG key ID: 253067914055423B
11 changed files with 334 additions and 76 deletions

View file

@ -18,6 +18,7 @@ public class BotConfig {
private final int aiDossierUpdateHours;
private final String aiDebateChannelId;
private final LavalinkConfig lavalinkConfig;
private final String externalUrl;
public BotConfig(
String mainToken,
@ -36,7 +37,8 @@ public class BotConfig {
int aiDossierMsgs,
int aiDossierUpdateHours,
String aiDebateChannelId,
LavalinkConfig lavalinkConfig) {
LavalinkConfig lavalinkConfig,
String externalUrl) {
this.mainToken = mainToken;
this.musicToken = musicToken;
this.radioToken = radioToken;
@ -54,6 +56,7 @@ public class BotConfig {
this.aiDossierUpdateHours = aiDossierUpdateHours;
this.aiDebateChannelId = aiDebateChannelId;
this.lavalinkConfig = lavalinkConfig;
this.externalUrl = externalUrl;
}
public String getMainToken() {
@ -127,4 +130,8 @@ public class BotConfig {
public LavalinkConfig getLavalinkConfig() {
return lavalinkConfig;
}
public String getExternalUrl() {
return externalUrl;
}
}

View file

@ -86,6 +86,9 @@ public final class ConfigLoader {
String aiDebateChannelId = dotenv.get("AI_DEBATE_CHANNEL_ID");
LavalinkConfig lavalinkConfig = getLavalinkConfig();
String externalUrl = getEnv(dotenv, "EXTERNAL_URL", "http://127.0.0.1:8712");
LoggerDecorator.success(logger, "Конфигурация загружена успешно");
return new BotConfig(
mainToken,
@ -104,6 +107,7 @@ public final class ConfigLoader {
aiDossierMsgs,
aiDossierUpdateHours,
aiDebateChannelId,
lavalinkConfig);
lavalinkConfig,
externalUrl);
}
}

View file

@ -77,6 +77,7 @@ public class BotInitializer {
JDA musicJda = createMusicJDA();
JDA radioJda = null;
if (config.getRadioToken() != null && !config.getRadioToken().isBlank()) {
sleep(3000);
LavalinkManager radioLavalink =
LavalinkManagerFactory.create(config, queueRepository, config.getRadioToken());
radioJda = createRadioJDA(radioLavalink);
@ -144,4 +145,12 @@ public class BotInitializer {
}
return JdaFactory.awaitJda(builder.build());
}
private static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

View file

@ -1,9 +1,12 @@
package creator.loki.feature.metrics;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.BindException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.Executors;
import com.google.inject.Inject;
@ -12,26 +15,27 @@ import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import creator.loki.config.BotConfig;
import creator.loki.core.Lifecycle;
import creator.loki.feature.tiktok.TempFile;
import creator.loki.feature.tiktok.TempFileManager;
import creator.loki.util.LoggerDecorator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Минимальный HTTP {@code GET /health} для оркестраторов (Docker/K8s). Порт из {@link
* BotConfig#getHealthHttpPort()} (0 = выкл).
*/
@Singleton
public class HealthHttpServer implements Lifecycle {
private static final Logger logger = LoggerFactory.getLogger(HealthHttpServer.class);
private final BotConfig config;
private final MetricsCollector metrics;
private final TempFileManager tempFileManager;
private HttpServer server;
@Inject
public HealthHttpServer(BotConfig config, MetricsCollector metrics) {
public HealthHttpServer(
BotConfig config, MetricsCollector metrics, TempFileManager tempFileManager) {
this.config = config;
this.metrics = metrics;
this.tempFileManager = tempFileManager;
}
public synchronized void start() {
@ -46,6 +50,7 @@ public class HealthHttpServer implements Lifecycle {
try {
server = bindHealthServer(port);
server.createContext("/health", this::handleHealth);
server.createContext("/d/", this::handleDownload);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();
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 {
try {
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
public synchronized void shutdown() {
if (server != null) {

View file

@ -35,7 +35,9 @@ public class ModerationLogger extends ListenerAdapter {
private void send(final Guild g, final EmbedBuilder eb) {
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;
ch.sendMessageEmbeds(eb.setTimestamp(OffsetDateTime.now()).build()).queue();
}
@ -46,11 +48,16 @@ public class ModerationLogger extends ListenerAdapter {
public void onMessageDelete(MessageDeleteEvent e) {
var content = messageCache.getContent(e.getMessageId());
var g = e.getGuild();
g.retrieveAuditLogs().type(ActionType.MESSAGE_DELETE).limit(5).queue(
g.retrieveAuditLogs()
.type(ActionType.MESSAGE_DELETE)
.limit(5)
.queue(
logs -> {
var entry = logs.stream()
var entry =
logs.stream()
.filter(log -> e.getMessageId().equals(log.getTargetId()))
.findFirst().orElse(null);
.findFirst()
.orElse(null);
send(g, LogEmbeds.messageDelete(entry, e.getChannel().getAsMention(), content));
});
}
@ -59,7 +66,10 @@ public class ModerationLogger extends ListenerAdapter {
public void onMessageBulkDelete(MessageBulkDeleteEvent e) {
var g = e.getGuild();
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 -> {
var entry = logs.stream().findFirst().orElse(null);
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());
if (entry == null) return null;
return LogEmbeds.ban(
ev.getUser().getAsMention(),
mod(entry),
entry.getReason(),
ev.getUser().getAvatarUrl());
ev.getUser().getAsMention(), mod(entry), entry.getReason(), ev.getUser().getAvatarUrl());
}
@Override
@ -91,10 +98,7 @@ public class ModerationLogger extends ListenerAdapter {
private EmbedBuilder unban(GuildUnbanEvent ev, List<AuditLogEntry> l) {
var entry = findEntry(l, ev.getUser().getId());
if (entry == null) return null;
return LogEmbeds.unban(
ev.getUser().getAsMention(),
mod(entry),
ev.getUser().getAvatarUrl());
return LogEmbeds.unban(ev.getUser().getAsMention(), mod(entry), ev.getUser().getAvatarUrl());
}
// ── Kick ──────────────────────────────────────────────────────────────────
@ -108,10 +112,7 @@ public class ModerationLogger extends ListenerAdapter {
var entry = findEntry(l, ev.getUser().getId());
if (entry == null) return null;
return LogEmbeds.kick(
ev.getUser().getAsMention(),
mod(entry),
entry.getReason(),
ev.getUser().getAvatarUrl());
ev.getUser().getAsMention(), mod(entry), entry.getReason(), ev.getUser().getAvatarUrl());
}
// ── Timeout ────────────────────────────────────────────────────────────────
@ -122,7 +123,8 @@ public class ModerationLogger extends ListenerAdapter {
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());
if (entry == null) return null;
return LogEmbeds.timeout(
@ -157,7 +159,8 @@ public class ModerationLogger extends ListenerAdapter {
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 entry = findEntry(l, member.getId(), "deaf");
return LogEmbeds.voiceDeafen(
@ -169,9 +172,9 @@ public class ModerationLogger extends ListenerAdapter {
// ── Helpers ────────────────────────────────────────────────────────────────
private void handle(Guild g, ActionType t, int limit, Function<List<AuditLogEntry>, EmbedBuilder> fn) {
g.retrieveAuditLogs().type(t).limit(limit).queue(
logs -> send(g, fn.apply(logs)));
private void handle(
Guild g, ActionType t, int limit, Function<List<AuditLogEntry>, EmbedBuilder> fn) {
g.retrieveAuditLogs().type(t).limit(limit).queue(logs -> send(g, fn.apply(logs)));
}
private static AuditLogEntry findEntry(List<AuditLogEntry> l, String targetId, String... key) {

View file

@ -55,7 +55,7 @@ public class LavalinkManager {
.setName("main")
.setServerUri(lavalinkConfig.getWsUri())
.setPassword(lavalinkConfig.getPassword())
.setHttpTimeout(30000L)
.setHttpTimeout(60000L)
.build());
registerListeners();

View 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;
}
}

View 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");
}
}
}

View file

@ -25,10 +25,15 @@ public class TikTokCommand implements Command {
private final TikTokApi tikTokApi;
private final OkHttpClient downloadClient;
private final TempFileManager tempFileManager;
private final String externalUrl;
@Inject
public TikTokCommand(TikTokApi tikTokApi) {
public TikTokCommand(
TikTokApi tikTokApi, TempFileManager tempFileManager, String externalUrl) {
this.tikTokApi = tikTokApi;
this.tempFileManager = tempFileManager;
this.externalUrl = externalUrl;
this.downloadClient =
new OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
@ -88,16 +93,9 @@ public class TikTokCommand implements Command {
byte[] videoBytes = downloadVideo(result.getVideoUrl());
if (videoBytes.length > MAX_FILE_SIZE) {
loadingMsg
.editMessageEmbeds(
EmbedTemplates.error("Ошибка", "Видео слишком большое (> 8MB)"))
.queue();
return;
}
String durationStr = formatDuration(result.getDuration());
if (videoBytes.length <= MAX_FILE_SIZE) {
MessageEmbed embed =
EmbedFactory.create()
.withTitle(truncate(result.getTitle(), 256))
@ -119,6 +117,33 @@ public class TikTokCommand implements Command {
EmbedTemplates.error(
"Ошибка", "Не удалось отправить видео"))
.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) {
loadingMsg
@ -158,8 +183,8 @@ public class TikTokCommand implements Command {
}
private String truncate(String str, final int maxLen) {
if (str == null) {
return "";
if (str == null || str.isBlank()) {
return "TikTok Video";
}
return str.length() > maxLen ? str.substring(0, maxLen - 3) + "..." : str;
}

View file

@ -1,17 +1,29 @@
package creator.loki.feature.tiktok;
import java.nio.file.Path;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import creator.loki.config.BotConfig;
public class TikTokModule extends AbstractModule {
@Override
protected void configure() {
bind(TikTokApi.class).in(Singleton.class);
protected void configure() {}
@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
TikTokCommand provideTikTokCommand(final TikTokApi tikTokApi) {
return new TikTokCommand(tikTokApi);
TikTokCommand provideTikTokCommand(
final TikTokApi tikTokApi, final TempFileManager tempFileManager, final BotConfig config) {
return new TikTokCommand(tikTokApi, tempFileManager, config.getExternalUrl());
}
}

View file

@ -27,7 +27,8 @@ class BotConfigTest {
30,
24,
null,
lavalink);
lavalink,
"http://127.0.0.1:8712");
assertEquals("token123", config.getMainToken());
assertEquals("token123", config.getToken());
assertNull(config.getMusicToken());
@ -46,7 +47,7 @@ class BotConfigTest {
BotConfig config =
new BotConfig(
null, null, null, null, null, 0, null, null, null, null, 10, 300, 3000, 30, 24, null,
null);
null, null);
assertNull(config.getMainToken());
assertNull(config.getMusicToken());
assertNull(config.getRadioToken());