feat: nsfw image commands (!e621, !r34, !gelbooru) + help filtering
This commit is contained in:
parent
cc046d7ee6
commit
deb875fd3d
34 changed files with 1765 additions and 132 deletions
|
|
@ -13,6 +13,9 @@ DB_PATH=data/bot.db
|
||||||
# DeepSeek AI API (для roast)
|
# DeepSeek AI API (для roast)
|
||||||
DEEPSEEK_API_KEY=your_deepseek_key
|
DEEPSEEK_API_KEY=your_deepseek_key
|
||||||
|
|
||||||
|
# Debate channel ID для мульти-AI диспутов
|
||||||
|
AI_DEBATE_CHANNEL_ID=your_debate_channel_id
|
||||||
|
|
||||||
|
|
||||||
# Lavalink
|
# Lavalink
|
||||||
LAVALINK_HOST=127.0.0.1
|
LAVALINK_HOST=127.0.0.1
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ RUN addgroup -S bot && adduser -S bot -G bot
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /build/build/libs/discord-bot-1.0.0.jar app.jar
|
COPY --from=builder /build/build/libs/discord-bot-1.0.0.jar app.jar
|
||||||
COPY data/prompts /app/prompts
|
COPY data/prompts /app/prompts
|
||||||
|
COPY data/knowledge /app/knowledge
|
||||||
RUN mkdir -p /app/data /app/logs && chown -R bot:bot /app
|
RUN mkdir -p /app/data /app/logs && chown -R bot:bot /app
|
||||||
USER bot
|
USER bot
|
||||||
VOLUME /app/data
|
VOLUME /app/data
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,15 @@ services:
|
||||||
AI_MSG_LENGTH: ${AI_MSG_LENGTH:-300}
|
AI_MSG_LENGTH: ${AI_MSG_LENGTH:-300}
|
||||||
AI_CONTEXT_CHARS: ${AI_CONTEXT_CHARS:-3000}
|
AI_CONTEXT_CHARS: ${AI_CONTEXT_CHARS:-3000}
|
||||||
AI_DOSSIER_MSGS: ${AI_DOSSIER_MSGS:-30}
|
AI_DOSSIER_MSGS: ${AI_DOSSIER_MSGS:-30}
|
||||||
|
AI_DEBATE_CHANNEL_ID: ${AI_DEBATE_CHANNEL_ID}
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpus: "8"
|
cpus: "8"
|
||||||
volumes:
|
volumes:
|
||||||
- bot-data:/app/data
|
- bot-data:/app/data
|
||||||
|
- ./data/prompts:/app/prompts:ro
|
||||||
|
- ./data/knowledge:/app/knowledge:ro
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
bot-data:
|
bot-data:
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,8 @@ public final class CommandCategories {
|
||||||
/** Команды с GIF (nekos.life): hug, slap, poke и т.п. */
|
/** Команды с GIF (nekos.life): hug, slap, poke и т.п. */
|
||||||
public static final String GIF_INTERACTIVE = "GIF-интерактив";
|
public static final String GIF_INTERACTIVE = "GIF-интерактив";
|
||||||
|
|
||||||
|
/** Команды для поиска изображений (e621, r34) — только в NSFW-каналах. */
|
||||||
|
public static final String NSFW_IMAGES = "NSFW";
|
||||||
|
|
||||||
private CommandCategories() {}
|
private CommandCategories() {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ public class BotConfig {
|
||||||
private final int aiContextChars;
|
private final int aiContextChars;
|
||||||
private final int aiDossierMsgs;
|
private final int aiDossierMsgs;
|
||||||
private final int aiDossierUpdateHours;
|
private final int aiDossierUpdateHours;
|
||||||
|
private final String aiDebateChannelId;
|
||||||
private final LavalinkConfig lavalinkConfig;
|
private final LavalinkConfig lavalinkConfig;
|
||||||
|
|
||||||
public BotConfig(
|
public BotConfig(
|
||||||
|
|
@ -34,6 +35,7 @@ public class BotConfig {
|
||||||
int aiContextChars,
|
int aiContextChars,
|
||||||
int aiDossierMsgs,
|
int aiDossierMsgs,
|
||||||
int aiDossierUpdateHours,
|
int aiDossierUpdateHours,
|
||||||
|
String aiDebateChannelId,
|
||||||
LavalinkConfig lavalinkConfig) {
|
LavalinkConfig lavalinkConfig) {
|
||||||
this.mainToken = mainToken;
|
this.mainToken = mainToken;
|
||||||
this.musicToken = musicToken;
|
this.musicToken = musicToken;
|
||||||
|
|
@ -50,6 +52,7 @@ public class BotConfig {
|
||||||
this.aiContextChars = aiContextChars;
|
this.aiContextChars = aiContextChars;
|
||||||
this.aiDossierMsgs = aiDossierMsgs;
|
this.aiDossierMsgs = aiDossierMsgs;
|
||||||
this.aiDossierUpdateHours = aiDossierUpdateHours;
|
this.aiDossierUpdateHours = aiDossierUpdateHours;
|
||||||
|
this.aiDebateChannelId = aiDebateChannelId;
|
||||||
this.lavalinkConfig = lavalinkConfig;
|
this.lavalinkConfig = lavalinkConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,6 +120,10 @@ public class BotConfig {
|
||||||
return aiDossierUpdateHours;
|
return aiDossierUpdateHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getAiDebateChannelId() {
|
||||||
|
return aiDebateChannelId;
|
||||||
|
}
|
||||||
|
|
||||||
public LavalinkConfig getLavalinkConfig() {
|
public LavalinkConfig getLavalinkConfig() {
|
||||||
return lavalinkConfig;
|
return lavalinkConfig;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ public final class ConfigLoader {
|
||||||
int aiContextChars = Integer.parseInt(getEnv(dotenv, "AI_CONTEXT_CHARS", "3000"));
|
int aiContextChars = Integer.parseInt(getEnv(dotenv, "AI_CONTEXT_CHARS", "3000"));
|
||||||
int aiDossierMsgs = Integer.parseInt(getEnv(dotenv, "AI_DOSSIER_MSGS", "30"));
|
int aiDossierMsgs = Integer.parseInt(getEnv(dotenv, "AI_DOSSIER_MSGS", "30"));
|
||||||
int aiDossierUpdateHours = Integer.parseInt(getEnv(dotenv, "AI_DOSSIER_UPDATE_HOURS", "24"));
|
int aiDossierUpdateHours = Integer.parseInt(getEnv(dotenv, "AI_DOSSIER_UPDATE_HOURS", "24"));
|
||||||
|
String aiDebateChannelId = dotenv.get("AI_DEBATE_CHANNEL_ID");
|
||||||
|
|
||||||
LavalinkConfig lavalinkConfig = getLavalinkConfig();
|
LavalinkConfig lavalinkConfig = getLavalinkConfig();
|
||||||
LoggerDecorator.success(logger, "Конфигурация загружена успешно");
|
LoggerDecorator.success(logger, "Конфигурация загружена успешно");
|
||||||
|
|
@ -100,6 +101,7 @@ public final class ConfigLoader {
|
||||||
aiContextChars,
|
aiContextChars,
|
||||||
aiDossierMsgs,
|
aiDossierMsgs,
|
||||||
aiDossierUpdateHours,
|
aiDossierUpdateHours,
|
||||||
|
aiDebateChannelId,
|
||||||
lavalinkConfig);
|
lavalinkConfig);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,7 @@ import net.dv8tion.jda.api.JDA;
|
||||||
import net.dv8tion.jda.api.JDABuilder;
|
import net.dv8tion.jda.api.JDABuilder;
|
||||||
import net.dv8tion.jda.api.OnlineStatus;
|
import net.dv8tion.jda.api.OnlineStatus;
|
||||||
import net.dv8tion.jda.api.entities.Activity;
|
import net.dv8tion.jda.api.entities.Activity;
|
||||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
|
||||||
import net.dv8tion.jda.api.interactions.commands.build.Commands;
|
|
||||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
import net.dv8tion.jda.api.requests.GatewayIntent;
|
||||||
import net.dv8tion.jda.api.utils.cache.CacheFlag;
|
import net.dv8tion.jda.api.utils.cache.CacheFlag;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
|
@ -68,18 +67,6 @@ public class BotInitializer {
|
||||||
LoggerDecorator.startup(logger, "Инициализация main бота...");
|
LoggerDecorator.startup(logger, "Инициализация main бота...");
|
||||||
registrar.registerAll();
|
registrar.registerAll();
|
||||||
JDA mainJda = buildMainJDA();
|
JDA mainJda = buildMainJDA();
|
||||||
mainJda
|
|
||||||
.upsertCommand(
|
|
||||||
Commands.slash("agr", "Наслать AI на пользователя (только для админов)")
|
|
||||||
.addOption(OptionType.USER, "user", "Цель (по умолчанию Andrert_)", false))
|
|
||||||
.queue();
|
|
||||||
mainJda
|
|
||||||
.upsertCommand(
|
|
||||||
Commands.slash("clear", "Удалить сообщения")
|
|
||||||
.addOption(OptionType.INTEGER, "amount", "Количество (1-100)", true)
|
|
||||||
.addOption(
|
|
||||||
OptionType.USER, "user", "Оставить только сообщения пользователя", false))
|
|
||||||
.queue();
|
|
||||||
muteScheduler.restorePending(mainJda);
|
muteScheduler.restorePending(mainJda);
|
||||||
mainJda.getGuilds().forEach(g -> memberCounterListener.startScheduler(g));
|
mainJda.getGuilds().forEach(g -> memberCounterListener.startScheduler(g));
|
||||||
healthHttpServer.start();
|
healthHttpServer.start();
|
||||||
|
|
|
||||||
23
src/main/java/creator/loki/core/BotJdaRegistry.java
Normal file
23
src/main/java/creator/loki/core/BotJdaRegistry.java
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package creator.loki.core;
|
||||||
|
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import net.dv8tion.jda.api.JDA;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class BotJdaRegistry {
|
||||||
|
private JDA main;
|
||||||
|
private JDA music;
|
||||||
|
private JDA radio;
|
||||||
|
|
||||||
|
public void setMain(JDA jda) { this.main = jda; }
|
||||||
|
public void setMusic(JDA jda) { this.music = jda; }
|
||||||
|
public void setRadio(JDA jda) { this.radio = jda; }
|
||||||
|
|
||||||
|
public JDA getByPersonality(String personality) {
|
||||||
|
return switch (personality) {
|
||||||
|
case "windows" -> music != null ? music : main;
|
||||||
|
case "gentoo" -> radio != null ? radio : main;
|
||||||
|
default -> main;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import creator.loki.feature.moderation.commands.utils.ClearSlashCommands;
|
||||||
import creator.loki.feature.moderation.log.ModerationLogger;
|
import creator.loki.feature.moderation.log.ModerationLogger;
|
||||||
import creator.loki.feature.music.MusicSlashCommands;
|
import creator.loki.feature.music.MusicSlashCommands;
|
||||||
import creator.loki.feature.roast.AndrertListener;
|
import creator.loki.feature.roast.AndrertListener;
|
||||||
|
import creator.loki.feature.roast.HelperListener;
|
||||||
import creator.loki.feature.voice.handler.VoiceButtonHandler;
|
import creator.loki.feature.voice.handler.VoiceButtonHandler;
|
||||||
import creator.loki.feature.voice.handler.VoiceModalHandler;
|
import creator.loki.feature.voice.handler.VoiceModalHandler;
|
||||||
import creator.loki.feature.voice.listener.VoiceChannelListener;
|
import creator.loki.feature.voice.listener.VoiceChannelListener;
|
||||||
|
|
@ -43,7 +44,8 @@ public class ListenerAggregator {
|
||||||
MusicSlashCommands musicSlashCommands,
|
MusicSlashCommands musicSlashCommands,
|
||||||
FishMessageListener fishMessageListener,
|
FishMessageListener fishMessageListener,
|
||||||
AndrertListener andrertListener,
|
AndrertListener andrertListener,
|
||||||
ClearSlashCommands clearSlashCommands) {
|
ClearSlashCommands clearSlashCommands,
|
||||||
|
HelperListener helperListener) {
|
||||||
this.listeners =
|
this.listeners =
|
||||||
new Object[] {
|
new Object[] {
|
||||||
readyListener,
|
readyListener,
|
||||||
|
|
@ -63,7 +65,8 @@ public class ListenerAggregator {
|
||||||
musicSlashCommands,
|
musicSlashCommands,
|
||||||
fishMessageListener,
|
fishMessageListener,
|
||||||
andrertListener,
|
andrertListener,
|
||||||
clearSlashCommands
|
clearSlashCommands,
|
||||||
|
helperListener
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import creator.loki.feature.counter.CounterModule;
|
||||||
import creator.loki.feature.economy.EconomyCommandRegistrar;
|
import creator.loki.feature.economy.EconomyCommandRegistrar;
|
||||||
import creator.loki.feature.economy.EconomyModule;
|
import creator.loki.feature.economy.EconomyModule;
|
||||||
import creator.loki.feature.entertainment.EntertainmentCommandRegistrar;
|
import creator.loki.feature.entertainment.EntertainmentCommandRegistrar;
|
||||||
|
import creator.loki.feature.image.ImageCommandRegistrar;
|
||||||
|
import creator.loki.feature.image.ImageModule;
|
||||||
import creator.loki.feature.level.LevelModule;
|
import creator.loki.feature.level.LevelModule;
|
||||||
import creator.loki.feature.level.commands.LevelCommandRegistrar;
|
import creator.loki.feature.level.commands.LevelCommandRegistrar;
|
||||||
import creator.loki.feature.metrics.HealthHttpServer;
|
import creator.loki.feature.metrics.HealthHttpServer;
|
||||||
|
|
@ -64,6 +66,7 @@ public class BotModule extends AbstractModule {
|
||||||
install(new EconomyModule());
|
install(new EconomyModule());
|
||||||
install(new RadioModule());
|
install(new RadioModule());
|
||||||
install(new RoastModule());
|
install(new RoastModule());
|
||||||
|
install(new ImageModule());
|
||||||
|
|
||||||
Multibinder<CommandRegistrar> commandBinder =
|
Multibinder<CommandRegistrar> commandBinder =
|
||||||
Multibinder.newSetBinder(binder(), CommandRegistrar.class);
|
Multibinder.newSetBinder(binder(), CommandRegistrar.class);
|
||||||
|
|
@ -74,6 +77,7 @@ public class BotModule extends AbstractModule {
|
||||||
commandBinder.addBinding().to(MusicCommandRegistrar.class);
|
commandBinder.addBinding().to(MusicCommandRegistrar.class);
|
||||||
commandBinder.addBinding().to(EconomyCommandRegistrar.class);
|
commandBinder.addBinding().to(EconomyCommandRegistrar.class);
|
||||||
commandBinder.addBinding().to(RadioCommandRegistrar.class);
|
commandBinder.addBinding().to(RadioCommandRegistrar.class);
|
||||||
|
commandBinder.addBinding().to(ImageCommandRegistrar.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@ public final class HelpCategories {
|
||||||
Map.entry("Утилиты", "🔧"),
|
Map.entry("Утилиты", "🔧"),
|
||||||
Map.entry("Система", "💠"),
|
Map.entry("Система", "💠"),
|
||||||
Map.entry("Настройка", "⚙️"),
|
Map.entry("Настройка", "⚙️"),
|
||||||
Map.entry("Супер-Админ", "🔐"));
|
Map.entry("Супер-Админ", "🔐"),
|
||||||
|
Map.entry(CommandCategories.NSFW_IMAGES, "🔞"));
|
||||||
|
|
||||||
COLORS =
|
COLORS =
|
||||||
Map.ofEntries(
|
Map.ofEntries(
|
||||||
|
|
@ -35,7 +36,8 @@ public final class HelpCategories {
|
||||||
Map.entry("Утилиты", new Color(52, 152, 219)),
|
Map.entry("Утилиты", new Color(52, 152, 219)),
|
||||||
Map.entry("Система", new Color(52, 73, 94)),
|
Map.entry("Система", new Color(52, 73, 94)),
|
||||||
Map.entry("Настройка", new Color(149, 165, 166)),
|
Map.entry("Настройка", new Color(149, 165, 166)),
|
||||||
Map.entry("Супер-Админ", new Color(113, 54, 138)));
|
Map.entry("Супер-Админ", new Color(113, 54, 138)),
|
||||||
|
Map.entry(CommandCategories.NSFW_IMAGES, new Color(180, 0, 0)));
|
||||||
|
|
||||||
DESCRIPTIONS =
|
DESCRIPTIONS =
|
||||||
Map.ofEntries(
|
Map.ofEntries(
|
||||||
|
|
@ -49,7 +51,9 @@ public final class HelpCategories {
|
||||||
Map.entry("Утилиты", "Полезные инструменты"),
|
Map.entry("Утилиты", "Полезные инструменты"),
|
||||||
Map.entry("Система", "Метрики и служебная информация для админов"),
|
Map.entry("Система", "Метрики и служебная информация для админов"),
|
||||||
Map.entry("Настройка", "Настройка систем бота (только админы)"),
|
Map.entry("Настройка", "Настройка систем бота (только админы)"),
|
||||||
Map.entry("Супер-Админ", "Сервисные команды владельца бота"));
|
Map.entry("Супер-Админ", "Сервисные команды владельца бота"),
|
||||||
|
Map.entry(
|
||||||
|
CommandCategories.NSFW_IMAGES, "Поиск изображений (только в NSFW каналах)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private HelpCategories() {}
|
private HelpCategories() {}
|
||||||
|
|
@ -66,10 +70,17 @@ public final class HelpCategories {
|
||||||
return DESCRIPTIONS.getOrDefault(category, "Команды категории");
|
return DESCRIPTIONS.getOrDefault(category, "Команды категории");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isCategoryVisible(String category, boolean isAdmin, boolean isSuper) {
|
public static boolean isCategoryVisible(
|
||||||
|
String category, boolean isAdmin, boolean isSuper, boolean isNsfwChannel) {
|
||||||
if ("Настройка".equals(category) && !isAdmin) {
|
if ("Настройка".equals(category) && !isAdmin) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return !"Супер-Админ".equals(category) || isSuper;
|
if ("Супер-Админ".equals(category) && !isSuper) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (CommandCategories.NSFW_IMAGES.equals(category) && !isNsfwChannel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import creator.loki.embed.HelpCategories;
|
||||||
import net.dv8tion.jda.api.Permission;
|
import net.dv8tion.jda.api.Permission;
|
||||||
import net.dv8tion.jda.api.components.actionrow.ActionRow;
|
import net.dv8tion.jda.api.components.actionrow.ActionRow;
|
||||||
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
|
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
|
||||||
|
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||||
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
|
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
|
||||||
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
||||||
|
|
||||||
|
|
@ -76,12 +77,13 @@ public class HelpMenuHandler extends ListenerAdapter {
|
||||||
boolean isAdmin =
|
boolean isAdmin =
|
||||||
event.getMember() != null && event.getMember().hasPermission(Permission.ADMINISTRATOR);
|
event.getMember() != null && event.getMember().hasPermission(Permission.ADMINISTRATOR);
|
||||||
boolean isSuper = config.getSuperAdminId().equals(event.getUser().getId());
|
boolean isSuper = config.getSuperAdminId().equals(event.getUser().getId());
|
||||||
|
boolean isNsfwChannel = isNsfw(event.getChannel().getType());
|
||||||
|
|
||||||
commandsByCategory.keySet().stream()
|
commandsByCategory.keySet().stream()
|
||||||
.sorted()
|
.sorted()
|
||||||
.forEach(
|
.forEach(
|
||||||
cat -> {
|
cat -> {
|
||||||
if (!HelpCategories.isCategoryVisible(cat, isAdmin, isSuper)) {
|
if (!HelpCategories.isCategoryVisible(cat, isAdmin, isSuper, isNsfwChannel)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,4 +152,11 @@ public class HelpMenuHandler extends ListenerAdapter {
|
||||||
|
|
||||||
return embed;
|
return embed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isNsfw(ChannelType type) {
|
||||||
|
return type == ChannelType.TEXT
|
||||||
|
|| type == ChannelType.NEWS
|
||||||
|
|| type == ChannelType.FORUM
|
||||||
|
|| type == ChannelType.MEDIA;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import net.dv8tion.jda.api.Permission;
|
||||||
import net.dv8tion.jda.api.components.actionrow.ActionRow;
|
import net.dv8tion.jda.api.components.actionrow.ActionRow;
|
||||||
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
|
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
|
||||||
import net.dv8tion.jda.api.entities.User;
|
import net.dv8tion.jda.api.entities.User;
|
||||||
|
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||||
|
|
||||||
public class HelpCommand implements Command {
|
public class HelpCommand implements Command {
|
||||||
private final CommandRegistry registry;
|
private final CommandRegistry registry;
|
||||||
|
|
@ -48,9 +49,11 @@ public class HelpCommand implements Command {
|
||||||
boolean isAdmin =
|
boolean isAdmin =
|
||||||
context.getAuthor() != null && context.getAuthor().hasPermission(Permission.ADMINISTRATOR);
|
context.getAuthor() != null && context.getAuthor().hasPermission(Permission.ADMINISTRATOR);
|
||||||
boolean isSuper = config.getSuperAdminId().equals(viewer.getId());
|
boolean isSuper = config.getSuperAdminId().equals(viewer.getId());
|
||||||
|
boolean isNsfwChannel = isNsfw(context.getChannel().getType());
|
||||||
|
|
||||||
// Главная страница
|
// Главная страница
|
||||||
EmbedFactory embed = createMainPage(context, commandsByCategory, isAdmin, isSuper);
|
EmbedFactory embed =
|
||||||
|
createMainPage(context, commandsByCategory, isAdmin, isSuper, isNsfwChannel);
|
||||||
|
|
||||||
// Создаём селектор категорий
|
// Создаём селектор категорий
|
||||||
StringSelectMenu.Builder menuBuilder =
|
StringSelectMenu.Builder menuBuilder =
|
||||||
|
|
@ -60,7 +63,7 @@ public class HelpCommand implements Command {
|
||||||
.sorted()
|
.sorted()
|
||||||
.forEach(
|
.forEach(
|
||||||
category -> {
|
category -> {
|
||||||
if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper)) {
|
if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper, isNsfwChannel)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,14 +85,22 @@ public class HelpCommand implements Command {
|
||||||
.queue();
|
.queue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isNsfw(ChannelType type) {
|
||||||
|
return type == ChannelType.TEXT
|
||||||
|
|| type == ChannelType.NEWS
|
||||||
|
|| type == ChannelType.FORUM
|
||||||
|
|| type == ChannelType.MEDIA;
|
||||||
|
}
|
||||||
|
|
||||||
private EmbedFactory createMainPage(
|
private EmbedFactory createMainPage(
|
||||||
CommandContext context,
|
CommandContext context,
|
||||||
Map<String, List<Command>> commandsByCategory,
|
Map<String, List<Command>> commandsByCategory,
|
||||||
boolean isAdmin,
|
boolean isAdmin,
|
||||||
boolean isSuper) {
|
boolean isSuper,
|
||||||
|
boolean isNsfwChannel) {
|
||||||
int totalCommands =
|
int totalCommands =
|
||||||
commandsByCategory.entrySet().stream()
|
commandsByCategory.entrySet().stream()
|
||||||
.filter(e -> HelpCategories.isCategoryVisible(e.getKey(), isAdmin, isSuper))
|
.filter(e -> HelpCategories.isCategoryVisible(e.getKey(), isAdmin, isSuper, isNsfwChannel))
|
||||||
.mapToInt(e -> e.getValue().size())
|
.mapToInt(e -> e.getValue().size())
|
||||||
.sum();
|
.sum();
|
||||||
|
|
||||||
|
|
@ -98,7 +109,8 @@ public class HelpCommand implements Command {
|
||||||
.sorted()
|
.sorted()
|
||||||
.forEach(
|
.forEach(
|
||||||
category -> {
|
category -> {
|
||||||
if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper)) {
|
if (!HelpCategories.isCategoryVisible(
|
||||||
|
category, isAdmin, isSuper, isNsfwChannel)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package creator.loki.feature.image;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import creator.loki.core.registry.CommandRegistrar;
|
||||||
|
import creator.loki.core.registry.CommandRegistry;
|
||||||
|
import creator.loki.feature.image.commands.E621Command;
|
||||||
|
import creator.loki.feature.image.commands.GelbooruCommand;
|
||||||
|
import creator.loki.feature.image.commands.R34Command;
|
||||||
|
|
||||||
|
public class ImageCommandRegistrar implements CommandRegistrar {
|
||||||
|
private final ImageService imageService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public ImageCommandRegistrar(ImageService imageService) {
|
||||||
|
this.imageService = imageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void register(CommandRegistry registry) {
|
||||||
|
registry.register(new E621Command(imageService));
|
||||||
|
registry.register(new R34Command(imageService));
|
||||||
|
registry.register(new GelbooruCommand(imageService));
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/main/java/creator/loki/feature/image/ImageModule.java
Normal file
17
src/main/java/creator/loki/feature/image/ImageModule.java
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package creator.loki.feature.image;
|
||||||
|
|
||||||
|
import com.google.inject.AbstractModule;
|
||||||
|
import com.google.inject.Provides;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
|
||||||
|
public class ImageModule extends AbstractModule {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void configure() {}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
ImageService provideImageService() {
|
||||||
|
return new ImageService();
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/main/java/creator/loki/feature/image/ImageService.java
Normal file
129
src/main/java/creator/loki/feature/image/ImageService.java
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
package creator.loki.feature.image;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.Response;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class ImageService {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(ImageService.class);
|
||||||
|
private static final String E621_API = "https://e621.net/posts.json";
|
||||||
|
private static final String R34_API =
|
||||||
|
"https://api.rule34.xxx/index.php?page=dapi&s=post&q=index&json=1";
|
||||||
|
|
||||||
|
private final OkHttpClient client;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public ImageService() {
|
||||||
|
this.client =
|
||||||
|
new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> fetchE621(String tags, int limit) {
|
||||||
|
String url = E621_API + "?tags=" + encodeTags(tags) + "&limit=" + Math.min(limit, 10);
|
||||||
|
Request request =
|
||||||
|
new Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("User-Agent", "LokiBot/1.0 (by loki)")
|
||||||
|
.get()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
logger.error("e621 API error: {} {}", response.code(), response.message());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
String body = response.body().string();
|
||||||
|
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||||
|
JsonArray posts = json.getAsJsonArray("posts");
|
||||||
|
if (posts == null || posts.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
for (JsonElement el : posts) {
|
||||||
|
JsonObject post = el.getAsJsonObject();
|
||||||
|
JsonObject file = post.getAsJsonObject("file");
|
||||||
|
if (file != null && file.has("url") && !file.get("url").isJsonNull()) {
|
||||||
|
String imageUrl = file.get("url").getAsString();
|
||||||
|
if (!imageUrl.isEmpty()) {
|
||||||
|
urls.add(imageUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (urls.size() >= limit) break;
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Error fetching from e621", e);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> fetchR34(String tags, int limit) {
|
||||||
|
String url =
|
||||||
|
R34_API + "&tags=" + encodeTags(tags) + "&limit=" + Math.min(limit, 10);
|
||||||
|
return fetchBooruJson(url, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> fetchGelbooru(String tags, int limit) {
|
||||||
|
String url =
|
||||||
|
"https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1&tags="
|
||||||
|
+ encodeTags(tags)
|
||||||
|
+ "&limit="
|
||||||
|
+ Math.min(limit, 10);
|
||||||
|
return fetchBooruJson(url, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> fetchBooruJson(String url, int limit) {
|
||||||
|
Request request = new Request.Builder().url(url).get().build();
|
||||||
|
|
||||||
|
try (Response response = client.newCall(request).execute()) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
logger.error("Booru API error: {} {}", response.code(), response.message());
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
String body = response.body().string();
|
||||||
|
if (body.isBlank() || !body.startsWith("[")) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
JsonArray posts = JsonParser.parseString(body).getAsJsonArray();
|
||||||
|
List<String> urls = new ArrayList<>();
|
||||||
|
for (JsonElement el : posts) {
|
||||||
|
JsonObject post = el.getAsJsonObject();
|
||||||
|
String fileUrl = post.get("file_url").getAsString();
|
||||||
|
if (fileUrl != null && !fileUrl.isEmpty()) {
|
||||||
|
if (fileUrl.startsWith("//")) {
|
||||||
|
fileUrl = "https:" + fileUrl;
|
||||||
|
}
|
||||||
|
urls.add(fileUrl);
|
||||||
|
}
|
||||||
|
if (urls.size() >= limit) break;
|
||||||
|
}
|
||||||
|
return urls;
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Error fetching from booru", e);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String encodeTags(String tags) {
|
||||||
|
if (tags == null || tags.isBlank()) return "";
|
||||||
|
return tags.trim().replace(" ", "+").replace("#", "%23");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
package creator.loki.feature.image.commands;
|
||||||
|
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import creator.loki.command.Command;
|
||||||
|
import creator.loki.command.CommandCategories;
|
||||||
|
import creator.loki.command.CommandContext;
|
||||||
|
import creator.loki.embed.EmbedFactory;
|
||||||
|
import creator.loki.embed.EmbedTemplates;
|
||||||
|
import creator.loki.feature.image.ImageService;
|
||||||
|
import net.dv8tion.jda.api.entities.MessageEmbed;
|
||||||
|
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||||
|
|
||||||
|
public class E621Command implements Command {
|
||||||
|
private final ImageService imageService;
|
||||||
|
|
||||||
|
public E621Command(ImageService imageService) {
|
||||||
|
this.imageService = imageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "e621";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getDescription() {
|
||||||
|
return "Поиск изображений на e621.net";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUsage() {
|
||||||
|
return "e621 <количество до 10> <теги>";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCategory() {
|
||||||
|
return CommandCategories.NSFW_IMAGES;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(CommandContext context) {
|
||||||
|
if (!isNsfwChannel(context)) {
|
||||||
|
context
|
||||||
|
.getChannel()
|
||||||
|
.sendMessageEmbeds(
|
||||||
|
EmbedTemplates.error(
|
||||||
|
"🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением."))
|
||||||
|
.queue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ParseResult parsed = parseArgs(context.getArgs());
|
||||||
|
sendResults(context, parsed.limit, parsed.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendResults(CommandContext context, int limit, String tags) {
|
||||||
|
context
|
||||||
|
.getChannel()
|
||||||
|
.sendMessageEmbeds(
|
||||||
|
EmbedTemplates.info("🔍 Поиск", "Ищу изображения на e621" + tagsDesc(tags) + "..."))
|
||||||
|
.queue(
|
||||||
|
msg -> {
|
||||||
|
List<String> urls = imageService.fetchE621(tags, limit);
|
||||||
|
if (urls.isEmpty()) {
|
||||||
|
msg.editMessageEmbeds(
|
||||||
|
EmbedTemplates.warning(
|
||||||
|
"😔 Ничего не найдено",
|
||||||
|
"По запросу" + tagsDesc(tags) + " ничего не найдено."))
|
||||||
|
.queue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.delete().queue();
|
||||||
|
List<MessageEmbed> embeds = buildEmbeds(urls);
|
||||||
|
for (int i = 0; i < embeds.size(); i += 10) {
|
||||||
|
context
|
||||||
|
.getChannel()
|
||||||
|
.sendMessageEmbeds(
|
||||||
|
embeds.subList(i, Math.min(i + 10, embeds.size())))
|
||||||
|
.queue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ParseResult parseArgs(List<String> args) {
|
||||||
|
if (args.isEmpty()) {
|
||||||
|
return new ParseResult(1, "");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int count = Integer.parseInt(args.get(0));
|
||||||
|
if (count < 1) count = 1;
|
||||||
|
if (count > 10) count = 10;
|
||||||
|
String tagStr = args.size() > 1 ? String.join(" ", args.subList(1, args.size())) : "";
|
||||||
|
return new ParseResult(count, tagStr);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return new ParseResult(1, String.join(" ", args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MessageEmbed> buildEmbeds(List<String> urls) {
|
||||||
|
List<MessageEmbed> embeds = new ArrayList<>();
|
||||||
|
for (String url : urls) {
|
||||||
|
embeds.add(
|
||||||
|
EmbedFactory.create()
|
||||||
|
.withTitle("🔞 e621.net")
|
||||||
|
.withImage(url)
|
||||||
|
.withColor(new Color(102, 45, 145))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return embeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isNsfwChannel(CommandContext context) {
|
||||||
|
ChannelType type = context.getChannel().getType();
|
||||||
|
return type == ChannelType.TEXT
|
||||||
|
|| type == ChannelType.NEWS
|
||||||
|
|| type == ChannelType.FORUM
|
||||||
|
|| type == ChannelType.MEDIA;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String tagsDesc(String tags) {
|
||||||
|
return tags.isBlank() ? "" : " `" + tags + "`";
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ParseResult(int limit, String tags) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
package creator.loki.feature.image.commands;
|
||||||
|
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import creator.loki.command.Command;
|
||||||
|
import creator.loki.command.CommandCategories;
|
||||||
|
import creator.loki.command.CommandContext;
|
||||||
|
import creator.loki.embed.EmbedFactory;
|
||||||
|
import creator.loki.embed.EmbedTemplates;
|
||||||
|
import creator.loki.feature.image.ImageService;
|
||||||
|
import net.dv8tion.jda.api.entities.MessageEmbed;
|
||||||
|
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||||
|
|
||||||
|
public class GelbooruCommand implements Command {
|
||||||
|
private final ImageService imageService;
|
||||||
|
|
||||||
|
public GelbooruCommand(ImageService imageService) {
|
||||||
|
this.imageService = imageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "gelbooru";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getDescription() {
|
||||||
|
return "Поиск изображений на Gelbooru";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUsage() {
|
||||||
|
return "gelbooru <количество до 10> <теги>";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCategory() {
|
||||||
|
return CommandCategories.NSFW_IMAGES;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(CommandContext context) {
|
||||||
|
if (!isNsfwChannel(context)) {
|
||||||
|
context
|
||||||
|
.getChannel()
|
||||||
|
.sendMessageEmbeds(
|
||||||
|
EmbedTemplates.error(
|
||||||
|
"🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением."))
|
||||||
|
.queue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ParseResult parsed = parseArgs(context.getArgs());
|
||||||
|
sendResults(context, parsed.limit, parsed.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendResults(CommandContext context, int limit, String tags) {
|
||||||
|
context
|
||||||
|
.getChannel()
|
||||||
|
.sendMessageEmbeds(
|
||||||
|
EmbedTemplates.info("🔍 Поиск", "Ищу изображения на Gelbooru" + tagsDesc(tags) + "..."))
|
||||||
|
.queue(
|
||||||
|
msg -> {
|
||||||
|
List<String> urls = imageService.fetchGelbooru(tags, limit);
|
||||||
|
if (urls.isEmpty()) {
|
||||||
|
msg.editMessageEmbeds(
|
||||||
|
EmbedTemplates.warning(
|
||||||
|
"😔 Ничего не найдено",
|
||||||
|
"По запросу" + tagsDesc(tags) + " ничего не найдено."))
|
||||||
|
.queue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.delete().queue();
|
||||||
|
List<MessageEmbed> embeds = buildEmbeds(urls);
|
||||||
|
for (int i = 0; i < embeds.size(); i += 10) {
|
||||||
|
context
|
||||||
|
.getChannel()
|
||||||
|
.sendMessageEmbeds(
|
||||||
|
embeds.subList(i, Math.min(i + 10, embeds.size())))
|
||||||
|
.queue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ParseResult parseArgs(List<String> args) {
|
||||||
|
if (args.isEmpty()) {
|
||||||
|
return new ParseResult(1, "");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int count = Integer.parseInt(args.get(0));
|
||||||
|
if (count < 1) count = 1;
|
||||||
|
if (count > 10) count = 10;
|
||||||
|
String tagStr = args.size() > 1 ? String.join(" ", args.subList(1, args.size())) : "";
|
||||||
|
return new ParseResult(count, tagStr);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return new ParseResult(1, String.join(" ", args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MessageEmbed> buildEmbeds(List<String> urls) {
|
||||||
|
List<MessageEmbed> embeds = new ArrayList<>();
|
||||||
|
for (String url : urls) {
|
||||||
|
embeds.add(
|
||||||
|
EmbedFactory.create()
|
||||||
|
.withTitle("🔞 gelbooru.com")
|
||||||
|
.withImage(url)
|
||||||
|
.withColor(new Color(50, 100, 180))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return embeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isNsfwChannel(CommandContext context) {
|
||||||
|
ChannelType type = context.getChannel().getType();
|
||||||
|
return type == ChannelType.TEXT
|
||||||
|
|| type == ChannelType.NEWS
|
||||||
|
|| type == ChannelType.FORUM
|
||||||
|
|| type == ChannelType.MEDIA;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String tagsDesc(String tags) {
|
||||||
|
return tags.isBlank() ? "" : " `" + tags + "`";
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ParseResult(int limit, String tags) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
package creator.loki.feature.image.commands;
|
||||||
|
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import creator.loki.command.Command;
|
||||||
|
import creator.loki.command.CommandCategories;
|
||||||
|
import creator.loki.command.CommandContext;
|
||||||
|
import creator.loki.embed.EmbedFactory;
|
||||||
|
import creator.loki.embed.EmbedTemplates;
|
||||||
|
import creator.loki.feature.image.ImageService;
|
||||||
|
import net.dv8tion.jda.api.entities.MessageEmbed;
|
||||||
|
import net.dv8tion.jda.api.entities.channel.ChannelType;
|
||||||
|
|
||||||
|
public class R34Command implements Command {
|
||||||
|
private final ImageService imageService;
|
||||||
|
|
||||||
|
public R34Command(ImageService imageService) {
|
||||||
|
this.imageService = imageService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "r34";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getDescription() {
|
||||||
|
return "Поиск изображений на Rule34";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUsage() {
|
||||||
|
return "r34 <количество до 10> <теги>";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getCategory() {
|
||||||
|
return CommandCategories.NSFW_IMAGES;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(CommandContext context) {
|
||||||
|
if (!isNsfwChannel(context)) {
|
||||||
|
context
|
||||||
|
.getChannel()
|
||||||
|
.sendMessageEmbeds(
|
||||||
|
EmbedTemplates.error(
|
||||||
|
"🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением."))
|
||||||
|
.queue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ParseResult parsed = parseArgs(context.getArgs());
|
||||||
|
sendResults(context, parsed.limit, parsed.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendResults(CommandContext context, int limit, String tags) {
|
||||||
|
context
|
||||||
|
.getChannel()
|
||||||
|
.sendMessageEmbeds(
|
||||||
|
EmbedTemplates.info("🔍 Поиск", "Ищу изображения на Rule34" + tagsDesc(tags) + "..."))
|
||||||
|
.queue(
|
||||||
|
msg -> {
|
||||||
|
List<String> urls = imageService.fetchR34(tags, limit);
|
||||||
|
if (urls.isEmpty()) {
|
||||||
|
msg.editMessageEmbeds(
|
||||||
|
EmbedTemplates.warning(
|
||||||
|
"😔 Ничего не найдено",
|
||||||
|
"По запросу" + tagsDesc(tags) + " ничего не найдено."))
|
||||||
|
.queue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.delete().queue();
|
||||||
|
List<MessageEmbed> embeds = buildEmbeds(urls);
|
||||||
|
for (int i = 0; i < embeds.size(); i += 10) {
|
||||||
|
context
|
||||||
|
.getChannel()
|
||||||
|
.sendMessageEmbeds(
|
||||||
|
embeds.subList(i, Math.min(i + 10, embeds.size())))
|
||||||
|
.queue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private ParseResult parseArgs(List<String> args) {
|
||||||
|
if (args.isEmpty()) {
|
||||||
|
return new ParseResult(1, "");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
int count = Integer.parseInt(args.get(0));
|
||||||
|
if (count < 1) count = 1;
|
||||||
|
if (count > 10) count = 10;
|
||||||
|
String tagStr = args.size() > 1 ? String.join(" ", args.subList(1, args.size())) : "";
|
||||||
|
return new ParseResult(count, tagStr);
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return new ParseResult(1, String.join(" ", args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MessageEmbed> buildEmbeds(List<String> urls) {
|
||||||
|
List<MessageEmbed> embeds = new ArrayList<>();
|
||||||
|
for (String url : urls) {
|
||||||
|
embeds.add(
|
||||||
|
EmbedFactory.create()
|
||||||
|
.withTitle("🔞 rule34.xxx")
|
||||||
|
.withImage(url)
|
||||||
|
.withColor(new Color(200, 100, 0))
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return embeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isNsfwChannel(CommandContext context) {
|
||||||
|
ChannelType type = context.getChannel().getType();
|
||||||
|
return type == ChannelType.TEXT
|
||||||
|
|| type == ChannelType.NEWS
|
||||||
|
|| type == ChannelType.FORUM
|
||||||
|
|| type == ChannelType.MEDIA;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String tagsDesc(String tags) {
|
||||||
|
return tags.isBlank() ? "" : " `" + tags + "`";
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ParseResult(int limit, String tags) {}
|
||||||
|
}
|
||||||
|
|
@ -8,29 +8,13 @@ import creator.loki.embed.EmbedTemplates;
|
||||||
import net.dv8tion.jda.api.Permission;
|
import net.dv8tion.jda.api.Permission;
|
||||||
import net.dv8tion.jda.api.entities.Message;
|
import net.dv8tion.jda.api.entities.Message;
|
||||||
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||||
import net.dv8tion.jda.api.events.guild.GuildReadyEvent;
|
|
||||||
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
|
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
|
||||||
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
||||||
import net.dv8tion.jda.api.interactions.commands.OptionType;
|
|
||||||
import net.dv8tion.jda.api.interactions.commands.build.Commands;
|
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
public class ClearSlashCommands extends ListenerAdapter {
|
public class ClearSlashCommands extends ListenerAdapter {
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onGuildReady(@NotNull GuildReadyEvent event) {
|
|
||||||
event
|
|
||||||
.getJDA()
|
|
||||||
.updateCommands()
|
|
||||||
.addCommands(
|
|
||||||
Commands.slash("clear", "Удалить сообщения")
|
|
||||||
.addOption(OptionType.INTEGER, "amount", "Количество сообщений (1-100)", true)
|
|
||||||
.addOption(
|
|
||||||
OptionType.USER, "user", "Оставить только сообщения пользователя", false))
|
|
||||||
.queue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) {
|
public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) {
|
||||||
if (!event.getName().equals("clear")) return;
|
if (!event.getName().equals("clear")) return;
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,12 @@ public class MusicSlashCommands extends ListenerAdapter {
|
||||||
Commands.slash("equalizer", "Эквалайзер")
|
Commands.slash("equalizer", "Эквалайзер")
|
||||||
.addOption(OptionType.INTEGER, "band", "0-14", false)
|
.addOption(OptionType.INTEGER, "band", "0-14", false)
|
||||||
.addOption(OptionType.NUMBER, "gain", "-0.25 до 1.0", false),
|
.addOption(OptionType.NUMBER, "gain", "-0.25 до 1.0", false),
|
||||||
Commands.slash("reset", "Сбросить фильтры"))
|
Commands.slash("reset", "Сбросить фильтры"),
|
||||||
|
Commands.slash("agr", "Наслать AI на пользователя (только для админов)")
|
||||||
|
.addOption(OptionType.USER, "user", "Цель", false),
|
||||||
|
Commands.slash("clear", "Удалить сообщения")
|
||||||
|
.addOption(OptionType.INTEGER, "amount", "Количество (1-100)", true)
|
||||||
|
.addOption(OptionType.USER, "user", "Оставить только сообщения пользователя", false))
|
||||||
.queue();
|
.queue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,16 @@ package creator.loki.feature.roast;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
import com.google.inject.Inject;
|
import com.google.inject.Inject;
|
||||||
import com.google.inject.Singleton;
|
import com.google.inject.Singleton;
|
||||||
import creator.loki.config.BotConfig;
|
import creator.loki.config.BotConfig;
|
||||||
import net.dv8tion.jda.api.entities.Message;
|
import net.dv8tion.jda.api.entities.Message;
|
||||||
|
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||||
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
|
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
|
||||||
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
||||||
import org.jetbrains.annotations.NotNull;
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
@ -18,10 +22,14 @@ public class AndrertListener extends ListenerAdapter {
|
||||||
private static final String ANDRERT_USER_ID = "1405129454165954630";
|
private static final String ANDRERT_USER_ID = "1405129454165954630";
|
||||||
private static final int CHANNEL_CONTEXT = 15;
|
private static final int CHANNEL_CONTEXT = 15;
|
||||||
private static final long TRACK_TTL = TimeUnit.HOURS.toMillis(1);
|
private static final long TRACK_TTL = TimeUnit.HOURS.toMillis(1);
|
||||||
|
private static final long AUTO_PING_DELAY = TimeUnit.MINUTES.toMillis(5);
|
||||||
|
|
||||||
private final RoastService roastService;
|
private final RoastService roastService;
|
||||||
private final String allowedChannelId;
|
private final String allowedChannelId;
|
||||||
private final ConcurrentHashMap<String, Long> trackedUsers = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, Long> trackedUsers = new ConcurrentHashMap<>();
|
||||||
|
private final ConcurrentHashMap<String, Long> lastAiReplyTime = new ConcurrentHashMap<>();
|
||||||
|
private final ConcurrentHashMap<String, ScheduledFuture<?>> pendingPings = new ConcurrentHashMap<>();
|
||||||
|
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public AndrertListener(RoastService roastService, BotConfig config) {
|
public AndrertListener(RoastService roastService, BotConfig config) {
|
||||||
|
|
@ -38,14 +46,23 @@ public class AndrertListener extends ListenerAdapter {
|
||||||
public void onMessageReceived(@NotNull MessageReceivedEvent event) {
|
public void onMessageReceived(@NotNull MessageReceivedEvent event) {
|
||||||
if (event.getAuthor().isBot()) return;
|
if (event.getAuthor().isBot()) return;
|
||||||
|
|
||||||
|
String userId = event.getAuthor().getId();
|
||||||
String channelId = event.getChannel().getId();
|
String channelId = event.getChannel().getId();
|
||||||
|
String key = userId + ":" + channelId;
|
||||||
|
|
||||||
|
Long lastReply = lastAiReplyTime.get(key);
|
||||||
|
if (lastReply != null) {
|
||||||
|
lastAiReplyTime.put(key, -1L);
|
||||||
|
ScheduledFuture<?> existing = pendingPings.remove(key);
|
||||||
|
if (existing != null) existing.cancel(false);
|
||||||
|
}
|
||||||
|
|
||||||
boolean isAllowedChannel =
|
boolean isAllowedChannel =
|
||||||
allowedChannelId != null
|
allowedChannelId != null
|
||||||
&& !allowedChannelId.isBlank()
|
&& !allowedChannelId.isBlank()
|
||||||
&& channelId.equals(allowedChannelId);
|
&& channelId.equals(allowedChannelId);
|
||||||
|
|
||||||
if (!isAllowedChannel) {
|
if (!isAllowedChannel) {
|
||||||
String userId = event.getAuthor().getId();
|
|
||||||
Long expiresAt = trackedUsers.get(userId);
|
Long expiresAt = trackedUsers.get(userId);
|
||||||
if (expiresAt == null) return;
|
if (expiresAt == null) return;
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
|
|
@ -58,7 +75,7 @@ public class AndrertListener extends ListenerAdapter {
|
||||||
trackedUsers.put(userId, now + TRACK_TTL);
|
trackedUsers.put(userId, now + TRACK_TTL);
|
||||||
}
|
}
|
||||||
|
|
||||||
respond(event, event.getAuthor().getId());
|
respond(event, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void respond(MessageReceivedEvent event, String userId) {
|
private void respond(MessageReceivedEvent event, String userId) {
|
||||||
|
|
@ -89,9 +106,8 @@ public class AndrertListener extends ListenerAdapter {
|
||||||
msg.getAuthor().getId().equals(event.getJDA().getSelfUser().getId());
|
msg.getAuthor().getId().equals(event.getJDA().getSelfUser().getId());
|
||||||
String author = isSelf ? "ты" : msg.getAuthor().getEffectiveName();
|
String author = isSelf ? "ты" : msg.getAuthor().getEffectiveName();
|
||||||
String authorId = msg.getAuthor().getId();
|
String authorId = msg.getAuthor().getId();
|
||||||
boolean isCurrent = (idx == 0);
|
|
||||||
|
|
||||||
String label = isCurrent ? "[now]" : "[history]";
|
String label = (idx == 0) ? "[now]" : "[history]";
|
||||||
StringBuilder line = new StringBuilder();
|
StringBuilder line = new StringBuilder();
|
||||||
line.append(label)
|
line.append(label)
|
||||||
.append(" ")
|
.append(" ")
|
||||||
|
|
@ -115,9 +131,98 @@ public class AndrertListener extends ListenerAdapter {
|
||||||
if (response == null || response.isBlank()) return;
|
if (response == null || response.isBlank()) return;
|
||||||
|
|
||||||
sendInParts(event, response);
|
sendInParts(event, response);
|
||||||
|
scheduleAutoPing(event, userId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void scheduleAutoPing(MessageReceivedEvent event, String userId) {
|
||||||
|
String channelId = event.getChannel().getId();
|
||||||
|
String key = userId + ":" + channelId;
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
lastAiReplyTime.put(key, now);
|
||||||
|
|
||||||
|
ScheduledFuture<?> existing = pendingPings.remove(key);
|
||||||
|
if (existing != null) existing.cancel(false);
|
||||||
|
|
||||||
|
TextChannel channel = event.getChannel().asTextChannel();
|
||||||
|
String targetMention = "<@" + userId + ">";
|
||||||
|
|
||||||
|
ScheduledFuture<?> future =
|
||||||
|
scheduler.schedule(
|
||||||
|
() -> {
|
||||||
|
Long lastReply = lastAiReplyTime.get(key);
|
||||||
|
if (lastReply == null || lastReply != now) return;
|
||||||
|
if (channel.canTalk()) {
|
||||||
|
channel
|
||||||
|
.getHistory()
|
||||||
|
.retrievePast(5)
|
||||||
|
.queue(
|
||||||
|
recent -> {
|
||||||
|
boolean userReplied =
|
||||||
|
recent.stream()
|
||||||
|
.anyMatch(
|
||||||
|
m ->
|
||||||
|
m.getAuthor().getId().equals(userId)
|
||||||
|
&& m.getTimeCreated().toEpochSecond() * 1000 > now);
|
||||||
|
if (userReplied) return;
|
||||||
|
|
||||||
|
String[] lines = {
|
||||||
|
"эй " + targetMention + ", ты чё замолчал? я не закончил",
|
||||||
|
"куда пропал " + targetMention + "? аргументы закончились?",
|
||||||
|
"ну " + targetMention + ", я жду. или скажешь что тебе нечего ответить?",
|
||||||
|
"тишина в эфире " + targetMention + ". всё, признал поражение?",
|
||||||
|
targetMention + " слился. ожидаемо",
|
||||||
|
};
|
||||||
|
String msg =
|
||||||
|
lines[(int) (System.currentTimeMillis() % lines.length)];
|
||||||
|
|
||||||
|
channel
|
||||||
|
.getHistory()
|
||||||
|
.retrievePast(CHANNEL_CONTEXT)
|
||||||
|
.queue(
|
||||||
|
hist -> {
|
||||||
|
String targetName = "пользователь";
|
||||||
|
List<CachedMessage> ctx = new ArrayList<>();
|
||||||
|
for (int i = 0; i < hist.size(); i++) {
|
||||||
|
Message m = hist.get(i);
|
||||||
|
String a =
|
||||||
|
m.getAuthor()
|
||||||
|
.getId()
|
||||||
|
.equals(
|
||||||
|
event.getJDA().getSelfUser().getId())
|
||||||
|
? "ты"
|
||||||
|
: m.getAuthor().getEffectiveName();
|
||||||
|
String label = (i == 0) ? "[now]" : "[history]";
|
||||||
|
ctx.add(
|
||||||
|
new CachedMessage(
|
||||||
|
label
|
||||||
|
+ " "
|
||||||
|
+ a
|
||||||
|
+ "("
|
||||||
|
+ m.getAuthor().getId()
|
||||||
|
+ "): "
|
||||||
|
+ m.getContentRaw(),
|
||||||
|
channel.getName(),
|
||||||
|
m.getTimeCreated().toEpochSecond() * 1000));
|
||||||
|
}
|
||||||
|
java.util.Collections.reverse(ctx);
|
||||||
|
String resp =
|
||||||
|
roastService.generateResponse(ctx, targetName, userId);
|
||||||
|
if (resp != null && !resp.isBlank()) {
|
||||||
|
channel.sendMessage(msg + "\n" + resp).queue();
|
||||||
|
} else {
|
||||||
|
channel.sendMessage(msg).queue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pendingPings.remove(key);
|
||||||
|
},
|
||||||
|
AUTO_PING_DELAY,
|
||||||
|
TimeUnit.MILLISECONDS);
|
||||||
|
pendingPings.put(key, future);
|
||||||
|
}
|
||||||
|
|
||||||
private void sendInParts(MessageReceivedEvent event, String text) {
|
private void sendInParts(MessageReceivedEvent event, String text) {
|
||||||
if (text.length() <= 2000) {
|
if (text.length() <= 2000) {
|
||||||
event.getMessage().reply(text).queue();
|
event.getMessage().reply(text).queue();
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import org.slf4j.LoggerFactory;
|
||||||
@Singleton
|
@Singleton
|
||||||
public class ContentFetcher {
|
public class ContentFetcher {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ContentFetcher.class);
|
private static final Logger logger = LoggerFactory.getLogger(ContentFetcher.class);
|
||||||
private static final Pattern URL_PATTERN = Pattern.compile("https?://[^\\s]+");
|
private static final Pattern URL_PATTERN = Pattern.compile("https?://[^\\s.,!?;:)\\]>]+");
|
||||||
private static final int MAX_FETCH_CHARS = 3000;
|
private static final int MAX_FETCH_CHARS = 3000;
|
||||||
private static final int MAX_PREVIEW_CHARS = 1000;
|
private static final int MAX_PREVIEW_CHARS = 1000;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package creator.loki.feature.roast;
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
|
||||||
|
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class DebateChannelListener extends ListenerAdapter {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(DebateChannelListener.class);
|
||||||
|
private final ConcurrentHashMap<String, Long> seenMessages = new ConcurrentHashMap<>();
|
||||||
|
private final DebateService debateService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public DebateChannelListener(DebateService debateService) {
|
||||||
|
this.debateService = debateService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessageReceived(@NotNull MessageReceivedEvent event) {
|
||||||
|
if (event.getAuthor().isBot()) return;
|
||||||
|
|
||||||
|
String msgId = event.getMessage().getId();
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
Long prev = seenMessages.put(msgId, now);
|
||||||
|
if (prev != null) return;
|
||||||
|
seenMessages.entrySet().removeIf(e -> now - e.getValue() > 5000);
|
||||||
|
|
||||||
|
logger.info("debate msg ch={} a={} text={}",
|
||||||
|
event.getChannel().getId(), event.getAuthor().getId(),
|
||||||
|
event.getMessage().getContentRaw().replace("\n", " "));
|
||||||
|
debateService.onHumanMessage(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
257
src/main/java/creator/loki/feature/roast/DebateService.java
Normal file
257
src/main/java/creator/loki/feature/roast/DebateService.java
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
package creator.loki.feature.roast;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.ConcurrentMap;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.ScheduledFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import creator.loki.config.BotConfig;
|
||||||
|
import creator.loki.core.BotJdaRegistry;
|
||||||
|
import creator.loki.feature.metrics.MetricsCollector;
|
||||||
|
import net.dv8tion.jda.api.JDA;
|
||||||
|
import net.dv8tion.jda.api.entities.Message;
|
||||||
|
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
|
||||||
|
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class DebateService {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(DebateService.class);
|
||||||
|
private static final Path PROMPTS_DIR =
|
||||||
|
Files.isDirectory(Paths.get("/app/prompts"))
|
||||||
|
? Paths.get("/app/prompts")
|
||||||
|
: Paths.get("data/prompts");
|
||||||
|
private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
private static final long BOT_START_TIME = System.currentTimeMillis();
|
||||||
|
private static final int CHANNEL_CONTEXT = 20;
|
||||||
|
private static final String[] PERSONALITIES = {"arch", "windows", "gentoo"};
|
||||||
|
private static final int MAX_ROUNDS = 15;
|
||||||
|
|
||||||
|
private final BotConfig config;
|
||||||
|
private final DeepSeekClient deepSeekClient;
|
||||||
|
private final WikipediaService wikipediaService;
|
||||||
|
private final KnowledgeService knowledgeService;
|
||||||
|
private final MetricsCollector metricsCollector;
|
||||||
|
private final BotJdaRegistry jdaRegistry;
|
||||||
|
private final String archPrompt;
|
||||||
|
private final String windowsPrompt;
|
||||||
|
private final String gentooPrompt;
|
||||||
|
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||||
|
private final ConcurrentMap<String, ChannelState> channels = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public DebateService(
|
||||||
|
BotConfig config,
|
||||||
|
DeepSeekClient deepSeekClient,
|
||||||
|
WikipediaService wikipediaService,
|
||||||
|
KnowledgeService knowledgeService,
|
||||||
|
MetricsCollector metricsCollector,
|
||||||
|
BotJdaRegistry jdaRegistry) {
|
||||||
|
this.config = config;
|
||||||
|
this.deepSeekClient = deepSeekClient;
|
||||||
|
this.wikipediaService = wikipediaService;
|
||||||
|
this.knowledgeService = knowledgeService;
|
||||||
|
this.metricsCollector = metricsCollector;
|
||||||
|
this.jdaRegistry = jdaRegistry;
|
||||||
|
this.archPrompt = loadPrompt("system-arch.txt");
|
||||||
|
this.windowsPrompt = loadPrompt("system-windows.txt");
|
||||||
|
this.gentooPrompt = loadPrompt("system-gentoo.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String loadPrompt(String filename) {
|
||||||
|
try {
|
||||||
|
Path p = PROMPTS_DIR.resolve(filename);
|
||||||
|
return Files.exists(p) ? Files.readString(p, StandardCharsets.UTF_8).strip() : "";
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Failed to load prompt {}", filename, e);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onHumanMessage(MessageReceivedEvent event) {
|
||||||
|
String debateId = config.getAiDebateChannelId();
|
||||||
|
if (debateId == null || debateId.isBlank()) return;
|
||||||
|
if (!event.getChannel().getId().equals(debateId)) return;
|
||||||
|
if (event.getAuthor().isBot()) return;
|
||||||
|
if (event.getMessage().getContentRaw().isBlank() && event.getMessage().getAttachments().isEmpty()) return;
|
||||||
|
|
||||||
|
TextChannel channel = event.getChannel().asTextChannel();
|
||||||
|
ChannelState state = channels.computeIfAbsent(debateId, k -> new ChannelState());
|
||||||
|
state.cancel();
|
||||||
|
state.lastHumanTime = System.currentTimeMillis();
|
||||||
|
state.lastPersonality = null;
|
||||||
|
state.roundCount = 0;
|
||||||
|
state.lastMessageId = event.getMessage().getId();
|
||||||
|
|
||||||
|
state.currentTask = scheduler.schedule(() ->
|
||||||
|
doRound(channel, state), 0, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doRound(TextChannel channel, ChannelState state) {
|
||||||
|
String debateId = channel.getId();
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - state.lastHumanTime > 300_000 || state.roundDone) return;
|
||||||
|
|
||||||
|
state.cancel();
|
||||||
|
state.roundDone = true;
|
||||||
|
|
||||||
|
channel.getHistory().retrievePast(CHANNEL_CONTEXT).queue(messages -> {
|
||||||
|
boolean hasNewHuman = messages.stream().anyMatch(m ->
|
||||||
|
!m.getAuthor().isBot() && m.getTimeCreated().toEpochSecond() * 1000 > state.lastHumanTime);
|
||||||
|
if (hasNewHuman) {
|
||||||
|
state.roundDone = false;
|
||||||
|
state.lastHumanTime = now;
|
||||||
|
state.lastPersonality = null;
|
||||||
|
state.currentTask = scheduler.schedule(() ->
|
||||||
|
doRound(channel, state), 0, TimeUnit.SECONDS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CachedMessage> ctx = buildContext(messages);
|
||||||
|
|
||||||
|
if (state.roundCount >= MAX_ROUNDS) return;
|
||||||
|
state.roundCount++;
|
||||||
|
|
||||||
|
String personality = pickPersonality(state.lastPersonality);
|
||||||
|
if (personality == null) return;
|
||||||
|
state.lastPersonality = personality;
|
||||||
|
|
||||||
|
String response = generateResponse(personality, ctx);
|
||||||
|
if (response == null || response.isBlank()) return;
|
||||||
|
|
||||||
|
JDA jda = jdaRegistry.getByPersonality(personality);
|
||||||
|
TextChannel target = jda.getTextChannelById(debateId);
|
||||||
|
if (target == null) return;
|
||||||
|
String replyToId = state.lastMessageId;
|
||||||
|
target.sendMessage(response)
|
||||||
|
.setMessageReference(replyToId)
|
||||||
|
.queue(m -> state.lastMessageId = m.getId());
|
||||||
|
|
||||||
|
state.currentTask = scheduler.schedule(() -> {
|
||||||
|
state.roundDone = false;
|
||||||
|
doRound(channel, state);
|
||||||
|
}, 0, TimeUnit.SECONDS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private String pickPersonality(String exclude) {
|
||||||
|
if (exclude == null) return PERSONALITIES[0];
|
||||||
|
for (int i = 0; i < PERSONALITIES.length; i++) {
|
||||||
|
if (PERSONALITIES[i].equals(exclude)) {
|
||||||
|
return PERSONALITIES[(i + 1) % PERSONALITIES.length];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return PERSONALITIES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateResponse(String personality, List<CachedMessage> ctx) {
|
||||||
|
String prompt;
|
||||||
|
String selfName;
|
||||||
|
String otherNames;
|
||||||
|
switch (personality) {
|
||||||
|
case "windows" -> {
|
||||||
|
prompt = windowsPrompt;
|
||||||
|
selfName = "шинд";
|
||||||
|
otherNames = "арч и генту";
|
||||||
|
}
|
||||||
|
case "gentoo" -> {
|
||||||
|
prompt = gentooPrompt;
|
||||||
|
selfName = "генту";
|
||||||
|
otherNames = "арч и шинд";
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
prompt = archPrompt;
|
||||||
|
selfName = "арч";
|
||||||
|
otherNames = "шинд и генту";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (prompt.isBlank()) return null;
|
||||||
|
|
||||||
|
prompt += "\n\nты " + selfName + ". два других ai в чате это " + otherNames;
|
||||||
|
|
||||||
|
long uptimeSec = (System.currentTimeMillis() - BOT_START_TIME) / 1000;
|
||||||
|
String now = LocalDateTime.now().format(DT_FMT);
|
||||||
|
prompt += "\nтекущее время " + now + " uptime бота " + (uptimeSec / 3600) + "ч "
|
||||||
|
+ ((uptimeSec % 3600) / 60) + "м " + (uptimeSec % 60) + "с";
|
||||||
|
|
||||||
|
StringBuilder userContent = new StringBuilder();
|
||||||
|
for (CachedMessage m : ctx) {
|
||||||
|
String t = m.content();
|
||||||
|
if (t.length() > config.getAiMsgLength()) t = t.substring(0, config.getAiMsgLength()) + "...";
|
||||||
|
userContent.append("[").append(m.channelName()).append("] ").append(t).append("\n");
|
||||||
|
if (userContent.length() > config.getAiContextChars()) break;
|
||||||
|
}
|
||||||
|
String text = userContent.toString().strip();
|
||||||
|
if (text.length() > config.getAiContextChars()) text = text.substring(0, config.getAiContextChars());
|
||||||
|
|
||||||
|
String wiki = wikipediaService.search(text);
|
||||||
|
if (wiki != null) text += "\n\n[википедия]:\n" + wiki;
|
||||||
|
|
||||||
|
String knowledge = knowledgeService.search(text);
|
||||||
|
if (knowledge != null) text += "\n\n[знания]:\n" + knowledge;
|
||||||
|
|
||||||
|
return deepSeekClient.callModel(prompt, text, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CachedMessage> buildContext(List<Message> messages) {
|
||||||
|
String archId = getBotId("arch");
|
||||||
|
String winId = getBotId("windows");
|
||||||
|
String gentooId = getBotId("gentoo");
|
||||||
|
|
||||||
|
List<CachedMessage> result = new ArrayList<>();
|
||||||
|
for (int i = messages.size() - 1; i >= 0; i--) {
|
||||||
|
Message msg = messages.get(i);
|
||||||
|
String name;
|
||||||
|
if (msg.getAuthor().isBot()) {
|
||||||
|
String id = msg.getAuthor().getId();
|
||||||
|
if (id.equals(archId)) name = "арч";
|
||||||
|
else if (id.equals(winId)) name = "шинд";
|
||||||
|
else if (id.equals(gentooId)) name = "генту";
|
||||||
|
else name = "бот";
|
||||||
|
} else {
|
||||||
|
name = msg.getAuthor().getEffectiveName();
|
||||||
|
}
|
||||||
|
String label = (i == messages.size() - 1) ? "[now]" : "[history]";
|
||||||
|
result.add(new CachedMessage(
|
||||||
|
label + " " + name + "(" + msg.getAuthor().getId() + "): " + msg.getContentRaw(),
|
||||||
|
msg.getChannel().getName(),
|
||||||
|
msg.getTimeCreated().toEpochSecond() * 1000));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getBotId(String personality) {
|
||||||
|
JDA jda = jdaRegistry.getByPersonality(personality);
|
||||||
|
return jda != null ? jda.getSelfUser().getId() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class ChannelState {
|
||||||
|
long lastHumanTime;
|
||||||
|
boolean roundDone;
|
||||||
|
int roundCount;
|
||||||
|
String lastPersonality;
|
||||||
|
String lastMessageId;
|
||||||
|
ScheduledFuture<?> currentTask;
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
if (currentTask != null) {
|
||||||
|
currentTask.cancel(false);
|
||||||
|
currentTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,6 @@ package creator.loki.feature.roast;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
import com.github.benmanes.caffeine.cache.Cache;
|
|
||||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
|
||||||
import com.google.gson.JsonArray;
|
import com.google.gson.JsonArray;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import com.google.gson.JsonParser;
|
import com.google.gson.JsonParser;
|
||||||
|
|
@ -28,7 +26,6 @@ public class DeepSeekClient {
|
||||||
|
|
||||||
private final OkHttpClient httpClient;
|
private final OkHttpClient httpClient;
|
||||||
private final String apiKey;
|
private final String apiKey;
|
||||||
private final Cache<String, String> responseCache;
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public DeepSeekClient(BotConfig config) {
|
public DeepSeekClient(BotConfig config) {
|
||||||
|
|
@ -39,8 +36,6 @@ public class DeepSeekClient {
|
||||||
.readTimeout(60, TimeUnit.SECONDS)
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
.build();
|
.build();
|
||||||
this.responseCache =
|
|
||||||
Caffeine.newBuilder().maximumSize(200).expireAfterWrite(1, TimeUnit.HOURS).build();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public String callModel(String systemPrompt, String userContent) {
|
public String callModel(String systemPrompt, String userContent) {
|
||||||
|
|
@ -50,10 +45,6 @@ public class DeepSeekClient {
|
||||||
public String callModel(String systemPrompt, String userContent, double temperature) {
|
public String callModel(String systemPrompt, String userContent, double temperature) {
|
||||||
if (apiKey == null || apiKey.isBlank()) return null;
|
if (apiKey == null || apiKey.isBlank()) return null;
|
||||||
|
|
||||||
String cacheKey = systemPrompt.hashCode() + ":" + userContent.hashCode();
|
|
||||||
String cached = responseCache.getIfPresent(cacheKey);
|
|
||||||
if (cached != null) return cached;
|
|
||||||
|
|
||||||
JsonObject body = new JsonObject();
|
JsonObject body = new JsonObject();
|
||||||
body.addProperty("model", MODEL);
|
body.addProperty("model", MODEL);
|
||||||
|
|
||||||
|
|
@ -98,9 +89,6 @@ public class DeepSeekClient {
|
||||||
} else if (msg.get("reasoning") != null && !msg.get("reasoning").isJsonNull()) {
|
} else if (msg.get("reasoning") != null && !msg.get("reasoning").isJsonNull()) {
|
||||||
content = msg.get("reasoning").getAsString().strip();
|
content = msg.get("reasoning").getAsString().strip();
|
||||||
}
|
}
|
||||||
if (content != null && !content.isBlank()) {
|
|
||||||
responseCache.put(cacheKey, content);
|
|
||||||
}
|
|
||||||
return content;
|
return content;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
logger.error("DeepSeek call failed", e);
|
logger.error("DeepSeek call failed", e);
|
||||||
|
|
|
||||||
178
src/main/java/creator/loki/feature/roast/HelperListener.java
Normal file
178
src/main/java/creator/loki/feature/roast/HelperListener.java
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
package creator.loki.feature.roast;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import creator.loki.config.BotConfig;
|
||||||
|
import net.dv8tion.jda.api.entities.Message;
|
||||||
|
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
|
||||||
|
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class HelperListener extends ListenerAdapter {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(HelperListener.class);
|
||||||
|
private static final Path PROMPTS_DIR =
|
||||||
|
Files.isDirectory(Paths.get("/app/prompts"))
|
||||||
|
? Paths.get("/app/prompts")
|
||||||
|
: Paths.get("data/prompts");
|
||||||
|
private static final int CHANNEL_CONTEXT = 20;
|
||||||
|
|
||||||
|
private final BotConfig config;
|
||||||
|
private final RoastService roastService;
|
||||||
|
private final String helperPrompt;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public HelperListener(BotConfig config, RoastService roastService) {
|
||||||
|
this.config = config;
|
||||||
|
this.roastService = roastService;
|
||||||
|
this.helperPrompt = loadPrompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String loadPrompt() {
|
||||||
|
try {
|
||||||
|
Path p = PROMPTS_DIR.resolve("system-helper.txt");
|
||||||
|
return Files.exists(p) ? Files.readString(p, StandardCharsets.UTF_8).strip() : "";
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.error("Failed to load helper prompt", e);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessageReceived(@NotNull MessageReceivedEvent event) {
|
||||||
|
String channelId = config.getAiDebateChannelId();
|
||||||
|
if (channelId == null || channelId.isBlank()) return;
|
||||||
|
if (!event.getChannel().getId().equals(channelId)) return;
|
||||||
|
if (event.getAuthor().isBot()) return;
|
||||||
|
if (event.getMessage().getContentRaw().isBlank() && event.getMessage().getAttachments().isEmpty()) return;
|
||||||
|
|
||||||
|
String raw = event.getMessage().getContentRaw();
|
||||||
|
if (!raw.isBlank() && !LanguageFilter.isAllowed(raw)) return;
|
||||||
|
|
||||||
|
respond(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void respond(MessageReceivedEvent event) {
|
||||||
|
event.getChannel().getHistory().retrievePast(CHANNEL_CONTEXT).queue(messages -> {
|
||||||
|
String targetName = event.getAuthor().getEffectiveName();
|
||||||
|
List<CachedMessage> context = new ArrayList<>();
|
||||||
|
for (int idx = 0; idx < messages.size(); idx++) {
|
||||||
|
Message msg = messages.get(idx);
|
||||||
|
boolean isSelf = msg.getAuthor().getId().equals(event.getJDA().getSelfUser().getId());
|
||||||
|
String author = isSelf ? "ты" : msg.getAuthor().getEffectiveName();
|
||||||
|
String authorId = msg.getAuthor().getId();
|
||||||
|
|
||||||
|
String label = (idx == 0) ? "[now]" : "[history]";
|
||||||
|
StringBuilder line = new StringBuilder();
|
||||||
|
line.append(label).append(" ").append(author).append("(").append(authorId).append("): ")
|
||||||
|
.append(msg.getContentRaw());
|
||||||
|
line.append(attachmentInfo(msg));
|
||||||
|
|
||||||
|
context.add(new CachedMessage(
|
||||||
|
line.toString(),
|
||||||
|
event.getChannel().getName(),
|
||||||
|
msg.getTimeCreated().toEpochSecond() * 1000));
|
||||||
|
}
|
||||||
|
java.util.Collections.reverse(context);
|
||||||
|
|
||||||
|
String response = roastService.generateResponse(context, targetName, null, helperPrompt);
|
||||||
|
if (response == null || response.isBlank()) return;
|
||||||
|
|
||||||
|
sendReply(event, response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendReply(MessageReceivedEvent event, String text) {
|
||||||
|
java.util.regex.Matcher m = java.util.regex.Pattern.compile(
|
||||||
|
"\\[file:([^\\]]+)\\](.*?)\\[/file\\]", java.util.regex.Pattern.DOTALL).matcher(text);
|
||||||
|
if (!m.find()) {
|
||||||
|
sendText(event, text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
StringBuffer remaining = new StringBuffer();
|
||||||
|
boolean first = true;
|
||||||
|
do {
|
||||||
|
String filename = m.group(1).strip();
|
||||||
|
String content = m.group(2);
|
||||||
|
m.appendReplacement(remaining, "");
|
||||||
|
var data = net.dv8tion.jda.api.utils.FileUpload.fromData(
|
||||||
|
content.getBytes(java.nio.charset.StandardCharsets.UTF_8), filename);
|
||||||
|
if (first) {
|
||||||
|
String msg = remaining.toString().strip();
|
||||||
|
event.getMessage().reply(msg == null ? "" : msg)
|
||||||
|
.addFiles(data).queue();
|
||||||
|
first = false;
|
||||||
|
} else {
|
||||||
|
event.getChannel().sendMessage(" ")
|
||||||
|
.addFiles(data).queue();
|
||||||
|
}
|
||||||
|
} while (m.find());
|
||||||
|
m.appendTail(remaining);
|
||||||
|
String rest = remaining.toString().strip();
|
||||||
|
if (!rest.isEmpty()) {
|
||||||
|
event.getChannel().sendMessage(rest).queue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendText(MessageReceivedEvent event, String text) {
|
||||||
|
if (text.length() <= 2000) {
|
||||||
|
event.getMessage().reply(text).queue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
int start = 0;
|
||||||
|
while (start < text.length() && parts.size() < 3) {
|
||||||
|
int end = Math.min(start + 1990, text.length());
|
||||||
|
if (end < text.length()) {
|
||||||
|
int nl = text.lastIndexOf('\n', end);
|
||||||
|
if (nl > start) end = nl;
|
||||||
|
}
|
||||||
|
parts.add(text.substring(start, end).strip());
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < parts.size(); i++) {
|
||||||
|
if (i == 0) {
|
||||||
|
event.getMessage().reply(parts.get(i)).queue();
|
||||||
|
} else {
|
||||||
|
event.getChannel().sendMessage(parts.get(i)).queue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String attachmentInfo(Message msg) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (var att : msg.getAttachments()) {
|
||||||
|
if (att.isImage()) {
|
||||||
|
sb.append(" [изображение: ").append(att.getFileName()).append("]");
|
||||||
|
} else if (att.isVideo()) {
|
||||||
|
sb.append(" [видео: ").append(att.getFileName()).append("]");
|
||||||
|
} else if (att.getFileName().endsWith(".txt")) {
|
||||||
|
String txt = roastService.readTextFilePreview(att.getUrl());
|
||||||
|
if (txt != null) {
|
||||||
|
sb.append("\n[содержимое txt файла ")
|
||||||
|
.append(att.getFileName())
|
||||||
|
.append("]:\n")
|
||||||
|
.append(txt);
|
||||||
|
} else {
|
||||||
|
sb.append(" [txt файл: ").append(att.getFileName()).append(" (не удалось прочитать)]");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb.append(" [файл: ").append(att.getFileName()).append("]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!msg.getStickers().isEmpty()) {
|
||||||
|
sb.append(" [стикер]");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
211
src/main/java/creator/loki/feature/roast/KnowledgeService.java
Normal file
211
src/main/java/creator/loki/feature/roast/KnowledgeService.java
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
package creator.loki.feature.roast;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class KnowledgeService {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(KnowledgeService.class);
|
||||||
|
private static final Path KNOWLEDGE_DIR =
|
||||||
|
Files.isDirectory(Paths.get("/app/knowledge"))
|
||||||
|
? Paths.get("/app/knowledge")
|
||||||
|
: Paths.get("data/knowledge");
|
||||||
|
private static final int MAX_CHARS = 2000;
|
||||||
|
private static final int RESULT_LIMIT = 3;
|
||||||
|
private static final Set<String> STOP_WORDS =
|
||||||
|
Set.of(
|
||||||
|
"это", "как", "так", "что", "он", "она", "они", "мы", "вы", "ты", "я",
|
||||||
|
"не", "да", "нет", "на", "в", "по", "для", "с", "у", "о", "от", "к",
|
||||||
|
"из", "за", "над", "под", "об", "же", "ли", "бы", "а", "и", "но",
|
||||||
|
"или", "то", "все", "его", "ее", "их", "еще", "уже", "когда",
|
||||||
|
"the", "a", "an", "is", "are", "was", "were", "be", "been",
|
||||||
|
"have", "has", "had", "do", "does", "did", "will", "would",
|
||||||
|
"can", "could", "may", "might", "shall", "should", "to", "of",
|
||||||
|
"in", "for", "with", "on", "at", "by", "from", "as", "into",
|
||||||
|
"it", "its", "this", "that", "these", "those", "i", "you",
|
||||||
|
"he", "she", "we", "they", "me", "him", "her", "us", "them",
|
||||||
|
"and", "but", "or", "not", "no", "if", "so", "about");
|
||||||
|
|
||||||
|
private final List<KnowledgeFile> files = new ArrayList<>();
|
||||||
|
|
||||||
|
public KnowledgeService() {
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadFiles() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(KNOWLEDGE_DIR);
|
||||||
|
List<Path> paths;
|
||||||
|
try (var stream = Files.walk(KNOWLEDGE_DIR, 3)) {
|
||||||
|
paths =
|
||||||
|
stream
|
||||||
|
.filter(p -> p.toString().endsWith(".txt") || p.toString().endsWith(".md"))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
for (Path p : paths) {
|
||||||
|
try {
|
||||||
|
String content = Files.readString(p, StandardCharsets.UTF_8);
|
||||||
|
String name = KNOWLEDGE_DIR.relativize(p).toString();
|
||||||
|
files.add(new KnowledgeFile(name, content));
|
||||||
|
logger.info("Loaded knowledge file: {} ({} chars)", name, content.length());
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warn("Failed to load knowledge file {}", p, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info("Loaded {} knowledge files", files.size());
|
||||||
|
} catch (IOException e) {
|
||||||
|
logger.warn("Failed to list knowledge directory", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String search(String text) {
|
||||||
|
if (text == null || text.isBlank() || files.isEmpty()) return null;
|
||||||
|
|
||||||
|
String[] words = extractKeywords(text);
|
||||||
|
if (words.length == 0) return null;
|
||||||
|
|
||||||
|
List<ScoredResult> results = new ArrayList<>();
|
||||||
|
for (KnowledgeFile file : files) {
|
||||||
|
int score = scoreFile(file, words);
|
||||||
|
if (score > 0) {
|
||||||
|
String snippet = findBestSnippet(file, words);
|
||||||
|
results.add(new ScoredResult(file.name, snippet, score));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.sort((a, b) -> Integer.compare(b.score, a.score));
|
||||||
|
if (results.isEmpty()) return null;
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
int count = 0;
|
||||||
|
for (ScoredResult r : results) {
|
||||||
|
if (count >= RESULT_LIMIT) break;
|
||||||
|
if (sb.length() + r.snippet.length() > MAX_CHARS) break;
|
||||||
|
if (count > 0) sb.append("\n---\n");
|
||||||
|
sb.append("[").append(r.fileName).append("] ").append(r.snippet);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
String result = sb.toString().strip();
|
||||||
|
return result.isBlank() ? null : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String[] extractKeywords(String text) {
|
||||||
|
String cleaned = text.toLowerCase()
|
||||||
|
.replaceAll("[^a-zа-яё0-9\\s]", " ")
|
||||||
|
.replaceAll("\\s+", " ")
|
||||||
|
.strip();
|
||||||
|
String[] all = cleaned.split(" ");
|
||||||
|
return Arrays.stream(all)
|
||||||
|
.filter(w -> w.length() > 2)
|
||||||
|
.filter(w -> !STOP_WORDS.contains(w))
|
||||||
|
.distinct()
|
||||||
|
.limit(30)
|
||||||
|
.toArray(String[]::new);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int scoreFile(KnowledgeFile file, String[] keywords) {
|
||||||
|
String lower = file.contentLower;
|
||||||
|
int score = 0;
|
||||||
|
for (String kw : keywords) {
|
||||||
|
int idx = 0;
|
||||||
|
int count = 0;
|
||||||
|
while ((idx = lower.indexOf(kw, idx)) != -1) {
|
||||||
|
count++;
|
||||||
|
idx += kw.length();
|
||||||
|
}
|
||||||
|
score += count * 10;
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String findBestSnippet(KnowledgeFile file, String[] keywords) {
|
||||||
|
String lower = file.contentLower;
|
||||||
|
int bestPos = -1;
|
||||||
|
int bestCount = 0;
|
||||||
|
|
||||||
|
for (String kw : keywords) {
|
||||||
|
int idx = lower.indexOf(kw);
|
||||||
|
if (idx == -1) continue;
|
||||||
|
int count = countInWindow(lower, idx, 500, keywords);
|
||||||
|
if (count > bestCount) {
|
||||||
|
bestCount = count;
|
||||||
|
bestPos = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestPos == -1) {
|
||||||
|
bestPos = Math.min(lower.length() / 2, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
int start = Math.max(0, bestPos - 200);
|
||||||
|
int end = Math.min(file.content.length(), bestPos + 600);
|
||||||
|
if (start > 0) {
|
||||||
|
int nl = file.content.indexOf('\n', start);
|
||||||
|
start = (nl != -1 && nl < bestPos) ? nl + 1 : start;
|
||||||
|
}
|
||||||
|
if (end < file.content.length()) {
|
||||||
|
int nl = file.content.lastIndexOf('\n', end);
|
||||||
|
end = (nl != -1 && nl > bestPos) ? nl : end;
|
||||||
|
}
|
||||||
|
|
||||||
|
String snippet = file.content.substring(start, end).strip();
|
||||||
|
if (snippet.length() > 800) {
|
||||||
|
snippet = snippet.substring(0, 797) + "...";
|
||||||
|
}
|
||||||
|
return snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int countInWindow(String text, int center, int windowSize, String[] keywords) {
|
||||||
|
int start = Math.max(0, center - windowSize);
|
||||||
|
int end = Math.min(text.length(), center + windowSize);
|
||||||
|
String window = text.substring(start, end);
|
||||||
|
int count = 0;
|
||||||
|
for (String kw : keywords) {
|
||||||
|
int idx = 0;
|
||||||
|
while ((idx = window.indexOf(kw, idx)) != -1) {
|
||||||
|
count++;
|
||||||
|
idx += kw.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class KnowledgeFile {
|
||||||
|
final String name;
|
||||||
|
final String content;
|
||||||
|
final String contentLower;
|
||||||
|
|
||||||
|
KnowledgeFile(String name, String content) {
|
||||||
|
this.name = name;
|
||||||
|
this.content = content;
|
||||||
|
this.contentLower = content.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class ScoredResult {
|
||||||
|
final String fileName;
|
||||||
|
final String snippet;
|
||||||
|
final int score;
|
||||||
|
|
||||||
|
ScoredResult(String fileName, String snippet, int score) {
|
||||||
|
this.fileName = fileName;
|
||||||
|
this.snippet = snippet;
|
||||||
|
this.score = score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,8 +9,9 @@ public class RoastModule extends AbstractModule {
|
||||||
bind(DeepSeekClient.class).in(Singleton.class);
|
bind(DeepSeekClient.class).in(Singleton.class);
|
||||||
bind(DossierManager.class).in(Singleton.class);
|
bind(DossierManager.class).in(Singleton.class);
|
||||||
bind(ContentFetcher.class).in(Singleton.class);
|
bind(ContentFetcher.class).in(Singleton.class);
|
||||||
bind(WebSearchService.class).in(Singleton.class);
|
bind(WikipediaService.class).in(Singleton.class);
|
||||||
bind(RoastService.class).in(Singleton.class);
|
bind(RoastService.class).in(Singleton.class);
|
||||||
bind(AndrertListener.class).in(Singleton.class);
|
bind(AndrertListener.class).in(Singleton.class);
|
||||||
|
bind(WebSearchService.class).in(Singleton.class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
@ -12,6 +14,7 @@ import java.util.regex.Pattern;
|
||||||
import com.google.inject.Inject;
|
import com.google.inject.Inject;
|
||||||
import com.google.inject.Singleton;
|
import com.google.inject.Singleton;
|
||||||
import creator.loki.config.BotConfig;
|
import creator.loki.config.BotConfig;
|
||||||
|
import creator.loki.feature.metrics.MetricsCollector;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
|
@ -22,12 +25,17 @@ public class RoastService {
|
||||||
Files.isDirectory(Paths.get("/app/prompts"))
|
Files.isDirectory(Paths.get("/app/prompts"))
|
||||||
? Paths.get("/app/prompts")
|
? Paths.get("/app/prompts")
|
||||||
: Paths.get("data/prompts");
|
: Paths.get("data/prompts");
|
||||||
|
private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
private static final long BOT_START_TIME = System.currentTimeMillis();
|
||||||
|
|
||||||
private final BotConfig config;
|
private final BotConfig config;
|
||||||
private final DeepSeekClient deepSeekClient;
|
private final DeepSeekClient deepSeekClient;
|
||||||
private final DossierManager dossierManager;
|
private final DossierManager dossierManager;
|
||||||
private final ContentFetcher contentFetcher;
|
private final ContentFetcher contentFetcher;
|
||||||
|
private final WikipediaService wikipediaService;
|
||||||
|
private final KnowledgeService knowledgeService;
|
||||||
private final WebSearchService webSearchService;
|
private final WebSearchService webSearchService;
|
||||||
|
private final MetricsCollector metricsCollector;
|
||||||
private final String systemPrompt;
|
private final String systemPrompt;
|
||||||
private final String dossierGeneratePrompt;
|
private final String dossierGeneratePrompt;
|
||||||
|
|
||||||
|
|
@ -37,16 +45,31 @@ public class RoastService {
|
||||||
DeepSeekClient deepSeekClient,
|
DeepSeekClient deepSeekClient,
|
||||||
DossierManager dossierManager,
|
DossierManager dossierManager,
|
||||||
ContentFetcher contentFetcher,
|
ContentFetcher contentFetcher,
|
||||||
WebSearchService webSearchService) {
|
WikipediaService wikipediaService,
|
||||||
|
KnowledgeService knowledgeService,
|
||||||
|
WebSearchService webSearchService,
|
||||||
|
MetricsCollector metricsCollector) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.deepSeekClient = deepSeekClient;
|
this.deepSeekClient = deepSeekClient;
|
||||||
this.dossierManager = dossierManager;
|
this.dossierManager = dossierManager;
|
||||||
this.contentFetcher = contentFetcher;
|
this.contentFetcher = contentFetcher;
|
||||||
|
this.wikipediaService = wikipediaService;
|
||||||
|
this.knowledgeService = knowledgeService;
|
||||||
this.webSearchService = webSearchService;
|
this.webSearchService = webSearchService;
|
||||||
|
this.metricsCollector = metricsCollector;
|
||||||
this.systemPrompt = loadPrompt("system.txt");
|
this.systemPrompt = loadPrompt("system.txt");
|
||||||
this.dossierGeneratePrompt = loadPrompt("dossier.txt");
|
this.dossierGeneratePrompt = loadPrompt("dossier.txt");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String currentTimeInfo() {
|
||||||
|
long uptimeSec = (System.currentTimeMillis() - BOT_START_TIME) / 1000;
|
||||||
|
long hours = uptimeSec / 3600;
|
||||||
|
long mins = (uptimeSec % 3600) / 60;
|
||||||
|
long secs = uptimeSec % 60;
|
||||||
|
String now = LocalDateTime.now().format(DT_FMT);
|
||||||
|
return String.format("текущее время %s uptime бота %dч %dм %dс всего команд выполнено %d", now, hours, mins, secs, metricsCollector.getTotalCommands());
|
||||||
|
}
|
||||||
|
|
||||||
private String loadPrompt(String filename) {
|
private String loadPrompt(String filename) {
|
||||||
try {
|
try {
|
||||||
return Files.readString(PROMPTS_DIR.resolve(filename), StandardCharsets.UTF_8).strip();
|
return Files.readString(PROMPTS_DIR.resolve(filename), StandardCharsets.UTF_8).strip();
|
||||||
|
|
@ -123,9 +146,57 @@ public class RoastService {
|
||||||
if (text.length() > config.getAiMsgLength()) {
|
if (text.length() > config.getAiMsgLength()) {
|
||||||
text = text.substring(0, config.getAiMsgLength()) + "...";
|
text = text.substring(0, config.getAiMsgLength()) + "...";
|
||||||
}
|
}
|
||||||
String parsed = contentFetcher.fetchUrlContent(text);
|
userContent.append("[").append(m.channelName()).append("] ").append(text).append("\n");
|
||||||
if (parsed != null) {
|
if (userContent.length() > config.getAiContextChars()) break;
|
||||||
text += "\n[содержимое по ссылке]:\n" + parsed;
|
}
|
||||||
|
String text = userContent.toString().strip();
|
||||||
|
if (text.length() > config.getAiContextChars()) {
|
||||||
|
text = text.substring(0, config.getAiContextChars());
|
||||||
|
}
|
||||||
|
|
||||||
|
systemPrompt += "\n\n" + currentTimeInfo();
|
||||||
|
|
||||||
|
text = currentTimeInfo() + "\n\n" + text;
|
||||||
|
|
||||||
|
String wikiContext = wikipediaService.search(text);
|
||||||
|
if (wikiContext != null) {
|
||||||
|
text += "\n\n[википедия]:\n" + wikiContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
String knowledge = knowledgeService.search(text);
|
||||||
|
if (knowledge != null) {
|
||||||
|
text += "\n\n[знания]:\n" + knowledge;
|
||||||
|
}
|
||||||
|
|
||||||
|
return deepSeekClient.callModel(systemPrompt, text, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateResponse(List<CachedMessage> context, String targetName, String userId, String customPrompt) {
|
||||||
|
String escapedName = escapeFormat(targetName != null ? targetName : "");
|
||||||
|
String systemPrompt = customPrompt.formatted(escapedName, escapedName, escapedName);
|
||||||
|
|
||||||
|
if (userId != null) {
|
||||||
|
String dossier = dossierManager.getDossier(userId);
|
||||||
|
if (dossier != null && !dossier.isBlank()) {
|
||||||
|
systemPrompt +=
|
||||||
|
"\n\nДОСЬЕ НА ПОЛЬЗОВАТЕЛЯ " + escapedName.replace("%%", "%") + ":\n" + dossier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CachedMessage> filtered = new ArrayList<>();
|
||||||
|
for (CachedMessage m : context) {
|
||||||
|
if (stripJailbreaks(m.content()) != null) {
|
||||||
|
filtered.add(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder userContent = new StringBuilder();
|
||||||
|
int start = Math.max(0, filtered.size() - config.getAiContextMsgs());
|
||||||
|
for (int i = start; i < filtered.size(); i++) {
|
||||||
|
CachedMessage m = filtered.get(i);
|
||||||
|
String text = m.content();
|
||||||
|
if (text.length() > config.getAiMsgLength()) {
|
||||||
|
text = text.substring(0, config.getAiMsgLength()) + "...";
|
||||||
}
|
}
|
||||||
userContent.append("[").append(m.channelName()).append("] ").append(text).append("\n");
|
userContent.append("[").append(m.channelName()).append("] ").append(text).append("\n");
|
||||||
if (userContent.length() > config.getAiContextChars()) break;
|
if (userContent.length() > config.getAiContextChars()) break;
|
||||||
|
|
@ -135,9 +206,23 @@ public class RoastService {
|
||||||
text = text.substring(0, config.getAiContextChars());
|
text = text.substring(0, config.getAiContextChars());
|
||||||
}
|
}
|
||||||
|
|
||||||
String webContext = webSearchService.search(text);
|
systemPrompt += "\n\n" + currentTimeInfo();
|
||||||
if (webContext != null) {
|
|
||||||
text += "\n\n[поиск в интернете]:\n" + webContext;
|
text = currentTimeInfo() + "\n\n" + text;
|
||||||
|
|
||||||
|
String wikiContext = wikipediaService.search(text);
|
||||||
|
if (wikiContext != null) {
|
||||||
|
text += "\n\n[википедия]:\n" + wikiContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
String knowledge = knowledgeService.search(text);
|
||||||
|
if (knowledge != null) {
|
||||||
|
text += "\n\n[знания]:\n" + knowledge;
|
||||||
|
}
|
||||||
|
|
||||||
|
String web = webSearchService.search(text);
|
||||||
|
if (web != null) {
|
||||||
|
text += "\n\n[web]:\n" + web;
|
||||||
}
|
}
|
||||||
|
|
||||||
return deepSeekClient.callModel(systemPrompt, text, 0.9);
|
return deepSeekClient.callModel(systemPrompt, text, 0.9);
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1,147 @@
|
||||||
package creator.loki.feature.roast;
|
package creator.loki.feature.roast;
|
||||||
|
|
||||||
|
import java.net.URLDecoder;
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
import com.google.gson.JsonArray;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import com.google.gson.JsonParser;
|
|
||||||
import com.google.inject.Singleton;
|
import com.google.inject.Singleton;
|
||||||
import okhttp3.OkHttpClient;
|
import okhttp3.OkHttpClient;
|
||||||
import okhttp3.Request;
|
import okhttp3.Request;
|
||||||
import okhttp3.Response;
|
import okhttp3.Response;
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.nodes.Document;
|
||||||
|
import org.jsoup.nodes.Element;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
public class WebSearchService {
|
public class WebSearchService {
|
||||||
private static final Logger logger = LoggerFactory.getLogger(WebSearchService.class);
|
private static final Logger logger = LoggerFactory.getLogger(WebSearchService.class);
|
||||||
private static final String DDG_URL = "https://api.duckduckgo.com/";
|
private static final int MAX_RESULT_CHARS = 3000;
|
||||||
|
|
||||||
private final OkHttpClient httpClient;
|
private final OkHttpClient httpClient;
|
||||||
|
|
||||||
public WebSearchService() {
|
public WebSearchService() {
|
||||||
this.httpClient =
|
this.httpClient = new OkHttpClient.Builder()
|
||||||
new OkHttpClient.Builder()
|
.connectTimeout(8, TimeUnit.SECONDS)
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
.readTimeout(12, TimeUnit.SECONDS)
|
||||||
.readTimeout(10, TimeUnit.SECONDS)
|
.followRedirects(true)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public String search(String text) {
|
public String search(String query) {
|
||||||
if (text == null || text.isBlank()) return null;
|
if (query == null || query.isBlank()) return null;
|
||||||
String query = text.length() > 200 ? text.substring(0, 200) : text;
|
|
||||||
try {
|
try {
|
||||||
String url =
|
List<SearchResult> results = searchDdg(query);
|
||||||
DDG_URL
|
if (results.isEmpty()) return null;
|
||||||
+ "?q="
|
|
||||||
+ URLEncoder.encode(query, StandardCharsets.UTF_8)
|
|
||||||
+ "&format=json&no_html=1&skip_disambig=1";
|
|
||||||
Request req = new Request.Builder().url(url).get().build();
|
|
||||||
try (Response resp = httpClient.newCall(req).execute()) {
|
|
||||||
if (!resp.isSuccessful() || resp.body() == null) return null;
|
|
||||||
JsonObject json = JsonParser.parseString(resp.body().string()).getAsJsonObject();
|
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
if (json.has("AbstractText") && !json.get("AbstractText").isJsonNull()) {
|
for (int i = 0; i < Math.min(results.size(), 3); i++) {
|
||||||
String abs = json.get("AbstractText").getAsString().strip();
|
SearchResult r = results.get(i);
|
||||||
if (!abs.isBlank()) sb.append(abs).append("\n");
|
sb.append(i + 1).append(". ").append(r.title).append("\n");
|
||||||
|
sb.append(r.snippet).append("\n");
|
||||||
|
sb.append(r.url).append("\n\n");
|
||||||
}
|
}
|
||||||
JsonArray topics = json.getAsJsonArray("RelatedTopics");
|
|
||||||
int count = 0;
|
SearchResult top = results.get(0);
|
||||||
if (topics != null) {
|
String pageContent = fetchPageText(top.url);
|
||||||
for (var el : topics) {
|
if (pageContent != null) {
|
||||||
if (count >= 5) break;
|
sb.append("--- страница ").append(top.title).append(" ---\n");
|
||||||
if (el.isJsonObject()) {
|
sb.append(pageContent).append("\n");
|
||||||
JsonObject t = el.getAsJsonObject();
|
|
||||||
if (t.has("Text") && !t.get("Text").isJsonNull()) {
|
|
||||||
String s = t.get("Text").getAsString().strip();
|
|
||||||
if (!s.isBlank()) {
|
|
||||||
sb.append("- ").append(s).append("\n");
|
|
||||||
count++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sb.length() > MAX_RESULT_CHARS) {
|
||||||
|
sb.setLength(MAX_RESULT_CHARS);
|
||||||
|
sb.append("...");
|
||||||
}
|
}
|
||||||
if (t.has("Topics") && !t.get("Topics").isJsonNull()) {
|
logger.info("Web search ok q={} results={} chars={}", query, results.size(), sb.length());
|
||||||
for (var sub : t.getAsJsonArray("Topics")) {
|
return sb.toString().strip();
|
||||||
if (count >= 5) break;
|
|
||||||
if (sub.isJsonObject()) {
|
|
||||||
JsonObject st = sub.getAsJsonObject();
|
|
||||||
if (st.has("Text") && !st.get("Text").isJsonNull()) {
|
|
||||||
String s = st.get("Text").getAsString().strip();
|
|
||||||
if (!s.isBlank()) {
|
|
||||||
sb.append("- ").append(s).append("\n");
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String result = sb.toString().strip();
|
|
||||||
return result.isBlank() ? null : result;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.warn("Web search failed", e);
|
logger.warn("Web search failed for: {}", query, e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<SearchResult> searchDdg(String query) {
|
||||||
|
List<SearchResult> results = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
String url = "https://html.duckduckgo.com/html/?q="
|
||||||
|
+ URLEncoder.encode(query, StandardCharsets.UTF_8);
|
||||||
|
Request req = new Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
.get()
|
||||||
|
.build();
|
||||||
|
try (Response resp = httpClient.newCall(req).execute()) {
|
||||||
|
if (!resp.isSuccessful() || resp.body() == null) return results;
|
||||||
|
String html = resp.body().string();
|
||||||
|
Document doc = Jsoup.parse(html);
|
||||||
|
for (Element a : doc.select("a.result__a")) {
|
||||||
|
if (results.size() >= 5) break;
|
||||||
|
String title = a.text().strip();
|
||||||
|
String href = a.attr("href");
|
||||||
|
String realUrl = extractUrl(href);
|
||||||
|
Element parent = a.parent();
|
||||||
|
Element snippet = parent != null ? parent.selectFirst(".result__snippet") : null;
|
||||||
|
String snip = snippet != null ? snippet.text().strip() : "";
|
||||||
|
if (!title.isEmpty() && !realUrl.isEmpty()) {
|
||||||
|
results.add(new SearchResult(title, realUrl, snip));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("DDG search failed for: {}", query, e);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractUrl(String ddgUrl) {
|
||||||
|
if (ddgUrl == null || ddgUrl.isBlank()) return "";
|
||||||
|
if (ddgUrl.startsWith("//")) ddgUrl = "https:" + ddgUrl;
|
||||||
|
try {
|
||||||
|
if (ddgUrl.contains("uddg=")) {
|
||||||
|
String enc = ddgUrl.substring(ddgUrl.indexOf("uddg=") + 5);
|
||||||
|
int amp = enc.indexOf('&');
|
||||||
|
if (amp > 0) enc = enc.substring(0, amp);
|
||||||
|
return URLDecoder.decode(enc, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
return ddgUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fetchPageText(String url) {
|
||||||
|
try {
|
||||||
|
Request req = new Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml")
|
||||||
|
.header("Accept-Language", "en,ru;q=0.9")
|
||||||
|
.get()
|
||||||
|
.build();
|
||||||
|
try (Response resp = httpClient.newCall(req).execute()) {
|
||||||
|
if (!resp.isSuccessful() || resp.body() == null) return null;
|
||||||
|
String html = resp.body().string();
|
||||||
|
Document doc = Jsoup.parse(html);
|
||||||
|
Element main = doc.selectFirst("main, article, .post-content, .entry-content, .readme, .markdown-body, #readme, .container-lg");
|
||||||
|
String text;
|
||||||
|
if (main != null) {
|
||||||
|
main.select("script, style, nav, footer, header, .sidebar, aside").remove();
|
||||||
|
text = main.text().strip();
|
||||||
|
} else {
|
||||||
|
doc.select("script, style, nav, footer, header, .sidebar, aside").remove();
|
||||||
|
text = doc.body().text().strip();
|
||||||
|
}
|
||||||
|
if (text.length() > 2000) text = text.substring(0, 2000) + "...";
|
||||||
|
return text.isEmpty() ? null : text;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.debug("Failed to fetch page: {}", url, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record SearchResult(String title, String url, String snippet) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package creator.loki.feature.roast;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.Response;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class WikipediaService {
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(WikipediaService.class);
|
||||||
|
private static final String SEARCH_URL = "https://ru.wikipedia.org/w/api.php?action=query&list=search&srsearch=%s&format=json&srlimit=3&srprop=snippet";
|
||||||
|
private static final String EXTRACT_URL = "https://ru.wikipedia.org/w/api.php?action=query&prop=extracts&exintro&explaintext&titles=%s&format=json&exlimit=1";
|
||||||
|
private static final int MAX_RESULT_CHARS = 1500;
|
||||||
|
|
||||||
|
private final OkHttpClient httpClient;
|
||||||
|
|
||||||
|
public WikipediaService() {
|
||||||
|
this.httpClient = new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String search(String query) {
|
||||||
|
if (query == null || query.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
String url = String.format(SEARCH_URL, URLEncoder.encode(query, StandardCharsets.UTF_8));
|
||||||
|
Request req = new Request.Builder().url(url).get().build();
|
||||||
|
try (Response resp = httpClient.newCall(req).execute()) {
|
||||||
|
if (!resp.isSuccessful() || resp.body() == null) return null;
|
||||||
|
JsonObject json = JsonParser.parseString(resp.body().string()).getAsJsonObject();
|
||||||
|
JsonArray results = json.getAsJsonObject("query").getAsJsonArray("search");
|
||||||
|
if (results == null || results.size() == 0) return null;
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < Math.min(results.size(), 3); i++) {
|
||||||
|
JsonObject r = results.get(i).getAsJsonObject();
|
||||||
|
String title = r.get("title").getAsString();
|
||||||
|
String snippet = r.get("snippet").getAsString()
|
||||||
|
.replaceAll("<[^>]+>", "")
|
||||||
|
.replaceAll("&[^;]+;", " ");
|
||||||
|
String pageContent = getExtract(title);
|
||||||
|
sb.append("=== ").append(title).append(" ===\n");
|
||||||
|
if (pageContent != null) {
|
||||||
|
sb.append(pageContent);
|
||||||
|
} else {
|
||||||
|
sb.append(snippet);
|
||||||
|
}
|
||||||
|
sb.append("\n\n");
|
||||||
|
if (sb.length() > MAX_RESULT_CHARS) {
|
||||||
|
sb.setLength(MAX_RESULT_CHARS);
|
||||||
|
sb.append("...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString().strip();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Wikipedia search failed for: {}", query, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getExtract(String title) {
|
||||||
|
try {
|
||||||
|
String url = String.format(EXTRACT_URL, URLEncoder.encode(title, StandardCharsets.UTF_8));
|
||||||
|
Request req = new Request.Builder().url(url).get().build();
|
||||||
|
try (Response resp = httpClient.newCall(req).execute()) {
|
||||||
|
if (!resp.isSuccessful() || resp.body() == null) return null;
|
||||||
|
JsonObject json = JsonParser.parseString(resp.body().string()).getAsJsonObject();
|
||||||
|
JsonObject pages = json.getAsJsonObject("query").getAsJsonObject("pages");
|
||||||
|
for (var key : pages.keySet()) {
|
||||||
|
JsonObject page = pages.getAsJsonObject(key);
|
||||||
|
if (page.has("extract") && !page.get("extract").isJsonNull()) {
|
||||||
|
String ext = page.get("extract").getAsString().strip();
|
||||||
|
if (ext.length() > 800) ext = ext.substring(0, 800) + "...";
|
||||||
|
return ext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to get extract for: {}", title, e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ class BotConfigTest {
|
||||||
3000,
|
3000,
|
||||||
30,
|
30,
|
||||||
24,
|
24,
|
||||||
|
null,
|
||||||
lavalink);
|
lavalink);
|
||||||
assertEquals("token123", config.getMainToken());
|
assertEquals("token123", config.getMainToken());
|
||||||
assertEquals("token123", config.getToken());
|
assertEquals("token123", config.getToken());
|
||||||
|
|
@ -44,7 +45,7 @@ class BotConfigTest {
|
||||||
void handlesNullValues() {
|
void handlesNullValues() {
|
||||||
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);
|
||||||
assertNull(config.getMainToken());
|
assertNull(config.getMainToken());
|
||||||
assertNull(config.getMusicToken());
|
assertNull(config.getMusicToken());
|
||||||
assertNull(config.getRadioToken());
|
assertNull(config.getRadioToken());
|
||||||
|
|
@ -61,7 +62,7 @@ class BotConfigTest {
|
||||||
LavalinkConfig lavalink = new LavalinkConfig("", 0, "", false);
|
LavalinkConfig lavalink = new LavalinkConfig("", 0, "", false);
|
||||||
BotConfig config =
|
BotConfig config =
|
||||||
new BotConfig(
|
new BotConfig(
|
||||||
"", "", "", "", "", 8080, "", "", null, null, 10, 300, 3000, 30, 24, lavalink);
|
"", "", "", "", "", 8080, "", "", null, null, 10, 300, 3000, 30, 24, null, lavalink);
|
||||||
assertEquals("", config.getMainToken());
|
assertEquals("", config.getMainToken());
|
||||||
assertEquals("", config.getMusicToken());
|
assertEquals("", config.getMusicToken());
|
||||||
assertEquals("", config.getRadioToken());
|
assertEquals("", config.getRadioToken());
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ class LavalinkManagerTest {
|
||||||
3000,
|
3000,
|
||||||
30,
|
30,
|
||||||
24,
|
24,
|
||||||
|
null,
|
||||||
lavalinkConfig);
|
lavalinkConfig);
|
||||||
|
|
||||||
var manager = new LavalinkManager(config, queueRepository);
|
var manager = new LavalinkManager(config, queueRepository);
|
||||||
|
|
@ -66,6 +67,7 @@ class LavalinkManagerTest {
|
||||||
3000,
|
3000,
|
||||||
30,
|
30,
|
||||||
24,
|
24,
|
||||||
|
null,
|
||||||
lavalinkConfig);
|
lavalinkConfig);
|
||||||
var manager = new LavalinkManager(config, queueRepository);
|
var manager = new LavalinkManager(config, queueRepository);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue